diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index 1f3a88b5..0ccd3040 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -8,8 +8,9 @@ //! //! MVP scope: Stage 1 = Pass 1 + the identical-expression rule only (no //! window-composition variants); Stage 2 = physical operator implementation -//! only (no materialization). Expected counts are 1 → 6 → 6 → 1, a subset of -//! the doc's 1 → 54 → 156 → 1. +//! only (no materialization). Counts follow the planner's output (user +//! decision): 1 → 24 → 24 → 1, because Pass 1 also offers exact accumulators. +//! The doc's 1 → 54 → 156 → 1 needs window composition and materialization. use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -342,6 +343,7 @@ fn example1_workload() -> PlanningWorkload { enum Q2Option { Exact, CountMinHeapPerJob, + CountSketchHeapPerJob, Hydra, } @@ -430,6 +432,11 @@ fn sketch_options(dag: &impl ExportedDag, nodes: &HashSet) -> BTreeSet + { + Q2Option::CountSketchHeapPerJob + } other => panic!("summary family outside Example 1: {kind:?} {other:?}"), }), _ => None, @@ -437,6 +444,22 @@ fn sketch_options(dag: &impl ExportedDag, nodes: &HashSet) -> BTreeSet) -> Vec { + let mut kinds: Vec<_> = nodes + .iter() + .filter_map(|&id| match payload(dag, id) { + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(kind, _), + .. + }) => Some(format!("{kind:?}")), + _ => None, + }) + .collect(); + kinds.sort(); + kinds +} + fn roots(query_roots: &[NodeId]) -> (NodeId, NodeId) { assert_eq!(query_roots.len(), 2, "every candidate covers Q1 and Q2"); (query_roots[0], query_roots[1]) @@ -482,16 +505,41 @@ fn pipeline() -> ( (workload, logical, physical) } -/// The six Example 1 MVP combinations: three Q2 options × separate/shared input. -fn expected_combinations() -> BTreeSet<(Q2Option, bool)> { - [ +/// One candidate's local choices: Q1's exact accumulators, Q2's top-k option +/// and Q2's exact accumulators. +type Choices = (Vec, Q2Option, Vec); + +fn choices(dag: &impl ExportedDag, query_roots: &[NodeId]) -> Choices { + let (q1, q2) = roots(query_roots); + let (c1, c2) = (closure(dag, q1), closure(dag, q2)); + let (option, _) = classify(dag, query_roots); + ( + exact_accumulators(dag, &c1), + option, + exact_accumulators(dag, &c2), + ) +} + +/// The 24 Pass 1 combinations: Q1's rate and sum each raw or an exact +/// accumulator (4) × Q2's top-k exact, Count-Min + heap or CountSketch + heap +/// (3) × Q2's sum_over_time raw or an exact accumulator (2). +fn expected_choices() -> BTreeSet { + let q1 = [vec![], vec!["Rate"], vec!["Sum"], vec!["Rate", "Sum"]]; + let q2 = [ Q2Option::Exact, Q2Option::CountMinHeapPerJob, - Q2Option::Hydra, - ] - .into_iter() - .flat_map(|o| [(o, false), (o, true)]) - .collect() + Q2Option::CountSketchHeapPerJob, + ]; + let owned = |kinds: &[&str]| kinds.iter().map(|k| k.to_string()).collect::>(); + let mut all = BTreeSet::new(); + for a in &q1 { + for &option in &q2 { + for b in [vec![], vec!["Sum"]] { + all.insert((owned(a), option, owned(&b))); + } + } + } + all } // ── Workload ───────────────────────────────────────────────────────────── @@ -560,21 +608,24 @@ fn stage0_queries_do_not_share_nodes() { // ── Stage 1 ────────────────────────────────────────────────────────────── -/// Stage 1 outputs exactly the 3 Q2 options × {separate, shared input} = 6 candidates. +/// Stage 1 outputs exactly the 24 Pass 1 combinations, each once, with +/// separate inputs (no Pass 2 sharing yet). #[test] -#[ignore = "Pass 1 yields 24 candidates (exact-accumulator alternatives for Q1's rate and sum and Q2's sum, and CountSketch+heap for Q2), there is no Hydra and no Pass 2 shared-input variant"] -fn stage1_has_six_candidates_covering_every_combination() { +fn stage1_has_24_candidates_covering_every_combination() { let (_, logical, _) = pipeline(); - assert_eq!(logical.len(), 6); + assert_eq!(logical.len(), 24); let found: BTreeSet<_> = logical .iter() - .map(|c| classify(&c.dag, &c.query_roots)) + .map(|c| choices(&c.dag, &c.query_roots)) .collect(); - assert_eq!( - found, - expected_combinations(), - "each combination exactly once" - ); + assert_eq!(found, expected_choices(), "each combination exactly once"); + for c in &logical { + assert!( + !classify(&c.dag, &c.query_roots).1, + "{}: shared input", + c.id + ); + } } /// Q1 is exact in every Stage 1 candidate: no summary is reachable from its root. @@ -592,10 +643,11 @@ fn stage1_q1_is_always_exact() { } } -/// Q2's summary families are exactly Count-Min + heap per job and Hydra over all jobs. +/// Q2's summary families are exactly Count-Min + heap and CountSketch + heap +/// per job, and Hydra over all jobs. #[test] -#[ignore = "Pass 1 also offers CountSketchWithHeap for Q2, which sketch_options rejects as outside Example 1 (spec ambiguity 8); Pass 1 has no Hydra alternative"] -fn stage1_q2_summary_families_are_count_min_heap_and_hydra() { +#[ignore = "missing feature: Pass 1 has no Hydra alternative"] +fn stage1_q2_summary_families_are_heap_sketches_and_hydra() { let (_, logical, _) = pipeline(); let families: BTreeSet<_> = logical .iter() @@ -606,13 +658,17 @@ fn stage1_q2_summary_families_are_count_min_heap_and_hydra() { .collect(); assert_eq!( families, - BTreeSet::from([Q2Option::CountMinHeapPerJob, Q2Option::Hydra]) + BTreeSet::from([ + Q2Option::CountMinHeapPerJob, + Q2Option::CountSketchHeapPerJob, + Q2Option::Hydra + ]) ); } /// Sharing adds a variant and keeps the independent one, for every Q2 option. #[test] -#[ignore = "Pass 1 also offers CountSketchWithHeap for Q2, which sketch_options rejects as outside Example 1 (spec ambiguity 8); Pass 2 (identical-expression sharing) is not implemented, so there is no shared variant"] +#[ignore = "missing features: Pass 2 (identical-expression sharing) is not implemented, so there is no shared-input variant; Pass 1 has no Hydra alternative"] fn stage1_keeps_independent_and_shared_variants() { let (_, logical, _) = pipeline(); let found: Vec<_> = logical @@ -622,6 +678,7 @@ fn stage1_keeps_independent_and_shared_variants() { for option in [ Q2Option::Exact, Q2Option::CountMinHeapPerJob, + Q2Option::CountSketchHeapPerJob, Q2Option::Hydra, ] { assert!( @@ -666,12 +723,11 @@ fn stage1_candidates_are_valid_and_uniquely_named() { // ── Stage 2 ────────────────────────────────────────────────────────────── -/// No candidate is discarded before Stage 3: Stage 2 maps the 6 logical candidates one-to-one. +/// No candidate is discarded before Stage 3: Stage 2 maps the 24 logical candidates one-to-one. #[test] -#[ignore = "Stage 2 maps the 24 Pass 1 candidates one-to-one (from_logical is a bijection), but the spec expects 6"] fn stage2_keeps_every_logical_candidate() { let (_, logical, physical) = pipeline(); - assert_eq!(physical.len(), 6); + assert_eq!(physical.len(), 24); let sources: BTreeSet<_> = physical.iter().map(|p| p.from_logical.as_str()).collect(); let logical_ids: BTreeSet<_> = logical.iter().map(|c| c.id.as_str()).collect(); assert_eq!(sources, logical_ids); @@ -681,7 +737,6 @@ fn stage2_keeps_every_logical_candidate() { /// Stage 2 preserves each logical candidate's Q2 option and input sharing. #[test] -#[ignore = "Pass 1 also offers CountSketchWithHeap for Q2, which sketch_options rejects as outside Example 1 (spec ambiguity 8)"] fn stage2_preserves_logical_choices() { let (_, logical, physical) = pipeline(); for p in &physical { @@ -698,14 +753,13 @@ fn stage2_preserves_logical_choices() { /// Exact TopK is implemented as a sort followed by a limit. #[test] -#[ignore = "Pass 1 also offers CountSketchWithHeap for Q2, which sketch_options rejects as outside Example 1 (spec ambiguity 8); also 8, not 2, candidates have an exact Q2"] fn stage2_exact_topk_is_sort_then_limit() { let (_, _, physical) = pipeline(); let exact: Vec<_> = physical .iter() .filter(|p| classify(&p.dag, &p.query_roots).0 == Q2Option::Exact) .collect(); - assert_eq!(exact.len(), 2); + assert_eq!(exact.len(), 8); for p in exact { let sort_then_limit = p.dag.edges.iter().any(|e| { relational(payload(&p.dag, e.producer)).as_deref() == Some("sort") @@ -717,7 +771,6 @@ fn stage2_exact_topk_is_sort_then_limit() { /// A summary Q2 is a build node feeding a top-10 estimation node, with no merge. #[test] -#[ignore = "Pass 1 also offers CountSketchWithHeap for Q2, which sketch_options rejects as outside Example 1 (spec ambiguity 8)"] fn stage2_summary_topk_is_build_then_estimate() { let (_, _, physical) = pipeline(); for p in &physical { @@ -816,20 +869,59 @@ fn compile_in_runtime(p: &PhysicalCandidate) -> Result<(), String> { } /// Runtime capability check (added by the implementer, not part of the -/// spec): the physical planner compiles every Stage 2 candidate. +/// spec): the physical planner compiles every candidate Stage 3 finds valid, +/// and rejects the invalid ones (Count-Min over weights not proven +/// non-negative) for the same reason Stage 3 gives. #[test] -#[ignore = "runtime gaps: the physical planner rejects CMS+heap without a non-negative \ - weight contract (Stage 3 rejects these as invalid too), and rejects every \ - CountSketch+heap top-k because the IR's SummaryEstimate{TopK} output schema \ - (partition keys + one encoded top-k column) is not the keyed-evaluation shape \ - it builds (input columns + item + Float64 score); 8 of 24 compile, all exact-Q2"] -fn stage2_every_candidate_compiles_in_the_physical_planner() { +fn stage2_runtime_compiles_exactly_the_candidates_stage3_finds_valid() { + let (workload, _, physical) = pipeline(); + let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); + let invalid: BTreeMap<_, _> = selection + .rejected + .iter() + .filter(|r| !r.valid) + .map(|r| (r.id.as_str(), r.reason.as_str())) + .collect(); + assert_eq!(invalid.len(), 8, "the Count-Min + heap candidates"); + for p in &physical { + let compiled = compile_in_runtime(p); + match invalid.get(p.id.as_str()) { + None => compiled.unwrap(), + Some(reason) => { + assert!( + reason.contains("CmsWithHeap needs non-negative update weights"), + "{}: {reason}", + p.id + ); + let error = compiled.expect_err(&p.id); + assert!( + error.contains("CMS requires a nonnegative weight contract"), + "{error}" + ); + } + } + } +} + +/// A CountSketch+heap top-k readout compiles: the IR's derived readout schema +/// is the ranked-rows shape the runtime's keyed evaluation produces. +#[test] +fn stage2_count_sketch_heap_topk_compiles_in_the_physical_planner() { let (_, _, physical) = pipeline(); - let failures: Vec<_> = physical + let count_sketch: Vec<_> = physical .iter() - .filter_map(|p| compile_in_runtime(p).err()) + .filter(|p| { + p.dag.nodes.iter().any(|n| { + matches!(&n.payload, Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), .. + }) if *kind.algorithm() == SketchAlgorithm::CountSketchWithHeap) + }) + }) .collect(); - assert!(failures.is_empty(), "{failures:#?}"); + assert!(!count_sketch.is_empty()); + for p in count_sketch { + compile_in_runtime(p).unwrap(); + } } /// The plan Stage 3 selects compiles in the physical planner (added by the @@ -884,13 +976,23 @@ fn stage3_selects_cheapest_valid() { } } -/// Every node is charged exactly once, so a shared input is costed once for both queries. +/// Every node is charged exactly once, so a shared input is costed once for +/// both queries. Stage 3 prices valid candidates only (user decision). #[test] -#[ignore = "Stage 3 prices valid candidates only (user decision); the 8 CMS+heap candidates are rejected as invalid (weights not proven non-negative) and have no cost"] fn stage3_charges_each_node_once() { let (workload, _, physical) = pipeline(); let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); + let invalid: BTreeSet<_> = selection + .rejected + .iter() + .filter(|r| !r.valid) + .map(|r| r.id.as_str()) + .collect(); for p in &physical { + if invalid.contains(p.id.as_str()) { + assert!(!selection.costs.contains_key(&p.id), "{} is priced", p.id); + continue; + } let cost = &selection.costs[&p.id]; let nodes: BTreeSet<_> = p.dag.nodes.iter().map(|n| n.id).collect(); let charged: BTreeSet<_> = cost.per_node.keys().copied().collect(); @@ -908,29 +1010,39 @@ fn stage3_charges_each_node_once() { } } -/// Sharing the input never costs more than reading it separately. +/// Sharing the input never costs more than reading it separately, for the +/// same local choices. Count-Min + heap is invalid here and has no cost. #[test] -#[ignore = "Pass 1 also offers CountSketchWithHeap for Q2, which sketch_options rejects as outside Example 1 (spec ambiguity 8); there are no shared-input variants to compare"] +#[ignore = "missing features: Pass 2 (identical-expression sharing) is not implemented, so there are no shared-input variants to compare; Pass 1 has no Hydra alternative"] fn stage3_shared_input_is_not_costlier() { let (workload, _, physical) = pipeline(); let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); let by_combo: BTreeMap<_, _> = physical .iter() - .map(|p| { - ( - classify(&p.dag, &p.query_roots), - selection.costs[&p.id].total, - ) + .filter_map(|p| { + let cost = selection.costs.get(&p.id)?.total; + let shared = classify(&p.dag, &p.query_roots).1; + Some(((choices(&p.dag, &p.query_roots), shared), cost)) }) .collect(); for option in [ Q2Option::Exact, - Q2Option::CountMinHeapPerJob, + Q2Option::CountSketchHeapPerJob, Q2Option::Hydra, ] { assert!( - by_combo[&(option, true)] <= by_combo[&(option, false)], - "{option:?}" + by_combo.keys().any(|((_, o, _), _)| *o == option), + "{option:?} has no priced candidate" ); } + for ((choice, shared), cost) in &by_combo { + if *shared { + let separate = by_combo[&(choice.clone(), false)]; + assert!(*cost <= separate, "{choice:?}"); + } + } + assert!( + by_combo.keys().any(|(_, shared)| *shared), + "no shared variant" + ); } diff --git a/crates/types/src/ir/operator/asap.rs b/crates/types/src/ir/operator/asap.rs index f3ca39e4..b0b34242 100644 --- a/crates/types/src/ir/operator/asap.rs +++ b/crates/types/src/ir/operator/asap.rs @@ -258,7 +258,7 @@ impl ASAPOp { SketchStatistic::PointCount { .. } => ("count", DataType::Int64), SketchStatistic::FrequencyL2 => ("frequency_l2", DataType::Float64), SketchStatistic::FrequencyEntropy => ("frequency_entropy", DataType::Float64), - SketchStatistic::TopK { .. } => ("topk", DataType::Utf8), + SketchStatistic::TopK { .. } => return ranked_rows_schema(summary_input), }; if matches!( summary_input.asap(), @@ -617,6 +617,79 @@ impl ASAPOp { } } +/// A top-k readout returns the selected rows, as every executable top-k does +/// (exact Sort → Limit, `EvaluatePopulation`): the state's partition keys, the +/// ranked item's identity columns, and the item's estimated `value`. +fn ranked_rows_schema(state: &OperatorNode) -> Result { + use crate::ir::schema::state_type::{EntityIdentity, SummaryInputExpr}; + fn source(state: &OperatorNode) -> Option<(&Schema, &SummaryUpdate)> { + match state.asap()? { + ASAPOp::SummaryAgg { child, input, .. } => Some((&child.schema, input)), + ASAPOp::SummaryMerge { children } => source(children.first()?), + _ => None, + } + } + fn items( + item: &SummaryInputExpr, + source: &Schema, + fields: &mut Vec, + ) -> Result<(), SchemaDerivationError> { + match item { + SummaryInputExpr::Column(column) => { + let index = crate::ir::scalar::resolve_column_ref(column, source) + .map_err(|e| SchemaDerivationError::InvalidScalarSignature(e.to_string()))?; + fields.push(source.fields[index].clone()); + } + SummaryInputExpr::Tuple(parts) => { + for part in parts { + items(part, source, fields)?; + } + } + // A label set without its columns is read back as its encoded identity. + SummaryInputExpr::EntityIdentity(EntityIdentity::PromqlLabelSet { .. }) => { + fields.push(Field::plain( + crate::ir::schema::PROMQL_SERIES_IDENTITY, + DataType::Utf8, + false, + )) + } + SummaryInputExpr::Constant(_) => { + return Err(SchemaDerivationError::InvalidScalarSignature( + "top-k item identity cannot be a constant".into(), + )) + } + } + Ok(()) + } + let Some(( + source, + SummaryUpdate { + item: Some(item), .. + }, + )) = source(state) + else { + return Err(SchemaDerivationError::InvalidScalarSignature( + "top-k readout requires state keyed by an item identity".into(), + )); + }; + let mut fields: Vec<_> = state + .schema + .fields + .iter() + .filter(|f| f.is_plain()) + .cloned() + .collect(); + items(item, source, &mut fields)?; + let key = (0..fields.len()).collect(); + fields.push(Field::plain("value", DataType::Float64, false)); + Ok(Schema { + fields, + time_index: None, + unique_keys: vec![key], + closed: true, + }) +} + /// The plain value an exact accumulator finalizes to. fn finalized_data_type(kind: &crate::ir::schema::state_type::ExactKind) -> DataType { use crate::ir::schema::ExactKind; diff --git a/crates/types/tests/structure_contract.rs b/crates/types/tests/structure_contract.rs index 850df5ab..46f9a41a 100644 --- a/crates/types/tests/structure_contract.rs +++ b/crates/types/tests/structure_contract.rs @@ -212,3 +212,74 @@ fn shared_construction_derives_both_operator_categories() { })) .is_err()); } + +/// A top-k readout derives the selected rows (partition keys, item identity, +/// Float64 `value`), the shape every executable top-k returns. +#[test] +fn topk_readout_derives_selected_rows() { + use asap_types::ir::operator::Reduction; + use asap_types::ir::scalar::ColumnRef; + use asap_types::ir::schema::{ + FieldDataType, GroupingStrategy, SketchAlgorithm, SketchKind, SketchParams, + SketchStatistic, SummaryInputExpr, SummaryUpdate, WeightDomain, + }; + use asap_types::ir::{ASAPOp, Operator}; + let rows = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Scan { + source: Source::Table { + table_ref: "t".into(), + }, + predicates: vec![], + schema: Schema::new(vec![ + Field::plain("job", DataType::Utf8, false), + Field::plain("series", DataType::Utf8, false), + Field::plain("x", DataType::Float64, false), + ]), + })) + .unwrap(); + let state = Rc::new( + OperatorNode::new(Operator::ASAP(ASAPOp::SummaryAgg { + child: rows, + family: FieldDataType::Sketch( + SketchKind::new( + SketchAlgorithm::CountSketchWithHeap, + SketchParams::CountSketchWithHeap { + width: 64, + depth: 3, + heap_size: 10, + }, + ), + GroupingStrategy::default(), + ), + input: SummaryUpdate { + item: Some(SummaryInputExpr::Column(ColumnRef::Named("series".into()))), + weight: SummaryInputExpr::Column(ColumnRef::Named("x".into())), + weight_domain: WeightDomain::UnknownOrSigned, + }, + reduction: Reduction::by(vec![0]), + grouping: GroupingStrategy::default(), + filter: None, + })) + .unwrap(), + ); + let topk = OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: state, + query: SketchStatistic::TopK { k: 5 }, + })) + .unwrap(); + topk.validate_structure().unwrap(); + let fields: Vec<_> = topk + .schema + .fields + .iter() + .map(|f| (f.name.as_str(), f.plain_dtype().cloned())) + .collect(); + assert_eq!( + fields, + [ + ("job", Some(DataType::Utf8)), + ("series", Some(DataType::Utf8)), + ("value", Some(DataType::Float64)), + ] + ); + assert_eq!(topk.schema.unique_keys, vec![vec![0, 1]]); +} diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index 2ce1057a..6df7fa73 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -6,14 +6,16 @@ dimensions". Tests: `crates/integration-tests/tests/planner_layering_example1.rs Viewer fixture shape: `tools/dag-viewer/examples/planner-layering-example1.expected.json`. This spec was written by a test designer who did not implement the stages. +The candidate counts were later changed to follow the planner's output (user +decision, see [Candidate counts](#candidate-counts)). ## Scope | Stage | Doc (#509) | MVP (this spec) | |---|---|---| | 0. Frontends | 1 workload `LogicalDAG` | same | -| 1. Logical ASAP | Pass 1 (3) × identical-expression rule (2) × window-composition rule (3 × 3) = **54** | Pass 1 × identical-expression rule = **6**. Window composition is blocked on #511 (#518, #522). | -| 2. Physical ASAP | Materialization options per window form = **156** | Physical operator implementation only, no materialization = **6** (the doc's Raw/Raw plans) | +| 1. Logical ASAP | Pass 1 (3) × identical-expression rule (2) × window-composition rule (3 × 3) = **54** | Pass 1 only = **24**. The identical-expression rule (Pass 2) and window composition (blocked on #511: #518, #522) are not implemented. | +| 2. Physical ASAP | Materialization options per window form = **156** | Physical operator implementation only, no materialization = **24** (the doc's Raw/Raw plans) | | 3. Selection | 1 plan | 1 plan | Every MVP candidate is one of the doc's candidates: in Stage 1, the one with no @@ -43,45 +45,39 @@ Invariants: * No summary node (sketch `summary_agg`, `summary_estimate`, `summary_merge`). * Q1 and Q2 share no node. Sharing is a Stage 1 decision. -## Stage 1: 6 candidates +## Candidate counts -| Id | Q1 | Q2 | Input | -|---|---|---|---| -| L1 | exact | exact (per-series sum, top 10 per job) | separate | -| L2 | exact | exact | shared | -| L3 | exact | Count-Min + top-*k* heap, one per job | separate | -| L4 | exact | Count-Min + top-*k* heap, one per job | shared | -| L5 | exact | Hydra (CMS) over all (job, series) keys, heap per job | separate | -| L6 | exact | Hydra (CMS) | shared | +The original spec expected 6 Stage 1 candidates: 3 Q2 options (exact, +Count-Min + heap, Hydra) × separate or shared input. It did not account for +Pass 1's exact-accumulator options: each of Q1's `rate` and `sum` and Q2's +`sum_over_time` may stay a raw aggregate or become an exact accumulator +(`SummaryAgg` → `FinalizeExactAccumulator`). Pass 1 also offers +CountSketch + heap for Q2's top-k. The planner therefore yields +2 × 2 (Q1) × 3 × 2 (Q2) = **24** candidates. Hydra and shared-input variants +are not produced yet; their tests stay ignored, naming the missing feature. -"Shared" means Q1 and Q2 read one `http_requests_total[1m]` input node -(scan and range). Ids and order are illustrative; tests identify candidates by -structure. +## Stage 1: 24 candidates + +Every combination of Q1 `rate` {raw, Rate acc} × Q1 `sum` {raw, Sum acc} × Q2 +top-k {exact, Count-Min + heap, CountSketch + heap} × Q2 `sum_over_time` +{raw, Sum acc}. Each query reads its own `http_requests_total[1m]` input. Invariants: -* Exactly 6 candidates, one per (Q2 option, separate or shared). No duplicates. +* Exactly 24 candidates, one per combination. No duplicates. * Every candidate covers both queries. -* Q1 never reaches a summary node. -* The sketch families in Q2 are exactly {`CmsWithHeap` per subpopulation, - Hydra `HydraCms`}. Exact Q2 has no sketch. -* For each Q2 option, both the independent and the shared variant are present. +* Q1 never reaches a sketch node. * Only the scan and range nodes may be shared. No summary is shared. -* Nothing is pruned: Count-Min and Hydra are admitted by ε = 0.01, δ = 0.001, - and Q1's exact target admits only the exact option. - -## Stage 2: 6 candidates +* Pending (ignored tests): the sketch families in Q2 are {`CmsWithHeap`, + `CountSketchWithHeap`} per subpopulation and Hydra `HydraCms` (no Hydra + alternative yet), and for each Q2 option both an independent and a shared + variant are present (needs Pass 2). -| Id | From | Q2 physical operators | Timing | -|---|---|---|---| -| P1 | L1 | sort (partition by job) → limit 10 | all query time | -| P2 | L2 | sort → limit 10 | all query time | -| P3 | L3 | CMS + heap build → top-10 estimate | all query time | -| P4 | L4 | CMS + heap build → top-10 estimate | all query time | -| P5 | L5 | Hydra build → top-10 estimate per job | all query time | -| P6 | L6 | Hydra build → top-10 estimate per job | all query time | +## Stage 2: 24 candidates -Q1 is the same in all six: scan → range → per-series rate → sum by (job). +`Pn` implements `Ln`. Exact Q2 top-k becomes sort (partition by job) → limit +10; a sketch Q2 is a heap-sketch build → top-10 estimate, which returns the +selected rows (`job`, series identity, `value`). Everything runs at query time. Invariants: @@ -89,10 +85,50 @@ Invariants: bijection onto the Stage 1 ids, so no valid candidate is dropped before Stage 3. * Each physical candidate keeps its logical Q2 option and input sharing. -* Exact TopK is implemented as a sort followed by a limit. +* Exact TopK is implemented as a sort followed by a limit (8 candidates). * A summary Q2 is a sketch build node feeding an estimation node. There is no merge node, because the MVP has no window summaries. * No node runs at ingestion time, because the MVP has no materialization. +* The runtime's physical planner compiles every candidate Stage 3 finds + valid, and rejects the 8 Count-Min + heap candidates for the reason Stage 3 + gives: their update weights are not proven non-negative. + +### Candidates for manual review + +Generated from `tools/dag-viewer/examples/planner-layering-example1.json`. +"acc" is an exact accumulator; "raw" keeps the relational aggregate. + +| Id | Label | Q1 choice | Q2 choice | Stage 3 outcome | Reason | +|---|---|---|---|---|---| +| P1 (L1) | Q1 exact · Q2 exact | rate: raw; sum: raw | top-k: exact (sort → limit); sum_over_time: raw | valid, costlier | 86.401 vs 79.401 cpu ms | +| P2 (L2) | Q1 exact · Q2 exact (Sum acc) | rate: raw; sum: raw | top-k: exact (sort → limit); sum_over_time: Sum acc | valid, costlier | 83.401 vs 79.401 cpu ms | +| P3 (L3) | Q1 exact · Q2 CMS+heap | rate: raw; sum: raw | top-k: Count-Min + heap; sum_over_time: raw | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P4 (L4) | Q1 exact · Q2 CMS+heap (Sum acc) | rate: raw; sum: raw | top-k: Count-Min + heap; sum_over_time: Sum acc | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P5 (L5) | Q1 exact · Q2 CountSketch+heap | rate: raw; sum: raw | top-k: CountSketch + heap; sum_over_time: raw | valid, costlier | 198.401 vs 79.401 cpu ms | +| P6 (L6) | Q1 exact · Q2 CountSketch+heap (Sum acc) | rate: raw; sum: raw | top-k: CountSketch + heap; sum_over_time: Sum acc | valid, costlier | 195.401 vs 79.401 cpu ms | +| P7 (L7) | Q1 exact (Rate acc) · Q2 exact | rate: Rate acc; sum: raw | top-k: exact (sort → limit); sum_over_time: raw | valid, costlier | 83.401 vs 79.401 cpu ms | +| P8 (L8) | Q1 exact (Rate acc) · Q2 exact (Sum acc) | rate: Rate acc; sum: raw | top-k: exact (sort → limit); sum_over_time: Sum acc | valid, costlier | 80.401 vs 79.401 cpu ms | +| P9 (L9) | Q1 exact (Rate acc) · Q2 CMS+heap | rate: Rate acc; sum: raw | top-k: Count-Min + heap; sum_over_time: raw | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P10 (L10) | Q1 exact (Rate acc) · Q2 CMS+heap (Sum acc) | rate: Rate acc; sum: raw | top-k: Count-Min + heap; sum_over_time: Sum acc | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P11 (L11) | Q1 exact (Rate acc) · Q2 CountSketch+heap | rate: Rate acc; sum: raw | top-k: CountSketch + heap; sum_over_time: raw | valid, costlier | 195.401 vs 79.401 cpu ms | +| P12 (L12) | Q1 exact (Rate acc) · Q2 CountSketch+heap (Sum acc) | rate: Rate acc; sum: raw | top-k: CountSketch + heap; sum_over_time: Sum acc | valid, costlier | 192.401 vs 79.401 cpu ms | +| P13 (L13) | Q1 exact (Sum acc) · Q2 exact | rate: raw; sum: Sum acc | top-k: exact (sort → limit); sum_over_time: raw | valid, costlier | 85.401 vs 79.401 cpu ms | +| P14 (L14) | Q1 exact (Sum acc) · Q2 exact (Sum acc) | rate: raw; sum: Sum acc | top-k: exact (sort → limit); sum_over_time: Sum acc | valid, costlier | 82.401 vs 79.401 cpu ms | +| P15 (L15) | Q1 exact (Sum acc) · Q2 CMS+heap | rate: raw; sum: Sum acc | top-k: Count-Min + heap; sum_over_time: raw | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P16 (L16) | Q1 exact (Sum acc) · Q2 CMS+heap (Sum acc) | rate: raw; sum: Sum acc | top-k: Count-Min + heap; sum_over_time: Sum acc | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P17 (L17) | Q1 exact (Sum acc) · Q2 CountSketch+heap | rate: raw; sum: Sum acc | top-k: CountSketch + heap; sum_over_time: raw | valid, costlier | 197.401 vs 79.401 cpu ms | +| P18 (L18) | Q1 exact (Sum acc) · Q2 CountSketch+heap (Sum acc) | rate: raw; sum: Sum acc | top-k: CountSketch + heap; sum_over_time: Sum acc | valid, costlier | 194.401 vs 79.401 cpu ms | +| P19 (L19) | Q1 exact (Sum acc, Rate acc) · Q2 exact | rate: Rate acc; sum: Sum acc | top-k: exact (sort → limit); sum_over_time: raw | valid, costlier | 82.401 vs 79.401 cpu ms | +| P20 (L20) | Q1 exact (Sum acc, Rate acc) · Q2 exact (Sum acc) | rate: Rate acc; sum: Sum acc | top-k: exact (sort → limit); sum_over_time: Sum acc | **selected** | cheapest valid (79.401 cpu ms) | +| P21 (L21) | Q1 exact (Sum acc, Rate acc) · Q2 CMS+heap | rate: Rate acc; sum: Sum acc | top-k: Count-Min + heap; sum_over_time: raw | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P22 (L22) | Q1 exact (Sum acc, Rate acc) · Q2 CMS+heap (Sum acc) | rate: Rate acc; sum: Sum acc | top-k: Count-Min + heap; sum_over_time: Sum acc | invalid | q2: CmsWithHeap needs non-negative update weights, and these are not proven non-negative | +| P23 (L23) | Q1 exact (Sum acc, Rate acc) · Q2 CountSketch+heap | rate: Rate acc; sum: Sum acc | top-k: CountSketch + heap; sum_over_time: raw | valid, costlier | 194.401 vs 79.401 cpu ms | +| P24 (L24) | Q1 exact (Sum acc, Rate acc) · Q2 CountSketch+heap (Sum acc) | rate: Rate acc; sum: Sum acc | top-k: CountSketch + heap; sum_over_time: Sum acc | valid, costlier | 191.401 vs 79.401 cpu ms | + +Count-Min + heap stays invalid: Q2 ranks `sum_over_time` of raw samples, and +nothing in the workload declares `http_requests_total` non-negative (no metric +type), so neither existing proof (`UnitCount`, `ResetAwareCounterDerivative`) +applies. ## Stage 3: 1 plan @@ -101,17 +137,19 @@ Invariants: * One selected id. Every other candidate is listed once as rejected, with a reason, and marked invalid (accuracy, latency or capability) or valid but costlier. -* Each candidate's cost has one entry per DAG node, and `total` is their sum. - A shared node is therefore charged once, for all its consumers. +* Each priced candidate's cost has one entry per DAG node, and `total` is + their sum. A shared node is therefore charged once, for all its consumers. + Stage 3 prices valid candidates only. * The selected plan is no more expensive than any valid candidate. * For each Q2 option, the shared-input candidate costs no more than its - separate counterpart. + separate counterpart (pending: needs Pass 2). Expected outcome, not asserted because it depends on the cost and accuracy models: a shared-input candidate wins, matching the doc's "Raw with a shared -input" winner. An exact Q2 (P1, P2) may be rejected for missing the 100 ms -bound. The doc's other typical winners need window forms or materialization and -are out of MVP scope. +input" winner. Today, without shared inputs, P20 (all exact, with exact +accumulators for Q1's rate and sum and Q2's sum_over_time) is selected. The +doc's other typical winners need window forms or materialization and are out +of MVP scope. ## Ambiguities and MVP deviations @@ -141,10 +179,10 @@ are out of MVP scope. 7. **Hydra's inner sketch.** The doc says "Hydra over the whole `job` column" without naming the inner sketch. The spec assumes `HydraCms`, the construction with the proven guarantee for frequencies. -8. **Other top-*k* families.** The code also knows `CountSketchWithHeap`. The - doc lists only Count-Min + heap and Hydra, so a third family fails the - tests until the doc adds it. +8. **Other top-*k* families.** The code also offers `CountSketchWithHeap`. + The doc lists only Count-Min + heap and Hydra; the tests follow the + planner and include it. 9. **Latency.** The doc says an exact top 10 rebuilt at every refresh "may - miss" 100 ms. Whether P1 and P2 are rejected is left to the cost model. + miss" 100 ms. Whether exact-Q2 candidates are rejected is left to the cost model. 10. **Accuracy of Count-Min heap merges** matters only with tumbling windows, which are out of MVP scope. diff --git a/tools/dag-viewer/examples/planner-layering-example1.json b/tools/dag-viewer/examples/planner-layering-example1.json index ae9ba1d7..3cfef1e2 100644 --- a/tools/dag-viewer/examples/planner-layering-example1.json +++ b/tools/dag-viewer/examples/planner-layering-example1.json @@ -2334,7 +2334,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -2342,7 +2350,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -3024,7 +3033,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -3032,7 +3049,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -3654,7 +3672,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -3662,7 +3688,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -4344,7 +4371,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -4352,7 +4387,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -6331,7 +6367,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -6339,7 +6383,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -7082,7 +7127,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -7090,7 +7143,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -7773,7 +7827,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -7781,7 +7843,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -8524,7 +8587,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -8532,7 +8603,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -10472,7 +10544,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -10480,7 +10560,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -11210,7 +11291,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -11218,7 +11307,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -11888,7 +11978,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -11896,7 +11994,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -12626,7 +12725,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -12634,7 +12741,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -14757,7 +14865,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -14765,7 +14881,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -15556,7 +15673,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -15564,7 +15689,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -16295,7 +16421,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -16303,7 +16437,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -17094,7 +17229,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -17102,7 +17245,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -20197,7 +20341,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -20205,7 +20357,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -21320,7 +21473,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -21328,7 +21489,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -22327,7 +22489,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -22335,7 +22505,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -23450,7 +23621,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -23458,7 +23637,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -26910,7 +27090,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -26918,7 +27106,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -28150,7 +28339,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -28158,7 +28355,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -29274,7 +29472,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -29282,7 +29488,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -30514,7 +30721,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -30522,7 +30737,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -33899,7 +34115,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -33907,7 +34131,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -35114,7 +35339,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -35122,7 +35355,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -36213,7 +36447,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -36221,7 +36463,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -37428,7 +37671,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -37436,7 +37687,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -41164,7 +41416,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -41172,7 +41432,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -42496,7 +42757,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -42504,7 +42773,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -43712,7 +43982,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -43720,7 +43998,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] }, @@ -45044,7 +45323,15 @@ "dtype": { "Plain": "utf8" }, - "name": "topk", + "name": "$promql_series_identity", + "nullable": false, + "table": null + }, + { + "dtype": { + "Plain": "float64" + }, + "name": "value", "nullable": false, "table": null } @@ -45052,7 +45339,8 @@ "time_index": null, "unique_keys": [ [ - 0 + 0, + 1 ] ] },