From ad449882c22be88f4eb864538ef9811f7b4c496f Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:52:09 +0000 Subject: [PATCH 01/19] test: acceptance spec and pending tests for planner-layering Example 1 Define MVP acceptance for #509 Example 1 before the Phase C stage APIs exist: 1 -> 6 -> 6 -> 1 candidates (Pass 1 x identical-expression sharing; physical operator implementation only, no materialization). - Spec with per-stage candidate tables, invariants and doc ambiguities. - Integration tests against todo!() stage stubs, ignored until the stages land; workload and Stage 0 frontend-shape tests run now. - Expected-only asap-stage-pipeline/v1 fixture for the DAG viewer. Co-Authored-By: Claude Opus 5.5 --- .../tests/planner_layering_example1.rs | 671 +++++++ .../planner-layering-example1-acceptance.md | 150 ++ .../planner-layering-example1.expected.json | 1603 +++++++++++++++++ 3 files changed, 2424 insertions(+) create mode 100644 crates/integration-tests/tests/planner_layering_example1.rs create mode 100644 docs/design_docs/proposals/planner-layering-example1-acceptance.md create mode 100644 tools/dag-viewer/examples/planner-layering-example1.expected.json diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs new file mode 100644 index 000000000..ad2c1a0be --- /dev/null +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -0,0 +1,671 @@ +//! Acceptance tests for #509 "Example 1: Aggregation over dimensions", MVP scope. +//! +//! Spec: `docs/design_docs/proposals/planner-layering-example1-acceptance.md`. +//! Written by the test designer before the Phase C stage APIs exist: every +//! stage test is `#[ignore]`d and calls the `todo!()` stubs in [`stages`], +//! which the implementer replaces with the real entry points. +//! +//! 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. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use asap_aware_mapping::PlanningModels; +use asap_types::ir::export::{LogicalASAPDAG, LogicalASAPNodeId, LogicalASAPOperatorPayload}; +use asap_types::post_asap::sketch::{GroupingStrategy, HydraKind, SketchAlgorithm}; +use asap_types::pre_asap::schema::FieldDataType; +use asap_types::types::AccuracyTarget; +use asap_types::workload::{ + AccuracyRequirement, DataArrival, DataDistribution, DataWorkload, DurationMs, Evidence, + EvidenceSource, LatencyRequirement, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryTimeScope, QueryWorkload, Rate, RepeatedDemand, RepeatingEntry, + RepetitionInterval, TimeSelection, +}; + +/// Hypothetical Phase C stage API. Replace each `todo!()` with the real call. +#[allow(dead_code, unused_variables)] +mod stages { + use super::*; + + /// One whole-workload candidate. `LogicalASAPDAG::root` names one query, + /// so `query_roots` carries one root per workload entry, in + /// `QueryWorkload::entries()` order (`[q1, q2]`), until the export + /// supports several roots. + #[derive(Debug, Clone)] + pub struct LogicalCandidate { + pub id: String, + pub label: String, + pub dag: LogicalASAPDAG, + pub query_roots: Vec, + } + + /// Placeholder for `asap_types::ir::export::PhysicalASAPDAG` (phase A). + /// Its payload is an alias of the logical payload, so payload-kind checks + /// carry over unchanged. + pub type PhysicalASAPDAG = LogicalASAPDAG; + + /// One Stage 2 candidate, derived from exactly one Stage 1 candidate. + #[derive(Debug, Clone)] + pub struct PhysicalCandidate { + pub id: String, + pub from_logical: String, + pub label: String, + pub dag: PhysicalASAPDAG, + pub query_roots: Vec, + } + + /// Whole-workload cost of one physical candidate; `per_node` has one + /// entry per DAG node, so a shared node is charged once. + #[derive(Debug, Clone)] + pub struct CandidateCost { + pub total: f64, + pub per_node: BTreeMap, + } + + /// A candidate Stage 3 did not select. `valid == false` means it failed + /// an accuracy, latency or capability check; `true` means it lost on cost. + #[derive(Debug, Clone)] + pub struct Rejection { + pub id: String, + pub valid: bool, + pub reason: String, + } + + /// Stage 3 output. Costs are keyed by physical candidate id; the viewer + /// document attaches them to the Stage 2 entries. + #[derive(Debug, Clone)] + pub struct Selection { + pub selected: String, + pub costs: BTreeMap, + pub rejected: Vec, + } + + /// Stage 0: frontends lower every query into one summary-free workload DAG. + pub fn stage0_logical(workload: &PlanningWorkload) -> LogicalCandidate { + todo!("Phase C: Stage 0 workload LogicalDAG") + } + + /// Stage 1: Pass 1 local alternatives × Pass 2 identical-expression + /// sharing. Takes the workload for accuracy requirements and time selection. + pub fn stage1_logical_asap( + workload: &PlanningWorkload, + logical: &LogicalCandidate, + ) -> Vec { + todo!("Phase C: Stage 1 CandidateLogicalASAPDAGs") + } + + /// Stage 2: physical operator implementation of every logical candidate + /// (no materialization in the MVP). + pub fn stage2_physical( + workload: &PlanningWorkload, + logical: &[LogicalCandidate], + ) -> Vec { + todo!("Phase C: Stage 2 CandidatePhysicalASAPDAGs") + } + + /// Stage 3: reject invalid candidates, cost the rest for the whole + /// workload, select the cheapest. + pub fn stage3_select( + workload: &PlanningWorkload, + physical: &[PhysicalCandidate], + models: PlanningModels<'_>, + ) -> Selection { + todo!("Phase C: Stage 3 plan selection") + } + + /// Stands in for `node.output_state.timing == IngestionTime` until + /// `PhysicalASAPDAG` lands. + pub fn runs_at_ingestion(candidate: &PhysicalCandidate, node: LogicalASAPNodeId) -> bool { + todo!("Phase C: read PhysicalASAPDAGNode::output_state.timing") + } +} + +use stages::*; + +// ── Example 1 workload ─────────────────────────────────────────────────── + +const Q1: &str = "sum by (job) (rate(http_requests_total[1m]))"; +const Q2: &str = "topk by (job) (10, sum_over_time(http_requests_total[1m]))"; + +fn declared(value: T) -> Evidence { + Evidence { + value: Some(value), + source: EvidenceSource::Declared, + ..Default::default() + } +} + +fn dashboard_panel(query: &str, requirements: QueryRequirements) -> RepeatingEntry { + RepeatingEntry { + query: Query(query.into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval(10_000)), + requirements, + predictability: Predictability::Predictable { known_at: None }, + time_selection: TimeSelection { + scope: QueryTimeScope::RealTime, + lookback: Some(DurationMs(60_000)), + as_of: None, + }, + } +} + +/// Example 1 queries over the shared data workload of #509. +fn example1_workload() -> PlanningWorkload { + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: None, + repeating_queries: Some(vec![ + dashboard_panel( + Q1, + QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::Exact), + response_latency: LatencyRequirement::Unspecified, + }, + ), + dashboard_panel( + Q2, + QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.001, + }), + response_latency: LatencyRequirement::ExplicitMaxMs(100.0), + }, + ), + ]), + }, + data_workload: Some(DataWorkload { + arrival: DataArrival::ContinuouslyIngesting, + data_ingestion_interval: declared(DurationMs(15_000)), + ingestion_volume: Evidence::default(), + ingestion_rate: declared(Rate(1_000_000.0 / 15.0)), + input_cardinality: declared(1_000_000), + distribution: declared(DataDistribution::Zipf), + }), + } +} + +// ── DAG helpers ────────────────────────────────────────────────────────── + +/// Q2's local option, read off its summary build node. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Q2Option { + Exact, + CountMinHeapPerJob, + Hydra, +} + +/// Every node `root` depends on, including itself. +fn closure(dag: &LogicalASAPDAG, root: LogicalASAPNodeId) -> HashSet { + let mut seen = HashSet::from([root]); + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + for edge in dag.edges.iter().filter(|e| e.consumer == node) { + if seen.insert(edge.producer) { + stack.push(edge.producer); + } + } + } + seen +} + +fn payload(dag: &LogicalASAPDAG, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload { + &dag.nodes.iter().find(|n| n.id == id).expect("node").payload +} + +fn is_summary(payload: &LogicalASAPOperatorPayload) -> bool { + matches!( + payload, + LogicalASAPOperatorPayload::SummaryAgg { + family: FieldDataType::Sketch(..), + .. + } | LogicalASAPOperatorPayload::SummaryEstimate { .. } + | LogicalASAPOperatorPayload::SummaryMerge + ) +} + +/// Sketch families built in `nodes`, as Example 1's Q2 options. +fn sketch_options(dag: &LogicalASAPDAG, nodes: &HashSet) -> BTreeSet { + nodes + .iter() + .filter_map(|&id| match payload(dag, id) { + LogicalASAPOperatorPayload::SummaryAgg { + family: FieldDataType::Sketch(kind, grouping), + .. + } => Some(match grouping { + GroupingStrategy::SharedMultiSubpopulation { + kind: HydraKind::HydraCms, + .. + } => Q2Option::Hydra, + GroupingStrategy::PerSubpopulationInstance + if *kind.algorithm() == SketchAlgorithm::CmsWithHeap => + { + Q2Option::CountMinHeapPerJob + } + other => panic!("summary family outside Example 1: {kind:?} {other:?}"), + }), + _ => None, + }) + .collect() +} + +fn roots(query_roots: &[LogicalASAPNodeId]) -> (LogicalASAPNodeId, LogicalASAPNodeId) { + assert_eq!(query_roots.len(), 2, "every candidate covers Q1 and Q2"); + (query_roots[0], query_roots[1]) +} + +/// Q2's option and whether Q1 and Q2 share any node, for one candidate. +fn classify(dag: &LogicalASAPDAG, query_roots: &[LogicalASAPNodeId]) -> (Q2Option, bool) { + let (q1, q2) = roots(query_roots); + let (c1, c2) = (closure(dag, q1), closure(dag, q2)); + let options = sketch_options(dag, &c2); + assert!(options.len() <= 1, "Q2 uses one local option: {options:?}"); + let option = options.into_iter().next().unwrap_or(Q2Option::Exact); + (option, !c1.is_disjoint(&c2)) +} + +/// The relational operator's wire `kind` (`"scan"`, `"sort"`, …); the +/// operator enum itself is not public outside `asap-types`. +fn relational(payload: &LogicalASAPOperatorPayload) -> Option { + match payload { + LogicalASAPOperatorPayload::Relational { .. } => { + let json = serde_json::to_value(payload).expect("payload serializes"); + json["operator"]["kind"].as_str().map(str::to_owned) + } + _ => None, + } +} + +fn pipeline() -> ( + PlanningWorkload, + Vec, + Vec, +) { + let workload = example1_workload(); + let logical = stage1_logical_asap(&workload, &stage0_logical(&workload)); + let physical = stage2_physical(&workload, &logical); + (workload, logical, physical) +} + +/// The six Example 1 MVP combinations: three Q2 options × separate/shared input. +fn expected_combinations() -> BTreeSet<(Q2Option, bool)> { + [ + Q2Option::Exact, + Q2Option::CountMinHeapPerJob, + Q2Option::Hydra, + ] + .into_iter() + .flat_map(|o| [(o, false), (o, true)]) + .collect() +} + +// ── Workload ───────────────────────────────────────────────────────────── + +/// The encoded workload is valid and normalizes to Q1 then Q2. +#[test] +fn workload_encodes_example1() { + let workload = example1_workload(); + workload.validate().expect("valid workload"); + let entries: Vec<_> = workload.query_workload.entries().collect(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].query.0, Q1); + assert_eq!(entries[1].query.0, Q2); +} + +// ── Stage 0 ────────────────────────────────────────────────────────────── + +/// Today's PromQL frontend already lowers each query to the doc's Stage 0 chain. +#[test] +fn stage0_frontend_lowers_each_query_to_doc_chain() { + let roots = asap_frontend_promql::unified::lower_promql_query_workload(&example1_workload(), 0) + .expect("Example 1 lowers"); + let chains: Vec> = roots + .iter() + .map(|root| { + let dag = asap_types::ir::export::compile_logical_asap_query(root).expect("compiles"); + let json = serde_json::to_value(&dag).expect("serializes"); + let mut ops: Vec = json["nodes"] + .as_array() + .unwrap() + .iter() + .map(|n| { + let op = &n["payload"]["operator"]; + match op["measures"][0]["kind"].as_str() { + Some(measure) => format!("aggregate:{measure}"), + None => op["kind"].as_str().unwrap_or("?").to_owned(), + } + }) + .collect(); + ops.sort(); // node ids are assigned in post-order; compare as a set of operations + ops + }) + .collect(); + assert_eq!( + chains, + [ + ["aggregate:rate", "aggregate:sum", "scan", "time_range"], + ["aggregate:sum", "aggregate:top_k", "scan", "time_range"], + ] + ); +} + +/// Stage 0 yields one workload DAG with one root per query and no summaries. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage0_one_summary_free_workload_dag() { + let stage0 = stage0_logical(&example1_workload()); + stage0.dag.validate().expect("valid DAG"); + roots(&stage0.query_roots); + assert!(stage0.dag.nodes.iter().all(|n| !is_summary(&n.payload))); +} + +/// Stage 0 keeps Q1 and Q2 separate; sharing is a Stage 1 decision. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage0_queries_do_not_share_nodes() { + let stage0 = stage0_logical(&example1_workload()); + let (q1, q2) = roots(&stage0.query_roots); + assert!(closure(&stage0.dag, q1).is_disjoint(&closure(&stage0.dag, q2))); +} + +// ── Stage 1 ────────────────────────────────────────────────────────────── + +/// Stage 1 outputs exactly the 3 Q2 options × {separate, shared input} = 6 candidates. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage1_has_six_candidates_covering_every_combination() { + let (_, logical, _) = pipeline(); + assert_eq!(logical.len(), 6); + let found: BTreeSet<_> = logical + .iter() + .map(|c| classify(&c.dag, &c.query_roots)) + .collect(); + assert_eq!( + found, + expected_combinations(), + "each combination exactly once" + ); +} + +/// Q1 is exact in every Stage 1 candidate: no summary is reachable from its root. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage1_q1_is_always_exact() { + let (_, logical, _) = pipeline(); + for c in &logical { + let (q1, _) = roots(&c.query_roots); + let reach = closure(&c.dag, q1); + assert!( + reach.iter().all(|&id| !is_summary(payload(&c.dag, id))), + "{}: Q1 reaches a summary", + c.id + ); + } +} + +/// Q2's summary families are exactly Count-Min + heap per job and Hydra over all jobs. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage1_q2_summary_families_are_count_min_heap_and_hydra() { + let (_, logical, _) = pipeline(); + let families: BTreeSet<_> = logical + .iter() + .flat_map(|c| { + let (_, q2) = roots(&c.query_roots); + sketch_options(&c.dag, &closure(&c.dag, q2)) + }) + .collect(); + assert_eq!( + families, + BTreeSet::from([Q2Option::CountMinHeapPerJob, Q2Option::Hydra]) + ); +} + +/// Sharing adds a variant and keeps the independent one, for every Q2 option. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage1_keeps_independent_and_shared_variants() { + let (_, logical, _) = pipeline(); + let found: Vec<_> = logical + .iter() + .map(|c| classify(&c.dag, &c.query_roots)) + .collect(); + for option in [ + Q2Option::Exact, + Q2Option::CountMinHeapPerJob, + Q2Option::Hydra, + ] { + assert!( + found.contains(&(option, false)), + "{option:?} independent missing" + ); + assert!(found.contains(&(option, true)), "{option:?} shared missing"); + } +} + +/// Only the raw input is shared between Q1 and Q2; no summary is shared. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage1_shares_input_but_never_a_summary() { + let (_, logical, _) = pipeline(); + for c in &logical { + let (q1, q2) = roots(&c.query_roots); + let shared = &closure(&c.dag, q1) & &closure(&c.dag, q2); + for id in shared { + let p = payload(&c.dag, id); + assert!( + matches!(relational(p).as_deref(), Some("scan" | "time_range")), + "{}: shared node {id:?} is not the range selector input: {p:?}", + c.id + ); + } + } +} + +/// Stage 1 candidates are valid DAGs with unique ids. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage1_candidates_are_valid_and_uniquely_named() { + let (_, logical, _) = pipeline(); + let ids: BTreeSet<_> = logical.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids.len(), logical.len()); + for c in &logical { + c.dag.validate().unwrap_or_else(|e| panic!("{}: {e}", c.id)); + } +} + +// ── Stage 2 ────────────────────────────────────────────────────────────── + +/// No candidate is discarded before Stage 3: Stage 2 maps the 6 logical candidates one-to-one. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage2_keeps_every_logical_candidate() { + let (_, logical, physical) = pipeline(); + assert_eq!(physical.len(), 6); + 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); + let ids: BTreeSet<_> = physical.iter().map(|p| p.id.as_str()).collect(); + assert_eq!(ids.len(), physical.len()); +} + +/// Stage 2 preserves each logical candidate's Q2 option and input sharing. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage2_preserves_logical_choices() { + let (_, logical, physical) = pipeline(); + for p in &physical { + let source = logical.iter().find(|c| c.id == p.from_logical).unwrap(); + assert_eq!( + classify(&p.dag, &p.query_roots), + classify(&source.dag, &source.query_roots), + "{} vs {}", + p.id, + source.id + ); + } +} + +/// Exact TopK is implemented as a sort followed by a limit. +#[test] +#[ignore = "pending Phase C stage APIs"] +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); + for p in exact { + let sort_then_limit = p.dag.edges.iter().any(|e| { + relational(payload(&p.dag, e.producer)).as_deref() == Some("sort") + && relational(payload(&p.dag, e.consumer)).as_deref() == Some("limit") + }); + assert!(sort_then_limit, "{}: no sort → limit", p.id); + } +} + +/// A summary Q2 is a build node feeding a top-10 estimation node, with no merge. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage2_summary_topk_is_build_then_estimate() { + let (_, _, physical) = pipeline(); + for p in &physical { + if classify(&p.dag, &p.query_roots).0 == Q2Option::Exact { + continue; + } + let kinds: Vec<_> = p.dag.nodes.iter().map(|n| &n.payload).collect(); + assert!( + !kinds + .iter() + .any(|k| matches!(k, LogicalASAPOperatorPayload::SummaryMerge)), + "{}: no window summaries in the MVP, so no merge", + p.id + ); + let build_to_estimate = p.dag.edges.iter().any(|e| { + matches!( + payload(&p.dag, e.producer), + LogicalASAPOperatorPayload::SummaryAgg { + family: FieldDataType::Sketch(..), + .. + } + ) && matches!( + payload(&p.dag, e.consumer), + LogicalASAPOperatorPayload::SummaryEstimate { .. } + ) + }); + assert!(build_to_estimate, "{}: no build → estimate", p.id); + } +} + +/// With no materialization in the MVP, every node runs at query time. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage2_everything_runs_at_query_time() { + let (_, _, physical) = pipeline(); + for p in &physical { + for n in &p.dag.nodes { + assert!( + !runs_at_ingestion(p, n.id), + "{}: {:?} at ingestion", + p.id, + n.id + ); + } + } +} + +// ── Stage 3 ────────────────────────────────────────────────────────────── + +/// Stage 3 selects one candidate and gives every other one a reason. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage3_selects_one_and_explains_the_rest() { + let (workload, _, physical) = pipeline(); + let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); + let all: BTreeSet<_> = physical.iter().map(|p| p.id.clone()).collect(); + let mut accounted: BTreeSet<_> = selection.rejected.iter().map(|r| r.id.clone()).collect(); + assert_eq!( + accounted.len(), + selection.rejected.len(), + "rejected once each" + ); + assert!(selection.rejected.iter().all(|r| !r.reason.is_empty())); + assert!( + accounted.insert(selection.selected.clone()), + "selected is not rejected" + ); + assert_eq!(accounted, all); +} + +/// The selected plan is the cheapest valid candidate for the whole workload. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage3_selects_cheapest_valid() { + 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(); + let best = selection.costs[&selection.selected].total; + for p in physical.iter().filter(|p| !invalid.contains(p.id.as_str())) { + assert!(best <= selection.costs[&p.id].total, "{} is cheaper", p.id); + } +} + +/// Every node is charged exactly once, so a shared input is costed once for both queries. +#[test] +#[ignore = "pending Phase C stage APIs"] +fn stage3_charges_each_node_once() { + let (workload, _, physical) = pipeline(); + let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); + for p in &physical { + 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(); + assert_eq!( + charged, nodes, + "{}: per-node costs cover each node once", + p.id + ); + let sum: f64 = cost.per_node.values().sum(); + assert!( + (cost.total - sum).abs() <= 1e-9 * sum.abs().max(1.0), + "{}", + p.id + ); + } +} + +/// Sharing the input never costs more than reading it separately. +#[test] +#[ignore = "pending Phase C stage APIs"] +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, + ) + }) + .collect(); + for option in [ + Q2Option::Exact, + Q2Option::CountMinHeapPerJob, + Q2Option::Hydra, + ] { + assert!( + by_combo[&(option, true)] <= by_combo[&(option, false)], + "{option:?}" + ); + } +} diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md new file mode 100644 index 000000000..2ce1057ad --- /dev/null +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -0,0 +1,150 @@ +# Planner layering, Example 1: acceptance spec (MVP) + +Audience: planner designers and the Phase C implementer. +Source: [planner-layering.md](planner-layering.md), "Example 1: Aggregation over +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. + +## 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) | +| 3. Selection | 1 plan | 1 plan | + +Every MVP candidate is one of the doc's candidates: in Stage 1, the one with no +window summary for either query; in Stage 2, the one where both queries are +**Raw** (state rebuilt from the last 1 min of raw samples at every refresh). + +## Workload + +Shared data workload: `continuously_ingesting`, 15 s ingestion interval, +1,000,000 series, about 66,667 samples/s, `zipf`, volume unknown. + +| Query | Repeats | `lookback` | `as_of` | Accuracy | Latency | +|---|---|---|---|---|---| +| Q1 `sum by (job) (rate(http_requests_total[1m]))` | 10 s | 1 m | evaluation time | exact | none | +| Q2 `topk by (job) (10, sum_over_time(http_requests_total[1m]))` | 10 s | 1 m | evaluation time | ε = 0.01, δ = 0.001 | ≤ 100 ms | + +## Stage 0: 1 candidate + +| Query | Chain | +|---|---| +| Q1 | scan `http_requests_total` → range 1m → rate (per series) → sum by (job) | +| Q2 | scan `http_requests_total` → range 1m → sum_over_time (per series) → topk by (job) (10) | + +Invariants: + +* Exactly one candidate, with one root per query, in workload entry order. +* 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 + +| 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 | + +"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. + +Invariants: + +* Exactly 6 candidates, one per (Q2 option, separate or shared). 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. +* 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 + +| 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 | + +Q1 is the same in all six: scan → range → per-series rate → sum by (job). + +Invariants: + +* Exactly one physical candidate per logical candidate. `from_logical` is a + 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. +* 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. + +## Stage 3: 1 plan + +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. +* 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. + +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. + +## Ambiguities and MVP deviations + +1. **Stage 2 is materialization-only in the doc.** Example 1 lists only + window-form or materialization options. The MVP reads "physical operator + implementation" from the Stage 2 section: "TopK as a sort followed by a + limit". This gives one physical candidate per logical candidate. The doc + names no alternative implementation (for example hash versus sort + aggregation) for this example. +2. **Where exact TopK becomes sort + limit.** The Pass 1 diagram already + draws exact Q2 as "sort + limit 10 per job", but the Stage 2 section calls + this a physical choice. Today the frontend emits `aggregate[top_k]`. The + spec requires sort → limit only by Stage 2. +3. **Minimal Pass 2.** Only the identical-expression rule applies. The + window-composition rule adds 48 of the doc's 54 candidates, and its + tumbling and sliding variants wait on #511. +4. **"Shared summary charged once"** does not apply literally: the doc says no + summary is shared in Example 1. The tests check the general form (each node + charged once) on the shared input node. +5. **Workload DAG with two roots.** `LogicalASAPDAG` has one `root`, but + Example 1 needs one DAG for both queries. The stubs carry `query_roots` + until the export supports several roots. +6. **Where cost is computed.** The viewer contract puts `cost` on Stage 2 + candidates, but the doc says only Stage 3 uses the cost model. The spec has + Stage 3 produce costs, and the viewer document attaches them to Stage 2 + entries. +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. +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. +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.expected.json b/tools/dag-viewer/examples/planner-layering-example1.expected.json new file mode 100644 index 000000000..c655fc33a --- /dev/null +++ b/tools/dag-viewer/examples/planner-layering-example1.expected.json @@ -0,0 +1,1603 @@ +{ + "format": "asap-stage-pipeline/v1", + "expected_only": true, + "_notes": [ + "Shape reference for planner-layering Example 1 (MVP): see docs/design_docs/proposals/planner-layering-example1-acceptance.md.", + "Nodes carry only payload kinds and a label, not full schemas. Real documents have the full LogicalASAPDAG/PhysicalASAPDAG nodes.", + "query_roots stands in for root: one workload DAG serves both queries, and LogicalASAPDAG has a single root.", + "Costs are null: the cost model decides them. The selection is illustrative (the doc's 'Raw with a shared input' winner); tests only require the cheapest valid candidate." + ], + "workload": { + "queries": [ + { + "id": "q1", + "language": "promql", + "text": "sum by (job) (rate(http_requests_total[1m]))", + "repeat_every_s": 10, + "lookback_s": 60, + "accuracy": "exact" + }, + { + "id": "q2", + "language": "promql", + "text": "topk by (job) (10, sum_over_time(http_requests_total[1m]))", + "repeat_every_s": 10, + "lookback_s": 60, + "accuracy": { + "epsilon": 0.01, + "delta": 0.001 + }, + "max_latency_ms": 100 + } + ] + }, + "stage0_logical": { + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)" + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)" + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 6, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum_over_time (per series)" + }, + { + "id": 7, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "topk by (job) (10)" + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + }, + { + "producer": 6, + "consumer": 7 + } + ], + "query_roots": { + "q1": 3, + "q2": 7 + } + } + }, + "stage1_logical_asap": { + "candidates": [ + { + "id": "L1", + "label": "Q1 exact · Q2 exact · separate input", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)" + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)" + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 6, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum_over_time (per series)" + }, + { + "id": 7, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "topk by (job) (10)" + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + }, + { + "producer": 6, + "consumer": 7 + } + ], + "query_roots": { + "q1": 3, + "q2": 7 + } + } + }, + { + "id": "L2", + "label": "Q1 exact · Q2 exact · shared input", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)" + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)" + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum_over_time (per series)" + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "topk by (job) (10)" + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 1, + "consumer": 4 + }, + { + "producer": 4, + "consumer": 5 + } + ], + "query_roots": { + "q1": 3, + "q2": 5 + } + } + }, + { + "id": "L3", + "label": "Q1 exact · Q2 Count-Min + heap · separate input", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)" + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)" + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 6, + "payload": { + "kind": "summary_agg" + }, + "label": "Count-Min + top-k heap, one per job" + }, + { + "id": 7, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job" + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + }, + { + "producer": 6, + "consumer": 7 + } + ], + "query_roots": { + "q1": 3, + "q2": 7 + } + } + }, + { + "id": "L4", + "label": "Q1 exact · Q2 Count-Min + heap · shared input", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)" + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)" + }, + { + "id": 4, + "payload": { + "kind": "summary_agg" + }, + "label": "Count-Min + top-k heap, one per job" + }, + { + "id": 5, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job" + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 1, + "consumer": 4 + }, + { + "producer": 4, + "consumer": 5 + } + ], + "query_roots": { + "q1": 3, + "q2": 5 + } + } + }, + { + "id": "L5", + "label": "Q1 exact · Q2 Hydra · separate input", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)" + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)" + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 6, + "payload": { + "kind": "summary_agg" + }, + "label": "Hydra (CMS) over all (job, series) keys + heap per job" + }, + { + "id": 7, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job" + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + }, + { + "producer": 6, + "consumer": 7 + } + ], + "query_roots": { + "q1": 3, + "q2": 7 + } + } + }, + { + "id": "L6", + "label": "Q1 exact · Q2 Hydra · shared input", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total" + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m" + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)" + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)" + }, + { + "id": 4, + "payload": { + "kind": "summary_agg" + }, + "label": "Hydra (CMS) over all (job, series) keys + heap per job" + }, + { + "id": 5, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job" + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 1, + "consumer": 4 + }, + { + "producer": 4, + "consumer": 5 + } + ], + "query_roots": { + "q1": 3, + "q2": 5 + } + } + } + ] + }, + "stage2_physical_asap": { + "candidates": [ + { + "id": "P1", + "from_logical": "L1", + "label": "Q1 exact · Q2 exact · separate input · sort + limit · query time", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 6, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum_over_time (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 7, + "payload": { + "kind": "relational", + "operator": { + "kind": "sort" + } + }, + "label": "sort by value, partition by job", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 8, + "payload": { + "kind": "relational", + "operator": { + "kind": "limit" + } + }, + "label": "limit 10", + "output_state": { + "timing": "QueryTime" + } + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + }, + { + "producer": 6, + "consumer": 7 + }, + { + "producer": 7, + "consumer": 8 + } + ], + "query_roots": { + "q1": 3, + "q2": 8 + } + }, + "cost": { + "total": null, + "unit": "cpu_ms_per_s", + "per_node": {} + } + }, + { + "id": "P2", + "from_logical": "L2", + "label": "Q1 exact · Q2 exact · shared input · sort + limit · query time", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum_over_time (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "sort" + } + }, + "label": "sort by value, partition by job", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 6, + "payload": { + "kind": "relational", + "operator": { + "kind": "limit" + } + }, + "label": "limit 10", + "output_state": { + "timing": "QueryTime" + } + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 1, + "consumer": 4 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + } + ], + "query_roots": { + "q1": 3, + "q2": 6 + } + }, + "cost": { + "total": null, + "unit": "cpu_ms_per_s", + "per_node": {} + } + }, + { + "id": "P3", + "from_logical": "L3", + "label": "Q1 exact · Q2 Count-Min + heap · separate input · build + estimate · query time", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 6, + "payload": { + "kind": "summary_agg" + }, + "label": "Count-Min + top-k heap, one per job", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 7, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job", + "output_state": { + "timing": "QueryTime" + } + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + }, + { + "producer": 6, + "consumer": 7 + } + ], + "query_roots": { + "q1": 3, + "q2": 7 + } + }, + "cost": { + "total": null, + "unit": "cpu_ms_per_s", + "per_node": {} + } + }, + { + "id": "P4", + "from_logical": "L4", + "label": "Q1 exact · Q2 Count-Min + heap · shared input · build + estimate · query time", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 4, + "payload": { + "kind": "summary_agg" + }, + "label": "Count-Min + top-k heap, one per job", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 5, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job", + "output_state": { + "timing": "QueryTime" + } + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 1, + "consumer": 4 + }, + { + "producer": 4, + "consumer": 5 + } + ], + "query_roots": { + "q1": 3, + "q2": 5 + } + }, + "cost": { + "total": null, + "unit": "cpu_ms_per_s", + "per_node": {} + } + }, + { + "id": "P5", + "from_logical": "L5", + "label": "Q1 exact · Q2 Hydra · separate input · build + estimate · query time", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 4, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 5, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 6, + "payload": { + "kind": "summary_agg" + }, + "label": "Hydra (CMS) over all (job, series) keys + heap per job", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 7, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job", + "output_state": { + "timing": "QueryTime" + } + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 4, + "consumer": 5 + }, + { + "producer": 5, + "consumer": 6 + }, + { + "producer": 6, + "consumer": 7 + } + ], + "query_roots": { + "q1": 3, + "q2": 7 + } + }, + "cost": { + "total": null, + "unit": "cpu_ms_per_s", + "per_node": {} + } + }, + { + "id": "P6", + "from_logical": "L6", + "label": "Q1 exact · Q2 Hydra · shared input · build + estimate · query time", + "dag": { + "nodes": [ + { + "id": 0, + "payload": { + "kind": "relational", + "operator": { + "kind": "scan" + } + }, + "label": "http_requests_total", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 1, + "payload": { + "kind": "relational", + "operator": { + "kind": "time_range" + } + }, + "label": "range 1m", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 2, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "rate (per series)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 3, + "payload": { + "kind": "relational", + "operator": { + "kind": "aggregate" + } + }, + "label": "sum by (job)", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 4, + "payload": { + "kind": "summary_agg" + }, + "label": "Hydra (CMS) over all (job, series) keys + heap per job", + "output_state": { + "timing": "QueryTime" + } + }, + { + "id": 5, + "payload": { + "kind": "summary_estimate" + }, + "label": "top 10 per job", + "output_state": { + "timing": "QueryTime" + } + } + ], + "edges": [ + { + "producer": 0, + "consumer": 1 + }, + { + "producer": 1, + "consumer": 2 + }, + { + "producer": 2, + "consumer": 3 + }, + { + "producer": 1, + "consumer": 4 + }, + { + "producer": 4, + "consumer": 5 + } + ], + "query_roots": { + "q1": 3, + "q2": 5 + } + }, + "cost": { + "total": null, + "unit": "cpu_ms_per_s", + "per_node": {} + } + } + ] + }, + "stage3_selection": { + "selected": "P6", + "rejected": [ + { + "id": "P1", + "reason": "valid; costlier than P6 (illustrative)" + }, + { + "id": "P2", + "reason": "valid; costlier than P6 (illustrative)" + }, + { + "id": "P3", + "reason": "valid; costlier than P6 (illustrative)" + }, + { + "id": "P4", + "reason": "valid; costlier than P6 (illustrative)" + }, + { + "id": "P5", + "reason": "valid; costlier than P6 (illustrative)" + } + ] + } +} From ba5e3ff5029a54087cfb7aa89a44a940b014929f Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:00:36 +0000 Subject: [PATCH 02/19] test: run the Example 1 acceptance tests against the real stages Replace the todo!() stubs with adapters over Stage 0-3, un-ignore the 11 tests that pass, and give each still-ignored test the precise difference between the implementation and the spec. Add runtime capability checks: the selected plan compiles in the physical planner; compiling every candidate stays ignored with the two runtime gaps it finds. Co-Authored-By: Claude Opus 5.5 --- .../tests/planner_layering_example1.rs | 361 +++++++++++++++--- 1 file changed, 308 insertions(+), 53 deletions(-) diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index ad2c1a0be..e497caa77 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -1,9 +1,10 @@ //! Acceptance tests for #509 "Example 1: Aggregation over dimensions", MVP scope. //! //! Spec: `docs/design_docs/proposals/planner-layering-example1-acceptance.md`. -//! Written by the test designer before the Phase C stage APIs exist: every -//! stage test is `#[ignore]`d and calls the `todo!()` stubs in [`stages`], -//! which the implementer replaces with the real entry points. +//! Written by the test designer before the Phase C stage APIs existed; the +//! implementer replaced the stubs in [`stages`] with adapters over the real +//! stages. Tests that still fail because the implementation differs from the +//! spec stay `#[ignore]`d with the difference as the reason. //! //! MVP scope: Stage 1 = Pass 1 + the identical-expression rule only (no //! window-composition variants); Stage 2 = physical operator implementation @@ -24,27 +25,32 @@ use asap_types::workload::{ RepetitionInterval, TimeSelection, }; -/// Hypothetical Phase C stage API. Replace each `todo!()` with the real call. -#[allow(dead_code, unused_variables)] +/// Adapters from the Phase C stage APIs to the shapes these tests were written +/// against. They only convert; every decision is the real stage's. +#[allow(dead_code)] mod stages { - use super::*; + use std::rc::Rc; - /// One whole-workload candidate. `LogicalASAPDAG::root` names one query, - /// so `query_roots` carries one root per workload entry, in - /// `QueryWorkload::entries()` order (`[q1, q2]`), until the export - /// supports several roots. + use super::*; + use asap_aware_mapping::logical_candidates::{ + compose_logical_candidate, enumerate_local_logical_candidates, + }; + use asap_types::ir::export::{compile_logical_asap_workload, LogicalASAPQueryRoot}; + use asap_types::ir::{OperatorNode, QueryRoot}; + + /// One whole-workload candidate. `query_roots` holds one root per + /// workload entry, in `QueryWorkload::entries()` order (`[q1, q2]`). + /// `roots` are the in-memory roots the next stage consumes. #[derive(Debug, Clone)] pub struct LogicalCandidate { pub id: String, pub label: String, pub dag: LogicalASAPDAG, pub query_roots: Vec, + pub roots: Vec>, } - /// Placeholder for `asap_types::ir::export::PhysicalASAPDAG` (phase A). - /// Its payload is an alias of the logical payload, so payload-kind checks - /// carry over unchanged. - pub type PhysicalASAPDAG = LogicalASAPDAG; + pub type PhysicalASAPDAG = asap_types::ir::export::PhysicalASAPDAG; /// One Stage 2 candidate, derived from exactly one Stage 1 candidate. #[derive(Debug, Clone)] @@ -54,6 +60,7 @@ mod stages { pub label: String, pub dag: PhysicalASAPDAG, pub query_roots: Vec, + pub stage2: asap_aware_mapping::physical_candidates::PhysicalCandidate, } /// Whole-workload cost of one physical candidate; `per_node` has one @@ -73,8 +80,7 @@ mod stages { pub reason: String, } - /// Stage 3 output. Costs are keyed by physical candidate id; the viewer - /// document attaches them to the Stage 2 entries. + /// Stage 3 output. Costs are keyed by physical candidate id. #[derive(Debug, Clone)] pub struct Selection { pub selected: String, @@ -82,27 +88,115 @@ mod stages { pub rejected: Vec, } + /// The frontend DAG with each PromQL series' full identity as a column, + /// the row representation per-series state needs at runtime. + fn lower(workload: &PlanningWorkload) -> Vec { + asap_frontend_promql::lower_promql_query_workload(workload, 0) + .expect("Example 1 lowers") + .into_iter() + .map(|root| match root { + QueryRoot::Operator(node) => QueryRoot::Operator( + asap_types::ir::schema_support::with_promql_series_identity(&node) + .expect("series identity"), + ), + QueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), + }) + .collect() + } + + fn candidate(id: String, label: String, roots: Vec) -> LogicalCandidate { + let dag = compile_logical_asap_workload(&roots).expect("logical export"); + let query_roots = dag + .roots + .iter() + .map(|root| match root { + LogicalASAPQueryRoot::Operator(id) => *id, + LogicalASAPQueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), + }) + .collect(); + let roots = roots + .into_iter() + .map(|root| match root { + QueryRoot::Operator(node) => node, + QueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), + }) + .collect(); + LogicalCandidate { + id, + label, + dag, + query_roots, + roots, + } + } + /// Stage 0: frontends lower every query into one summary-free workload DAG. pub fn stage0_logical(workload: &PlanningWorkload) -> LogicalCandidate { - todo!("Phase C: Stage 0 workload LogicalDAG") + candidate("S0".into(), "frontend".into(), lower(workload)) } - /// Stage 1: Pass 1 local alternatives × Pass 2 identical-expression - /// sharing. Takes the workload for accuracy requirements and time selection. + /// Stage 1: every combination of Pass 1 local alternatives (Pass 2 is + /// not implemented). Lowers `workload` again: Pass 1 reads the in-memory + /// DAG, not the Stage 0 export. pub fn stage1_logical_asap( workload: &PlanningWorkload, - logical: &LogicalCandidate, + _logical: &LogicalCandidate, ) -> Vec { - todo!("Phase C: Stage 1 CandidateLogicalASAPDAGs") + let inventory = + enumerate_local_logical_candidates(lower(workload).into_iter().enumerate().collect()) + .expect("Pass 1"); + let mut choices = vec![vec![]]; + for target in &inventory.targets { + choices = choices + .into_iter() + .flat_map(|prefix: Vec| { + (0..target.alternatives.len()).map(move |i| { + let mut choice = prefix.clone(); + choice.push(i); + choice + }) + }) + .collect(); + } + choices + .into_iter() + .enumerate() + .map(|(index, choice)| { + let roots = compose_logical_candidate(&inventory, &choice) + .expect("composes") + .into_iter() + .map(|(_, root)| root) + .collect(); + candidate(format!("L{}", index + 1), format!("{choice:?}"), roots) + }) + .collect() } /// Stage 2: physical operator implementation of every logical candidate /// (no materialization in the MVP). pub fn stage2_physical( - workload: &PlanningWorkload, + _workload: &PlanningWorkload, logical: &[LogicalCandidate], ) -> Vec { - todo!("Phase C: Stage 2 CandidatePhysicalASAPDAGs") + logical + .iter() + .enumerate() + .map(|(index, l)| { + let mut stage2 = + asap_aware_mapping::physical_candidates::stage2_physical(&l.id, &l.roots) + .unwrap_or_else(|e| panic!("{}: {e}", l.id)); + stage2.id = format!("P{}", index + 1); + stage2.label = l.label.clone(); + PhysicalCandidate { + id: stage2.id.clone(), + from_logical: stage2.from_logical.clone(), + label: stage2.label.clone(), + dag: stage2.dag.clone(), + query_roots: stage2.dag.roots.clone(), + stage2, + } + }) + .collect() } /// Stage 3: reject invalid candidates, cost the rest for the whole @@ -112,13 +206,61 @@ mod stages { physical: &[PhysicalCandidate], models: PlanningModels<'_>, ) -> Selection { - todo!("Phase C: Stage 3 plan selection") + let targets: Vec<_> = workload + .query_workload + .entries() + .map(|entry| Some(entry.requirements.accuracy.target())) + .collect(); + let candidates: Vec<_> = physical.iter().map(|p| p.stage2.clone()).collect(); + let selection = asap_aware_mapping::plan_selection::stage3_select( + &candidates, + &targets, + workload.data_workload.as_ref().expect("data workload"), + models, + ) + .expect("Stage 3 selects"); + Selection { + selected: selection.selected, + costs: selection + .costs + .into_iter() + .map(|(id, cost)| { + let per_node = cost + .per_node + .into_iter() + .map(|(node, c)| (node, c.cost)) + .collect(); + ( + id, + CandidateCost { + total: cost.total, + per_node, + }, + ) + }) + .collect(), + rejected: selection + .rejected + .into_iter() + .map(|r| Rejection { + id: r.id, + valid: r.valid, + reason: r.reason, + }) + .collect(), + } } - /// Stands in for `node.output_state.timing == IngestionTime` until - /// `PhysicalASAPDAG` lands. pub fn runs_at_ingestion(candidate: &PhysicalCandidate, node: LogicalASAPNodeId) -> bool { - todo!("Phase C: read PhysicalASAPDAGNode::output_state.timing") + candidate + .dag + .nodes + .iter() + .find(|n| n.id == node) + .expect("node") + .output_state + .timing + == asap_types::post_asap::ExecutionTiming::IngestionTime } } @@ -198,22 +340,59 @@ enum Q2Option { Hydra, } +/// The logical and physical exports share node ids, payloads and edge +/// endpoints; the helpers below read only those. +trait ExportedDag { + fn producers(&self, consumer: LogicalASAPNodeId) -> Vec; + fn node_payload(&self, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload; +} + +impl ExportedDag for LogicalASAPDAG { + fn producers(&self, consumer: LogicalASAPNodeId) -> Vec { + let edges = self.edges.iter().filter(|e| e.consumer == consumer); + edges.map(|e| e.producer).collect() + } + fn node_payload(&self, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload { + &self + .nodes + .iter() + .find(|n| n.id == id) + .expect("node") + .payload + } +} + +impl ExportedDag for PhysicalASAPDAG { + fn producers(&self, consumer: LogicalASAPNodeId) -> Vec { + let edges = self.edges.iter().filter(|e| e.consumer == consumer); + edges.map(|e| e.producer).collect() + } + fn node_payload(&self, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload { + &self + .nodes + .iter() + .find(|n| n.id == id) + .expect("node") + .payload + } +} + /// Every node `root` depends on, including itself. -fn closure(dag: &LogicalASAPDAG, root: LogicalASAPNodeId) -> HashSet { +fn closure(dag: &impl ExportedDag, root: LogicalASAPNodeId) -> HashSet { let mut seen = HashSet::from([root]); let mut stack = vec![root]; while let Some(node) = stack.pop() { - for edge in dag.edges.iter().filter(|e| e.consumer == node) { - if seen.insert(edge.producer) { - stack.push(edge.producer); + for producer in dag.producers(node) { + if seen.insert(producer) { + stack.push(producer); } } } seen } -fn payload(dag: &LogicalASAPDAG, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload { - &dag.nodes.iter().find(|n| n.id == id).expect("node").payload +fn payload(dag: &impl ExportedDag, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload { + dag.node_payload(id) } fn is_summary(payload: &LogicalASAPOperatorPayload) -> bool { @@ -228,7 +407,10 @@ fn is_summary(payload: &LogicalASAPOperatorPayload) -> bool { } /// Sketch families built in `nodes`, as Example 1's Q2 options. -fn sketch_options(dag: &LogicalASAPDAG, nodes: &HashSet) -> BTreeSet { +fn sketch_options( + dag: &impl ExportedDag, + nodes: &HashSet, +) -> BTreeSet { nodes .iter() .filter_map(|&id| match payload(dag, id) { @@ -258,7 +440,7 @@ fn roots(query_roots: &[LogicalASAPNodeId]) -> (LogicalASAPNodeId, LogicalASAPNo } /// Q2's option and whether Q1 and Q2 share any node, for one candidate. -fn classify(dag: &LogicalASAPDAG, query_roots: &[LogicalASAPNodeId]) -> (Q2Option, bool) { +fn classify(dag: &impl ExportedDag, query_roots: &[LogicalASAPNodeId]) -> (Q2Option, bool) { let (q1, q2) = roots(query_roots); let (c1, c2) = (closure(dag, q1), closure(dag, q2)); let options = sketch_options(dag, &c2); @@ -320,7 +502,7 @@ fn workload_encodes_example1() { /// Today's PromQL frontend already lowers each query to the doc's Stage 0 chain. #[test] fn stage0_frontend_lowers_each_query_to_doc_chain() { - let roots = asap_frontend_promql::unified::lower_promql_query_workload(&example1_workload(), 0) + let roots = asap_frontend_promql::lower_promql_query_workload(&example1_workload(), 0) .expect("Example 1 lowers"); let chains: Vec> = roots .iter() @@ -354,7 +536,6 @@ fn stage0_frontend_lowers_each_query_to_doc_chain() { /// Stage 0 yields one workload DAG with one root per query and no summaries. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage0_one_summary_free_workload_dag() { let stage0 = stage0_logical(&example1_workload()); stage0.dag.validate().expect("valid DAG"); @@ -364,7 +545,6 @@ fn stage0_one_summary_free_workload_dag() { /// Stage 0 keeps Q1 and Q2 separate; sharing is a Stage 1 decision. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage0_queries_do_not_share_nodes() { let stage0 = stage0_logical(&example1_workload()); let (q1, q2) = roots(&stage0.query_roots); @@ -375,7 +555,7 @@ fn stage0_queries_do_not_share_nodes() { /// Stage 1 outputs exactly the 3 Q2 options × {separate, shared input} = 6 candidates. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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() { let (_, logical, _) = pipeline(); assert_eq!(logical.len(), 6); @@ -392,7 +572,6 @@ fn stage1_has_six_candidates_covering_every_combination() { /// Q1 is exact in every Stage 1 candidate: no summary is reachable from its root. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage1_q1_is_always_exact() { let (_, logical, _) = pipeline(); for c in &logical { @@ -408,7 +587,7 @@ fn stage1_q1_is_always_exact() { /// Q2's summary families are exactly Count-Min + heap per job and Hydra over all jobs. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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() { let (_, logical, _) = pipeline(); let families: BTreeSet<_> = logical @@ -426,7 +605,7 @@ fn stage1_q2_summary_families_are_count_min_heap_and_hydra() { /// Sharing adds a variant and keeps the independent one, for every Q2 option. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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"] fn stage1_keeps_independent_and_shared_variants() { let (_, logical, _) = pipeline(); let found: Vec<_> = logical @@ -448,7 +627,6 @@ fn stage1_keeps_independent_and_shared_variants() { /// Only the raw input is shared between Q1 and Q2; no summary is shared. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage1_shares_input_but_never_a_summary() { let (_, logical, _) = pipeline(); for c in &logical { @@ -467,7 +645,6 @@ fn stage1_shares_input_but_never_a_summary() { /// Stage 1 candidates are valid DAGs with unique ids. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage1_candidates_are_valid_and_uniquely_named() { let (_, logical, _) = pipeline(); let ids: BTreeSet<_> = logical.iter().map(|c| c.id.as_str()).collect(); @@ -481,7 +658,7 @@ fn stage1_candidates_are_valid_and_uniquely_named() { /// No candidate is discarded before Stage 3: Stage 2 maps the 6 logical candidates one-to-one. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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); @@ -494,7 +671,7 @@ fn stage2_keeps_every_logical_candidate() { /// Stage 2 preserves each logical candidate's Q2 option and input sharing. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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 { @@ -511,7 +688,7 @@ fn stage2_preserves_logical_choices() { /// Exact TopK is implemented as a sort followed by a limit. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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 @@ -530,7 +707,7 @@ 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 = "pending Phase C stage APIs"] +#[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 { @@ -563,7 +740,6 @@ fn stage2_summary_topk_is_build_then_estimate() { /// With no materialization in the MVP, every node runs at query time. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage2_everything_runs_at_query_time() { let (_, _, physical) = pipeline(); for p in &physical { @@ -578,11 +754,91 @@ fn stage2_everything_runs_at_query_time() { } } +/// Compile `p` in the physical planner (the runtime capability check). Inputs +/// are what a deployment supplies: raw series for each sub-DAG the planner +/// runs as a retained PromQL expression, and the samples of each time range a +/// native operator reads. +fn compile_in_runtime(p: &PhysicalCandidate) -> Result<(), String> { + use asap_physical_operators::physical_planner::{compile, promql_fallback, InputContract}; + use asap_types::ir::export::compile_physical_asap_workload_with_node_ids; + use std::sync::Arc; + let ids = compile_physical_asap_workload_with_node_ids(&p.stage2.roots) + .expect("re-export") + .node_ids; + let mut inputs = BTreeMap::new(); + let mut pending = p.dag.roots.clone(); + let mut seen = HashSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let node = ids.operator_node(id).expect("node"); + let time_range = relational(payload(&p.dag, id)).as_deref() == Some("time_range"); + // Raw samples a summary reads are an input, not a retained expression. + let summary_input = time_range + && p.dag.edges.iter().any(|e| { + e.producer == id + && matches!( + payload(&p.dag, e.consumer), + LogicalASAPOperatorPayload::SummaryAgg { .. } + ) + }); + let fallback = (!node.contains_asap() && !summary_input) + .then(|| promql_fallback::raw_series(node).ok()) + .flatten(); + if let Some(selectors) = fallback { + for (i, (_, schema)) in selectors.into_iter().enumerate() { + let slot = promql_fallback::raw_series_input(u64::from(id.0), i); + inputs.insert(slot, InputContract::bounded(schema)); + } + } else if time_range { + let schema = p.dag.nodes.iter().find(|n| n.id == id).unwrap(); + let schema = Arc::new(schema.output_schema.clone()); + inputs.insert(u64::from(id.0), InputContract::bounded(schema)); + } else { + pending.extend(p.dag.producers(id)); + } + } + let roots: Vec = p.dag.roots.iter().map(|id| u64::from(id.0)).collect(); + compile(&p.dag, inputs, &roots) + .map(|_| ()) + .map_err(|e| format!("{} ({}): {e}", p.id, p.label)) +} + +/// Runtime capability check (added by the implementer, not part of the +/// spec): the physical planner compiles every Stage 2 candidate. +#[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() { + let (_, _, physical) = pipeline(); + let failures: Vec<_> = physical + .iter() + .filter_map(|p| compile_in_runtime(p).err()) + .collect(); + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// The plan Stage 3 selects compiles in the physical planner (added by the +/// implementer, not part of the spec). +#[test] +fn stage3_selected_plan_compiles_in_the_physical_planner() { + let (workload, _, physical) = pipeline(); + let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); + let selected = physical + .iter() + .find(|p| p.id == selection.selected) + .unwrap(); + compile_in_runtime(selected).unwrap(); +} + // ── Stage 3 ────────────────────────────────────────────────────────────── /// Stage 3 selects one candidate and gives every other one a reason. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage3_selects_one_and_explains_the_rest() { let (workload, _, physical) = pipeline(); let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); @@ -603,7 +859,6 @@ fn stage3_selects_one_and_explains_the_rest() { /// The selected plan is the cheapest valid candidate for the whole workload. #[test] -#[ignore = "pending Phase C stage APIs"] fn stage3_selects_cheapest_valid() { let (workload, _, physical) = pipeline(); let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); @@ -621,7 +876,7 @@ fn stage3_selects_cheapest_valid() { /// Every node is charged exactly once, so a shared input is costed once for both queries. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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()); @@ -645,7 +900,7 @@ fn stage3_charges_each_node_once() { /// Sharing the input never costs more than reading it separately. #[test] -#[ignore = "pending Phase C stage APIs"] +#[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"] fn stage3_shared_input_is_not_costlier() { let (workload, _, physical) = pipeline(); let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); From acd6fdcc9de96feced2c389d0ffa441f82f521e5 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:09:01 +0000 Subject: [PATCH 03/19] test: read Example 1 logical stages as flat DAGs after the export removal Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_example1.rs | 152 ++++++++++-------- 1 file changed, 81 insertions(+), 71 deletions(-) diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index e497caa77..6fba769c1 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -14,7 +14,8 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use asap_aware_mapping::PlanningModels; -use asap_types::ir::export::{LogicalASAPDAG, LogicalASAPNodeId, LogicalASAPOperatorPayload}; +use asap_types::ir::flat::{flatten, FlatDag, NodeId}; +use asap_types::ir::{ASAPOp, Operator}; use asap_types::post_asap::sketch::{GroupingStrategy, HydraKind, SketchAlgorithm}; use asap_types::pre_asap::schema::FieldDataType; use asap_types::types::AccuracyTarget; @@ -25,6 +26,8 @@ use asap_types::workload::{ RepetitionInterval, TimeSelection, }; +type Payload = Operator; + /// Adapters from the Phase C stage APIs to the shapes these tests were written /// against. They only convert; every decision is the real stage's. #[allow(dead_code)] @@ -35,7 +38,6 @@ mod stages { use asap_aware_mapping::logical_candidates::{ compose_logical_candidate, enumerate_local_logical_candidates, }; - use asap_types::ir::export::{compile_logical_asap_workload, LogicalASAPQueryRoot}; use asap_types::ir::{OperatorNode, QueryRoot}; /// One whole-workload candidate. `query_roots` holds one root per @@ -45,12 +47,12 @@ mod stages { pub struct LogicalCandidate { pub id: String, pub label: String, - pub dag: LogicalASAPDAG, - pub query_roots: Vec, + pub dag: FlatDag, + pub query_roots: Vec, pub roots: Vec>, } - pub type PhysicalASAPDAG = asap_types::ir::export::PhysicalASAPDAG; + pub type PhysicalASAPDAG = asap_types::ir::physical_export::PhysicalASAPDAG; /// One Stage 2 candidate, derived from exactly one Stage 1 candidate. #[derive(Debug, Clone)] @@ -59,7 +61,7 @@ mod stages { pub from_logical: String, pub label: String, pub dag: PhysicalASAPDAG, - pub query_roots: Vec, + pub query_roots: Vec, pub stage2: asap_aware_mapping::physical_candidates::PhysicalCandidate, } @@ -68,7 +70,7 @@ mod stages { #[derive(Debug, Clone)] pub struct CandidateCost { pub total: f64, - pub per_node: BTreeMap, + pub per_node: BTreeMap, } /// A candidate Stage 3 did not select. `valid == false` means it failed @@ -105,13 +107,16 @@ mod stages { } fn candidate(id: String, label: String, roots: Vec) -> LogicalCandidate { - let dag = compile_logical_asap_workload(&roots).expect("logical export"); + for root in &roots { + root.validate_structure().expect("valid DAG"); + } + let dag = flatten(&roots).0; let query_roots = dag .roots .iter() .map(|root| match root { - LogicalASAPQueryRoot::Operator(id) => *id, - LogicalASAPQueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), + QueryRoot::Operator(id) => *id, + QueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), }) .collect(); let roots = roots @@ -251,7 +256,7 @@ mod stages { } } - pub fn runs_at_ingestion(candidate: &PhysicalCandidate, node: LogicalASAPNodeId) -> bool { + pub fn runs_at_ingestion(candidate: &PhysicalCandidate, node: NodeId) -> bool { candidate .dag .nodes @@ -340,34 +345,33 @@ enum Q2Option { Hydra, } -/// The logical and physical exports share node ids, payloads and edge -/// endpoints; the helpers below read only those. +/// The logical (flat) and physical DAGs share node ids and payloads; the +/// helpers below read only those and each node's producers. trait ExportedDag { - fn producers(&self, consumer: LogicalASAPNodeId) -> Vec; - fn node_payload(&self, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload; + fn producers(&self, consumer: NodeId) -> Vec; + fn node_payload(&self, id: NodeId) -> &Payload; } -impl ExportedDag for LogicalASAPDAG { - fn producers(&self, consumer: LogicalASAPNodeId) -> Vec { - let edges = self.edges.iter().filter(|e| e.consumer == consumer); - edges.map(|e| e.producer).collect() +impl ExportedDag for FlatDag { + fn producers(&self, consumer: NodeId) -> Vec { + self.nodes[consumer] + .operator + .children() + .into_iter() + .copied() + .collect() } - fn node_payload(&self, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload { - &self - .nodes - .iter() - .find(|n| n.id == id) - .expect("node") - .payload + fn node_payload(&self, id: NodeId) -> &Payload { + &self.nodes[id].operator } } impl ExportedDag for PhysicalASAPDAG { - fn producers(&self, consumer: LogicalASAPNodeId) -> Vec { + fn producers(&self, consumer: NodeId) -> Vec { let edges = self.edges.iter().filter(|e| e.consumer == consumer); edges.map(|e| e.producer).collect() } - fn node_payload(&self, id: LogicalASAPNodeId) -> &LogicalASAPOperatorPayload { + fn node_payload(&self, id: NodeId) -> &Payload { &self .nodes .iter() @@ -378,7 +382,7 @@ impl ExportedDag for PhysicalASAPDAG { } /// Every node `root` depends on, including itself. -fn closure(dag: &impl ExportedDag, root: LogicalASAPNodeId) -> HashSet { +fn closure(dag: &impl ExportedDag, root: NodeId) -> HashSet { let mut seen = HashSet::from([root]); let mut stack = vec![root]; while let Some(node) = stack.pop() { @@ -391,33 +395,32 @@ fn closure(dag: &impl ExportedDag, root: LogicalASAPNodeId) -> HashSet &LogicalASAPOperatorPayload { +fn payload(dag: &impl ExportedDag, id: NodeId) -> &Payload { dag.node_payload(id) } -fn is_summary(payload: &LogicalASAPOperatorPayload) -> bool { +fn is_summary(payload: &Payload) -> bool { matches!( payload, - LogicalASAPOperatorPayload::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - } | LogicalASAPOperatorPayload::SummaryEstimate { .. } - | LogicalASAPOperatorPayload::SummaryMerge + Operator::ASAP( + ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(..), + .. + } | ASAPOp::SummaryEstimate { .. } + | ASAPOp::SummaryMerge { .. } + ) ) } /// Sketch families built in `nodes`, as Example 1's Q2 options. -fn sketch_options( - dag: &impl ExportedDag, - nodes: &HashSet, -) -> BTreeSet { +fn sketch_options(dag: &impl ExportedDag, nodes: &HashSet) -> BTreeSet { nodes .iter() .filter_map(|&id| match payload(dag, id) { - LogicalASAPOperatorPayload::SummaryAgg { + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, grouping), .. - } => Some(match grouping { + }) => Some(match grouping { GroupingStrategy::SharedMultiSubpopulation { kind: HydraKind::HydraCms, .. @@ -434,13 +437,13 @@ fn sketch_options( .collect() } -fn roots(query_roots: &[LogicalASAPNodeId]) -> (LogicalASAPNodeId, LogicalASAPNodeId) { +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]) } /// Q2's option and whether Q1 and Q2 share any node, for one candidate. -fn classify(dag: &impl ExportedDag, query_roots: &[LogicalASAPNodeId]) -> (Q2Option, bool) { +fn classify(dag: &impl ExportedDag, query_roots: &[NodeId]) -> (Q2Option, bool) { let (q1, q2) = roots(query_roots); let (c1, c2) = (closure(dag, q1), closure(dag, q2)); let options = sketch_options(dag, &c2); @@ -449,16 +452,23 @@ fn classify(dag: &impl ExportedDag, query_roots: &[LogicalASAPNodeId]) -> (Q2Opt (option, !c1.is_disjoint(&c2)) } -/// The relational operator's wire `kind` (`"scan"`, `"sort"`, …); the -/// operator enum itself is not public outside `asap-types`. -fn relational(payload: &LogicalASAPOperatorPayload) -> Option { +/// The non-ASAP operator's kind in snake case (`"scan"`, `"sort"`, …). +fn relational(payload: &Payload) -> Option { match payload { - LogicalASAPOperatorPayload::Relational { .. } => { - let json = serde_json::to_value(payload).expect("payload serializes"); - json["operator"]["kind"].as_str().map(str::to_owned) + Operator::NonASAP(_) => Some(snake_case(payload.kind_name())), + Operator::ASAP(_) => None, + } +} + +fn snake_case(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_ascii_uppercase() && i > 0 { + out.push('_'); } - _ => None, + out.push(c.to_ascii_lowercase()); } + out } fn pipeline() -> ( @@ -507,17 +517,15 @@ fn stage0_frontend_lowers_each_query_to_doc_chain() { let chains: Vec> = roots .iter() .map(|root| { - let dag = asap_types::ir::export::compile_logical_asap_query(root).expect("compiles"); - let json = serde_json::to_value(&dag).expect("serializes"); - let mut ops: Vec = json["nodes"] - .as_array() - .unwrap() + let dag = flatten(std::slice::from_ref(root)).0; + let mut ops: Vec = dag + .nodes .iter() .map(|n| { - let op = &n["payload"]["operator"]; - match op["measures"][0]["kind"].as_str() { + let json = serde_json::to_value(&n.operator).expect("serializes"); + match json["NonASAP"]["Aggregate"]["measures"][0]["kind"].as_str() { Some(measure) => format!("aggregate:{measure}"), - None => op["kind"].as_str().unwrap_or("?").to_owned(), + None => relational(&n.operator).unwrap_or_else(|| "?".into()), } }) .collect(); @@ -538,9 +546,8 @@ fn stage0_frontend_lowers_each_query_to_doc_chain() { #[test] fn stage0_one_summary_free_workload_dag() { let stage0 = stage0_logical(&example1_workload()); - stage0.dag.validate().expect("valid DAG"); roots(&stage0.query_roots); - assert!(stage0.dag.nodes.iter().all(|n| !is_summary(&n.payload))); + assert!(stage0.dag.nodes.iter().all(|n| !is_summary(&n.operator))); } /// Stage 0 keeps Q1 and Q2 separate; sharing is a Stage 1 decision. @@ -650,7 +657,10 @@ fn stage1_candidates_are_valid_and_uniquely_named() { let ids: BTreeSet<_> = logical.iter().map(|c| c.id.as_str()).collect(); assert_eq!(ids.len(), logical.len()); for c in &logical { - c.dag.validate().unwrap_or_else(|e| panic!("{}: {e}", c.id)); + for root in &c.roots { + root.validate_structure() + .unwrap_or_else(|e| panic!("{}: {e}", c.id)); + } } } @@ -718,20 +728,20 @@ fn stage2_summary_topk_is_build_then_estimate() { assert!( !kinds .iter() - .any(|k| matches!(k, LogicalASAPOperatorPayload::SummaryMerge)), + .any(|k| matches!(k, Operator::ASAP(ASAPOp::SummaryMerge { .. }))), "{}: no window summaries in the MVP, so no merge", p.id ); let build_to_estimate = p.dag.edges.iter().any(|e| { matches!( payload(&p.dag, e.producer), - LogicalASAPOperatorPayload::SummaryAgg { + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(..), .. - } + }) ) && matches!( payload(&p.dag, e.consumer), - LogicalASAPOperatorPayload::SummaryEstimate { .. } + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) ) }); assert!(build_to_estimate, "{}: no build → estimate", p.id); @@ -760,7 +770,7 @@ fn stage2_everything_runs_at_query_time() { /// native operator reads. fn compile_in_runtime(p: &PhysicalCandidate) -> Result<(), String> { use asap_physical_operators::physical_planner::{compile, promql_fallback, InputContract}; - use asap_types::ir::export::compile_physical_asap_workload_with_node_ids; + use asap_types::ir::physical_export::compile_physical_asap_workload_with_node_ids; use std::sync::Arc; let ids = compile_physical_asap_workload_with_node_ids(&p.stage2.roots) .expect("re-export") @@ -780,7 +790,7 @@ fn compile_in_runtime(p: &PhysicalCandidate) -> Result<(), String> { e.producer == id && matches!( payload(&p.dag, e.consumer), - LogicalASAPOperatorPayload::SummaryAgg { .. } + Operator::ASAP(ASAPOp::SummaryAgg { .. }) ) }); let fallback = (!node.contains_asap() && !summary_input) @@ -788,18 +798,18 @@ fn compile_in_runtime(p: &PhysicalCandidate) -> Result<(), String> { .flatten(); if let Some(selectors) = fallback { for (i, (_, schema)) in selectors.into_iter().enumerate() { - let slot = promql_fallback::raw_series_input(u64::from(id.0), i); + let slot = promql_fallback::raw_series_input(id as u64, i); inputs.insert(slot, InputContract::bounded(schema)); } } else if time_range { let schema = p.dag.nodes.iter().find(|n| n.id == id).unwrap(); let schema = Arc::new(schema.output_schema.clone()); - inputs.insert(u64::from(id.0), InputContract::bounded(schema)); + inputs.insert(id as u64, InputContract::bounded(schema)); } else { pending.extend(p.dag.producers(id)); } } - let roots: Vec = p.dag.roots.iter().map(|id| u64::from(id.0)).collect(); + let roots: Vec = p.dag.roots.iter().map(|&id| id as u64).collect(); compile(&p.dag, inputs, &roots) .map(|_| ()) .map_err(|e| format!("{} ({}): {e}", p.id, p.label)) From 0f8ad79fc920b1e7a675a3a90ce22b7ffcc23c0c Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:54:03 +0000 Subject: [PATCH 04/19] test: Example 1 acceptance on the composed stages (1 -> 3 -> 3 -> 1) The tests run Pass 1 and composition, optimize_physical_asap and select_plan through a shared tests/planner_layering_common pipeline, and follow the decided behavior: three Pass 1 candidates (Q2 exact, CMS+heap, CountSketch+heap), every sub-DAG NotMaterialized, P1 selected, CMS+heap invalid (weights not proven non-negative) and CountSketch+heap invalid (a Top-K output has no guarantee and no deployment evidence). Hydra (#605), Pass 2 sharing (#588) and the two runtime gaps (#593, #579) stay ignored with the PR that delivers them. Tests are named after the stages; the compile check no longer uses the PromQL fallback. The spec lists where the doc and the planner differ. The expected-only viewer fixture is removed: the generated four-stage fixture replaces it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_common/mod.rs | 785 ++++++++ .../tests/planner_layering_example1.rs | 926 +++------- .../planner-layering-example1-acceptance.md | 215 ++- .../planner-layering-example1.expected.json | 1603 ----------------- 4 files changed, 1160 insertions(+), 2369 deletions(-) create mode 100644 crates/integration-tests/tests/planner_layering_common/mod.rs delete mode 100644 tools/dag-viewer/examples/planner-layering-example1.expected.json diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs new file mode 100644 index 000000000..67c88b4f5 --- /dev/null +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -0,0 +1,785 @@ +//! Shared helpers for the #509 Examples 1–4 acceptance tests +//! (`planner_layering_example{1,2,3,4}.rs`). Specs: +//! `docs/design_docs/proposals/planner-layering-example{1,2,3,4}-acceptance.md`. +//! +//! [`run_stages`] runs the real stages: logical ASAP-aware optimization +//! (Pass 1 and composition), physical ASAP-aware optimization and plan +//! selection. The functions under "Pending adapters" read properties the IR +//! cannot express yet (window summaries, retention). They return today's +//! only possible answer; the implementer of each feature replaces the body +//! with a read of the new IR. Tests that depend on them are `#[ignore]`d +//! with the feature. +#![allow(dead_code)] + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use asap_aware_mapping::per_computation_candidates::{ + compose_logical_candidate, generate_per_computation_candidates, +}; +use asap_aware_mapping::physical_asap_optimization::{optimize_physical_asap, PhysicalCandidate}; +use asap_aware_mapping::plan_selection::{select_plan, Selection}; +use asap_aware_mapping::PlanningModels; +use asap_types::ir::flat::{flatten, FlatDag, NodeId}; +use asap_types::ir::physical_export::PhysicalASAPDAG; +use asap_types::ir::{ASAPOp, Operator, QueryRoot}; +use asap_types::post_asap::{ + FieldDataType, Materialization, SketchAlgorithm, SketchParams, SketchStatistic, +}; +use asap_types::types::AccuracyTarget; +use asap_types::workload::{ + AccuracyRequirement, BatchEntry, DataArrival, DataDistribution, DataWorkload, DurationMs, + Evidence, EvidenceSource, LatencyRequirement, PlanningWorkload, Predictability, Query, + QueryLanguage, QueryRecurrence, QueryRequirements, QueryTimeScope, QueryWorkload, Rate, + RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, TimestampMs, +}; + +pub type Payload = Operator; + +pub fn declared(value: T) -> Evidence { + Evidence { + value: Some(value), + source: EvidenceSource::Declared, + ..Default::default() + } +} + +// ── Workloads shared by Examples 3 and 4 ──────────────────────────────── + +pub const MINUTE_MS: u64 = 60_000; +/// PromQL's `y` is 365 days. +pub const YEAR_MS: u64 = 365 * 24 * 60 * MINUTE_MS; +/// Pattern A's batch execution time T (2026-01-01T00:00:00Z). +pub const T_MS: u64 = 1_767_225_600_000; + +/// The #509 shared data workload with `arrival`. +pub fn shared_data_workload(arrival: DataArrival) -> DataWorkload { + DataWorkload { + data_ingestion_interval: declared(DurationMs(15_000)), + ingestion_volume: Evidence::default(), + // Data at rest has no ingestion rate (`validate` rejects one). + ingestion_rate: match arrival { + DataArrival::AtRest => Evidence::default(), + _ => declared(Rate(1_000_000.0 / 15.0)), + }, + input_cardinality: declared(1_000_000), + distribution: declared(DataDistribution::Zipf), + arrival, + } +} + +/// Pattern A's five queries: (PromQL, `lookback`, `as_of` − T). +pub const PATTERN_A: [(&str, u64, u64); 5] = [ + ("quantile_over_time(0.99, latency_ms[5y])", 5 * YEAR_MS, 0), + ("quantile_over_time(0.99, latency_ms[1y])", YEAR_MS, 0), + ( + "quantile_over_time(0.99, latency_ms[1y] offset 1y)", + YEAR_MS, + YEAR_MS, + ), + ( + "quantile_over_time(0.99, latency_ms[1y] offset 2y)", + YEAR_MS, + 2 * YEAR_MS, + ), + ( + "quantile_over_time(0.99, latency_ms[3y] offset 2y)", + 3 * YEAR_MS, + 2 * YEAR_MS, + ), +]; + +/// How Pattern A's batch recurs (Example 4 varies it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatternARecurrence { + /// As given: one `ad_hoc` batch run once at T. + OnceAdHoc, + /// Example 4: repeated monthly and `Predictable { known_at }`; each run + /// reads the intervals ending at its own evaluation time. + MonthlyPredictable, +} + +fn pattern_a_requirements() -> QueryRequirements { + QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon: 0.005, + delta: 0.01, + }), + response_latency: LatencyRequirement::Unspecified, + } +} + +/// Example 3, Pattern A: p99 over five historical intervals. +pub fn pattern_a(recurrence: PatternARecurrence, arrival: DataArrival) -> PlanningWorkload { + let (batch, repeating) = match recurrence { + PatternARecurrence::OnceAdHoc => ( + Some( + PATTERN_A + .iter() + .map(|&(query, lookback, before_t)| BatchEntry { + query: Query(query.into()), + requirements: pattern_a_requirements(), + predictability: Predictability::AdHoc, + invocations: 1, + execute_at: Some(TimestampMs(T_MS)), + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(lookback)), + as_of: Some(TimestampMs(T_MS - before_t)), + }, + }) + .collect(), + ), + None, + ), + PatternARecurrence::MonthlyPredictable => ( + None, + Some( + PATTERN_A + .iter() + .map(|&(query, lookback, _)| RepeatingEntry { + query: Query(query.into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval( + (30 * 24 * 60 * MINUTE_MS) as u32, + )), + requirements: pattern_a_requirements(), + predictability: Predictability::Predictable { + known_at: Some(TimestampMs(T_MS)), + }, + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(lookback)), + as_of: None, + }, + }) + .collect(), + ), + ), + }; + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: batch, + repeating_queries: repeating, + }, + data_workload: Some(shared_data_workload(arrival)), + } +} + +pub const PATTERN_B: &str = "quantile_over_time(0.99, latency_ms[5m])"; +pub const PATTERN_B_WINDOW_MS: u64 = 5 * MINUTE_MS; +pub const PATTERN_B_INTERVAL_MS: u64 = MINUTE_MS; + +/// Example 3, Pattern B: a p99 panel over the last 5 min, every minute. +pub fn pattern_b() -> PlanningWorkload { + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: None, + repeating_queries: Some(vec![RepeatingEntry { + query: Query(PATTERN_B.into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval( + PATTERN_B_INTERVAL_MS as u32, + )), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }), + response_latency: LatencyRequirement::ExplicitMaxMs(200.0), + }, + predictability: Predictability::Predictable { known_at: None }, + time_selection: TimeSelection { + scope: QueryTimeScope::RealTime, + lookback: Some(DurationMs(PATTERN_B_WINDOW_MS)), + as_of: None, + }, + }]), + }, + data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + } +} + +/// Lower and plan a PromQL workload. +pub fn run_promql(workload: &PlanningWorkload) -> Run { + run_stages(workload, lower_promql(workload)) +} + +/// Whether `form` answers a query window by merging several summaries. +pub fn needs_merge(form: WindowForm, query_window_ms: u64) -> bool { + match form { + WindowForm::None => false, + WindowForm::Sliding { length_ms, .. } => length_ms < query_window_ms, + WindowForm::Tumbling { .. } | WindowForm::ExponentialHistogram { .. } => true, + } +} + +// ── Language-specific frontends ────────────────────────────────────────── + +/// PromQL workload roots, each series' identity as a column (the row +/// representation per-series state needs at runtime). +pub fn lower_promql(workload: &PlanningWorkload) -> Vec { + asap_frontend_promql::lower_promql_query_workload(workload, 0) + .expect("workload lowers") + .into_iter() + .map(|root| match root { + QueryRoot::Operator(node) => QueryRoot::Operator( + asap_types::ir::schema_support::with_promql_series_identity(&node) + .expect("series identity"), + ), + scalar => scalar, + }) + .collect() +} + +/// The non-ASAP operator's kind or the aggregate's first measure as +/// `aggregate:`, for each node of one query's frontend DAG, sorted +/// (node ids are post-order; compare as a set of operations). +pub fn frontend_operations(root: &QueryRoot) -> Vec { + let dag = flatten(std::slice::from_ref(root)).0; + let mut ops: Vec = dag + .nodes + .iter() + .map(|n| { + let json = serde_json::to_value(&n.operator).expect("serializes"); + match json["NonASAP"]["Aggregate"]["measures"][0]["kind"].as_str() { + Some(measure) => format!("aggregate:{measure}"), + None => relational(&n.operator).unwrap_or_else(|| "?".into()), + } + }) + .collect(); + ops.sort(); + ops +} + +// ── Pipeline ───────────────────────────────────────────────────────────── + +/// One logical candidate. `query_roots` has one root per workload entry, in +/// `QueryWorkload::entries()` order; `roots` are the in-memory roots. +#[derive(Debug, Clone)] +pub struct Logical { + pub id: String, + /// The choice of a per-computation candidate for every Pass 1 target. + pub label: String, + pub dag: FlatDag, + pub query_roots: Vec, + pub roots: Vec, +} + +impl Logical { + /// Whether two queries read a common node. Only Pass 2's + /// identical-expression rule (#588) shares a node across queries. + pub fn shared_input(&self) -> bool { + !cross_query_nodes(&self.dag, &self.query_roots).is_empty() + } +} + +/// One physical candidate, derived from the logical candidate +/// `candidate.from_logical`. +#[derive(Debug, Clone)] +pub struct Physical { + pub id: String, + pub from_logical: String, + pub query_roots: Vec, + pub candidate: PhysicalCandidate, +} + +impl Physical { + pub fn dag(&self) -> &PhysicalASAPDAG { + &self.candidate.dag + } +} + +#[derive(Debug, Clone)] +pub struct Run { + pub language_specific_frontends: Logical, + pub logical: Vec, + pub physical: Vec, + /// Logical candidates physical ASAP-aware optimization cannot implement: + /// (logical id, reason). + pub pruned: Vec<(String, String)>, + pub selection: Selection, +} + +impl Run { + pub fn invalid(&self) -> BTreeMap<&str, &str> { + self.selection + .rejected + .iter() + .filter(|r| !r.valid) + .map(|r| (r.id.as_str(), r.reason.as_str())) + .collect() + } + + pub fn cost(&self, id: &str) -> Option { + self.selection.costs.get(id).map(|c| c.total) + } + + pub fn physical(&self, id: &str) -> &Physical { + self.physical.iter().find(|p| p.id == id).expect("id") + } + + pub fn physical_of<'a>(&'a self, logical: &'a Logical) -> impl Iterator { + self.physical + .iter() + .filter(move |p| p.from_logical == logical.id) + } +} + +fn logical(id: String, label: String, roots: Vec) -> Logical { + for root in &roots { + root.validate_structure().expect("valid DAG"); + } + let dag = flatten(&roots).0; + let query_roots = dag + .roots + .iter() + .map(|root| match root { + QueryRoot::Operator(id) => *id, + QueryRoot::Scalar(_) => panic!("the examples have operator roots"), + }) + .collect(); + Logical { + id, + label, + dag, + query_roots, + roots, + } +} + +/// Every choice of a per-computation candidate for every target, the last +/// target varying fastest. +fn choices(counts: &[usize]) -> Vec> { + counts.iter().fold(vec![vec![]], |prefixes, &count| { + prefixes + .into_iter() + .flat_map(|prefix| { + (0..count).map(move |i| { + let mut choice = prefix.clone(); + choice.push(i); + choice + }) + }) + .collect() + }) +} + +/// The frontends' DAG, then logical ASAP-aware optimization (Pass 1 and +/// composition; Pass 2 is not implemented), physical ASAP-aware +/// optimization and plan selection over `roots`, the lowered entries of +/// `workload`. Plan selection reads only the accuracy targets and the data +/// workload; recurrence and predictability (which materialization needs, +/// Example 4) are not passed. +pub fn run_stages(workload: &PlanningWorkload, roots: Vec) -> Run { + let language_specific_frontends = logical("S0".into(), "frontends".into(), roots.clone()); + let inventory = generate_per_computation_candidates(roots.into_iter().enumerate().collect()) + .expect("Pass 1"); + let counts: Vec<_> = inventory + .targets + .iter() + .map(|target| target.choices.candidates.len()) + .collect(); + let mut logical_candidates = Vec::new(); + let mut physical = Vec::new(); + let mut pruned = Vec::new(); + for (index, choice) in choices(&counts).into_iter().enumerate() { + let id = format!("L{}", index + 1); + let roots: Vec<_> = compose_logical_candidate(&inventory, &choice) + .unwrap_or_else(|e| panic!("{id} {choice:?} composes: {e}")) + .into_iter() + .map(|(_, root)| root) + .collect(); + match optimize_physical_asap(&id, &roots) { + Ok(mut candidate) => { + candidate.id = format!("P{}", index + 1); + candidate.label = format!("{choice:?}"); + physical.push(Physical { + id: candidate.id.clone(), + from_logical: id.clone(), + query_roots: candidate.dag.roots.clone(), + candidate, + }); + } + Err(e) => pruned.push((id.clone(), e.to_string())), + } + logical_candidates.push(logical(id, format!("{choice:?}"), roots)); + } + let targets: Vec<_> = workload + .query_workload + .entries() + .map(|entry| Some(entry.requirements.accuracy.target())) + .collect(); + let candidates: Vec<_> = physical.iter().map(|p| p.candidate.clone()).collect(); + let selection = select_plan( + &candidates, + &targets, + workload.data_workload.as_ref().expect("data workload"), + PlanningModels::builtin(), + ) + .expect("plan selection selects"); + Run { + language_specific_frontends, + logical: logical_candidates, + physical, + pruned, + selection, + } +} + +// ── DAG helpers ────────────────────────────────────────────────────────── + +/// The logical (flat) and physical DAGs share node ids and payloads; the +/// helpers below read only those and each node's producers and consumers. +pub trait ExportedDag { + fn producers(&self, consumer: NodeId) -> Vec; + fn consumers(&self, producer: NodeId) -> Vec; + fn node_ids(&self) -> Vec; + fn payload(&self, id: NodeId) -> &Payload; +} + +impl ExportedDag for FlatDag { + fn producers(&self, consumer: NodeId) -> Vec { + self.nodes[consumer] + .operator + .children() + .into_iter() + .copied() + .collect() + } + fn consumers(&self, producer: NodeId) -> Vec { + self.node_ids() + .into_iter() + .filter(|&id| self.producers(id).contains(&producer)) + .collect() + } + fn node_ids(&self) -> Vec { + (0..self.nodes.len()).collect() + } + fn payload(&self, id: NodeId) -> &Payload { + &self.nodes[id].operator + } +} + +impl ExportedDag for PhysicalASAPDAG { + fn producers(&self, consumer: NodeId) -> Vec { + let edges = self.edges.iter().filter(|e| e.consumer == consumer); + edges.map(|e| e.producer).collect() + } + fn consumers(&self, producer: NodeId) -> Vec { + let edges = self.edges.iter().filter(|e| e.producer == producer); + edges.map(|e| e.consumer).collect() + } + fn node_ids(&self) -> Vec { + self.nodes.iter().map(|n| n.id).collect() + } + fn payload(&self, id: NodeId) -> &Payload { + &self + .nodes + .iter() + .find(|n| n.id == id) + .expect("node") + .payload + } +} + +/// Every node `root` depends on, including itself. +pub fn closure(dag: &impl ExportedDag, root: NodeId) -> HashSet { + let mut seen = HashSet::from([root]); + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + for producer in dag.producers(node) { + if seen.insert(producer) { + stack.push(producer); + } + } + } + seen +} + +/// The queries (indexes into `query_roots`) whose result depends on `node`. +pub fn readers(dag: &impl ExportedDag, query_roots: &[NodeId], node: NodeId) -> BTreeSet { + query_roots + .iter() + .enumerate() + .filter(|(_, &root)| closure(dag, root).contains(&node)) + .map(|(q, _)| q) + .collect() +} + +/// The non-ASAP operator's kind in snake case (`"scan"`, `"time_range"`, …). +pub fn relational(payload: &Payload) -> Option { + match payload { + Operator::NonASAP(_) => Some(snake_case(payload.kind_name())), + Operator::ASAP(_) => None, + } +} + +fn snake_case(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_ascii_uppercase() && i > 0 { + out.push('_'); + } + out.push(c.to_ascii_lowercase()); + } + out +} + +pub fn is_summary(payload: &Payload) -> bool { + matches!( + payload, + Operator::ASAP( + ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(..), + .. + } | ASAPOp::SummaryEstimate { .. } + | ASAPOp::SummaryMerge { .. } + ) + ) +} + +pub fn is_merge(payload: &Payload) -> bool { + matches!(payload, Operator::ASAP(ASAPOp::SummaryMerge { .. })) +} + +/// The sketch build nodes (`SummaryAgg` over a sketch family) of `dag`, +/// with their algorithm and parameters. +pub fn sketch_builds(dag: &impl ExportedDag) -> Vec<(NodeId, SketchAlgorithm, SketchParams)> { + dag.node_ids() + .into_iter() + .filter_map(|id| match dag.payload(id) { + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) => Some((id, kind.algorithm().clone(), kind.params().clone())), + _ => None, + }) + .collect() +} + +/// The estimation nodes that read `build`, directly or through merges. +pub fn estimates_of(dag: &impl ExportedDag, build: NodeId) -> Vec<(NodeId, SketchStatistic)> { + let mut out = Vec::new(); + let mut stack = vec![build]; + let mut seen = HashSet::new(); + while let Some(node) = stack.pop() { + for consumer in dag.consumers(node) { + if !seen.insert(consumer) { + continue; + } + match dag.payload(consumer) { + Operator::ASAP(ASAPOp::SummaryEstimate { query, .. }) => { + out.push((consumer, query.clone())) + } + Operator::ASAP(ASAPOp::SummaryMerge { .. }) => stack.push(consumer), + _ => {} + } + } + } + out +} + +/// One query's per-computation choice: the sorted sketch algorithms built in +/// its closure, or `"exact"` when it builds none. +pub fn query_option(dag: &impl ExportedDag, query_roots: &[NodeId], query: usize) -> String { + let reach = closure(dag, query_roots[query]); + let mut names: Vec<_> = sketch_builds(dag) + .into_iter() + .filter(|(id, ..)| reach.contains(id)) + .map(|(_, algorithm, _)| format!("{algorithm:?}")) + .collect(); + names.sort(); + if names.is_empty() { + "exact".into() + } else { + names.join("+") + } +} + +/// Nodes reachable from more than one query root. +pub fn cross_query_nodes(dag: &impl ExportedDag, query_roots: &[NodeId]) -> BTreeSet { + dag.node_ids() + .into_iter() + .filter(|&id| readers(dag, query_roots, id).len() > 1) + .collect() +} + +pub fn assert_valid_and_uniquely_named(run: &Run) { + let ids: BTreeSet<_> = run.logical.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids.len(), run.logical.len(), "unique logical ids"); + for c in &run.logical { + for root in &c.roots { + root.validate_structure() + .unwrap_or_else(|e| panic!("{}: {e}", c.id)); + } + } +} + +/// Physical ASAP-aware optimization maps the logical candidates one-to-one +/// onto physical ones, so no candidate is discarded before plan selection. +pub fn assert_one_physical_candidate_per_logical(run: &Run) { + assert!(run.pruned.is_empty(), "pruned: {:?}", run.pruned); + let sources: BTreeSet<_> = run + .physical + .iter() + .map(|p| p.from_logical.as_str()) + .collect(); + let logical: BTreeSet<_> = run.logical.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(sources, logical); + assert_eq!(run.physical.len(), run.logical.len()); + let ids: BTreeSet<_> = run.physical.iter().map(|p| p.id.as_str()).collect(); + assert_eq!(ids.len(), run.physical.len(), "unique physical ids"); +} + +/// One selected id; every other candidate rejected once, with a reason. +pub fn assert_selects_one_and_explains_the_rest(run: &Run) { + let all: BTreeSet<_> = run.physical.iter().map(|p| p.id.clone()).collect(); + let mut accounted: BTreeSet<_> = run + .selection + .rejected + .iter() + .map(|r| r.id.clone()) + .collect(); + assert_eq!( + accounted.len(), + run.selection.rejected.len(), + "rejected once each" + ); + assert!(run.selection.rejected.iter().all(|r| !r.reason.is_empty())); + assert!( + accounted.insert(run.selection.selected.clone()), + "selected is not rejected" + ); + assert_eq!(accounted, all); +} + +/// The selected plan costs no more than any valid candidate. +pub fn assert_selects_cheapest_valid(run: &Run) { + let invalid = run.invalid(); + let best = run + .cost(&run.selection.selected) + .expect("selected is priced"); + for p in run + .physical + .iter() + .filter(|p| !invalid.contains_key(p.id.as_str())) + { + assert!(best <= run.cost(&p.id).unwrap(), "{} is cheaper", p.id); + } +} + +/// Each valid candidate's cost has one entry per DAG node and `total` is +/// their sum, so a node read by several queries is charged once. Invalid +/// candidates are not priced. +pub fn assert_each_node_charged_once(run: &Run) { + let invalid = run.invalid(); + for p in &run.physical { + if invalid.contains_key(p.id.as_str()) { + assert!(run.cost(&p.id).is_none(), "{} is priced", p.id); + continue; + } + let cost = &run.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(); + assert_eq!(charged, nodes, "{}", p.id); + let sum: f64 = cost.per_node.values().map(|c| c.cost).sum(); + assert!( + (cost.total - sum).abs() <= 1e-9 * sum.abs().max(1.0), + "{}", + p.id + ); + } +} + +/// The evaluation interval of entry `query`, for a fixed-interval repeating +/// entry. +pub fn evaluation_interval_ms(workload: &PlanningWorkload, query: usize) -> Option { + match &workload.query_workload.entries().nth(query)?.recurrence { + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(i)) => Some(u64::from(i.0)), + _ => None, + } +} + +pub fn lookback_ms(workload: &PlanningWorkload, query: usize) -> u64 { + let entry = workload.query_workload.entries().nth(query).expect("entry"); + let DurationMs(ms) = entry.time_selection.lookback.expect("lookback"); + ms +} + +// ── Materialization ────────────────────────────────────────────────────── + +/// A node's materialization option (#509 "Materialization"), without its +/// storage medium. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum MaterializationOption { + /// Runs as data arrives; output stored before any query asks. + MatAtIngestion, + /// Runs when a query first needs it; output kept for later executions + /// and other queries of the batch. + MatAtQuery, + /// Runs at query time for each execution; output discarded. + NotMaterialized, +} + +/// The materialization option physical ASAP-aware optimization assigned to +/// `node` (#667). +pub fn materialization(p: &Physical, node: NodeId) -> MaterializationOption { + let n = p.dag().nodes.iter().find(|n| n.id == node).expect("node"); + match n.materialization { + Materialization::MatAtIngestion(_) => MaterializationOption::MatAtIngestion, + Materialization::MatAtQuery(_) => MaterializationOption::MatAtQuery, + Materialization::NotMaterialized => MaterializationOption::NotMaterialized, + } +} + +pub fn runs_at_ingestion(p: &Physical, node: NodeId) -> bool { + let n = p.dag().nodes.iter().find(|n| n.id == node).expect("node"); + n.output_state.timing == asap_types::post_asap::ExecutionTiming::IngestionTime +} + +/// Every node upstream of an ingestion-time node also runs at ingestion time. +pub fn assert_ingestion_upstream_is_ingestion(run: &Run) { + for p in &run.physical { + for n in &p.dag().nodes { + if !runs_at_ingestion(p, n.id) { + continue; + } + for up in closure(p.dag(), n.id) { + assert!( + runs_at_ingestion(p, up), + "{}: {up:?} feeds ingestion-time {:?} at query time", + p.id, + n.id + ); + } + } + } +} + +// ── Pending adapters ───────────────────────────────────────────────────── + +/// The window summary a summary build node maintains (#509 Pass 2, +/// window-composition rule). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum WindowForm { + /// Rebuilt from the query window's raw samples (no window summary). + None, + /// Windows of `length_ms` starting every `slide_ms`. + Sliding { length_ms: u64, slide_ms: u64 }, + /// Back-to-back windows of `length_ms`. + Tumbling { length_ms: u64 }, + /// EH buckets covering `horizon_ms` of history. + ExponentialHistogram { horizon_ms: u64 }, +} + +/// Pending (needs Pass 2 window composition, #601/#628): the IR has no +/// window summary yet, so every build is rebuilt from its query window. +pub fn window_form(_dag: &impl ExportedDag, _build: NodeId) -> WindowForm { + WindowForm::None +} + +/// Pending (needs retention in the physical DAG): how long a materialized +/// node's output is kept, in event time. `None` until the IR records it. +pub fn retention_ms(_p: &Physical, _node: NodeId) -> Option { + None +} diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index 6fba769c1..6b835683a 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -1,289 +1,35 @@ -//! Acceptance tests for #509 "Example 1: Aggregation over dimensions", MVP scope. +//! Acceptance tests for #509 "Example 1: Aggregation over dimensions". //! //! Spec: `docs/design_docs/proposals/planner-layering-example1-acceptance.md`. -//! Written by the test designer before the Phase C stage APIs existed; the -//! implementer replaced the stubs in [`stages`] with adapters over the real -//! stages. Tests that still fail because the implementation differs from the -//! spec stay `#[ignore]`d with the difference as the reason. +//! Written by a test designer before the stages existed, then run against +//! the real stages. Tests whose expectation needs a feature not in the stack +//! yet are `#[ignore]`d with the PR or issue that delivers it. //! -//! 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. +//! Scope: logical ASAP-aware optimization = Pass 1 only (no Pass 2); +//! physical ASAP-aware optimization = operator implementation with every +//! sub-DAG `NotMaterialized`. Counts are 1 → 3 → 3 → 1, a subset of the +//! doc's 1 → 54 → 156 → 1. + +mod planner_layering_common; use std::collections::{BTreeMap, BTreeSet, HashSet}; -use asap_aware_mapping::PlanningModels; -use asap_types::ir::flat::{flatten, FlatDag, NodeId}; +use asap_types::ir::flat::NodeId; use asap_types::ir::{ASAPOp, Operator}; -use asap_types::post_asap::sketch::{GroupingStrategy, HydraKind, SketchAlgorithm}; -use asap_types::pre_asap::schema::FieldDataType; +use asap_types::post_asap::{FieldDataType, GroupingStrategy, HydraKind, SketchAlgorithm}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - AccuracyRequirement, DataArrival, DataDistribution, DataWorkload, DurationMs, Evidence, - EvidenceSource, LatencyRequirement, PlanningWorkload, Predictability, Query, QueryLanguage, - QueryRequirements, QueryTimeScope, QueryWorkload, Rate, RepeatedDemand, RepeatingEntry, - RepetitionInterval, TimeSelection, + AccuracyRequirement, DataArrival, DurationMs, LatencyRequirement, PlanningWorkload, + Predictability, Query, QueryLanguage, QueryRequirements, QueryTimeScope, QueryWorkload, + RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, }; - -type Payload = Operator; - -/// Adapters from the Phase C stage APIs to the shapes these tests were written -/// against. They only convert; every decision is the real stage's. -#[allow(dead_code)] -mod stages { - use std::rc::Rc; - - use super::*; - use asap_aware_mapping::logical_candidates::{ - compose_logical_candidate, enumerate_local_logical_candidates, - }; - use asap_types::ir::{OperatorNode, QueryRoot}; - - /// One whole-workload candidate. `query_roots` holds one root per - /// workload entry, in `QueryWorkload::entries()` order (`[q1, q2]`). - /// `roots` are the in-memory roots the next stage consumes. - #[derive(Debug, Clone)] - pub struct LogicalCandidate { - pub id: String, - pub label: String, - pub dag: FlatDag, - pub query_roots: Vec, - pub roots: Vec>, - } - - pub type PhysicalASAPDAG = asap_types::ir::physical_export::PhysicalASAPDAG; - - /// One Stage 2 candidate, derived from exactly one Stage 1 candidate. - #[derive(Debug, Clone)] - pub struct PhysicalCandidate { - pub id: String, - pub from_logical: String, - pub label: String, - pub dag: PhysicalASAPDAG, - pub query_roots: Vec, - pub stage2: asap_aware_mapping::physical_candidates::PhysicalCandidate, - } - - /// Whole-workload cost of one physical candidate; `per_node` has one - /// entry per DAG node, so a shared node is charged once. - #[derive(Debug, Clone)] - pub struct CandidateCost { - pub total: f64, - pub per_node: BTreeMap, - } - - /// A candidate Stage 3 did not select. `valid == false` means it failed - /// an accuracy, latency or capability check; `true` means it lost on cost. - #[derive(Debug, Clone)] - pub struct Rejection { - pub id: String, - pub valid: bool, - pub reason: String, - } - - /// Stage 3 output. Costs are keyed by physical candidate id. - #[derive(Debug, Clone)] - pub struct Selection { - pub selected: String, - pub costs: BTreeMap, - pub rejected: Vec, - } - - /// The frontend DAG with each PromQL series' full identity as a column, - /// the row representation per-series state needs at runtime. - fn lower(workload: &PlanningWorkload) -> Vec { - asap_frontend_promql::lower_promql_query_workload(workload, 0) - .expect("Example 1 lowers") - .into_iter() - .map(|root| match root { - QueryRoot::Operator(node) => QueryRoot::Operator( - asap_types::ir::schema_support::with_promql_series_identity(&node) - .expect("series identity"), - ), - QueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), - }) - .collect() - } - - fn candidate(id: String, label: String, roots: Vec) -> LogicalCandidate { - for root in &roots { - root.validate_structure().expect("valid DAG"); - } - let dag = flatten(&roots).0; - let query_roots = dag - .roots - .iter() - .map(|root| match root { - QueryRoot::Operator(id) => *id, - QueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), - }) - .collect(); - let roots = roots - .into_iter() - .map(|root| match root { - QueryRoot::Operator(node) => node, - QueryRoot::Scalar(_) => panic!("Example 1 has operator roots"), - }) - .collect(); - LogicalCandidate { - id, - label, - dag, - query_roots, - roots, - } - } - - /// Stage 0: frontends lower every query into one summary-free workload DAG. - pub fn stage0_logical(workload: &PlanningWorkload) -> LogicalCandidate { - candidate("S0".into(), "frontend".into(), lower(workload)) - } - - /// Stage 1: every combination of Pass 1 local alternatives (Pass 2 is - /// not implemented). Lowers `workload` again: Pass 1 reads the in-memory - /// DAG, not the Stage 0 export. - pub fn stage1_logical_asap( - workload: &PlanningWorkload, - _logical: &LogicalCandidate, - ) -> Vec { - let inventory = - enumerate_local_logical_candidates(lower(workload).into_iter().enumerate().collect()) - .expect("Pass 1"); - let mut choices = vec![vec![]]; - for target in &inventory.targets { - choices = choices - .into_iter() - .flat_map(|prefix: Vec| { - (0..target.alternatives.len()).map(move |i| { - let mut choice = prefix.clone(); - choice.push(i); - choice - }) - }) - .collect(); - } - choices - .into_iter() - .enumerate() - .map(|(index, choice)| { - let roots = compose_logical_candidate(&inventory, &choice) - .expect("composes") - .into_iter() - .map(|(_, root)| root) - .collect(); - candidate(format!("L{}", index + 1), format!("{choice:?}"), roots) - }) - .collect() - } - - /// Stage 2: physical operator implementation of every logical candidate - /// (no materialization in the MVP). - pub fn stage2_physical( - _workload: &PlanningWorkload, - logical: &[LogicalCandidate], - ) -> Vec { - logical - .iter() - .enumerate() - .map(|(index, l)| { - let mut stage2 = - asap_aware_mapping::physical_candidates::stage2_physical(&l.id, &l.roots) - .unwrap_or_else(|e| panic!("{}: {e}", l.id)); - stage2.id = format!("P{}", index + 1); - stage2.label = l.label.clone(); - PhysicalCandidate { - id: stage2.id.clone(), - from_logical: stage2.from_logical.clone(), - label: stage2.label.clone(), - dag: stage2.dag.clone(), - query_roots: stage2.dag.roots.clone(), - stage2, - } - }) - .collect() - } - - /// Stage 3: reject invalid candidates, cost the rest for the whole - /// workload, select the cheapest. - pub fn stage3_select( - workload: &PlanningWorkload, - physical: &[PhysicalCandidate], - models: PlanningModels<'_>, - ) -> Selection { - let targets: Vec<_> = workload - .query_workload - .entries() - .map(|entry| Some(entry.requirements.accuracy.target())) - .collect(); - let candidates: Vec<_> = physical.iter().map(|p| p.stage2.clone()).collect(); - let selection = asap_aware_mapping::plan_selection::stage3_select( - &candidates, - &targets, - workload.data_workload.as_ref().expect("data workload"), - models, - ) - .expect("Stage 3 selects"); - Selection { - selected: selection.selected, - costs: selection - .costs - .into_iter() - .map(|(id, cost)| { - let per_node = cost - .per_node - .into_iter() - .map(|(node, c)| (node, c.cost)) - .collect(); - ( - id, - CandidateCost { - total: cost.total, - per_node, - }, - ) - }) - .collect(), - rejected: selection - .rejected - .into_iter() - .map(|r| Rejection { - id: r.id, - valid: r.valid, - reason: r.reason, - }) - .collect(), - } - } - - pub fn runs_at_ingestion(candidate: &PhysicalCandidate, node: NodeId) -> bool { - candidate - .dag - .nodes - .iter() - .find(|n| n.id == node) - .expect("node") - .output_state - .timing - == asap_types::post_asap::ExecutionTiming::IngestionTime - } -} - -use stages::*; +use planner_layering_common::*; // ── Example 1 workload ─────────────────────────────────────────────────── const Q1: &str = "sum by (job) (rate(http_requests_total[1m]))"; const Q2: &str = "topk by (job) (10, sum_over_time(http_requests_total[1m]))"; -fn declared(value: T) -> Evidence { - Evidence { - value: Some(value), - source: EvidenceSource::Declared, - ..Default::default() - } -} - fn dashboard_panel(query: &str, requirements: QueryRequirements) -> RepeatingEntry { RepeatingEntry { query: Query(query.into()), @@ -324,113 +70,57 @@ fn example1_workload() -> PlanningWorkload { ), ]), }, - data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: declared(DurationMs(15_000)), - ingestion_volume: Evidence::default(), - ingestion_rate: declared(Rate(1_000_000.0 / 15.0)), - input_cardinality: declared(1_000_000), - distribution: declared(DataDistribution::Zipf), - }), + data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), } } -// ── DAG helpers ────────────────────────────────────────────────────────── +fn pipeline() -> Run { + run_promql(&example1_workload()) +} -/// Q2's local option, read off its summary build node. +// ── Helpers ────────────────────────────────────────────────────────────── + +/// Q2's per-computation option for its top-k, read off its summary build node. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Q2Option { Exact, CountMinHeapPerJob, + CountSketchHeapPerJob, Hydra, } -/// The logical (flat) and physical DAGs share node ids and payloads; the -/// helpers below read only those and each node's producers. -trait ExportedDag { - fn producers(&self, consumer: NodeId) -> Vec; - fn node_payload(&self, id: NodeId) -> &Payload; -} - -impl ExportedDag for FlatDag { - fn producers(&self, consumer: NodeId) -> Vec { - self.nodes[consumer] - .operator - .children() - .into_iter() - .copied() - .collect() - } - fn node_payload(&self, id: NodeId) -> &Payload { - &self.nodes[id].operator - } -} - -impl ExportedDag for PhysicalASAPDAG { - fn producers(&self, consumer: NodeId) -> Vec { - let edges = self.edges.iter().filter(|e| e.consumer == consumer); - edges.map(|e| e.producer).collect() - } - fn node_payload(&self, id: NodeId) -> &Payload { - &self - .nodes - .iter() - .find(|n| n.id == id) - .expect("node") - .payload - } -} - -/// Every node `root` depends on, including itself. -fn closure(dag: &impl ExportedDag, root: NodeId) -> HashSet { - let mut seen = HashSet::from([root]); - let mut stack = vec![root]; - while let Some(node) = stack.pop() { - for producer in dag.producers(node) { - if seen.insert(producer) { - stack.push(producer); - } - } - } - seen -} - -fn payload(dag: &impl ExportedDag, id: NodeId) -> &Payload { - dag.node_payload(id) -} - -fn is_summary(payload: &Payload) -> bool { - matches!( - payload, - Operator::ASAP( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - } | ASAPOp::SummaryEstimate { .. } - | ASAPOp::SummaryMerge { .. } - ) - ) -} +const PASS1_Q2_OPTIONS: [Q2Option; 3] = [ + Q2Option::Exact, + Q2Option::CountMinHeapPerJob, + Q2Option::CountSketchHeapPerJob, +]; /// Sketch families built in `nodes`, as Example 1's Q2 options. fn sketch_options(dag: &impl ExportedDag, nodes: &HashSet) -> BTreeSet { nodes .iter() - .filter_map(|&id| match payload(dag, id) { + .filter_map(|&id| match dag.payload(id) { Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, grouping), .. - }) => Some(match grouping { - GroupingStrategy::SharedMultiSubpopulation { - kind: HydraKind::HydraCms, - .. - } => Q2Option::Hydra, - GroupingStrategy::PerSubpopulationInstance - if *kind.algorithm() == SketchAlgorithm::CmsWithHeap => - { + }) => Some(match (grouping, kind.algorithm()) { + ( + GroupingStrategy::SharedMultiSubpopulation { + kind: HydraKind::HydraCms, + .. + }, + _, + ) => Q2Option::Hydra, + (GroupingStrategy::PerSubpopulationInstance, SketchAlgorithm::CmsWithHeap) => { Q2Option::CountMinHeapPerJob } - other => panic!("summary family outside Example 1: {kind:?} {other:?}"), + ( + GroupingStrategy::PerSubpopulationInstance, + SketchAlgorithm::CountSketchWithHeap, + ) => Q2Option::CountSketchHeapPerJob, + (other, algorithm) => { + panic!("summary family outside Example 1: {algorithm:?} {other:?}") + } }), _ => None, }) @@ -447,51 +137,19 @@ fn classify(dag: &impl ExportedDag, query_roots: &[NodeId]) -> (Q2Option, bool) let (q1, q2) = roots(query_roots); let (c1, c2) = (closure(dag, q1), closure(dag, q2)); let options = sketch_options(dag, &c2); - assert!(options.len() <= 1, "Q2 uses one local option: {options:?}"); + assert!(options.len() <= 1, "Q2 uses one option: {options:?}"); let option = options.into_iter().next().unwrap_or(Q2Option::Exact); (option, !c1.is_disjoint(&c2)) } -/// The non-ASAP operator's kind in snake case (`"scan"`, `"sort"`, …). -fn relational(payload: &Payload) -> Option { - match payload { - Operator::NonASAP(_) => Some(snake_case(payload.kind_name())), - Operator::ASAP(_) => None, - } -} - -fn snake_case(name: &str) -> String { - let mut out = String::new(); - for (i, c) in name.chars().enumerate() { - if c.is_ascii_uppercase() && i > 0 { - out.push('_'); - } - out.push(c.to_ascii_lowercase()); - } - out -} - -fn pipeline() -> ( - PlanningWorkload, - Vec, - Vec, -) { - let workload = example1_workload(); - let logical = stage1_logical_asap(&workload, &stage0_logical(&workload)); - let physical = stage2_physical(&workload, &logical); - (workload, logical, physical) -} - -/// The six Example 1 MVP combinations: three Q2 options × separate/shared input. -fn expected_combinations() -> BTreeSet<(Q2Option, bool)> { - [ - Q2Option::Exact, - Q2Option::CountMinHeapPerJob, - Q2Option::Hydra, - ] - .into_iter() - .flat_map(|o| [(o, false), (o, true)]) - .collect() +fn physical_with(run: &Run, option: Q2Option) -> &Physical { + let mut found = run + .physical + .iter() + .filter(|p| classify(p.dag(), &p.query_roots).0 == option); + let p = found.next().unwrap_or_else(|| panic!("no {option:?}")); + assert!(found.next().is_none(), "one {option:?} candidate"); + p } // ── Workload ───────────────────────────────────────────────────────────── @@ -507,32 +165,13 @@ fn workload_encodes_example1() { assert_eq!(entries[1].query.0, Q2); } -// ── Stage 0 ────────────────────────────────────────────────────────────── +// ── Language-specific frontends ────────────────────────────────────────── -/// Today's PromQL frontend already lowers each query to the doc's Stage 0 chain. +/// The PromQL frontend lowers each query to the doc's chain. #[test] -fn stage0_frontend_lowers_each_query_to_doc_chain() { - let roots = asap_frontend_promql::lower_promql_query_workload(&example1_workload(), 0) - .expect("Example 1 lowers"); - let chains: Vec> = roots - .iter() - .map(|root| { - let dag = flatten(std::slice::from_ref(root)).0; - let mut ops: Vec = dag - .nodes - .iter() - .map(|n| { - let json = serde_json::to_value(&n.operator).expect("serializes"); - match json["NonASAP"]["Aggregate"]["measures"][0]["kind"].as_str() { - Some(measure) => format!("aggregate:{measure}"), - None => relational(&n.operator).unwrap_or_else(|| "?".into()), - } - }) - .collect(); - ops.sort(); // node ids are assigned in post-order; compare as a set of operations - ops - }) - .collect(); +fn language_specific_frontends_lower_each_query_to_doc_chain() { + let roots = lower_promql(&example1_workload()); + let chains: Vec<_> = roots.iter().map(frontend_operations).collect(); assert_eq!( chains, [ @@ -542,62 +181,79 @@ fn stage0_frontend_lowers_each_query_to_doc_chain() { ); } -/// Stage 0 yields one workload DAG with one root per query and no summaries. +/// The frontends yield one workload DAG with one root per query and no summaries. #[test] -fn stage0_one_summary_free_workload_dag() { - let stage0 = stage0_logical(&example1_workload()); - roots(&stage0.query_roots); - assert!(stage0.dag.nodes.iter().all(|n| !is_summary(&n.operator))); +fn language_specific_frontends_one_summary_free_workload_dag() { + let frontends = pipeline().language_specific_frontends; + roots(&frontends.query_roots); + assert!(frontends.dag.nodes.iter().all(|n| !is_summary(&n.operator))); } -/// Stage 0 keeps Q1 and Q2 separate; sharing is a Stage 1 decision. +/// The frontends keep Q1 and Q2 separate; sharing is a logical ASAP-aware optimization decision. #[test] -fn stage0_queries_do_not_share_nodes() { - let stage0 = stage0_logical(&example1_workload()); - let (q1, q2) = roots(&stage0.query_roots); - assert!(closure(&stage0.dag, q1).is_disjoint(&closure(&stage0.dag, q2))); +fn language_specific_frontends_queries_do_not_share_nodes() { + let frontends = pipeline().language_specific_frontends; + assert!(!frontends.shared_input()); } -// ── Stage 1 ────────────────────────────────────────────────────────────── +// ── Logical ASAP-aware optimization ────────────────────────────────────── -/// Stage 1 outputs exactly the 3 Q2 options × {separate, shared input} = 6 candidates. +/// Pass 1 gives 1 × 3 = 3 candidates: Q2's top-k is exact, Count-Min + heap or CountSketch + heap. #[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() { - let (_, logical, _) = pipeline(); - assert_eq!(logical.len(), 6); - let found: BTreeSet<_> = logical +fn logical_asap_optimization_has_three_pass1_candidates() { + let run = pipeline(); + assert_eq!(run.logical.len(), 3); + let found: BTreeSet<_> = run + .logical .iter() .map(|c| classify(&c.dag, &c.query_roots)) .collect(); - assert_eq!( - found, - expected_combinations(), - "each combination exactly once" - ); + let expected: BTreeSet<_> = PASS1_Q2_OPTIONS.into_iter().map(|o| (o, false)).collect(); + assert_eq!(found, expected, "each Q2 option exactly once"); } -/// Q1 is exact in every Stage 1 candidate: no summary is reachable from its root. +/// Q1 is exact in every candidate: no summary is reachable from its root. #[test] -fn stage1_q1_is_always_exact() { - let (_, logical, _) = pipeline(); - for c in &logical { +fn logical_asap_optimization_q1_is_always_exact() { + let run = pipeline(); + for c in &run.logical { let (q1, _) = roots(&c.query_roots); - let reach = closure(&c.dag, q1); assert!( - reach.iter().all(|&id| !is_summary(payload(&c.dag, id))), + closure(&c.dag, q1) + .iter() + .all(|&id| !is_summary(c.dag.payload(id))), "{}: Q1 reaches a summary", c.id ); } } -/// Q2's summary families are exactly Count-Min + heap per job and Hydra over all jobs. +/// Every Pass 1 target's exact option is its exact accumulator: Q1's rate and sum and Q2's sum_over_time. +#[test] +fn logical_asap_optimization_exact_targets_use_accumulators() { + let run = pipeline(); + for c in &run.logical { + let accumulators = c + .dag + .nodes + .iter() + .filter(|n| { + matches!( + n.operator, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) + ) + }) + .count(); + assert_eq!(accumulators, 3, "{}", c.id); + } +} + +/// Q2's summary families are Count-Min + heap and CountSketch + heap, one per job. #[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() { - let (_, logical, _) = pipeline(); - let families: BTreeSet<_> = logical +fn logical_asap_optimization_q2_summary_families() { + let run = pipeline(); + let families: BTreeSet<_> = run + .logical .iter() .flat_map(|c| { let (_, q2) = roots(&c.query_roots); @@ -606,41 +262,47 @@ 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 + ]) ); } -/// Sharing adds a variant and keeps the independent one, for every Q2 option. +/// Pass 1 also offers Hydra (HydraCms over all (job, series) keys, a heap per job) for Q2's top-k. #[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"] -fn stage1_keeps_independent_and_shared_variants() { - let (_, logical, _) = pipeline(); - let found: Vec<_> = logical +#[ignore = "needs Pass 1 Hydra for grouped approximate counts (#605)"] +fn logical_asap_optimization_q2_offers_hydra() { + let run = pipeline(); + assert!(run + .logical + .iter() + .any(|c| classify(&c.dag, &c.query_roots).0 == Q2Option::Hydra)); +} + +/// Pass 2's identical-expression rule adds a shared-input variant of every Q2 option and keeps the independent one. +#[test] +#[ignore = "needs the Pass 2 identical-expression rule (#588)"] +fn logical_asap_optimization_keeps_independent_and_shared_variants() { + let run = pipeline(); + let found: Vec<_> = run + .logical .iter() .map(|c| classify(&c.dag, &c.query_roots)) .collect(); - for option in [ - Q2Option::Exact, - Q2Option::CountMinHeapPerJob, - Q2Option::Hydra, - ] { - assert!( - found.contains(&(option, false)), - "{option:?} independent missing" - ); - assert!(found.contains(&(option, true)), "{option:?} shared missing"); + for option in PASS1_Q2_OPTIONS { + assert!(found.contains(&(option, false)), "{option:?} independent"); + assert!(found.contains(&(option, true)), "{option:?} shared"); } } -/// Only the raw input is shared between Q1 and Q2; no summary is shared. +/// Only the raw input is ever shared between Q1 and Q2; no summary is shared. #[test] -fn stage1_shares_input_but_never_a_summary() { - let (_, logical, _) = pipeline(); - for c in &logical { - let (q1, q2) = roots(&c.query_roots); - let shared = &closure(&c.dag, q1) & &closure(&c.dag, q2); - for id in shared { - let p = payload(&c.dag, id); +fn logical_asap_optimization_shares_input_but_never_a_summary() { + let run = pipeline(); + for c in &run.logical { + for id in cross_query_nodes(&c.dag, &c.query_roots) { + let p = c.dag.payload(id); assert!( matches!(relational(p).as_deref(), Some("scan" | "time_range")), "{}: shared node {id:?} is not the range selector input: {p:?}", @@ -650,44 +312,30 @@ fn stage1_shares_input_but_never_a_summary() { } } -/// Stage 1 candidates are valid DAGs with unique ids. +/// Logical candidates are valid DAGs with unique ids. #[test] -fn stage1_candidates_are_valid_and_uniquely_named() { - let (_, logical, _) = pipeline(); - let ids: BTreeSet<_> = logical.iter().map(|c| c.id.as_str()).collect(); - assert_eq!(ids.len(), logical.len()); - for c in &logical { - for root in &c.roots { - root.validate_structure() - .unwrap_or_else(|e| panic!("{}: {e}", c.id)); - } - } +fn logical_asap_optimization_candidates_are_valid_and_uniquely_named() { + assert_valid_and_uniquely_named(&pipeline()); } -// ── Stage 2 ────────────────────────────────────────────────────────────── +// ── Physical ASAP-aware optimization ───────────────────────────────────── -/// No candidate is discarded before Stage 3: Stage 2 maps the 6 logical candidates one-to-one. +/// No candidate is discarded before plan selection: the 3 logical candidates map 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); - 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); - let ids: BTreeSet<_> = physical.iter().map(|p| p.id.as_str()).collect(); - assert_eq!(ids.len(), physical.len()); -} - -/// Stage 2 preserves each logical candidate's Q2 option and input sharing. +fn physical_asap_optimization_keeps_every_logical_candidate() { + let run = pipeline(); + assert_one_physical_candidate_per_logical(&run); + assert_eq!(run.physical.len(), 3); +} + +/// Each physical candidate keeps its logical 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 { - let source = logical.iter().find(|c| c.id == p.from_logical).unwrap(); +fn physical_asap_optimization_preserves_logical_choices() { + let run = pipeline(); + for p in &run.physical { + let source = run.logical.iter().find(|c| c.id == p.from_logical).unwrap(); assert_eq!( - classify(&p.dag, &p.query_roots), + classify(p.dag(), &p.query_roots), classify(&source.dag, &source.query_roots), "{} vs {}", p.id, @@ -698,49 +346,38 @@ 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); - for p in exact { - let sort_then_limit = p.dag.edges.iter().any(|e| { - relational(payload(&p.dag, e.producer)).as_deref() == Some("sort") - && relational(payload(&p.dag, e.consumer)).as_deref() == Some("limit") - }); - assert!(sort_then_limit, "{}: no sort → limit", p.id); - } +fn physical_asap_optimization_exact_topk_is_sort_then_limit() { + let run = pipeline(); + let p = physical_with(&run, Q2Option::Exact); + let sort_then_limit = p.dag().edges.iter().any(|e| { + relational(p.dag().payload(e.producer)).as_deref() == Some("sort") + && relational(p.dag().payload(e.consumer)).as_deref() == Some("limit") + }); + assert!(sort_then_limit, "{}: no sort → limit", p.id); } /// 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 { - if classify(&p.dag, &p.query_roots).0 == Q2Option::Exact { +fn physical_asap_optimization_summary_topk_is_build_then_estimate() { + let run = pipeline(); + for p in &run.physical { + if classify(p.dag(), &p.query_roots).0 == Q2Option::Exact { continue; } - let kinds: Vec<_> = p.dag.nodes.iter().map(|n| &n.payload).collect(); assert!( - !kinds - .iter() - .any(|k| matches!(k, Operator::ASAP(ASAPOp::SummaryMerge { .. }))), - "{}: no window summaries in the MVP, so no merge", + !p.dag().nodes.iter().any(|n| is_merge(&n.payload)), + "{}: no window summaries, so no merge", p.id ); - let build_to_estimate = p.dag.edges.iter().any(|e| { + let build_to_estimate = p.dag().edges.iter().any(|e| { matches!( - payload(&p.dag, e.producer), + p.dag().payload(e.producer), Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(..), .. }) ) && matches!( - payload(&p.dag, e.consumer), + p.dag().payload(e.consumer), Operator::ASAP(ASAPOp::SummaryEstimate { .. }) ) }); @@ -748,189 +385,138 @@ fn stage2_summary_topk_is_build_then_estimate() { } } -/// With no materialization in the MVP, every node runs at query time. +/// Every sub-DAG is NotMaterialized, so every node runs at query time. #[test] -fn stage2_everything_runs_at_query_time() { - let (_, _, physical) = pipeline(); - for p in &physical { - for n in &p.dag.nodes { - assert!( - !runs_at_ingestion(p, n.id), - "{}: {:?} at ingestion", +fn physical_asap_optimization_nothing_is_materialized() { + let run = pipeline(); + for p in &run.physical { + for n in &p.dag().nodes { + assert_eq!( + materialization(p, n.id), + MaterializationOption::NotMaterialized, + "{}: {:?}", p.id, n.id ); + assert!(!runs_at_ingestion(p, n.id), "{}: {:?}", p.id, n.id); } } } -/// Compile `p` in the physical planner (the runtime capability check). Inputs -/// are what a deployment supplies: raw series for each sub-DAG the planner -/// runs as a retained PromQL expression, and the samples of each time range a -/// native operator reads. -fn compile_in_runtime(p: &PhysicalCandidate) -> Result<(), String> { - use asap_physical_operators::physical_planner::{compile, promql_fallback, InputContract}; - use asap_types::ir::physical_export::compile_physical_asap_workload_with_node_ids; +/// Compile `p` in the physical planner (the runtime capability check), with +/// the samples each time range reads as its input. +fn compile_in_runtime(p: &Physical) -> Result<(), String> { + use asap_physical_operators::physical_planner::{compile, InputContract}; use std::sync::Arc; - let ids = compile_physical_asap_workload_with_node_ids(&p.stage2.roots) - .expect("re-export") - .node_ids; + let dag = p.dag(); let mut inputs = BTreeMap::new(); - let mut pending = p.dag.roots.clone(); + let mut pending = dag.roots.clone(); let mut seen = HashSet::new(); while let Some(id) = pending.pop() { if !seen.insert(id) { continue; } - let node = ids.operator_node(id).expect("node"); - let time_range = relational(payload(&p.dag, id)).as_deref() == Some("time_range"); - // Raw samples a summary reads are an input, not a retained expression. - let summary_input = time_range - && p.dag.edges.iter().any(|e| { - e.producer == id - && matches!( - payload(&p.dag, e.consumer), - Operator::ASAP(ASAPOp::SummaryAgg { .. }) - ) - }); - let fallback = (!node.contains_asap() && !summary_input) - .then(|| promql_fallback::raw_series(node).ok()) - .flatten(); - if let Some(selectors) = fallback { - for (i, (_, schema)) in selectors.into_iter().enumerate() { - let slot = promql_fallback::raw_series_input(id as u64, i); - inputs.insert(slot, InputContract::bounded(schema)); - } - } else if time_range { - let schema = p.dag.nodes.iter().find(|n| n.id == id).unwrap(); - let schema = Arc::new(schema.output_schema.clone()); + if relational(dag.payload(id)).as_deref() == Some("time_range") { + let node = dag.nodes.iter().find(|n| n.id == id).unwrap(); + let schema = Arc::new(node.output_schema.clone()); inputs.insert(id as u64, InputContract::bounded(schema)); } else { - pending.extend(p.dag.producers(id)); + pending.extend(dag.producers(id)); } } - let roots: Vec = p.dag.roots.iter().map(|&id| id as u64).collect(); - compile(&p.dag, inputs, &roots) + let roots: Vec = dag.roots.iter().map(|&id| id as u64).collect(); + compile(dag, inputs, &roots) .map(|_| ()) - .map_err(|e| format!("{} ({}): {e}", p.id, p.label)) + .map_err(|e| format!("{} ({}): {e}", p.id, p.candidate.label)) } /// 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 physical candidate. #[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() { - let (_, _, physical) = pipeline(); - let failures: Vec<_> = physical +#[ignore = "runtime gaps: CMS+heap needs a non-negative weight contract (#593); \ + CountSketch+heap's SummaryEstimate{TopK} output is not the keyed-evaluation \ + shape the physical planner builds (#579)"] +fn physical_asap_optimization_every_candidate_compiles_in_the_physical_planner() { + let run = pipeline(); + let failures: Vec<_> = run + .physical .iter() .filter_map(|p| compile_in_runtime(p).err()) .collect(); assert!(failures.is_empty(), "{failures:#?}"); } -/// The plan Stage 3 selects compiles in the physical planner (added by the -/// implementer, not part of the spec). +// ── Plan selection ─────────────────────────────────────────────────────── + +/// Plan selection selects one candidate and gives every other one a reason. #[test] -fn stage3_selected_plan_compiles_in_the_physical_planner() { - let (workload, _, physical) = pipeline(); - let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); - let selected = physical - .iter() - .find(|p| p.id == selection.selected) - .unwrap(); - compile_in_runtime(selected).unwrap(); +fn plan_selection_selects_one_and_explains_the_rest() { + assert_selects_one_and_explains_the_rest(&pipeline()); } -// ── Stage 3 ────────────────────────────────────────────────────────────── - -/// Stage 3 selects one candidate and gives every other one a reason. +/// The exact Q2 is selected. Count-Min + heap is invalid because its weights +/// are not proven non-negative; CountSketch + heap is invalid because its +/// top-k output has no guarantee and the deployment has no evidence for it. #[test] -fn stage3_selects_one_and_explains_the_rest() { - let (workload, _, physical) = pipeline(); - let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); - let all: BTreeSet<_> = physical.iter().map(|p| p.id.clone()).collect(); - let mut accounted: BTreeSet<_> = selection.rejected.iter().map(|r| r.id.clone()).collect(); +fn plan_selection_selects_exact_q2_and_rejects_both_heaps() { + let run = pipeline(); assert_eq!( - accounted.len(), - selection.rejected.len(), - "rejected once each" + run.selection.selected, + physical_with(&run, Q2Option::Exact).id ); - assert!(selection.rejected.iter().all(|r| !r.reason.is_empty())); + let invalid = run.invalid(); + assert_eq!(invalid.len(), 2, "{invalid:?}"); + let cms = &physical_with(&run, Q2Option::CountMinHeapPerJob).id; assert!( - accounted.insert(selection.selected.clone()), - "selected is not rejected" + invalid[cms.as_str()].contains("non-negative"), + "{invalid:?}" + ); + let count_sketch = &physical_with(&run, Q2Option::CountSketchHeapPerJob).id; + assert!( + invalid[count_sketch.as_str()].contains("no guarantee"), + "{invalid:?}" ); - assert_eq!(accounted, all); } /// The selected plan is the cheapest valid candidate for the whole workload. #[test] -fn stage3_selects_cheapest_valid() { - 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(); - let best = selection.costs[&selection.selected].total; - for p in physical.iter().filter(|p| !invalid.contains(p.id.as_str())) { - assert!(best <= selection.costs[&p.id].total, "{} is cheaper", p.id); - } +fn plan_selection_selects_cheapest_valid() { + assert_selects_cheapest_valid(&pipeline()); } -/// Every node is charged exactly once, so a shared input is costed once for both queries. +/// Every node of a valid candidate is charged exactly once, so a shared input is costed once for both queries. #[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()); - for p in &physical { - 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(); - assert_eq!( - charged, nodes, - "{}: per-node costs cover each node once", - p.id - ); - let sum: f64 = cost.per_node.values().sum(); - assert!( - (cost.total - sum).abs() <= 1e-9 * sum.abs().max(1.0), - "{}", - p.id - ); - } +fn plan_selection_charges_each_node_once() { + assert_each_node_charged_once(&pipeline()); +} + +/// The plan selection picks compiles in the physical planner (added by the +/// implementer, not part of the spec). +#[test] +fn plan_selection_selected_plan_compiles_in_the_physical_planner() { + let run = pipeline(); + compile_in_runtime(run.physical(&run.selection.selected)).unwrap(); } /// Sharing the input never costs more than reading it separately. #[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"] -fn stage3_shared_input_is_not_costlier() { - let (workload, _, physical) = pipeline(); - let selection = stage3_select(&workload, &physical, PlanningModels::builtin()); - let by_combo: BTreeMap<_, _> = physical +#[ignore = "needs the Pass 2 identical-expression rule (#588): there are no shared-input variants to compare"] +fn plan_selection_shared_input_is_not_costlier() { + let run = pipeline(); + let by_combo: BTreeMap<_, _> = run + .physical .iter() - .map(|p| { - ( - classify(&p.dag, &p.query_roots), - selection.costs[&p.id].total, - ) - }) + .filter_map(|p| Some((classify(p.dag(), &p.query_roots), run.cost(&p.id)?))) .collect(); - for option in [ - Q2Option::Exact, - Q2Option::CountMinHeapPerJob, - Q2Option::Hydra, - ] { - assert!( - by_combo[&(option, true)] <= by_combo[&(option, false)], - "{option:?}" - ); + let mut compared = 0; + for option in PASS1_Q2_OPTIONS { + if let (Some(shared), Some(separate)) = ( + by_combo.get(&(option, true)), + by_combo.get(&(option, false)), + ) { + assert!(shared <= separate, "{option:?}"); + compared += 1; + } } + assert!(compared > 0); } diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index 2ce1057ad..3ee71787f 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -1,23 +1,28 @@ # Planner layering, Example 1: acceptance spec (MVP) -Audience: planner designers and the Phase C implementer. +Audience: planner designers and the implementers of the planning stages. Source: [planner-layering.md](planner-layering.md), "Example 1: Aggregation over -dimensions". Tests: `crates/integration-tests/tests/planner_layering_example1.rs`. -Viewer fixture shape: `tools/dag-viewer/examples/planner-layering-example1.expected.json`. +dimensions". Tests: `crates/integration-tests/tests/planner_layering_example1.rs` +(helpers in `tests/planner_layering_common/`). Viewer fixture: +`tools/dag-viewer/examples/planner-layering-example1.json`, written by +`stage_pipeline --example planner-layering-1`. -This spec was written by a test designer who did not implement the stages. +This spec was written by a test designer before the stages existed. A test +whose expectation needs a feature that is not in the stack yet is +`#[ignore]`d with the PR or issue that delivers it. ## 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) | -| 3. Selection | 1 plan | 1 plan | - -Every MVP candidate is one of the doc's candidates: in Stage 1, the one with no -window summary for either query; in Stage 2, the one where both queries are +| Language-specific frontends | 1 workload `LogicalDAG` | same | +| Logical ASAP-aware optimization | Pass 1 (3) × identical-expression rule (2) × window-composition rule (3 × 3) = **54** | Pass 1 only = **3**. Pass 2 is not implemented: the identical-expression rule is #588, window composition #601. | +| Physical ASAP-aware optimization | Materialization options per window form = **156** | Physical operator implementation, every sub-DAG `NotMaterialized` = **3** (the doc's Raw plans). Materialization enumeration is #604. | +| Plan selection | 1 plan | 1 plan | + +Every MVP candidate is one of the doc's candidates: in logical ASAP-aware +optimization, the one with separate inputs and no window summary for either +query; in physical ASAP-aware optimization, the one where both queries are **Raw** (state rebuilt from the last 1 min of raw samples at every refresh). ## Workload @@ -30,7 +35,7 @@ Shared data workload: `continuously_ingesting`, 15 s ingestion interval, | Q1 `sum by (job) (rate(http_requests_total[1m]))` | 10 s | 1 m | evaluation time | exact | none | | Q2 `topk by (job) (10, sum_over_time(http_requests_total[1m]))` | 10 s | 1 m | evaluation time | ε = 0.01, δ = 0.001 | ≤ 100 ms | -## Stage 0: 1 candidate +## Language-specific frontends: 1 candidate | Query | Chain | |---|---| @@ -40,111 +45,129 @@ Shared data workload: `continuously_ingesting`, 15 s ingestion interval, Invariants: * Exactly one candidate, with one root per query, in workload entry order. -* No summary node (sketch `summary_agg`, `summary_estimate`, `summary_merge`). -* Q1 and Q2 share no node. Sharing is a Stage 1 decision. +* No summary node (sketch `SummaryAgg`, `SummaryEstimate`, `SummaryMerge`). +* Q1 and Q2 share no node. Sharing is a logical ASAP-aware optimization + decision. -## Stage 1: 6 candidates +## Logical ASAP-aware optimization: 3 candidates -| 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 | +Pass 1 targets and their per-computation candidates: + +| Query | Target | Candidates | +|---|---|---| +| Q1 | per-series `rate` | exact Rate accumulator | +| Q1 | per-`job` `sum` | exact Sum accumulator | +| Q2 | per-series `sum_over_time` | exact Sum accumulator | +| Q2 | per-`job` top-*k* | exact, Count-Min + heap per job, CountSketch + heap per job | + +| Id | Q1 | Q2 top-*k* | +|---|---|---| +| L1 | exact | exact (sort + limit 10 per job) | +| L2 | exact | Count-Min + top-*k* heap, one per job | +| L3 | exact | CountSketch + top-*k* heap, one per job | -"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. +Ids follow the enumeration order; tests identify candidates by structure. Invariants: -* Exactly 6 candidates, one per (Q2 option, separate or shared). No duplicates. +* Exactly 3 candidates, one per Q2 option. 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. -* 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, +* Every target's exact option is its exact accumulator (`SummaryAgg` with an + exact family → `FinalizeExactAccumulator`): three per candidate. +* Q2's sketch families are exactly {`CmsWithHeap`, `CountSketchWithHeap`}, + each per subpopulation (job). Exact Q2 has no sketch. +* Only the scan and range nodes may be shared between the queries. No summary + is shared. (Today nothing is shared.) +* Nothing is pruned: the heap sketches are admitted by ε = 0.01, δ = 0.001, and Q1's exact target admits only the exact option. -## Stage 2: 6 candidates +Ignored until their feature lands: -| Id | From | Q2 physical operators | Timing | +* Hydra (`HydraCms` over all (job, series) keys, a heap per job) as a third + Q2 summary (#605). +* A shared-input variant of every Q2 option next to the independent one + (Pass 2 identical-expression rule, #588). + +## Physical ASAP-aware optimization: 3 candidates + +| Id | From | Q2 physical operators | Materialization | |---|---|---|---| -| 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 | +| P1 | L1 | sort (partition by job) → limit 10 | all `NotMaterialized` | +| P2 | L2 | CMS + heap build → top-10 estimate | all `NotMaterialized` | +| P3 | L3 | CountSketch + heap build → top-10 estimate | all `NotMaterialized` | -Q1 is the same in all six: scan → range → per-series rate → sum by (job). +Q1 is the same in all three: scan → range → rate accumulator → sum +accumulator, each finalized. Invariants: -* Exactly one physical candidate per logical candidate. `from_logical` is a - bijection onto the Stage 1 ids, so no valid candidate is dropped before - Stage 3. +* Exactly one physical candidate per logical candidate, and none pruned. + `from_logical` is a bijection onto the logical ids, so no candidate is + dropped before plan selection. * Each physical candidate keeps its logical Q2 option and input sharing. -* Exact TopK is implemented as a sort followed by a limit. +* Exact top-*k* is implemented as a sort followed by a limit. * 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. + merge node, because there are no window summaries. +* Every node is `NotMaterialized` and runs at query time. + +Runtime capability check (not part of the doc): every candidate compiles in +the physical planner. Ignored on two runtime gaps: CMS + heap needs a +non-negative weight contract (#593), and the CountSketch + heap estimate's +output is not the keyed-evaluation shape the physical planner builds (#579). -## Stage 3: 1 plan +## Plan selection: 1 plan + +| Id | Outcome | Reason | +|---|---|---| +| P1 | selected | the only valid candidate | +| P2 | invalid | Count-Min needs non-negative update weights, and `sum_over_time` weights are not proven non-negative | +| P3 | invalid | a top-*k* output has no guarantee (a heap's bound is per item frequency, not top-*k* membership), and the deployment has no evidence that it meets the target | 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. + reason, and marked invalid or valid but costlier. +* Only valid candidates are priced. Each valid candidate's cost has one entry + per DAG node, and `total` is their sum, so a shared node is charged once. * 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. - -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. - -## Ambiguities and MVP deviations - -1. **Stage 2 is materialization-only in the doc.** Example 1 lists only - window-form or materialization options. The MVP reads "physical operator - implementation" from the Stage 2 section: "TopK as a sort followed by a - limit". This gives one physical candidate per logical candidate. The doc - names no alternative implementation (for example hash versus sort - aggregation) for this example. -2. **Where exact TopK becomes sort + limit.** The Pass 1 diagram already - draws exact Q2 as "sort + limit 10 per job", but the Stage 2 section calls - this a physical choice. Today the frontend emits `aggregate[top_k]`. The - spec requires sort → limit only by Stage 2. -3. **Minimal Pass 2.** Only the identical-expression rule applies. The - window-composition rule adds 48 of the doc's 54 candidates, and its - tumbling and sliding variants wait on #511. -4. **"Shared summary charged once"** does not apply literally: the doc says no - summary is shared in Example 1. The tests check the general form (each node - charged once) on the shared input node. -5. **Workload DAG with two roots.** `LogicalASAPDAG` has one `root`, but - Example 1 needs one DAG for both queries. The stubs carry `query_roots` - until the export supports several roots. -6. **Where cost is computed.** The viewer contract puts `cost` on Stage 2 - candidates, but the doc says only Stage 3 uses the cost model. The spec has - Stage 3 produce costs, and the viewer document attaches them to Stage 2 - entries. -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. -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. -10. **Accuracy of Count-Min heap merges** matters only with tumbling windows, - which are out of MVP scope. +* The selected plan compiles in the physical planner. + +Ignored until Pass 2 (#588): for each Q2 option, the shared-input candidate +costs no more than its separate counterpart. + +The doc's typical winners need window forms or materialization, and its +"Raw with a shared input" needs Pass 2. Latency bounds are not checked, so +whether an exact Q2 misses 100 ms is not tested. + +## Where the doc and the planner differ + +1. **Q2's top-*k* options.** The doc lists "exact, Count-Min + heap, Hydra". + The planner offers exact, Count-Min + heap and CountSketch + heap; Hydra + comes in #605. The tests follow the planner. +2. **Count-Min + heap is invalid.** The doc expects the accuracy model to + judge it. Plan selection admits Count-Min only over weights proven + non-negative, which `sum_over_time` over an arbitrary metric is not + (counter declarations: #593). +3. **No summary is selectable.** The doc expects a summary for Q2 to win in + most deployments. A composed top-*k* estimate has no guarantee, so without + deployment evidence every heap candidate is invalid and the exact plan is + selected. +4. **Stage 2 is materialization in the doc's example.** Example 1 lists only + window-form and materialization options. The MVP reads "physical operator + implementation" from the Stage 2 section ("TopK as a sort followed by a + limit"), which gives one physical candidate per logical candidate. +5. **Where exact top-*k* becomes sort + limit.** The Pass 1 diagram draws + exact Q2 as "sort + limit 10 per job", but the frontend emits + `Aggregate{[TopK]}` and the Stage 2 section calls sort + limit a physical + choice. The tests require sort → limit only after physical ASAP-aware + optimization. +6. **Hydra's inner sketch.** The doc says "Hydra over the whole `job` + column" without naming the inner sketch. The test assumes `HydraCms`. +7. **"Shared summary charged once"** does not apply literally: the doc says + no summary is shared in Example 1. The tests check the general form (each + node charged once). +8. **Latency.** The doc says an exact top 10 rebuilt at every refresh "may + miss" 100 ms. Plan selection does not check latency yet. +9. **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.expected.json b/tools/dag-viewer/examples/planner-layering-example1.expected.json deleted file mode 100644 index c655fc33a..000000000 --- a/tools/dag-viewer/examples/planner-layering-example1.expected.json +++ /dev/null @@ -1,1603 +0,0 @@ -{ - "format": "asap-stage-pipeline/v1", - "expected_only": true, - "_notes": [ - "Shape reference for planner-layering Example 1 (MVP): see docs/design_docs/proposals/planner-layering-example1-acceptance.md.", - "Nodes carry only payload kinds and a label, not full schemas. Real documents have the full LogicalASAPDAG/PhysicalASAPDAG nodes.", - "query_roots stands in for root: one workload DAG serves both queries, and LogicalASAPDAG has a single root.", - "Costs are null: the cost model decides them. The selection is illustrative (the doc's 'Raw with a shared input' winner); tests only require the cheapest valid candidate." - ], - "workload": { - "queries": [ - { - "id": "q1", - "language": "promql", - "text": "sum by (job) (rate(http_requests_total[1m]))", - "repeat_every_s": 10, - "lookback_s": 60, - "accuracy": "exact" - }, - { - "id": "q2", - "language": "promql", - "text": "topk by (job) (10, sum_over_time(http_requests_total[1m]))", - "repeat_every_s": 10, - "lookback_s": 60, - "accuracy": { - "epsilon": 0.01, - "delta": 0.001 - }, - "max_latency_ms": 100 - } - ] - }, - "stage0_logical": { - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)" - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)" - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 6, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum_over_time (per series)" - }, - { - "id": 7, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "topk by (job) (10)" - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - }, - { - "producer": 6, - "consumer": 7 - } - ], - "query_roots": { - "q1": 3, - "q2": 7 - } - } - }, - "stage1_logical_asap": { - "candidates": [ - { - "id": "L1", - "label": "Q1 exact · Q2 exact · separate input", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)" - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)" - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 6, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum_over_time (per series)" - }, - { - "id": 7, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "topk by (job) (10)" - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - }, - { - "producer": 6, - "consumer": 7 - } - ], - "query_roots": { - "q1": 3, - "q2": 7 - } - } - }, - { - "id": "L2", - "label": "Q1 exact · Q2 exact · shared input", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)" - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)" - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum_over_time (per series)" - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "topk by (job) (10)" - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 1, - "consumer": 4 - }, - { - "producer": 4, - "consumer": 5 - } - ], - "query_roots": { - "q1": 3, - "q2": 5 - } - } - }, - { - "id": "L3", - "label": "Q1 exact · Q2 Count-Min + heap · separate input", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)" - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)" - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 6, - "payload": { - "kind": "summary_agg" - }, - "label": "Count-Min + top-k heap, one per job" - }, - { - "id": 7, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job" - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - }, - { - "producer": 6, - "consumer": 7 - } - ], - "query_roots": { - "q1": 3, - "q2": 7 - } - } - }, - { - "id": "L4", - "label": "Q1 exact · Q2 Count-Min + heap · shared input", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)" - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)" - }, - { - "id": 4, - "payload": { - "kind": "summary_agg" - }, - "label": "Count-Min + top-k heap, one per job" - }, - { - "id": 5, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job" - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 1, - "consumer": 4 - }, - { - "producer": 4, - "consumer": 5 - } - ], - "query_roots": { - "q1": 3, - "q2": 5 - } - } - }, - { - "id": "L5", - "label": "Q1 exact · Q2 Hydra · separate input", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)" - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)" - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 6, - "payload": { - "kind": "summary_agg" - }, - "label": "Hydra (CMS) over all (job, series) keys + heap per job" - }, - { - "id": 7, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job" - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - }, - { - "producer": 6, - "consumer": 7 - } - ], - "query_roots": { - "q1": 3, - "q2": 7 - } - } - }, - { - "id": "L6", - "label": "Q1 exact · Q2 Hydra · shared input", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total" - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m" - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)" - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)" - }, - { - "id": 4, - "payload": { - "kind": "summary_agg" - }, - "label": "Hydra (CMS) over all (job, series) keys + heap per job" - }, - { - "id": 5, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job" - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 1, - "consumer": 4 - }, - { - "producer": 4, - "consumer": 5 - } - ], - "query_roots": { - "q1": 3, - "q2": 5 - } - } - } - ] - }, - "stage2_physical_asap": { - "candidates": [ - { - "id": "P1", - "from_logical": "L1", - "label": "Q1 exact · Q2 exact · separate input · sort + limit · query time", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 6, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum_over_time (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 7, - "payload": { - "kind": "relational", - "operator": { - "kind": "sort" - } - }, - "label": "sort by value, partition by job", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 8, - "payload": { - "kind": "relational", - "operator": { - "kind": "limit" - } - }, - "label": "limit 10", - "output_state": { - "timing": "QueryTime" - } - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - }, - { - "producer": 6, - "consumer": 7 - }, - { - "producer": 7, - "consumer": 8 - } - ], - "query_roots": { - "q1": 3, - "q2": 8 - } - }, - "cost": { - "total": null, - "unit": "cpu_ms_per_s", - "per_node": {} - } - }, - { - "id": "P2", - "from_logical": "L2", - "label": "Q1 exact · Q2 exact · shared input · sort + limit · query time", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum_over_time (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "sort" - } - }, - "label": "sort by value, partition by job", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 6, - "payload": { - "kind": "relational", - "operator": { - "kind": "limit" - } - }, - "label": "limit 10", - "output_state": { - "timing": "QueryTime" - } - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 1, - "consumer": 4 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - } - ], - "query_roots": { - "q1": 3, - "q2": 6 - } - }, - "cost": { - "total": null, - "unit": "cpu_ms_per_s", - "per_node": {} - } - }, - { - "id": "P3", - "from_logical": "L3", - "label": "Q1 exact · Q2 Count-Min + heap · separate input · build + estimate · query time", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 6, - "payload": { - "kind": "summary_agg" - }, - "label": "Count-Min + top-k heap, one per job", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 7, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job", - "output_state": { - "timing": "QueryTime" - } - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - }, - { - "producer": 6, - "consumer": 7 - } - ], - "query_roots": { - "q1": 3, - "q2": 7 - } - }, - "cost": { - "total": null, - "unit": "cpu_ms_per_s", - "per_node": {} - } - }, - { - "id": "P4", - "from_logical": "L4", - "label": "Q1 exact · Q2 Count-Min + heap · shared input · build + estimate · query time", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 4, - "payload": { - "kind": "summary_agg" - }, - "label": "Count-Min + top-k heap, one per job", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 5, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job", - "output_state": { - "timing": "QueryTime" - } - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 1, - "consumer": 4 - }, - { - "producer": 4, - "consumer": 5 - } - ], - "query_roots": { - "q1": 3, - "q2": 5 - } - }, - "cost": { - "total": null, - "unit": "cpu_ms_per_s", - "per_node": {} - } - }, - { - "id": "P5", - "from_logical": "L5", - "label": "Q1 exact · Q2 Hydra · separate input · build + estimate · query time", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 4, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 5, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 6, - "payload": { - "kind": "summary_agg" - }, - "label": "Hydra (CMS) over all (job, series) keys + heap per job", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 7, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job", - "output_state": { - "timing": "QueryTime" - } - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 4, - "consumer": 5 - }, - { - "producer": 5, - "consumer": 6 - }, - { - "producer": 6, - "consumer": 7 - } - ], - "query_roots": { - "q1": 3, - "q2": 7 - } - }, - "cost": { - "total": null, - "unit": "cpu_ms_per_s", - "per_node": {} - } - }, - { - "id": "P6", - "from_logical": "L6", - "label": "Q1 exact · Q2 Hydra · shared input · build + estimate · query time", - "dag": { - "nodes": [ - { - "id": 0, - "payload": { - "kind": "relational", - "operator": { - "kind": "scan" - } - }, - "label": "http_requests_total", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 1, - "payload": { - "kind": "relational", - "operator": { - "kind": "time_range" - } - }, - "label": "range 1m", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 2, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "rate (per series)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 3, - "payload": { - "kind": "relational", - "operator": { - "kind": "aggregate" - } - }, - "label": "sum by (job)", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 4, - "payload": { - "kind": "summary_agg" - }, - "label": "Hydra (CMS) over all (job, series) keys + heap per job", - "output_state": { - "timing": "QueryTime" - } - }, - { - "id": 5, - "payload": { - "kind": "summary_estimate" - }, - "label": "top 10 per job", - "output_state": { - "timing": "QueryTime" - } - } - ], - "edges": [ - { - "producer": 0, - "consumer": 1 - }, - { - "producer": 1, - "consumer": 2 - }, - { - "producer": 2, - "consumer": 3 - }, - { - "producer": 1, - "consumer": 4 - }, - { - "producer": 4, - "consumer": 5 - } - ], - "query_roots": { - "q1": 3, - "q2": 5 - } - }, - "cost": { - "total": null, - "unit": "cpu_ms_per_s", - "per_node": {} - } - } - ] - }, - "stage3_selection": { - "selected": "P6", - "rejected": [ - { - "id": "P1", - "reason": "valid; costlier than P6 (illustrative)" - }, - { - "id": "P2", - "reason": "valid; costlier than P6 (illustrative)" - }, - { - "id": "P3", - "reason": "valid; costlier than P6 (illustrative)" - }, - { - "id": "P4", - "reason": "valid; costlier than P6 (illustrative)" - }, - { - "id": "P5", - "reason": "valid; costlier than P6 (illustrative)" - } - ] - } -} From 3f5174f2b3215cbf7a8ab2582072820e08e6cbdb Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:06:34 +0000 Subject: [PATCH 05/19] test: port #590's Example 2-4 acceptance specs and tests The specs and tests from #590 run the current stages through the shared planner_layering_common pipeline (Pass 1 and composition, optimize_physical_asap, select_plan); tests are named after the stages. Materialization is read from the physical DAG (#667); window forms and retention stay pending adapters. Behavior that works passes (29 tests); every expectation that needs a feature not in this stack is ignored with the PR that delivers it (29 tests): Pass 2 sharing (#588, #595, #596), window composition (#601, #628, #634), materialization enumeration and MatAtQuery (#604, #625), UnivMon sizing and accuracy (#621), SQL recognition and COUNT(*) composition (#598, #603) and plan selection row estimates (#632). Each spec's "Today" column states the current output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_example2.rs | 554 ++++++++++++++++++ .../tests/planner_layering_example3.rs | 441 ++++++++++++++ .../tests/planner_layering_example4.rs | 337 +++++++++++ .../planner-layering-example1-acceptance.md | 15 +- .../planner-layering-example2-acceptance.md | 146 +++++ .../planner-layering-example3-acceptance.md | 115 ++++ .../planner-layering-example4-acceptance.md | 151 +++++ 7 files changed, 1752 insertions(+), 7 deletions(-) create mode 100644 crates/integration-tests/tests/planner_layering_example2.rs create mode 100644 crates/integration-tests/tests/planner_layering_example3.rs create mode 100644 crates/integration-tests/tests/planner_layering_example4.rs create mode 100644 docs/design_docs/proposals/planner-layering-example2-acceptance.md create mode 100644 docs/design_docs/proposals/planner-layering-example3-acceptance.md create mode 100644 docs/design_docs/proposals/planner-layering-example4-acceptance.md diff --git a/crates/integration-tests/tests/planner_layering_example2.rs b/crates/integration-tests/tests/planner_layering_example2.rs new file mode 100644 index 000000000..717d93acf --- /dev/null +++ b/crates/integration-tests/tests/planner_layering_example2.rs @@ -0,0 +1,554 @@ +//! Acceptance tests for #509 "Example 2: One summary for several +//! computations — the summary-capability rule in Pass 2". +//! +//! Spec: `docs/design_docs/proposals/planner-layering-example2-acceptance.md`. +//! Written by a test designer who does not implement the stages. Tests that +//! need an unimplemented feature are `#[ignore]`d, naming it and the PR that +//! delivers it. +//! +//! The doc's workload is SQL. The SQL frontend lowers Q1 to a distinct count +//! but not Q2 and Q3 to `Entropy` and `L2` (the doc's own TODO, #598), so +//! the planning stages run on a PromQL stand-in with the same structure: +//! three statistics of one input over the same 1-min window, through the +//! frontend's `distinct_over_time`, `entropy_over_time` and `l2_over_time`. + +mod planner_layering_common; + +use std::collections::BTreeSet; + +use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; +use asap_types::ir::QueryRoot; +use asap_types::post_asap::{Field, Schema, SketchAlgorithm, SketchParams, SketchStatistic}; +use asap_types::pre_asap::DataType; +use asap_types::types::AccuracyTarget; +use asap_types::workload::{ + AccuracyRequirement, DataArrival, DataDistribution, DataWorkload, DurationMs, + LatencyRequirement, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, + QueryTimeScope, QueryWorkload, Rate, RepeatedDemand, RepeatingEntry, RepetitionInterval, + SqlDialect, TimeSelection, +}; +use planner_layering_common::*; + +// ── Example 2 workload ─────────────────────────────────────────────────── + +const SQL_Q1: &str = + "SELECT COUNT(DISTINCT src_ip) FROM flows WHERE ts >= now() - INTERVAL '1 minute'"; +const SQL_Q2: &str = "SELECT -SUM(p * LN(p)) FROM (\ + SELECT COUNT(*) * 1.0 / SUM(COUNT(*)) OVER () AS p FROM flows \ + WHERE ts >= now() - INTERVAL '1 minute' GROUP BY src_ip)"; +const SQL_Q3: &str = "SELECT SQRT(SUM(c * c)) FROM (\ + SELECT src_ip, COUNT(*) AS c FROM flows \ + WHERE ts >= now() - INTERVAL '1 minute' GROUP BY src_ip)"; + +/// PromQL stand-in for Q1–Q3 (see the module docs). +const PROMQL_Q1: &str = "distinct_over_time(flows_src_ip[1m])"; +const PROMQL_Q2: &str = "entropy_over_time(flows_src_ip[1m])"; +const PROMQL_Q3: &str = "l2_over_time(flows_src_ip[1m])"; + +/// (ε, δ) of Q1, Q2, Q3. Q3 is the strictest. +const ACCURACY: [(f64, f64); 3] = [(0.02, 0.01), (0.05, 0.01), (0.01, 0.01)]; + +fn panel(query: &str, (epsilon, delta): (f64, f64)) -> RepeatingEntry { + RepeatingEntry { + query: Query(query.into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval(10_000)), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon, + delta, + }), + response_latency: LatencyRequirement::Unspecified, + }, + predictability: Predictability::Predictable { known_at: None }, + time_selection: TimeSelection { + scope: QueryTimeScope::RealTime, + lookback: Some(DurationMs(60_000)), + as_of: None, + }, + } +} + +/// The shared data workload with 10,000,000 distinct source IPs. +fn data_workload(ingestion_interval: bool) -> DataWorkload { + DataWorkload { + arrival: DataArrival::ContinuouslyIngesting, + data_ingestion_interval: if ingestion_interval { + declared(DurationMs(15_000)) + } else { + Default::default() + }, + ingestion_volume: Default::default(), + ingestion_rate: declared(Rate(1_000_000.0 / 15.0)), + input_cardinality: declared(10_000_000), + distribution: declared(DataDistribution::Zipf), + } +} + +fn workload(language: QueryLanguage, queries: [&str; 3]) -> PlanningWorkload { + let sql = matches!(language, QueryLanguage::SQL(_)); + PlanningWorkload { + query_workload: QueryWorkload { + language, + query_batch: None, + repeating_queries: Some( + queries + .iter() + .zip(ACCURACY) + .map(|(q, accuracy)| panel(q, accuracy)) + .collect(), + ), + }, + // `data_ingestion_interval` is not needed for SQL; PromQL needs it. + data_workload: Some(data_workload(!sql)), + } +} + +/// Example 2 as the doc writes it. +fn sql_workload() -> PlanningWorkload { + workload( + QueryLanguage::SQL(SqlDialect::DataFusionSQL), + [SQL_Q1, SQL_Q2, SQL_Q3], + ) +} + +fn standin_workload() -> PlanningWorkload { + workload(QueryLanguage::PromQL, [PROMQL_Q1, PROMQL_Q2, PROMQL_Q3]) +} + +/// `flows(ts, src_ip)`: the doc names only these two columns. +fn catalog() -> SqlCatalog { + SqlCatalog::new().with_table( + "flows", + Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("src_ip", DataType::Utf8, false), + ], + 0, + vec![], + ), + ) +} + +async fn lower_sql(workload: &PlanningWorkload) -> Vec { + let mut roots = Vec::new(); + for entry in workload.query_workload.entries() { + let root = lower_sql_dialect( + &entry.query.0, + &catalog(), + SqlDialect::DataFusionSQL, + entry.requirements.accuracy.target(), + ) + .await + .unwrap_or_else(|e| panic!("{}: {e}", entry.query.0)); + roots.push(QueryRoot::Operator(root)); + } + roots +} + +fn pipeline() -> Run { + let workload = standin_workload(); + run_stages(&workload, lower_promql(&workload)) +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/// UnivMon build nodes of `c` with the queries that read each. +fn univmons(c: &Logical) -> Vec<(BTreeSet, SketchParams, BTreeSet)> { + sketch_builds(&c.dag) + .into_iter() + .filter(|(_, algorithm, _)| *algorithm == SketchAlgorithm::UnivMon) + .map(|(id, _, params)| { + let statistics = estimates_of(&c.dag, id) + .into_iter() + .map(|(_, s)| format!("{s:?}")) + .collect(); + (readers(&c.dag, &c.query_roots, id), params, statistics) + }) + .collect() +} + +/// The UnivMon read by every query in `queries` and by no other. +fn shared_univmon(c: &Logical, queries: &[usize]) -> Option { + let want: BTreeSet<_> = queries.iter().copied().collect(); + univmons(c) + .into_iter() + .find(|(readers, ..)| *readers == want) + .map(|(_, params, _)| params) +} + +/// Whether some summary build is read by more than one query. +fn shares_a_summary(c: &Logical) -> bool { + sketch_builds(&c.dag) + .iter() + .any(|(id, ..)| readers(&c.dag, &c.query_roots, *id).len() > 1) +} + +fn options(run: &Run, query: usize) -> BTreeSet { + run.logical + .iter() + .filter(|c| !shares_a_summary(c)) + .map(|c| query_option(&c.dag, &c.query_roots, query)) + .collect() +} + +/// UnivMon state size: heap entries plus counters over every layer. +fn footprint(params: &SketchParams) -> u64 { + let SketchParams::UnivMon { + heap_size, + sketch_rows, + sketch_cols, + layers, + } = params + else { + panic!("UnivMon params: {params:?}"); + }; + u64::from(*heap_size) + u64::from(*sketch_rows) * u64::from(*sketch_cols) * u64::from(*layers) +} + +/// Q`query`'s own UnivMon parameters, from an independent candidate. +fn own_univmon(run: &Run, query: usize) -> SketchParams { + run.logical + .iter() + .filter(|c| !shares_a_summary(c)) + .find_map(|c| shared_univmon(c, &[query])) + .expect("Pass 1 offers UnivMon") +} + +// ── Workload and language-specific frontends ───────────────────────────────────────────────── + +/// The encoded SQL workload is valid and normalizes to Q1, Q2, Q3. +#[test] +fn workload_encodes_example2() { + let workload = sql_workload(); + workload.validate().expect("valid workload"); + let queries: Vec<_> = workload + .query_workload + .entries() + .map(|e| e.query.0) + .collect(); + assert_eq!(queries, [SQL_Q1, SQL_Q2, SQL_Q3]); + standin_workload().validate().expect("valid stand-in"); +} + +/// The SQL frontend lowers Q1 to a distinct count over `flows`. +#[tokio::test] +async fn language_specific_frontends_sql_q1_lowers_to_a_distinct_count() { + let roots = lower_sql(&sql_workload()).await; + let ops = frontend_operations(&roots[0]); + assert!(ops.contains(&"scan".to_string()), "{ops:?}"); + assert!( + ops.contains(&"aggregate:cardinality".to_string()), + "{ops:?}" + ); +} + +/// The SQL frontend recognizes Q2 as `Entropy(src_ip)` and Q3 as `L2(src_ip)`. +#[tokio::test] +#[ignore = "needs SQL frontend recognition of the Entropy and L2 idioms (#598)"] +async fn language_specific_frontends_sql_q2_q3_lower_to_entropy_and_l2() { + let roots = lower_sql(&sql_workload()).await; + assert!(frontend_operations(&roots[1]).contains(&"aggregate:frequency_entropy".to_string())); + assert!(frontend_operations(&roots[2]).contains(&"aggregate:frequency_l2".to_string())); +} + +/// Once the SQL forms are recognized, the SQL workload has the stand-in's Pass 1 options. +#[tokio::test] +#[ignore = "needs SQL frontend recognition of the Entropy and L2 idioms (#598) and SQL COUNT(*) candidates that compose on flows(ts, src_ip) (#603)"] +async fn logical_asap_optimization_sql_has_the_standin_options() { + let workload = sql_workload(); + let sql = run_stages(&workload, lower_sql(&workload).await); + let standin = pipeline(); + for q in 0..3 { + assert_eq!(options(&sql, q), options(&standin, q), "Q{}", q + 1); + } +} + +/// The stand-in lowers to the three statistics, each over a 1-min range of one input. +#[test] +fn language_specific_frontends_standin_lowers_to_three_statistics_of_one_input() { + let roots = lower_promql(&standin_workload()); + let ops: Vec<_> = roots.iter().map(frontend_operations).collect(); + for (ops, statistic) in ops.iter().zip([ + "aggregate:cardinality", + "aggregate:frequency_entropy", + "aggregate:frequency_l2", + ]) { + let mut want = vec!["scan".to_string(), statistic.into(), "time_range".into()]; + want.sort(); + assert_eq!(ops, &want); + } +} + +/// The frontends give one summary-free workload DAG whose queries share no node. +#[test] +fn language_specific_frontends_one_summary_free_dag_without_sharing() { + let run = pipeline(); + let s0 = &run.language_specific_frontends; + assert_eq!(s0.query_roots.len(), 3); + assert!(s0.dag.nodes.iter().all(|n| !is_summary(&n.operator))); + assert!(cross_query_nodes(&s0.dag, &s0.query_roots).is_empty()); +} + +// ── Logical ASAP-aware optimization ────────────────────────────────────── + +/// Pass 1 offers an exact and a UnivMon option for each of the three statistics. +#[test] +fn logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic() { + let run = pipeline(); + for q in 0..3 { + let found = options(&run, q); + assert!(found.contains("exact"), "Q{}: {found:?}", q + 1); + assert!(found.contains("UnivMon"), "Q{}: {found:?}", q + 1); + } +} + +/// Pass 1 offers a specialized distinct-count summary for Q1. +#[test] +fn logical_asap_optimization_pass1_offers_a_distinct_count_summary() { + let found = options(&pipeline(), 0); + assert!( + found + .iter() + .any(|o| ["Hll", "Theta", "Kmv"].contains(&o.as_str())), + "{found:?}" + ); +} + +/// Pass 1 offers a specialized entropy summary for Q2 and a norm summary for Q3. +#[test] +#[ignore = "needs Pass 1 specialized entropy and L2 summary families (no PR yet)"] +fn logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries() { + let run = pipeline(); + for q in [1, 2] { + let found = options(&run, q); + assert!( + found.iter().any(|o| o != "exact" && o != "UnivMon"), + "Q{}: {found:?}", + q + 1 + ); + } +} + +/// Every combination of the three queries' own options is kept as an independent candidate. +#[test] +fn logical_asap_optimization_keeps_every_independent_combination() { + let run = pipeline(); + let found: BTreeSet<_> = run + .logical + .iter() + .filter(|c| !shares_a_summary(c)) + .map(|c| { + (0..3) + .map(|q| query_option(&c.dag, &c.query_roots, q)) + .collect::>() + }) + .collect(); + let (o1, o2, o3) = (options(&run, 0), options(&run, 1), options(&run, 2)); + assert_eq!(found.len(), o1.len() * o2.len() * o3.len()); +} + +/// The summary-capability rule adds one UnivMon build feeding a distinct-count, an entropy and an L2 estimate. +#[test] +#[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] +fn logical_asap_optimization_summary_capability_adds_one_univmon_for_all_three() { + let run = pipeline(); + let shared = run.logical.iter().find(|c| { + let ums = univmons(c); + ums.len() == 1 && ums[0].0 == BTreeSet::from([0, 1, 2]) + }); + let c = shared.expect("a candidate with one UnivMon read by Q1, Q2 and Q3"); + let statistics = &univmons(c)[0].2; + for s in [ + SketchStatistic::Cardinality, + SketchStatistic::FrequencyEntropy, + SketchStatistic::FrequencyL2, + ] { + assert!(statistics.contains(&format!("{s:?}")), "{statistics:?}"); + } +} + +/// For each pair, a candidate shares one UnivMon between the two while the third keeps each of its own options. +#[test] +#[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] +fn logical_asap_optimization_summary_capability_adds_pairwise_shared_univmons() { + let run = pipeline(); + for (pair, third) in [([0, 1], 2), ([0, 2], 1), ([1, 2], 0)] { + let thirds: BTreeSet<_> = run + .logical + .iter() + .filter(|c| shared_univmon(c, &pair).is_some()) + .map(|c| query_option(&c.dag, &c.query_roots, third)) + .collect(); + assert_eq!(thirds, options(&run, third), "pair {pair:?}"); + } +} + +/// A UnivMon shared by several statistics is sized for the strictest consumer. +#[test] +#[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] +fn logical_asap_optimization_shared_univmon_is_sized_for_the_strictest_consumer() { + let run = pipeline(); + let strictest = |queries: &[usize]| { + *queries + .iter() + .min_by(|a, b| ACCURACY[**a].0.total_cmp(&ACCURACY[**b].0)) + .unwrap() + }; + let mut seen = 0; + for queries in [vec![0, 1, 2], vec![0, 1], vec![0, 2], vec![1, 2]] { + for c in &run.logical { + if let Some(params) = shared_univmon(c, &queries) { + seen += 1; + assert_eq!(params, own_univmon(&run, strictest(&queries)), "{}", c.id); + } + } + } + assert!(seen > 0, "no shared UnivMon"); +} + +/// Pass 1 sizes UnivMon for its query's target: ε = 0.01 (Q3) needs a larger state than ε = 0.05 (Q2). +#[test] +#[ignore = "needs UnivMon sizing for an accuracy target (#621); its shape is fixed"] +fn logical_asap_optimization_univmon_is_sized_per_accuracy_target() { + let run = pipeline(); + assert!(footprint(&own_univmon(&run, 2)) > footprint(&own_univmon(&run, 1))); +} + +/// Only a UnivMon, never a specialized summary, is shared across the statistics. +#[test] +fn logical_asap_optimization_only_univmon_is_shared_across_statistics() { + let run = pipeline(); + for c in &run.logical { + for (id, algorithm, _) in sketch_builds(&c.dag) { + if readers(&c.dag, &c.query_roots, id).len() > 1 { + assert_eq!(algorithm, SketchAlgorithm::UnivMon, "{}", c.id); + } + } + } +} + +/// Logical candidates are valid DAGs with unique ids. +#[test] +fn logical_asap_optimization_candidates_are_valid_and_uniquely_named() { + assert_valid_and_uniquely_named(&pipeline()); +} + +// ── Physical ASAP-aware optimization ───────────────────────────────────── + +/// No candidate is discarded before plan selection: logical candidates map one-to-one onto physical ones. +#[test] +fn physical_asap_optimization_keeps_every_logical_candidate() { + assert_one_physical_candidate_per_logical(&pipeline()); +} + +/// Physical ASAP-aware optimization keeps the shared UnivMon as one build node read by all three queries. +#[test] +#[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] +fn physical_asap_optimization_keeps_the_shared_univmon_as_one_build() { + let run = pipeline(); + let mut seen = 0; + for c in run + .logical + .iter() + .filter(|c| shared_univmon(c, &[0, 1, 2]).is_some()) + { + for p in run.physical_of(c) { + seen += 1; + let builds: Vec<_> = sketch_builds(p.dag()) + .into_iter() + .filter(|(_, a, _)| *a == SketchAlgorithm::UnivMon) + .collect(); + assert_eq!(builds.len(), 1, "{}", p.id); + assert_eq!(readers(p.dag(), &p.query_roots, builds[0].0).len(), 3); + } + } + assert!(seen > 0); +} + +// ── Plan selection ─────────────────────────────────────────────────────── + +/// Plan selection selects one candidate and gives every other one a reason. +#[test] +fn plan_selection_selects_one_and_explains_the_rest() { + assert_selects_one_and_explains_the_rest(&pipeline()); +} + +/// The selected plan is the cheapest valid candidate for the whole workload. +#[test] +fn plan_selection_selects_cheapest_valid() { + assert_selects_cheapest_valid(&pipeline()); +} + +/// Every node of a valid candidate is charged once, so a summary read by several queries is costed once. +#[test] +fn plan_selection_charges_each_node_once() { + assert_each_node_charged_once(&pipeline()); +} + +/// A UnivMon estimate has a guarantee, so plan selection judges it against its target instead of rejecting it for having none. +#[test] +#[ignore = "needs a UnivMon accuracy model: L2 in #621; distinct count and entropy have none yet"] +fn plan_selection_judges_univmon_with_an_accuracy_model() { + let run = pipeline(); + let invalid = run.invalid(); + for c in &run.logical { + for p in run.physical_of(c) { + let Some(reason) = invalid.get(p.id.as_str()) else { + continue; + }; + for q in 0..3 { + if query_option(&c.dag, &c.query_roots, q) == "UnivMon" { + let unguaranteed = format!("q{}: the output has no guarantee", q + 1); + assert!(!reason.starts_with(&unguaranteed), "{}: {reason}", p.id); + } + } + } + } +} + +/// One UnivMon for all three costs no more than three separate UnivMons. +#[test] +#[ignore = "needs one UnivMon for all three (#595, #596) and a UnivMon accuracy model (#621)"] +fn plan_selection_shared_univmon_costs_no_more_than_three() { + let run = pipeline(); + let cost = |c: &Logical| run.cost(&run.physical_of(c).next().unwrap().id); + let separate: Vec<_> = run + .logical + .iter() + .filter(|c| { + !shares_a_summary(c) + && (0..3).all(|q| query_option(&c.dag, &c.query_roots, q) == "UnivMon") + }) + .map(|c| cost(c).unwrap_or_else(|| panic!("{} is not priced", c.id))) + .collect(); + assert!(!separate.is_empty(), "independent all-UnivMon candidate"); + let mut compared = 0; + for shared in run + .logical + .iter() + .filter(|c| shared_univmon(c, &[0, 1, 2]).is_some()) + { + let shared_cost = cost(shared).unwrap_or_else(|| panic!("{} is not priced", shared.id)); + for separate_cost in &separate { + assert!( + shared_cost <= *separate_cost, + "{shared_cost} vs {separate_cost}" + ); + } + compared += 1; + } + assert!(compared > 0); +} + +/// The selected plan updates at most one UnivMon per flow record: separate UnivMons are dominated by the shared one. +#[test] +fn plan_selection_selected_plan_has_at_most_one_univmon() { + let run = pipeline(); + let selected = run.physical(&run.selection.selected); + let count = sketch_builds(selected.dag()) + .iter() + .filter(|(_, a, _)| *a == SketchAlgorithm::UnivMon) + .count(); + assert!(count <= 1, "{}: {count} UnivMons", selected.id); +} diff --git a/crates/integration-tests/tests/planner_layering_example3.rs b/crates/integration-tests/tests/planner_layering_example3.rs new file mode 100644 index 000000000..1e531c581 --- /dev/null +++ b/crates/integration-tests/tests/planner_layering_example3.rs @@ -0,0 +1,441 @@ +//! Acceptance tests for #509 "Example 3: Aggregation over windows — the +//! window-composition rule in Pass 2". +//! +//! Spec: `docs/design_docs/proposals/planner-layering-example3-acceptance.md`. +//! Written by a test designer who does not implement the stages. Tests that +//! need an unimplemented feature are `#[ignore]`d, naming it and the PR that +//! delivers it. Window summaries are read through +//! `planner_layering_common::window_form`, which the window-composition +//! implementer fills in. + +mod planner_layering_common; + +use std::collections::BTreeSet; + +use asap_types::ir::flat::NodeId; +use asap_types::post_asap::{SketchAlgorithm, SketchParams, SketchStatistic}; +use asap_types::workload::DataArrival; +use planner_layering_common::*; + +fn run_a() -> Run { + run_promql(&pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed, + )) +} + +fn run_b() -> Run { + run_promql(&pattern_b()) +} + +/// The per-computation options of `query` across all candidates. +fn options(run: &Run, query: usize) -> BTreeSet { + run.logical + .iter() + .map(|c| query_option(&c.dag, &c.query_roots, query)) + .collect() +} + +fn op(name: &str) -> String { + name.to_string() +} + +/// The summary builds of `c`: (build, algorithm, window form, readers). +fn windowed_builds(c: &Logical) -> Vec<(NodeId, SketchAlgorithm, WindowForm, BTreeSet)> { + sketch_builds(&c.dag) + .into_iter() + .map(|(id, algorithm, _)| { + let form = window_form(&c.dag, id); + (id, algorithm, form, readers(&c.dag, &c.query_roots, id)) + }) + .collect() +} + +// ── Workloads ──────────────────────────────────────────────────────────── + +/// Pattern A is one ad hoc batch of five queries run once at T; Pattern B one repeating query. +#[test] +fn workload_encodes_example3() { + let a = pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed); + a.validate().expect("valid Pattern A"); + let queries: Vec<_> = a.query_workload.entries().map(|e| e.query.0).collect(); + assert_eq!(queries, PATTERN_A.map(|(q, ..)| q)); + let b = pattern_b(); + b.validate().expect("valid Pattern B"); + assert_eq!(evaluation_interval_ms(&b, 0), Some(PATTERN_B_INTERVAL_MS)); + assert_eq!(lookback_ms(&b, 0), PATTERN_B_WINDOW_MS); +} + +// ── Pattern A: language-specific frontends ─────────────────────────────── + +/// Each Pattern A query lowers to scan → (time shift) → range → quantile. +#[test] +fn language_specific_frontends_a_lowers_each_query_to_its_interval() { + let roots = lower_promql(&pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed, + )); + let ops: Vec<_> = roots.iter().map(frontend_operations).collect(); + let plain = vec![op("aggregate:quantile"), op("scan"), op("time_range")]; + let shifted = vec![ + op("aggregate:quantile"), + op("scan"), + op("time_range"), + op("time_shift"), + ]; + assert_eq!( + ops, + [ + plain.clone(), + plain, + shifted.clone(), + shifted.clone(), + shifted + ] + ); +} + +/// The frontends give one summary-free DAG whose five queries share no node. +#[test] +fn language_specific_frontends_a_one_summary_free_dag_without_sharing() { + let run = run_a(); + let s0 = &run.language_specific_frontends; + assert_eq!(s0.query_roots.len(), 5); + assert!(s0.dag.nodes.iter().all(|n| !is_summary(&n.operator))); + assert!(cross_query_nodes(&s0.dag, &s0.query_roots).is_empty()); +} + +// ── Pattern A: logical ASAP-aware optimization ─────────────────────────── + +/// Pass 1 offers each query an exact and a KLL candidate over its own interval. +#[test] +fn logical_asap_optimization_a_pass1_offers_exact_and_kll_per_query() { + let run = run_a(); + for q in 0..5 { + let found = options(&run, q); + assert!( + found.contains("exact") && found.contains("Kll"), + "q{}: {found:?}", + q + 1 + ); + } +} + +/// The five independent KLLs: one per query, each read by its query only, all sized for ε = 0.005. +#[test] +fn logical_asap_optimization_a_keeps_five_independent_klls() { + let run = run_a(); + let all_kll = run + .logical + .iter() + .find(|c| { + !c.shared_input() && (0..5).all(|q| query_option(&c.dag, &c.query_roots, q) == "Kll") + }) + .expect("independent all-KLL candidate"); + let builds = sketch_builds(&all_kll.dag); + assert_eq!(builds.len(), 5); + let readers: BTreeSet<_> = builds + .iter() + .map(|(id, ..)| readers(&all_kll.dag, &all_kll.query_roots, *id)) + .collect(); + assert_eq!(readers, (0..5).map(|q| BTreeSet::from([q])).collect()); + let sizes: BTreeSet<_> = builds + .iter() + .map(|(_, _, params)| match params { + SketchParams::Kll { k } => *k, + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(sizes.len(), 1, "one ε, one KLL size: {sizes:?}"); +} + +/// The identical-expression rule shares only raw input (scan, range, shift), never a quantile or summary, and keeps the unshared variant. +#[test] +#[ignore = "needs the Pass 2 identical-expression rule (#588)"] +fn logical_asap_optimization_a_identical_expression_rule_shares_only_raw_input() { + let run = run_a(); + assert!(run.logical.iter().any(|c| c.shared_input())); + assert!(run.logical.iter().any(|c| !c.shared_input())); + for c in &run.logical { + for id in cross_query_nodes(&c.dag, &c.query_roots) { + let kind = relational(c.dag.payload(id)); + assert!( + matches!(kind.as_deref(), Some("scan" | "time_range" | "time_shift")), + "{}: shared {kind:?}", + c.id + ); + } + } +} + +/// One Exponential Histogram of KLLs over [T − 5y, T] serves all five queries, each through its own merge and p99 estimate. +#[test] +#[ignore = "needs Pass 2 window composition across queries: one window summary for all five (#628, #634)"] +fn logical_asap_optimization_a_window_composition_adds_one_eh_for_all_five() { + let run = run_a(); + let found = run.logical.iter().find(|c| { + windowed_builds(c).iter().any(|(_, a, form, readers)| { + *a == SketchAlgorithm::Kll + && matches!(form, WindowForm::ExponentialHistogram { horizon_ms } if *horizon_ms >= 5 * YEAR_MS) + && readers.len() == 5 + }) + }); + let c = found.expect("a candidate with one EH of KLLs read by all five queries"); + let (eh, ..) = windowed_builds(c) + .into_iter() + .find(|(_, _, form, _)| matches!(form, WindowForm::ExponentialHistogram { .. })) + .unwrap(); + let estimates = estimates_of(&c.dag, eh); + assert_eq!(estimates.len(), 5, "one estimate per query"); + for (estimate, statistic) in estimates { + assert_eq!(statistic, SketchStatistic::Quantile { q: 0.99 }); + let merged = c + .dag + .producers(estimate) + .into_iter() + .any(|p| is_merge(c.dag.payload(p))); + assert!( + merged, + "{}: estimate {estimate:?} does not read a merge", + c.id + ); + } +} + +/// The shared EH candidate is added next to the independent KLL candidates, not instead of them. +#[test] +#[ignore = "needs Pass 2 window composition across queries: one window summary for all five (#628, #634)"] +fn logical_asap_optimization_a_keeps_independent_and_shared_window_summaries() { + let run = run_a(); + let shared = run.logical.iter().any(|c| { + windowed_builds(c).iter().any(|(_, _, f, r)| { + matches!(f, WindowForm::ExponentialHistogram { .. }) && r.len() == 5 + }) + }); + let independent = run.logical.iter().any(|c| { + let builds = windowed_builds(c); + builds.len() == 5 + && builds.iter().all(|(_, a, f, r)| { + *a == SketchAlgorithm::Kll && *f == WindowForm::None && r.len() == 1 + }) + }); + assert!(shared && independent); +} + +/// Pass 2 adds a candidate for each way of grouping two queries onto one shared window summary. +#[test] +#[ignore = "needs Pass 2 window composition across queries: partial groupings (#628, #634)"] +fn logical_asap_optimization_a_window_composition_groups_every_pair() { + let run = run_a(); + let groups: BTreeSet<_> = run + .logical + .iter() + .flat_map(windowed_builds) + .filter(|(_, _, f, r)| *f != WindowForm::None && r.len() > 1) + .map(|(.., r)| r) + .collect(); + for a in 0..5 { + for b in a + 1..5 { + assert!( + groups.contains(&BTreeSet::from([a, b])), + "pair q{} q{}", + a + 1, + b + 1 + ); + } + } +} + +// ── Pattern A: plan selection ──────────────────────────────────────────── + +/// Plan selection selects one Pattern A candidate, explains the rest, and picks the cheapest valid one. +#[test] +fn plan_selection_a_selects_cheapest_valid() { + let run = run_a(); + assert_selects_one_and_explains_the_rest(&run); + assert_selects_cheapest_valid(&run); +} + +/// Every node is charged once, so a node read by several queries is costed once. +#[test] +fn plan_selection_a_charges_each_node_once() { + assert_each_node_charged_once(&run_a()); +} + +/// Sharing the scan never costs more than scanning separately, for the same per-computation choices. +#[test] +#[ignore = "needs the Pass 2 identical-expression rule (#588) and plan selection row estimates for time-shifted scans and narrower time ranges (#632)"] +fn plan_selection_a_shared_scan_is_not_costlier() { + let run = run_a(); + let key = |c: &Logical| { + (0..5) + .map(|q| query_option(&c.dag, &c.query_roots, q)) + .collect::>() + }; + let cost = |c: &Logical| run.cost(&run.physical_of(c).next().unwrap().id); + let mut compared = 0; + for shared in run.logical.iter().filter(|c| c.shared_input()) { + let separate = run + .logical + .iter() + .find(|c| !c.shared_input() && key(c) == key(shared)) + .expect("independent counterpart"); + if let (Some(s), Some(i)) = (cost(shared), cost(separate)) { + assert!(s <= i, "{} {s} vs {} {i}", shared.id, separate.id); + compared += 1; + } + } + assert!(compared > 0); +} + +// ── Pattern B: language-specific frontends and Pass 1 ──────────────────── + +/// Pattern B lowers to scan → range 5m → quantile, with no summary. +#[test] +fn language_specific_frontends_b_lowers_to_one_range_quantile() { + let run = run_b(); + let roots = lower_promql(&pattern_b()); + assert_eq!( + frontend_operations(&roots[0]), + [op("aggregate:quantile"), op("scan"), op("time_range")] + ); + assert!(run + .language_specific_frontends + .dag + .nodes + .iter() + .all(|n| !is_summary(&n.operator))); +} + +/// Pass 1 offers an exact and a KLL candidate for the 5-min window. +#[test] +fn logical_asap_optimization_b_pass1_offers_exact_and_kll() { + let found = options(&run_b(), 0); + assert!( + found.contains("exact") && found.contains("Kll"), + "{found:?}" + ); +} + +// ── Pattern B: window composition ──────────────────────────────────────── + +/// Each Pass 1 option gets three window forms: none, a 5-min sliding window with a 1-min slide, and 1-min tumbling windows. +#[test] +#[ignore = "needs Pass 2 window composition: tumbling windows (#601) and sliding windows (no PR yet)"] +fn logical_asap_optimization_b_window_composition_adds_sliding_and_tumbling_per_option() { + let run = run_b(); + let summaries: BTreeSet<_> = options(&run, 0) + .into_iter() + .filter(|o| o != "exact") + .collect(); + let found: BTreeSet<_> = run + .logical + .iter() + .flat_map(windowed_builds) + .map(|(_, a, f, _)| (format!("{a:?}"), f)) + .collect(); + let forms = [ + WindowForm::None, + WindowForm::Sliding { + length_ms: PATTERN_B_WINDOW_MS, + slide_ms: PATTERN_B_INTERVAL_MS, + }, + WindowForm::Tumbling { + length_ms: PATTERN_B_INTERVAL_MS, + }, + ]; + for option in &summaries { + for form in forms { + assert!(found.contains(&(option.clone(), form)), "{option} {form:?}"); + } + } +} + +/// Window parameters are legal: L divides W, s divides L and the evaluation interval, a tumbling length divides both. +#[test] +fn logical_asap_optimization_b_window_parameters_are_legal() { + let (w, every) = (PATTERN_B_WINDOW_MS, PATTERN_B_INTERVAL_MS); + for c in &run_b().logical { + for (_, _, form, _) in windowed_builds(c) { + match form { + WindowForm::Sliding { + length_ms, + slide_ms, + } => { + assert_eq!(w % length_ms, 0, "{}: L | W", c.id); + assert_eq!(length_ms % slide_ms, 0, "{}: s | L", c.id); + assert_eq!(every % slide_ms, 0, "{}: s | interval", c.id); + } + WindowForm::Tumbling { length_ms } => { + assert_eq!(w % length_ms, 0, "{}: length | W", c.id); + assert_eq!(every % length_ms, 0, "{}: length | interval", c.id); + } + WindowForm::ExponentialHistogram { .. } => { + panic!("{}: Pattern B has no EH", c.id) + } + WindowForm::None => {} + } + } + } +} + +/// A sliding window with L = W reads one completed window (no merge); tumbling windows merge W / length of them. +#[test] +#[ignore = "needs Pass 2 window composition: tumbling windows (#601) and sliding windows (no PR yet)"] +fn logical_asap_optimization_b_merge_only_where_the_window_form_needs_it() { + let run = run_b(); + let mut seen = BTreeSet::new(); + for c in &run.logical { + for (build, _, form, _) in windowed_builds(c) { + let merged = estimates_of(&c.dag, build).iter().any(|(e, _)| { + c.dag + .producers(*e) + .into_iter() + .any(|p| is_merge(c.dag.payload(p))) + }); + assert_eq!( + merged, + needs_merge(form, PATTERN_B_WINDOW_MS), + "{}: {form:?}", + c.id + ); + if form != WindowForm::None { + seen.insert(merged); + } + } + } + assert_eq!( + seen, + BTreeSet::from([false, true]), + "both a sliding and a tumbling candidate" + ); +} + +/// A window form that merges is used only with a summary whose states merge (KLL, DDSketch). +#[test] +fn logical_asap_optimization_window_merges_use_mergeable_summaries() { + for (run, window) in [(run_a(), 5 * YEAR_MS), (run_b(), PATTERN_B_WINDOW_MS)] { + for c in &run.logical { + for (_, algorithm, form, _) in windowed_builds(c) { + if needs_merge(form, window) { + assert!( + matches!(algorithm, SketchAlgorithm::Kll | SketchAlgorithm::DDSketch), + "{}: {algorithm:?} {form:?}", + c.id + ); + } + } + } + } +} + +// ── Pattern B: plan selection ──────────────────────────────────────────── + +/// Plan selection selects one Pattern B candidate, explains the rest, and picks the cheapest valid one. +#[test] +fn plan_selection_b_selects_cheapest_valid() { + let run = run_b(); + assert_selects_one_and_explains_the_rest(&run); + assert_selects_cheapest_valid(&run); + assert_each_node_charged_once(&run); +} diff --git a/crates/integration-tests/tests/planner_layering_example4.rs b/crates/integration-tests/tests/planner_layering_example4.rs new file mode 100644 index 000000000..01d6dc523 --- /dev/null +++ b/crates/integration-tests/tests/planner_layering_example4.rs @@ -0,0 +1,337 @@ +//! Acceptance tests for #509 "Example 4: Materialization of window summaries +//! in physical planning". +//! +//! Spec: `docs/design_docs/proposals/planner-layering-example4-acceptance.md`. +//! Written by a test designer who does not implement the stages. Tests that +//! need an unimplemented feature are `#[ignore]`d, naming it and the PR that +//! delivers it. Materialization is read from the physical DAG (#667); window +//! summaries and retention are read through the pending adapters in +//! `planner_layering_common`, which the implementers fill in. +//! +//! The workloads are Example 3's, with Pattern A's recurrence and data +//! arrival varied as the doc does. + +mod planner_layering_common; + +use std::collections::{BTreeMap, BTreeSet}; + +use asap_types::ir::flat::NodeId; +use asap_types::post_asap::SketchAlgorithm; +use asap_types::workload::{DataArrival, PlanningWorkload}; +use planner_layering_common::*; + +/// Pattern A as given: one ad hoc batch at T over mixed data. +fn once() -> PlanningWorkload { + pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed) +} + +fn monthly() -> PlanningWorkload { + pattern_a(PatternARecurrence::MonthlyPredictable, DataArrival::Mixed) +} + +fn at_rest() -> PlanningWorkload { + pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::AtRest) +} + +/// The window-summary build of `c` with form `want` (`Some(form)` matches +/// exactly; `None` matches any EH) read by `readers` queries. +fn window_build(c: &Logical, want: Option, readers_count: usize) -> Option { + sketch_builds(&c.dag) + .into_iter() + .find_map(|(id, algorithm, _)| { + let form = window_form(&c.dag, id); + let matches = match want { + Some(want) => form == want, + None => matches!(form, WindowForm::ExponentialHistogram { .. }), + }; + (algorithm == SketchAlgorithm::Kll + && matches + && readers(&c.dag, &c.query_roots, id).len() == readers_count) + .then_some(id) + }) +} + +/// The physical candidates of the logical candidate with a KLL window +/// summary of `form` read by `readers_count` queries, keyed by the +/// materialization of that build. Several logical candidates may match (for +/// example with and without the shared input); the first is used. +fn options_of( + run: &Run, + form: Option, + readers_count: usize, +) -> BTreeMap { + let logical = run + .logical + .iter() + .find(|c| window_build(c, form, readers_count).is_some()) + .unwrap_or_else(|| { + let form = form.map_or("EH".to_string(), |f| format!("{f:?}")); + panic!("no logical candidate with a {form} KLL window summary") + }); + let build = window_build(logical, form, readers_count).unwrap(); + // Physical ASAP-aware optimization renumbers nodes; find the build again + // in each physical DAG. + run.physical_of(logical) + .map(|p| { + let id = sketch_builds(p.dag()) + .into_iter() + .find(|(id, a, _)| { + *a == SketchAlgorithm::Kll + && window_form(p.dag(), *id) == window_form(&logical.dag, build) + }) + .map(|(id, ..)| id) + .expect("the window summary survives physical ASAP-aware optimization"); + (materialization(p, id), (p.id.clone(), id)) + }) + .collect() +} + +fn eh_options(run: &Run) -> BTreeMap { + options_of(run, None, 5) +} + +fn tumbling() -> Option { + Some(WindowForm::Tumbling { + length_ms: PATTERN_B_INTERVAL_MS, + }) +} + +fn sliding() -> Option { + Some(WindowForm::Sliding { + length_ms: PATTERN_B_WINDOW_MS, + slide_ms: PATTERN_B_INTERVAL_MS, + }) +} + +fn cost(run: &Run, id: &str) -> f64 { + run.cost(id).unwrap_or_else(|| panic!("{id} is not priced")) +} + +use MaterializationOption::{MatAtIngestion, MatAtQuery, NotMaterialized}; + +// ── Constraints over every candidate ───────────────────────────────────── + +/// Every node upstream of an ingestion-time node also runs at ingestion time, in every workload variant. +#[test] +fn physical_asap_optimization_ingestion_time_upstream_is_ingestion_time() { + for workload in [once(), monthly(), at_rest(), pattern_b()] { + assert_ingestion_upstream_is_ingestion(&run_promql(&workload)); + } +} + +/// Data at rest has no ingestion, so no candidate runs anything at ingestion time. +#[test] +fn physical_asap_optimization_at_rest_runs_nothing_at_ingestion_time() { + let run = run_promql(&at_rest()); + for p in &run.physical { + for n in &p.dag().nodes { + assert!(!runs_at_ingestion(p, n.id), "{}: {:?}", p.id, n.id); + } + } +} + +/// A materialized output is kept for as long as its consumers read: it covers every reader's lookback and offset. +#[test] +fn physical_asap_optimization_materialized_output_covers_its_consumers() { + let offsets: Vec = PATTERN_A.iter().map(|(_, _, before_t)| *before_t).collect(); + for (workload, offsets) in [ + (once(), offsets.clone()), + (monthly(), offsets.clone()), + (at_rest(), offsets), + (pattern_b(), vec![0]), + ] { + let run = run_promql(&workload); + for p in &run.physical { + for n in &p.dag().nodes { + if materialization(p, n.id) == NotMaterialized { + continue; + } + let need = readers(p.dag(), &p.query_roots, n.id) + .into_iter() + .map(|q| lookback_ms(&workload, q) + offsets[q]) + .max() + .unwrap_or(0); + let kept = retention_ms(p, n.id).unwrap_or_else(|| { + panic!("{}: {:?} is materialized without a retention", p.id, n.id) + }); + assert!(kept >= need, "{}: {:?} kept {kept} < {need}", p.id, n.id); + } + } + } +} + +// ── Pattern A ──────────────────────────────────────────────────────────── + +/// The shared EH candidate yields A1 (query time, kept), A2 (ingestion time) and A3 (not materialized). +#[test] +#[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] +fn physical_asap_optimization_a_shared_eh_has_three_materialization_options() { + let found: BTreeSet<_> = eh_options(&run_promql(&once())).into_keys().collect(); + assert_eq!( + found, + BTreeSet::from([MatAtIngestion, MatAtQuery, NotMaterialized]) + ); +} + +/// With data at rest, A2 is not generated; A1 and A3 remain. +#[test] +#[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] +fn physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option() { + let found: BTreeSet<_> = eh_options(&run_promql(&at_rest())).into_keys().collect(); + assert_eq!(found, BTreeSet::from([MatAtQuery, NotMaterialized])); +} + +/// A materialized shared EH is one build node read by all five queries, charged once. +#[test] +#[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] +fn physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers() { + let run = run_promql(&once()); + for (m, (id, build)) in eh_options(&run) { + if m == NotMaterialized { + continue; + } + let p = run.physical(&id); + let ehs = sketch_builds(p.dag()) + .into_iter() + .filter(|(b, ..)| { + matches!( + window_form(p.dag(), *b), + WindowForm::ExponentialHistogram { .. } + ) + }) + .count(); + assert_eq!(ehs, 1, "{id}"); + assert_eq!(readers(p.dag(), &p.query_roots, build).len(), 5, "{id}"); + assert!( + run.selection.costs[&id].per_node.contains_key(&build), + "{id}" + ); + } +} + +/// A3 rebuilds the EH for each of the five queries, so it costs more than A1, which builds it once. +#[test] +#[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] +fn plan_selection_a_rebuilding_per_query_costs_more_than_building_once() { + let run = run_promql(&once()); + let options = eh_options(&run); + let (a1, a3) = (&options[&MatAtQuery].0, &options[&NotMaterialized].0); + assert!( + cost(&run, a3) > cost(&run, a1), + "A3 {} vs A1 {}", + cost(&run, a3), + cost(&run, a1) + ); +} + +/// As given (run once, ad hoc), A1 is the cheapest of the three: A2 maintains years of history for one batch. +#[test] +#[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] +fn plan_selection_a_once_adhoc_prefers_the_query_time_eh() { + let run = run_promql(&once()); + let options = eh_options(&run); + let a1 = cost(&run, &options[&MatAtQuery].0); + for (m, (id, _)) in &options { + assert!(a1 <= cost(&run, id), "A1 {a1} vs {m:?} {}", cost(&run, id)); + } +} + +/// Repeated monthly and predictable, A2's maintenance is shared by many batches, so A2 gains on A1. +#[test] +#[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] +fn plan_selection_a_monthly_amortizes_ingestion_time_maintenance() { + let ratio = |workload: PlanningWorkload| { + let run = run_promql(&workload); + let options = eh_options(&run); + cost(&run, &options[&MatAtIngestion].0) / cost(&run, &options[&MatAtQuery].0) + }; + let (once, monthly) = (ratio(once()), ratio(monthly())); + assert!(monthly < once, "A2/A1: monthly {monthly} vs once {once}"); +} + +// ── Pattern B ──────────────────────────────────────────────────────────── + +/// The 1-min tumbling KLL candidate yields B1 (ingestion time), B2 (not materialized) and B3 (query time, kept). +#[test] +#[ignore = "needs tumbling windows (#601), materialization enumeration (#604) and MatAtQuery panes (#625)"] +fn physical_asap_optimization_b_tumbling_kll_has_three_materialization_options() { + let found: BTreeSet<_> = options_of(&run_promql(&pattern_b()), tumbling(), 1) + .into_keys() + .collect(); + assert_eq!( + found, + BTreeSet::from([MatAtIngestion, MatAtQuery, NotMaterialized]) + ); +} + +/// B1 builds the tumbling KLLs at ingestion time and merges and estimates at query time. +#[test] +#[ignore = "needs tumbling windows (#601) and materialization enumeration (#604)"] +fn physical_asap_optimization_b_b1_builds_at_ingestion_and_merges_at_query_time() { + let run = run_promql(&pattern_b()); + let (id, build) = options_of(&run, tumbling(), 1)[&MatAtIngestion].clone(); + let p = run.physical(&id); + assert!(runs_at_ingestion(p, build)); + let estimates = estimates_of(p.dag(), build); + assert!(!estimates.is_empty()); + for (estimate, _) in estimates { + assert!( + !runs_at_ingestion(p, estimate), + "{id}: estimate at ingestion" + ); + let merge = p + .dag() + .producers(estimate) + .into_iter() + .find(|&m| is_merge(p.dag().payload(m))); + let merge = merge.unwrap_or_else(|| panic!("{id}: no merge before the estimate")); + assert!(!runs_at_ingestion(p, merge), "{id}: merge at ingestion"); + } +} + +/// B3 runs nothing at ingestion time; it keeps the tumbling KLLs it builds at query time. +#[test] +#[ignore = "needs tumbling windows (#601) and MatAtQuery panes (#625)"] +fn physical_asap_optimization_b_b3_keeps_query_time_windows() { + let run = run_promql(&pattern_b()); + let (id, _) = options_of(&run, tumbling(), 1)[&MatAtQuery].clone(); + let p = run.physical(&id); + assert!( + p.dag().nodes.iter().all(|n| !runs_at_ingestion(p, n.id)), + "{id}" + ); +} + +/// The sliding-window KLL is kept from ingestion time or from query time; not materializing it is the no-window plan. +#[test] +#[ignore = "needs sliding windows (no PR yet) and materialization enumeration (#604)"] +fn physical_asap_optimization_b_sliding_kll_has_two_materialization_options() { + let found: BTreeSet<_> = options_of(&run_promql(&pattern_b()), sliding(), 1) + .into_keys() + .collect(); + assert_eq!(found, BTreeSet::from([MatAtIngestion, MatAtQuery])); +} + +/// B2 rebuilds all five tumbling KLLs at every evaluation, so it costs at least B1 and B3. +#[test] +#[ignore = "needs tumbling windows (#601), materialization enumeration (#604) and MatAtQuery panes (#625)"] +fn plan_selection_b_rebuilding_every_window_costs_most() { + let run = run_promql(&pattern_b()); + let options = options_of(&run, tumbling(), 1); + let b2 = cost(&run, &options[&NotMaterialized].0); + for m in [MatAtIngestion, MatAtQuery] { + assert!(cost(&run, &options[&m].0) <= b2, "{m:?} vs B2 {b2}"); + } +} + +/// Repeating over arriving data, the built-in models pick B1 among the tumbling options. +#[test] +#[ignore = "needs tumbling windows (#601), materialization enumeration (#604) and MatAtQuery panes (#625)"] +fn plan_selection_b_prefers_ingestion_time_tumbling_windows() { + let run = run_promql(&pattern_b()); + let options = options_of(&run, tumbling(), 1); + let b1 = cost(&run, &options[&MatAtIngestion].0); + for (m, (id, _)) in &options { + assert!(b1 <= cost(&run, id), "B1 {b1} vs {m:?} {}", cost(&run, id)); + } +} diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index 3ee71787f..e2f4c1684 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -153,15 +153,16 @@ whether an exact Q2 misses 100 ms is not tested. most deployments. A composed top-*k* estimate has no guarantee, so without deployment evidence every heap candidate is invalid and the exact plan is selected. -4. **Stage 2 is materialization in the doc's example.** Example 1 lists only - window-form and materialization options. The MVP reads "physical operator - implementation" from the Stage 2 section ("TopK as a sort followed by a - limit"), which gives one physical candidate per logical candidate. +4. **Physical ASAP-aware optimization is materialization in the doc's + example.** Example 1 lists only window-form and materialization options. + The MVP reads "physical operator implementation" from the physical + ASAP-aware optimization section ("TopK as a sort followed by a limit"), + which gives one physical candidate per logical candidate. 5. **Where exact top-*k* becomes sort + limit.** The Pass 1 diagram draws exact Q2 as "sort + limit 10 per job", but the frontend emits - `Aggregate{[TopK]}` and the Stage 2 section calls sort + limit a physical - choice. The tests require sort → limit only after physical ASAP-aware - optimization. + `Aggregate{[TopK]}` and the physical ASAP-aware optimization section calls + sort + limit a physical choice. The tests require sort → limit only after + physical ASAP-aware optimization. 6. **Hydra's inner sketch.** The doc says "Hydra over the whole `job` column" without naming the inner sketch. The test assumes `HydraCms`. 7. **"Shared summary charged once"** does not apply literally: the doc says diff --git a/docs/design_docs/proposals/planner-layering-example2-acceptance.md b/docs/design_docs/proposals/planner-layering-example2-acceptance.md new file mode 100644 index 000000000..23aec0df6 --- /dev/null +++ b/docs/design_docs/proposals/planner-layering-example2-acceptance.md @@ -0,0 +1,146 @@ +# Planner layering, Example 2: acceptance spec + +Audience: planner designers and the implementers of the summary-capability +rule, UnivMon sizing and accuracy, and SQL `Entropy`/`L2` recognition. +Source: [planner-layering.md](planner-layering.md), "Example 2: One summary for +several computations". Tests: +`crates/integration-tests/tests/planner_layering_example2.rs` (helpers in +`tests/planner_layering_common/`). + +This spec was written by a test designer who does not implement the stages. +Tests run the stages through `planner_layering_common::run_stages`: Pass 1 +and composition, `optimize_physical_asap` and `select_plan`. A test that +needs a missing feature is `#[ignore]`d with the PR that delivers it. + +## Workload + +Three SQL dashboard panels over `flows`, every 10 s, `lookback` 1 m, `as_of` +evaluation time, no latency requirement. + +| Query | Computation | SQL (abridged) | Accuracy | +|---|---|---|---| +| Q1 | `Distinct(src_ip)` | `SELECT COUNT(DISTINCT src_ip) FROM flows WHERE ts >= now() - INTERVAL '1 minute'` | ε = 0.02, δ = 0.01 | +| Q2 | `Entropy(src_ip)` | `SELECT -SUM(p * LN(p)) FROM (SELECT COUNT(*) * 1.0 / SUM(COUNT(*)) OVER () AS p … GROUP BY src_ip)` | ε = 0.05, δ = 0.01 | +| Q3 | `L2(src_ip)` | `SELECT SQRT(SUM(c * c)) FROM (SELECT src_ip, COUNT(*) AS c … GROUP BY src_ip)` | ε = 0.01, δ = 0.01 | + +Data workload: the shared one (`continuously_ingesting`, about 66,667 +samples/s, `zipf`, volume unknown) with `input_cardinality` = 10,000,000 and no +`data_ingestion_interval`. The catalog is `flows(ts, src_ip)`; the doc names +no other column. + +**Stand-in.** The SQL frontend lowers Q1 to a distinct count but Q2 and Q3 to +exact relational plans (the doc's own TODO; #598). The planning stages +therefore run on a PromQL stand-in with the same structure, three statistics +of one input over the same 1-min window: `distinct_over_time(flows_src_ip[1m])`, +`entropy_over_time(flows_src_ip[1m])` and `l2_over_time(flows_src_ip[1m])`, +with Q1–Q3's targets and a 15-s ingestion interval (PromQL needs one). + +## Expected candidates per stage + +| Stage | Doc | Today (stand-in) | +|---|---|---| +| Language-specific frontends | One summary-free DAG: `Distinct`, `Entropy`, `L2` over `flows.src_ip`, last 1 m | Same for the stand-in. SQL: Q1 is `aggregate:cardinality`; Q2 and Q3 are `count` per `src_ip` → window function / projection → `sum` (not recognized). | +| Logical ASAP-aware optimization, Pass 1 | Each computation: exact, a specialized summary, UnivMon. 3 × 3 × 3 = **27** | Q1: exact, HLL, Theta, KMV, UnivMon. Q2, Q3: exact, UnivMon (no specialized entropy or norm summary). 5 × 2 × 2 = **20** | +| Logical ASAP-aware optimization, Pass 2 | Summary-capability rule: + 1 (one UnivMon for all three, sized for ε = 0.01) + 9 (3 pairs share a UnivMon × 3 options of the third). Independent kept. **37**. Window composition left out. | Not implemented: **20**, no candidate shares a node. | +| Physical ASAP-aware optimization | Same options as Example 1: no window form → rebuilt from raw samples each refresh. | One physical candidate per logical candidate, every sub-DAG `NotMaterialized`: **20**. | +| Plan selection | One UnivMon sized for ε = 0.01 vs three separate summaries; the shared one usually wins (one update per flow record instead of three). | HLL is invalid (its bound has no failure probability for the (ε, δ) target) and so is every UnivMon (its estimates have no guarantee). Q1 on Theta or KMV with Q2, Q3 exact is valid but costlier. The all-exact plan is selected. | + +## Invariants and tests + +### Language-specific frontends + +| Invariant | Test | Status | +|---|---|---| +| The SQL workload is valid, in entry order | `workload_encodes_example2` | passes | +| SQL Q1 lowers to a distinct count over `flows` | `language_specific_frontends_sql_q1_lowers_to_a_distinct_count` | passes | +| SQL Q2 and Q3 lower to `FrequencyEntropy` and `FrequencyL2` | `language_specific_frontends_sql_q2_q3_lower_to_entropy_and_l2` | ignored: #598 | +| The stand-in lowers to the three statistics over one 1-min range | `language_specific_frontends_standin_lowers_to_three_statistics_of_one_input` | passes | +| One summary-free DAG; the queries share no node | `language_specific_frontends_one_summary_free_dag_without_sharing` | passes | + +### Logical ASAP-aware optimization + +| Invariant | Test | Status | +|---|---|---| +| Once recognized, the SQL workload has the stand-in's Pass 1 options | `logical_asap_optimization_sql_has_the_standin_options` | ignored: #598, and #603 (ambiguity 9) | +| Each statistic has an exact and a UnivMon option | `logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic` | passes | +| Q1 has a specialized distinct-count summary (HLL, Theta or KMV) | `logical_asap_optimization_pass1_offers_a_distinct_count_summary` | passes | +| Q2 and Q3 have a specialized entropy and norm summary | `logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries` | ignored: no PR yet | +| Every combination of the queries' own options is kept, with no shared summary | `logical_asap_optimization_keeps_every_independent_combination` | passes | +| A candidate has one UnivMon build read by all three queries, feeding a distinct-count, an entropy and an L2 estimate | `logical_asap_optimization_summary_capability_adds_one_univmon_for_all_three` | ignored: #595, #596 | +| For each pair, a candidate shares one UnivMon between the two while the third takes each of its own options | `logical_asap_optimization_summary_capability_adds_pairwise_shared_univmons` | ignored: #595, #596 | +| A shared UnivMon has the parameters Pass 1 gives its strictest consumer alone | `logical_asap_optimization_shared_univmon_is_sized_for_the_strictest_consumer` | ignored: #595, #596 | +| UnivMon is sized per target: Q3's (ε = 0.01) is larger than Q2's (ε = 0.05) | `logical_asap_optimization_univmon_is_sized_per_accuracy_target` | ignored: #621 | +| Only a UnivMon is ever shared across statistics | `logical_asap_optimization_only_univmon_is_shared_across_statistics` | passes (vacuously today) | +| Candidates are valid DAGs with unique ids | `logical_asap_optimization_candidates_are_valid_and_uniquely_named` | passes | + +### Physical ASAP-aware optimization + +| Invariant | Test | Status | +|---|---|---| +| One physical candidate per logical candidate, none pruned | `physical_asap_optimization_keeps_every_logical_candidate` | passes | +| The shared UnivMon stays one build read by all three queries | `physical_asap_optimization_keeps_the_shared_univmon_as_one_build` | ignored: #595, #596 | + +Materialization and window variants are out of scope: the doc leaves out the +window-composition variants for this example, and a summary with no window +form is rebuilt at every refresh (Example 1's table). + +### Plan selection + +| Invariant | Test | Status | +|---|---|---| +| One selected id; every other candidate rejected once with a reason | `plan_selection_selects_one_and_explains_the_rest` | passes | +| The selected plan is the cheapest valid one | `plan_selection_selects_cheapest_valid` | passes | +| Each node of a valid candidate is charged once (a shared summary is costed once) | `plan_selection_charges_each_node_once` | passes | +| A UnivMon estimate has a guarantee, so it is judged against its target instead of rejected for having none | `plan_selection_judges_univmon_with_an_accuracy_model` | ignored: L2 in #621; distinct count and entropy have no PR | +| One UnivMon for all three costs no more than three separate UnivMons | `plan_selection_shared_univmon_costs_no_more_than_three` | ignored: #595, #596, #621 | +| Selection direction: the selected plan has at most one UnivMon, because one shared UnivMon dominates several over the same input | `plan_selection_selected_plan_has_at_most_one_univmon` | passes (vacuously today: no UnivMon is valid) | + +The doc's "the shared candidate usually wins" depends on the cost model, so +it is tested only as the dominance above: one build sized for the strictest +consumer costs no more than three builds that include one of that size. Whether +it also beats the exact plans is the model's call. + +## Ambiguities and conflicts with the planner + +1. **SQL recognition.** Q2 and Q3 lower to exact relational plans, so the doc's + workload has no `Entropy` or `L2` intent (#598). Hence the stand-in. The + stand-in's statistics are per series (`PerEntity`), the doc's over the + whole column; with one `flows_src_ip` series they coincide. +2. **Pass 1 options.** The doc gives each computation three options. The + planner gives Q1 five (three specialized distinct-count summaries) and Q2, + Q3 two (no specialized entropy or norm summary). The tests check presence, + not counts. +3. **Candidate count.** The doc's 37 excludes the identical-expression rule's + shared-input variant (Example 1 counts it). Neither Pass 2 rule exists yet + (#588, #595), so today there are 20. Tests do not pin a count. +4. **Independent UnivMons on a shared input.** The doc keeps independent + candidates. Once Pass 2 shares the input, identical UnivMon builds may + merge; the tests require the independent combinations only without a + shared summary. +5. **Sizing.** "Sized for the strictest requirement, ε = 0.01" presumes + UnivMon sizing per ε; UnivMon has a fixed shape today (#621). +6. **Accuracy.** Plan selection accepts an output whose guarantee meets the + target or for which the deployment has evidence. No UnivMon estimate has + a guarantee, so every UnivMon candidate is invalid; #621 certifies L2 + only. HLL is rejected against an (ε, δ) target because its bound has no + failure probability; the doc treats a distinct summary as valid. +7. **Pairwise count.** "The third keeps any of its own 3 options" includes its + own UnivMon, sized for itself. The test requires the third's full Pass 1 + option set. +8. **Data workload.** Only `input_cardinality` and `data_ingestion_interval` + change, so the ingestion rate stays about 66,667/s with 10,000,000 keys. +9. **SQL candidates that fail to compose.** On `flows(ts, src_ip)`, sketch + candidates for `COUNT(*) … GROUP BY src_ip` reference + `ColumnRef::SampleValue`, and the schema has no `value` column, so + composition fails (#603). This is a Pass 1 defect independent of this + example. + +## Devtool + +No `--example planner-layering-2` yet. It needs: + +* SQL `Entropy` and `L2` recognition (#598), so the demo shows the doc's + workload rather than exact relational plans; +* the Pass 1 fix in ambiguity 9 (#603); +* a SQL lowering path with the `flows` catalog in `stage_pipeline` (devtool + only; it lowers PromQL only today). diff --git a/docs/design_docs/proposals/planner-layering-example3-acceptance.md b/docs/design_docs/proposals/planner-layering-example3-acceptance.md new file mode 100644 index 000000000..d9eafe92b --- /dev/null +++ b/docs/design_docs/proposals/planner-layering-example3-acceptance.md @@ -0,0 +1,115 @@ +# Planner layering, Example 3: acceptance spec + +Audience: planner designers and the implementer of Pass 2's +window-composition rule. +Source: [planner-layering.md](planner-layering.md), "Example 3: Aggregation over +windows" and the Pass 2 section. Tests: +`crates/integration-tests/tests/planner_layering_example3.rs` (helpers and the +shared Example 3/4 workloads in `tests/planner_layering_common/`). Devtool: +`stage_pipeline --example planner-layering-3a` and `planner-layering-3b`. + +This spec was written by a test designer who does not implement the stages. +Tests run the stages through `planner_layering_common::run_stages`. A test +that needs a missing feature is `#[ignore]`d with the PR that delivers it. + +## Workload + +Shared data workload, except Pattern A's `arrival` is `mixed`. + +**Pattern A.** One `query_batch`, `invocations: 1` at T, `ad_hoc`, ε = 0.005, +δ = 0.01. Tests use T = 2026-01-01T00:00:00Z and a 365-day year. + +| Query | `lookback` | `as_of` | +|---|---|---| +| q1 `quantile_over_time(0.99, latency_ms[5y])` | 5 y | T | +| q2 `quantile_over_time(0.99, latency_ms[1y])` | 1 y | T | +| q3 `quantile_over_time(0.99, latency_ms[1y] offset 1y)` | 1 y | T − 1 y | +| q4 `quantile_over_time(0.99, latency_ms[1y] offset 2y)` | 1 y | T − 2 y | +| q5 `quantile_over_time(0.99, latency_ms[3y] offset 2y)` | 3 y | T − 2 y | + +**Pattern B.** `quantile_over_time(0.99, latency_ms[5m])`, every 1 min, +`lookback` 5 m, `as_of` evaluation time, ε = 0.01, δ = 0.01, ≤ 200 ms. + +## Expected candidates per stage + +| Stage | Doc | Today | +|---|---|---| +| Language-specific frontends | A: five scan → range → quantile chains. B: one. | Same; q3–q5 also have a `time_shift`. | +| Logical ASAP-aware optimization, Pass 1 | A: exact or KLL over its own interval per query, 2⁵ = 32. B: exact, KLL. | A: exact, KLL (k = 547), DDSketch per query, 3⁵ = **243**. B: exact, KLL (k = 269), DDSketch, **3**. | +| Logical ASAP-aware optimization, Pass 2 | A: window composition adds one EH of KLLs over [T − 5 y, T], one merge + p99 estimate per query, plus a candidate for every grouping of two or more queries onto shared window summaries ("hundreds"). B: each Pass 1 option × {none, sliding L = 5 min s = 1 min (5 active windows), 1-min tumbling (merge 5)} = 6. Independent kept. | Not implemented: no shared input, no window summaries. | +| Physical ASAP-aware optimization | See Example 4. | One physical candidate per logical candidate, every sub-DAG `NotMaterialized`. | +| Plan selection | Not stated for Example 3. | Every candidate is valid. A: five independent KLLs (DDSketch ties on cost). B: KLL (DDSketch ties). | + +## Invariants and tests + +### Pattern A + +| Invariant | Test | Status | +|---|---|---| +| Both workloads are valid; Pattern B repeats every 1 min over 5 min | `workload_encodes_example3` | passes | +| Each query lowers to scan → (time shift) → range → quantile | `language_specific_frontends_a_lowers_each_query_to_its_interval` | passes | +| One summary-free frontend DAG; the queries share no node | `language_specific_frontends_a_one_summary_free_dag_without_sharing` | passes | +| Each query has an exact and a KLL option | `logical_asap_optimization_a_pass1_offers_exact_and_kll_per_query` | passes | +| Five independent KLLs, each read by one query, all the same size | `logical_asap_optimization_a_keeps_five_independent_klls` | passes | +| The identical-expression rule shares only raw input (scan, range, shift) and keeps the unshared variant | `logical_asap_optimization_a_identical_expression_rule_shares_only_raw_input` | ignored: #588 | +| One EH of KLLs over ≥ 5 y is read by all five queries; each has its own merge feeding a p99 estimate | `logical_asap_optimization_a_window_composition_adds_one_eh_for_all_five` | ignored: #628, #634 | +| The EH candidate coexists with the five independent KLLs | `logical_asap_optimization_a_keeps_independent_and_shared_window_summaries` | ignored: #628, #634 | +| Every pair of queries shares a window summary in some candidate | `logical_asap_optimization_a_window_composition_groups_every_pair` | ignored: #628, #634 | +| One selected id, the rest explained; the selected plan is the cheapest valid one | `plan_selection_a_selects_cheapest_valid` | passes | +| Each node of a valid candidate is charged once | `plan_selection_a_charges_each_node_once` | passes | +| For the same per-computation choices, sharing the scan costs no more than separate scans | `plan_selection_a_shared_scan_is_not_costlier` | ignored: #588, #632 (see ambiguity 7) | + +### Pattern B + +| Invariant | Test | Status | +|---|---|---| +| The query lowers to scan → range 5m → quantile, no summary | `language_specific_frontends_b_lowers_to_one_range_quantile` | passes | +| Exact and KLL options | `logical_asap_optimization_b_pass1_offers_exact_and_kll` | passes | +| Each summary option appears with no window, a 5-min sliding window with a 1-min slide, and 1-min tumbling windows | `logical_asap_optimization_b_window_composition_adds_sliding_and_tumbling_per_option` | ignored: tumbling #601, sliding no PR yet | +| Window parameters are legal: L divides W; s divides L and the evaluation interval; a tumbling length divides W and the interval | `logical_asap_optimization_b_window_parameters_are_legal` | passes (vacuously today) | +| Sliding with L = W has no merge; tumbling merges before the estimate | `logical_asap_optimization_b_merge_only_where_the_window_form_needs_it` | ignored: tumbling #601, sliding no PR yet | +| A window form that merges uses a mergeable summary (KLL, DDSketch), in both patterns | `logical_asap_optimization_window_merges_use_mergeable_summaries` | passes (vacuously today) | +| One selected, cheapest valid, each node charged once | `plan_selection_b_selects_cheapest_valid` | passes | + +**Reading window summaries.** The IR has no window summary yet, so tests read +it through `planner_layering_common::window_form(dag, build)`, which returns +`WindowForm::None` today. The window-composition implementer makes it return +`Sliding { length_ms, slide_ms }`, `Tumbling { length_ms }` or +`ExponentialHistogram { horizon_ms }` from the new IR. Merges are read as +`SummaryMerge` nodes between the build and the `SummaryEstimate`. + +## Ambiguities and conflicts with the planner + +1. **Pass 1 families.** The doc offers exact and KLL; the planner also offers + DDSketch. Tests check presence of exact and KLL, and require window forms + for every summary option. +2. **Exact in window forms.** Pattern B counts 2 × 3 = 6, so the exact option + also gets sliding and tumbling forms. Tumbling needs a mergeable exact + state for quantiles (the retained values), which the planner has no + accumulator for. Tests do not require window forms for exact. +3. **Groupings.** "One candidate for every way of grouping two or more queries + onto shared window summaries" leaves open whether a grouping is a set + partition (several shared summaries at once) and how ungrouped queries + combine with their Pass 1 options. Tests require only every pair and all + five. +4. **Yearly tumbling windows** "would also work" for Pattern A. The doc does + not say whether Pass 2 generates them. Tests neither require nor forbid + them. +5. **EH accuracy.** A boundary inside an EH bucket is approximate. Whether the + accuracy model charges that against ε = 0.005 is not stated; not tested. +6. **`as_of` and `offset`.** The table gives `as_of` = T − 1 y and the query + text has `offset 1y`. We read `as_of` as the window's end (the result of + the offset at T), not as a second shift. The frontend lowers the offset + from the text. +7. **Plan selection statistics.** Time-shifted scans are priced as one minute + of data instead of years, and a 1-y range over a shared 5-y scan passes + all of its rows (#632). Once Pass 2 shares the scan (#588), these + estimates decide whether sharing looks cheaper, so the invariant test + waits on both. +8. **Identical-expression sharing.** Not mentioned by the doc. Once + implemented (#588), the planner can share the `latency_ms` scan and the + 2-y time shift read by q4 and q5 (the shift sits below the range). +9. **Additional sliding forms.** L shorter than W (merged) is allowed by the + Pass 2 rules but not listed for Pattern B. Tests allow extra forms. +10. **Latency.** Pattern B's ≤ 200 ms is not checked by any test; plan + selection does not check latency yet. diff --git a/docs/design_docs/proposals/planner-layering-example4-acceptance.md b/docs/design_docs/proposals/planner-layering-example4-acceptance.md new file mode 100644 index 000000000..cfa2663e0 --- /dev/null +++ b/docs/design_docs/proposals/planner-layering-example4-acceptance.md @@ -0,0 +1,151 @@ +# Planner layering, Example 4: acceptance spec + +Audience: planner designers and the implementers of materialization in +physical ASAP-aware optimization and its pricing in plan selection. +Source: [planner-layering.md](planner-layering.md), "Example 4: Materialization +of window summaries in physical planning" and the Materialization section. +Tests: `crates/integration-tests/tests/planner_layering_example4.rs` (helpers +and workloads in `tests/planner_layering_common/`). + +This spec was written by a test designer who does not implement the stages. +Tests run the stages through `planner_layering_common::run_stages`. A test +that needs a missing feature is `#[ignore]`d with the PR that delivers it. + +## Workloads + +Example 3's two patterns ([its spec](planner-layering-example3-acceptance.md)), +with Pattern A varied as the doc does: + +| Variant | Recurrence | Predictability | `arrival` | +|---|---|---|---| +| A, as given | batch, `invocations: 1` at T | `ad_hoc` | `mixed` | +| A, monthly | repeating every 30 days, window ending at each run | `Predictable { known_at: T }` | `mixed` | +| A, at rest | batch, `invocations: 1` at T | `ad_hoc` | `at_rest` (no ingestion rate) | +| B | repeating every 1 min | `Predictable` | `continuously_ingesting` | + +## Expected physical candidates + +Only the physical candidates of the shared logical candidate are specified; +every other logical candidate gets its own the same way (not counted). + +**Pattern A, shared EH of KLLs (read by q1–q5).** + +| Candidate | Materialization of the EH | Expected | +|---|---|---| +| A1 | query time, kept for the batch | generated; selected as given | +| A2 | ingestion time, old data backfilled once | generated unless `at_rest`; gains with monthly recurrence | +| A3 | not materialized: each query rebuilds it | generated; costs more than A1 | + +**Pattern B, 1-min tumbling KLLs.** + +| Candidate | Materialization of the tumbling KLLs | Expected | +|---|---|---| +| B1 | ingestion time, kept 5 min; merge 5 + p99 at query time | generated; usually selected | +| B2 | not materialized: rebuild all 5 from 5 min of raw samples | generated; costs at least B1 and B3 | +| B3 | query time, kept 5 min: build only the newest | generated; no ingestion-time node | + +**Pattern B, sliding window (L = 5 min, s = 1 min).** Ingestion time or query +time, both kept. Not materializing it is the no-window plan, so it is not a +separate option. + +**Today:** no window summaries, and physical ASAP-aware optimization assigns +every sub-DAG `NotMaterialized` (#667's options exist in the IR; their +enumeration is #604), so none of these candidates exists. + +## Invariants and tests + +**Reading materialization.** The physical DAG records each node's +`Materialization` (#667). Tests read it through +`planner_layering_common::materialization(p, node)` without the storage +medium (`MatAtIngestion`, `MatAtQuery`, `NotMaterialized`), and the kept +event-time span through `retention_ms(p, node)`, which returns `None` until +the IR records retention. + +### Constraints over every candidate + +| Invariant | Test | Status | +|---|---|---| +| Every node upstream of an ingestion-time node also runs at ingestion time (all variants) | `physical_asap_optimization_ingestion_time_upstream_is_ingestion_time` | passes (vacuously today) | +| With data at rest, no node runs at ingestion time | `physical_asap_optimization_at_rest_runs_nothing_at_ingestion_time` | passes (vacuously today) | +| A materialized output covers its consumers: retention ≥ max over its readers of `lookback` + offset | `physical_asap_optimization_materialized_output_covers_its_consumers` | passes (vacuously today) | + +### Pattern A + +| Invariant | Test | Status | +|---|---|---| +| The shared EH yields exactly A1, A2, A3 | `physical_asap_optimization_a_shared_eh_has_three_materialization_options` | ignored: #628, #634, #604, #625 | +| At rest, A2 is not generated; A1 and A3 remain | `physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option` | ignored: #628, #634, #604, #625 | +| A materialized EH is one build node read by all five queries, priced once | `physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers` | ignored: #628, #634, #604, #625 | +| A3 costs strictly more than A1 (five builds instead of one) | `plan_selection_a_rebuilding_per_query_costs_more_than_building_once` | ignored: #628, #634, #604, #625 | +| As given, A1 is the cheapest of the three | `plan_selection_a_once_adhoc_prefers_the_query_time_eh` | ignored: #628, #634, #604, #625 | +| Monthly, A2's cost relative to A1 drops: cost(A2)/cost(A1) monthly < as given | `plan_selection_a_monthly_amortizes_ingestion_time_maintenance` | ignored: #628, #634, #604, #625 | + +### Pattern B + +| Invariant | Test | Status | +|---|---|---| +| The tumbling KLL candidate yields exactly B1, B2, B3 | `physical_asap_optimization_b_tumbling_kll_has_three_materialization_options` | ignored: #601, #604, #625 | +| B1 builds at ingestion time; its merge and estimate run at query time | `physical_asap_optimization_b_b1_builds_at_ingestion_and_merges_at_query_time` | ignored: #601, #604 | +| B3 runs nothing at ingestion time | `physical_asap_optimization_b_b3_keeps_query_time_windows` | ignored: #601, #625 | +| The sliding KLL yields exactly ingestion time and query time, kept | `physical_asap_optimization_b_sliding_kll_has_two_materialization_options` | ignored: sliding windows (no PR yet), #604 | +| B2 costs at least B1 and B3 | `plan_selection_b_rebuilding_every_window_costs_most` | ignored: #601, #604, #625 | +| With the built-in models, B1 is the cheapest of the three | `plan_selection_b_prefers_ingestion_time_tumbling_windows` | ignored: #601, #604, #625 | + +### Selection direction, relative to the cost model + +* A3 > A1 and B2 ≥ B1, B3 hold under any model that charges each build: A3 + and B2 do strictly more of the same work. +* A1 ≤ A2 as given, and B1 ≤ B3, depend on the model. The doc's reasons are + that A2 maintains years of history for one batch, and that B3 adds the + newest build to every evaluation and needs raw data at query time. The + tests assert the doc's direction under the built-in models; if the model + prices an ingestion-time update above a query-time one, B3 may win and the + doc allows it ("B3 can win when ingestion-time work is expensive"). +* Monthly recurrence is tested as a ratio, so it does not depend on whether A2 + wins outright ("A2 can win"). + +## Ambiguities and conflicts with the planner + +1. **Charging A3.** Plan selection charges each node once, which is right for a + shared, materialized summary. A3 is one logical EH node that is not + materialized, so it is rebuilt by each of its five consumers. The tests + require A3 to cost more than A1. Either plan selection charges a + non-materialized node once per consuming query, or physical ASAP-aware + optimization duplicates it per consumer (which makes A3 look like five + independent EHs). Decision needed. +2. **Recurrence input.** Materialization depends on `recurrence`, + `predictability` and `arrival`, but `optimize_physical_asap` takes only + the logical candidate and `select_plan` only the accuracy targets and + data workload. They need the per-entry recurrence for A2's amortization + and for B1 (#604). +3. **Monthly variant.** The doc does not say whether each monthly run still + ends at T or at its own run time, nor give `known_at`. Tests use a + repeating entry whose window ends at each run, known at T. +4. **Retention unit.** "Kept 5 min" is read as the event-time span the stored + state covers. For A1, which lives only for the batch, that is 5 y of + history. The test requires retention ≥ each reader's `lookback` + offset. +5. **Backfill.** A2 backfills old data once. Whether that one-time cost is + charged to the plan, and how it is amortized, is not stated; not tested. +6. **Pruning A2 at rest.** "A2 is not generated" is read as physical + ASAP-aware optimization not generating it, not a plan selection + rejection. +7. **Upstream of an ingestion-time node.** The constraint covers the scan and + range too, so an ingestion-time build's whole input chain must be marked + ingestion time in the physical DAG. +8. **Other logical candidates.** "Every other logical candidate gets its own + physical candidates the same way" gives no counts; tests count only the + shared candidate's options. +9. **B3 retention.** The doc says B3 keeps 5 min but uses "4 kept" windows + plus the newest. The test asks for 5 min (the query window). + +## Devtool + +No `--example planner-layering-4`. Pattern A and B already run as +`planner-layering-3a` and `3b`, and until physical ASAP-aware optimization +materializes something, Example 4's variants plan identically. A useful id +needs: + +* Pass 2 window composition and materialization enumeration (#601, #604); +* the workload's recurrence passed to physical ASAP-aware optimization; +* the variant (as given, monthly, at rest) selectable in `stage_pipeline`; +* the viewer showing each node's materialization and retention. From 21c988bb8e565dda4f63bfca6e782bc5f261a913 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:06:34 +0000 Subject: [PATCH 06/19] feat(devtools): stage_pipeline examples for #509 Example 3 Ported from #590: --example planner-layering-3a (Pattern A, ad hoc batch of five p99 reports) and planner-layering-3b (Pattern B, repeating 5-min p99), the Example 3 acceptance tests' workloads. Example 1's output is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/devtools/src/bin/stage_pipeline.rs | 114 ++++++++++++++++++++-- 1 file changed, 104 insertions(+), 10 deletions(-) diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index dab589ccc..824457de9 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -1,5 +1,7 @@ // cargo run -p asap-devtools --bin stage_pipeline -- \ // --example planner-layering-1 --out planner-layering-example1.json +// (also planner-layering-3a and planner-layering-3b: #509 Example 3, +// Patterns A and B) // cargo run -p asap-devtools --bin stage_pipeline -- \ // --promql "topk by (job) (10, rate(x[1m]))" --epsilon 0.01 --delta 0.001 --out run.json // @@ -46,11 +48,12 @@ use asap_types::workload::{ AccuracyRequirement, BatchEntry, DataArrival, DataDistribution, DataWorkload, DurationMs, Evidence, EvidenceSource, LatencyRequirement, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRecurrence, QueryRequirements, QueryTimeScope, QueryWorkload, Rate, - RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, + RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, TimestampMs, }; use serde_json::{json, Value}; -const USAGE: &str = "usage: stage_pipeline (--example planner-layering-1 | --promql ... \ +const USAGE: &str = + "usage: stage_pipeline (--example planner-layering-{1,3a,3b} | --promql ... \ [--epsilon --delta ] [--interval-ms ]) [--max-candidates ] --out "; fn main() { @@ -83,6 +86,8 @@ fn run(args: Vec) -> Result<(), String> { } let workload = match (example.as_deref(), queries.is_empty()) { (Some("planner-layering-1"), true) => planner_layering_example1(), + (Some("planner-layering-3a"), true) => planner_layering_example3a(), + (Some("planner-layering-3b"), true) => planner_layering_example3b(), (Some(other), true) => return Err(format!("unknown example {other}")), (None, false) => { let accuracy = match (epsilon, delta) { @@ -422,14 +427,103 @@ fn planner_layering_example1() -> PlanningWorkload { ), ]), }, - data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: declared(DurationMs(15_000)), - ingestion_volume: Evidence::default(), - ingestion_rate: declared(Rate(1_000_000.0 / 15.0)), - input_cardinality: declared(1_000_000), - distribution: declared(DataDistribution::Zipf), - }), + data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + } +} + +/// The shared data workload of #509 with `arrival`. +fn shared_data_workload(arrival: DataArrival) -> DataWorkload { + DataWorkload { + arrival, + data_ingestion_interval: declared(DurationMs(15_000)), + ingestion_volume: Evidence::default(), + ingestion_rate: declared(Rate(1_000_000.0 / 15.0)), + input_cardinality: declared(1_000_000), + distribution: declared(DataDistribution::Zipf), + } +} + +/// #509 Example 3, Pattern A: an ad hoc batch of five p99 reports over +/// historical intervals, run once at T (2026-01-01), over mixed data. +fn planner_layering_example3a() -> PlanningWorkload { + const YEAR_MS: u64 = 365 * 24 * 3_600_000; + const T_MS: u64 = 1_767_225_600_000; + let queries = [ + ("quantile_over_time(0.99, latency_ms[5y])", 5 * YEAR_MS, 0), + ("quantile_over_time(0.99, latency_ms[1y])", YEAR_MS, 0), + ( + "quantile_over_time(0.99, latency_ms[1y] offset 1y)", + YEAR_MS, + YEAR_MS, + ), + ( + "quantile_over_time(0.99, latency_ms[1y] offset 2y)", + YEAR_MS, + 2 * YEAR_MS, + ), + ( + "quantile_over_time(0.99, latency_ms[3y] offset 2y)", + 3 * YEAR_MS, + 2 * YEAR_MS, + ), + ]; + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some( + queries + .into_iter() + .map(|(query, lookback, before_t)| BatchEntry { + query: Query(query.into()), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon: 0.005, + delta: 0.01, + }), + response_latency: LatencyRequirement::Unspecified, + }, + predictability: Predictability::AdHoc, + invocations: 1, + execute_at: Some(TimestampMs(T_MS)), + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(lookback)), + as_of: Some(TimestampMs(T_MS - before_t)), + }, + }) + .collect(), + ), + repeating_queries: None, + }, + data_workload: Some(shared_data_workload(DataArrival::Mixed)), + } +} + +/// #509 Example 3, Pattern B: a p99 panel over the last 5 min, every minute. +fn planner_layering_example3b() -> PlanningWorkload { + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: None, + repeating_queries: Some(vec![RepeatingEntry { + query: Query("quantile_over_time(0.99, latency_ms[5m])".into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval(60_000)), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }), + response_latency: LatencyRequirement::ExplicitMaxMs(200.0), + }, + predictability: Predictability::Predictable { known_at: None }, + time_selection: TimeSelection { + scope: QueryTimeScope::RealTime, + lookback: Some(DurationMs(300_000)), + as_of: None, + }, + }]), + }, + data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), } } From 074c51a30b0a6066e6c9065e1f9f94b69e0973fb Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:29:55 +0000 Subject: [PATCH 07/19] test: follow #576's per-consumer charging and exact top-k columns Plan selection charges a NotMaterialized node once per consuming query, so the charging helper checks NodeCost.multiplier and the Example 4 A3 ambiguity is recorded as decided. Example 1 checks that exact Q2 sorts by the ranked value and returns [job, topk_10]. The stage viewer guide's tab 1 row now describes the actual all-exact result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_common/mod.rs | 25 ++++++++-- .../tests/planner_layering_example1.rs | 50 +++++++++++++++++-- .../tests/planner_layering_example2.rs | 10 ++-- .../tests/planner_layering_example3.rs | 8 +-- .../tests/planner_layering_example4.rs | 4 +- .../planner-layering-example1-acceptance.md | 14 ++++-- .../planner-layering-example2-acceptance.md | 9 ++-- .../planner-layering-example3-acceptance.md | 2 +- .../planner-layering-example4-acceptance.md | 16 +++--- docs/user_guide_docs/stage-viewer.md | 2 +- 10 files changed, 106 insertions(+), 34 deletions(-) diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index 67c88b4f5..3ccad26e8 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -669,9 +669,12 @@ pub fn assert_selects_cheapest_valid(run: &Run) { } /// Each valid candidate's cost has one entry per DAG node and `total` is -/// their sum, so a node read by several queries is charged once. Invalid -/// candidates are not priced. -pub fn assert_each_node_charged_once(run: &Run) { +/// their sum. A materialized node is charged once; a `NotMaterialized` node +/// once per consuming query, since it is recomputed for each (#509). With no +/// materialized node in the DAG (every candidate until #604), a node's +/// multiplier is the number of queries that read it. Invalid candidates are +/// not priced. +pub fn assert_each_node_charged_per_consuming_query(run: &Run) { let invalid = run.invalid(); for p in &run.physical { if invalid.contains_key(p.id.as_str()) { @@ -688,6 +691,22 @@ pub fn assert_each_node_charged_once(run: &Run) { "{}", p.id ); + let none_materialized = nodes + .iter() + .all(|&n| materialization(p, n) == MaterializationOption::NotMaterialized); + for (&id, c) in &cost.per_node { + let expected = if materialization(p, id) != MaterializationOption::NotMaterialized { + Some(1) + } else if none_materialized { + Some(readers(p.dag(), &p.query_roots, id).len() as u64) + } else { + None + }; + match expected { + Some(times) => assert_eq!(c.multiplier, times, "{}: {id:?}", p.id), + None => assert!(c.multiplier >= 1, "{}: {id:?}", p.id), + } + } } } diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index 6b835683a..0e9a39b54 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -15,7 +15,7 @@ mod planner_layering_common; use std::collections::{BTreeMap, BTreeSet, HashSet}; use asap_types::ir::flat::NodeId; -use asap_types::ir::{ASAPOp, Operator}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot, ScalarExpr, SortKey}; use asap_types::post_asap::{FieldDataType, GroupingStrategy, HydraKind, SketchAlgorithm}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ @@ -356,6 +356,48 @@ fn physical_asap_optimization_exact_topk_is_sort_then_limit() { assert!(sort_then_limit, "{}: no sort → limit", p.id); } +/// Exact Q2 sorts each job's series by the ranked value, descending, and a +/// `Project` over the limit restores the logical `[job, topk_10]` columns. +#[test] +fn physical_asap_optimization_exact_topk_returns_job_and_topk_10() { + let run = pipeline(); + let p = physical_with(&run, Q2Option::Exact); + let QueryRoot::Operator(root) = &p.candidate.roots[1] else { + panic!("{}: Q2 is an operator root", p.id) + }; + let names: Vec<_> = root.schema.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["job", "topk_10"], "{}", p.id); + let NonASAPOp::Project { child: limit, .. } = root.expect_non_asap() else { + panic!("{}: Q2's root is a Project", p.id) + }; + let NonASAPOp::Limit { + n: Some(10), + child: sort, + .. + } = limit.expect_non_asap() + else { + panic!("{}: a limit 10 under the Project", p.id) + }; + let NonASAPOp::Sort { + keys, + partition_by, + child, + } = sort.expect_non_asap() + else { + panic!("{}: a sort under the limit", p.id) + }; + let [SortKey { + expr: ScalarExpr::Column(ranked), + ascending: false, + .. + }] = keys.as_slice() + else { + panic!("{}: one descending column key: {keys:?}", p.id) + }; + assert_eq!(child.schema.fields[*ranked].name, "value", "{}", p.id); + assert_eq!(partition_by.len(), 1, "{}: partitioned by job", p.id); +} + /// A summary Q2 is a build node feeding a top-10 estimation node, with no merge. #[test] fn physical_asap_optimization_summary_topk_is_build_then_estimate() { @@ -484,10 +526,10 @@ fn plan_selection_selects_cheapest_valid() { assert_selects_cheapest_valid(&pipeline()); } -/// Every node of a valid candidate is charged exactly once, so a shared input is costed once for both queries. +/// Every node of a valid candidate is charged once per query that reads it: nothing is materialized, so a shared node is recomputed for each. #[test] -fn plan_selection_charges_each_node_once() { - assert_each_node_charged_once(&pipeline()); +fn plan_selection_charges_each_node_per_consuming_query() { + assert_each_node_charged_per_consuming_query(&pipeline()); } /// The plan selection picks compiles in the physical planner (added by the diff --git a/crates/integration-tests/tests/planner_layering_example2.rs b/crates/integration-tests/tests/planner_layering_example2.rs index 717d93acf..2da07e5aa 100644 --- a/crates/integration-tests/tests/planner_layering_example2.rs +++ b/crates/integration-tests/tests/planner_layering_example2.rs @@ -480,10 +480,10 @@ fn plan_selection_selects_cheapest_valid() { assert_selects_cheapest_valid(&pipeline()); } -/// Every node of a valid candidate is charged once, so a summary read by several queries is costed once. +/// Every node of a valid candidate is charged once per query that reads it: nothing is materialized, so a shared node is recomputed for each. #[test] -fn plan_selection_charges_each_node_once() { - assert_each_node_charged_once(&pipeline()); +fn plan_selection_charges_each_node_per_consuming_query() { + assert_each_node_charged_per_consuming_query(&pipeline()); } /// A UnivMon estimate has a guarantee, so plan selection judges it against its target instead of rejecting it for having none. @@ -507,9 +507,9 @@ fn plan_selection_judges_univmon_with_an_accuracy_model() { } } -/// One UnivMon for all three costs no more than three separate UnivMons. +/// One materialized UnivMon for all three costs no more than three separate UnivMons. #[test] -#[ignore = "needs one UnivMon for all three (#595, #596) and a UnivMon accuracy model (#621)"] +#[ignore = "needs one UnivMon for all three (#595, #596), a UnivMon accuracy model (#621) and materializing it (#604): not materialized, it is charged once per consuming query"] fn plan_selection_shared_univmon_costs_no_more_than_three() { let run = pipeline(); let cost = |c: &Logical| run.cost(&run.physical_of(c).next().unwrap().id); diff --git a/crates/integration-tests/tests/planner_layering_example3.rs b/crates/integration-tests/tests/planner_layering_example3.rs index 1e531c581..24a1d07a9 100644 --- a/crates/integration-tests/tests/planner_layering_example3.rs +++ b/crates/integration-tests/tests/planner_layering_example3.rs @@ -256,10 +256,10 @@ fn plan_selection_a_selects_cheapest_valid() { assert_selects_cheapest_valid(&run); } -/// Every node is charged once, so a node read by several queries is costed once. +/// Every node of a valid candidate is charged once per query that reads it: nothing is materialized, so a shared node is recomputed for each. #[test] -fn plan_selection_a_charges_each_node_once() { - assert_each_node_charged_once(&run_a()); +fn plan_selection_a_charges_each_node_per_consuming_query() { + assert_each_node_charged_per_consuming_query(&run_a()); } /// Sharing the scan never costs more than scanning separately, for the same per-computation choices. @@ -437,5 +437,5 @@ fn plan_selection_b_selects_cheapest_valid() { let run = run_b(); assert_selects_one_and_explains_the_rest(&run); assert_selects_cheapest_valid(&run); - assert_each_node_charged_once(&run); + assert_each_node_charged_per_consuming_query(&run); } diff --git a/crates/integration-tests/tests/planner_layering_example4.rs b/crates/integration-tests/tests/planner_layering_example4.rs index 01d6dc523..b9a293e7f 100644 --- a/crates/integration-tests/tests/planner_layering_example4.rs +++ b/crates/integration-tests/tests/planner_layering_example4.rs @@ -209,7 +209,9 @@ fn physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers( } } -/// A3 rebuilds the EH for each of the five queries, so it costs more than A1, which builds it once. +/// A3 rebuilds the EH for each of the five queries (plan selection charges a +/// `NotMaterialized` node once per consuming query), so it costs more than A1, +/// which builds it once. #[test] #[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] fn plan_selection_a_rebuilding_per_query_costs_more_than_building_once() { diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index e2f4c1684..fb4181c6f 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -106,7 +106,9 @@ Invariants: `from_logical` is a bijection onto the logical ids, so no candidate is dropped before plan selection. * Each physical candidate keeps its logical Q2 option and input sharing. -* Exact top-*k* is implemented as a sort followed by a limit. +* Exact top-*k* is implemented as a sort followed by a limit: each job's + series sorted by the ranked value, descending, then a `Project` that + restores the logical root columns `[job, topk_10]`. * A summary Q2 is a sketch build node feeding an estimation node. There is no merge node, because there are no window summaries. * Every node is `NotMaterialized` and runs at query time. @@ -129,7 +131,9 @@ Invariants: * One selected id. Every other candidate is listed once as rejected, with a reason, and marked invalid or valid but costlier. * Only valid candidates are priced. Each valid candidate's cost has one entry - per DAG node, and `total` is their sum, so a shared node is charged once. + per DAG node, and `total` is their sum. A `NotMaterialized` node is charged + once per query that reads it (its multiplier), since it is recomputed for + each; a materialized node once. Every node is `NotMaterialized` today. * The selected plan is no more expensive than any valid candidate. * The selected plan compiles in the physical planner. @@ -166,8 +170,10 @@ whether an exact Q2 misses 100 ms is not tested. 6. **Hydra's inner sketch.** The doc says "Hydra over the whole `job` column" without naming the inner sketch. The test assumes `HydraCms`. 7. **"Shared summary charged once"** does not apply literally: the doc says - no summary is shared in Example 1. The tests check the general form (each - node charged once). + no summary is shared in Example 1. Plan selection charges a + `NotMaterialized` node once per consuming query and a materialized one + once; the tests check that rule. A shared input therefore saves nothing + until it is materialized (#604). 8. **Latency.** The doc says an exact top 10 rebuilt at every refresh "may miss" 100 ms. Plan selection does not check latency yet. 9. **Accuracy of Count-Min heap merges** matters only with tumbling windows, diff --git a/docs/design_docs/proposals/planner-layering-example2-acceptance.md b/docs/design_docs/proposals/planner-layering-example2-acceptance.md index 23aec0df6..bfe40f750 100644 --- a/docs/design_docs/proposals/planner-layering-example2-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example2-acceptance.md @@ -90,15 +90,18 @@ form is rebuilt at every refresh (Example 1's table). |---|---|---| | One selected id; every other candidate rejected once with a reason | `plan_selection_selects_one_and_explains_the_rest` | passes | | The selected plan is the cheapest valid one | `plan_selection_selects_cheapest_valid` | passes | -| Each node of a valid candidate is charged once (a shared summary is costed once) | `plan_selection_charges_each_node_once` | passes | +| Each node of a valid candidate is charged once per consuming query while `NotMaterialized` (all today), once when materialized | `plan_selection_charges_each_node_per_consuming_query` | passes | | A UnivMon estimate has a guarantee, so it is judged against its target instead of rejected for having none | `plan_selection_judges_univmon_with_an_accuracy_model` | ignored: L2 in #621; distinct count and entropy have no PR | -| One UnivMon for all three costs no more than three separate UnivMons | `plan_selection_shared_univmon_costs_no_more_than_three` | ignored: #595, #596, #621 | +| One materialized UnivMon for all three costs no more than three separate UnivMons | `plan_selection_shared_univmon_costs_no_more_than_three` | ignored: #595, #596, #621, #604 | | Selection direction: the selected plan has at most one UnivMon, because one shared UnivMon dominates several over the same input | `plan_selection_selected_plan_has_at_most_one_univmon` | passes (vacuously today: no UnivMon is valid) | The doc's "the shared candidate usually wins" depends on the cost model, so it is tested only as the dominance above: one build sized for the strictest consumer costs no more than three builds that include one of that size. Whether -it also beats the exact plans is the model's call. +it also beats the exact plans is the model's call. The dominance needs the +shared UnivMon materialized (#604): plan selection charges a `NotMaterialized` +node once per consuming query, so unmaterialized it costs three builds of the +strictest size. ## Ambiguities and conflicts with the planner diff --git a/docs/design_docs/proposals/planner-layering-example3-acceptance.md b/docs/design_docs/proposals/planner-layering-example3-acceptance.md index d9eafe92b..512958824 100644 --- a/docs/design_docs/proposals/planner-layering-example3-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example3-acceptance.md @@ -56,7 +56,7 @@ Shared data workload, except Pattern A's `arrival` is `mixed`. | The EH candidate coexists with the five independent KLLs | `logical_asap_optimization_a_keeps_independent_and_shared_window_summaries` | ignored: #628, #634 | | Every pair of queries shares a window summary in some candidate | `logical_asap_optimization_a_window_composition_groups_every_pair` | ignored: #628, #634 | | One selected id, the rest explained; the selected plan is the cheapest valid one | `plan_selection_a_selects_cheapest_valid` | passes | -| Each node of a valid candidate is charged once | `plan_selection_a_charges_each_node_once` | passes | +| Each node of a valid candidate is charged once per consuming query while `NotMaterialized` (all today), once when materialized | `plan_selection_a_charges_each_node_per_consuming_query` | passes | | For the same per-computation choices, sharing the scan costs no more than separate scans | `plan_selection_a_shared_scan_is_not_costlier` | ignored: #588, #632 (see ambiguity 7) | ### Pattern B diff --git a/docs/design_docs/proposals/planner-layering-example4-acceptance.md b/docs/design_docs/proposals/planner-layering-example4-acceptance.md index cfa2663e0..f318b4501 100644 --- a/docs/design_docs/proposals/planner-layering-example4-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example4-acceptance.md @@ -76,7 +76,7 @@ the IR records retention. | The shared EH yields exactly A1, A2, A3 | `physical_asap_optimization_a_shared_eh_has_three_materialization_options` | ignored: #628, #634, #604, #625 | | At rest, A2 is not generated; A1 and A3 remain | `physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option` | ignored: #628, #634, #604, #625 | | A materialized EH is one build node read by all five queries, priced once | `physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers` | ignored: #628, #634, #604, #625 | -| A3 costs strictly more than A1 (five builds instead of one) | `plan_selection_a_rebuilding_per_query_costs_more_than_building_once` | ignored: #628, #634, #604, #625 | +| A3 costs strictly more than A1 (five builds instead of one: a `NotMaterialized` node is charged per consuming query) | `plan_selection_a_rebuilding_per_query_costs_more_than_building_once` | ignored: #628, #634, #604, #625 | | As given, A1 is the cheapest of the three | `plan_selection_a_once_adhoc_prefers_the_query_time_eh` | ignored: #628, #634, #604, #625 | | Monthly, A2's cost relative to A1 drops: cost(A2)/cost(A1) monthly < as given | `plan_selection_a_monthly_amortizes_ingestion_time_maintenance` | ignored: #628, #634, #604, #625 | @@ -106,13 +106,13 @@ the IR records retention. ## Ambiguities and conflicts with the planner -1. **Charging A3.** Plan selection charges each node once, which is right for a - shared, materialized summary. A3 is one logical EH node that is not - materialized, so it is rebuilt by each of its five consumers. The tests - require A3 to cost more than A1. Either plan selection charges a - non-materialized node once per consuming query, or physical ASAP-aware - optimization duplicates it per consumer (which makes A3 look like five - independent EHs). Decision needed. +1. **Charging A3 (decided).** A3 is one logical EH node that is not + materialized, so it is rebuilt by each of its five consumers. Plan + selection charges a `NotMaterialized` node once per consuming query + (`NodeCost::multiplier`) and a materialized node once; physical ASAP-aware + optimization does not duplicate the node. So A3 costs five EH builds and + A1 one, as the tests require. The A3 tests stay ignored only because the + shared EH and its materialization options do not exist yet. 2. **Recurrence input.** Materialization depends on `recurrence`, `predictability` and `arrival`, but `optimize_physical_asap` takes only the logical candidate and `select_plan` only the accuracy targets and diff --git a/docs/user_guide_docs/stage-viewer.md b/docs/user_guide_docs/stage-viewer.md index ff324c19f..2f2852aff 100644 --- a/docs/user_guide_docs/stage-viewer.md +++ b/docs/user_guide_docs/stage-viewer.md @@ -68,7 +68,7 @@ plan wins. | Tab | Workload | What to look for | | --- | --- | --- | -| 1 · dashboard panels | Two PromQL panels over 1M counter series every 10 s: an exact per-job rate, and the approximate top-10 series per job | The exact query stays exact, the top-k uses one CMS+heap over the raw samples, and both read one shared scan | +| 1 · dashboard panels | Two PromQL panels over 1M counter series every 10 s: an exact per-job rate, and the approximate top-10 series per job | The exact query stays exact; the CMS+heap and CountSketch+heap top-k plans are rejected with their reasons, so the all-exact plan wins; each query reads its own scan until cross-query sharing lands | | 2 · SQL flow statistics | Distinct sources, entropy and L2 of per-source counts over the last minute of flows | Every UnivMon plan is invalid with the reason "no accuracy model for UnivMon", so an exact plan wins | | 3a · historical p99 batch | Five p99 reports over 1–5 years, run once | The cheapest plan shares one input scan across the five KLL sketches | | 3b · live p99 panel | p99 over the last 5 min every minute, 1M series | Keeping KLL panes from ingestion time costs more memory than building the sketch at each evaluation, and rebuilding all panes at query time is over the 200 ms latency bound | From 748232ffaf6d5755c4055b710431411e083daab5 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:55:43 +0000 Subject: [PATCH 08/19] refactor(devtools): one planning pipeline for stage_pipeline and the acceptance tests run_planning_stages and the #509 example workloads move into the asap-devtools library; stage_pipeline serializes the run and the planner-layering tests adapt it. The shared data workload drops ingestion_rate for data at rest, which validate() requires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- Cargo.lock | 1 + crates/devtools/src/bin/stage_pipeline.rs | 348 ++---------------- crates/devtools/src/lib.rs | 7 + .../devtools/src/planner_layering_examples.rs | 233 ++++++++++++ crates/devtools/src/planning_stages.rs | 231 ++++++++++++ crates/integration-tests/Cargo.toml | 1 + .../tests/planner_layering_common/mod.rs | 310 +++------------- .../tests/planner_layering_example1.rs | 63 +--- 8 files changed, 558 insertions(+), 636 deletions(-) create mode 100644 crates/devtools/src/planner_layering_examples.rs create mode 100644 crates/devtools/src/planning_stages.rs diff --git a/Cargo.lock b/Cargo.lock index de76c1704..f40752fa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -388,6 +388,7 @@ name = "asap-integration-tests" version = "0.1.0" dependencies = [ "asap-aware-mapping", + "asap-devtools", "asap-frontend-promql", "asap-frontend-sql", "asap-physical-operators", diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 824457de9..6e895b043 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -30,25 +30,18 @@ // query; without them the queries are exact. `--interval-ms` is the source // cadence PromQL needs (default 15000). -use std::rc::Rc; - -use asap_aware_mapping::per_computation_candidates::{ - compose_logical_candidate, generate_per_computation_candidates, ComputationCandidate, - PerComputationCandidates, +use asap_aware_mapping::plan_selection::{Rejection, Selection, SelectionError}; +use asap_devtools::planner_layering_examples::{ + declared, example1, pattern_a, pattern_b, PatternARecurrence, }; -use asap_aware_mapping::physical_asap_optimization::optimize_physical_asap; -use asap_aware_mapping::plan_selection::{select_plan, Rejection, Selection, SelectionError}; -use asap_aware_mapping::PlanningModels; +use asap_devtools::planning_stages::{lower_promql_roots, run_planning_stages}; use asap_types::ir::flat::{flatten, FlatDag}; -use asap_types::ir::schema_support::with_promql_series_identity; -use asap_types::ir::{OperatorNode, QueryRoot}; -use asap_types::post_asap::SketchAlgorithm; +use asap_types::ir::QueryRoot; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - AccuracyRequirement, BatchEntry, DataArrival, DataDistribution, DataWorkload, DurationMs, - Evidence, EvidenceSource, LatencyRequirement, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRecurrence, QueryRequirements, QueryTimeScope, QueryWorkload, Rate, - RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, TimestampMs, + AccuracyRequirement, BatchEntry, DataArrival, DataWorkload, DurationMs, LatencyRequirement, + PlanningWorkload, Query, QueryLanguage, QueryRecurrence, QueryRequirements, QueryWorkload, + RepeatedDemand, }; use serde_json::{json, Value}; @@ -85,9 +78,11 @@ fn run(args: Vec) -> Result<(), String> { } } let workload = match (example.as_deref(), queries.is_empty()) { - (Some("planner-layering-1"), true) => planner_layering_example1(), - (Some("planner-layering-3a"), true) => planner_layering_example3a(), - (Some("planner-layering-3b"), true) => planner_layering_example3b(), + (Some("planner-layering-1"), true) => example1(), + (Some("planner-layering-3a"), true) => { + pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed) + } + (Some("planner-layering-3b"), true) => pattern_b(), (Some(other), true) => return Err(format!("unknown example {other}")), (None, false) => { let accuracy = match (epsilon, delta) { @@ -107,88 +102,39 @@ fn run(args: Vec) -> Result<(), String> { } fn stage_pipeline(workload: &PlanningWorkload, max_candidates: usize) -> Result { - // PromQL rows carry each series' full identity as a column: the row - // representation per-series state needs at runtime. - let roots = asap_frontend_promql::lower_promql_query_workload(workload, 0) - .map_err(|e| format!("lowering: {e}"))? - .into_iter() - .map(|root| match root { - QueryRoot::Operator(node) => with_promql_series_identity(&node) - .map(QueryRoot::Operator) - .map_err(|e| format!("series identity: {e}")), - scalar => Ok(scalar), - }) - .collect::, _>>()?; - let frontends_dag = export(&roots)?; - let inventory = generate_per_computation_candidates(roots.into_iter().enumerate().collect()) - .map_err(|e| format!("Pass 1: {e}"))?; - let combinations = inventory - .targets + let roots = lower_promql_roots(workload)?; + let run = run_planning_stages(workload, roots, Some(max_candidates))?; + // A choice that composition cannot substitute yet is listed with its + // error rather than ending the run. + let candidates: Vec<_> = run + .logical .iter() - .map(|target| target.choices.candidates.len()) - .product::(); - let owners = target_owners(&inventory); - let mut candidates = Vec::new(); - let mut physical = Vec::new(); - let mut pruned = Vec::new(); - let mut choice = vec![0; inventory.targets.len()]; - for index in 0..combinations.min(max_candidates) { - let (id, label) = ( - format!("L{}", index + 1), - label(&inventory, &owners, &choice), - ); - let mut candidate = json!({ "id": id, "label": label }); - // A choice that composition cannot substitute yet is listed with its - // error rather than ending the run. - match compose_logical_candidate(&inventory, &choice) { - Ok(roots) => { - let roots: Vec<_> = roots.into_iter().map(|(_, root)| root).collect(); - candidate["dag"] = json!(export(&roots)?); - match optimize_physical_asap(&id, &roots) { - Ok(mut physical_candidate) => { - physical_candidate.id = format!("P{}", index + 1); - physical_candidate.label = label; - physical.push(physical_candidate); - } - Err(e) => pruned.push( - json!({ "from_logical": id, "label": label, "reason": e.to_string() }), - ), - } - } - Err(error) => candidate["error"] = json!(error.to_string()), - } - candidates.push(candidate); - // Mixed-radix increment: the last target varies fastest. - for (digit, target) in choice.iter_mut().zip(&inventory.targets).rev() { - *digit += 1; - if *digit < target.choices.candidates.len() { - break; - } - *digit = 0; - } - } - let targets: Vec<_> = workload - .query_workload - .entries() - .map(|entry| Some(entry.requirements.accuracy.target())) + .map(|c| match &c.roots { + Ok(roots) => json!({ "id": c.id, "label": c.label, "dag": export(roots) }), + Err(error) => json!({ "id": c.id, "label": c.label, "error": error }), + }) .collect(); - let data = workload.data_workload.clone().unwrap_or_default(); - let selection = select_plan(&physical, &targets, &data, PlanningModels::builtin()); - let physical_json: Vec<_> = physical + let physical: Vec<_> = run + .physical .iter() .map(|p| json!({ "id": p.id, "from_logical": p.from_logical, "label": p.label, "dag": p.dag })) .collect(); + let pruned: Vec<_> = run + .pruned + .iter() + .map(|p| json!({ "from_logical": p.from_logical, "label": p.label, "reason": p.reason })) + .collect(); Ok(json!({ "format": "asap-stage-pipeline/v1", "workload": { "queries": workload_queries(workload) }, - "language_specific_frontends": { "dag": frontends_dag }, + "language_specific_frontends": { "dag": export(&run.frontends) }, "logical_asap_optimization": { - "combinations": combinations, - "capped": combinations > max_candidates, + "combinations": run.combinations, + "capped": run.capped(), "candidates": candidates, }, - "physical_asap_optimization": { "candidates": physical_json, "pruned": pruned }, - "plan_selection": plan_selection_json(&selection), + "physical_asap_optimization": { "candidates": physical, "pruned": pruned }, + "plan_selection": plan_selection_json(&run.selection), })) } @@ -238,83 +184,8 @@ fn rejections_json(rejected: &[Rejection]) -> Vec { /// A logical DAG as a flat node list: node `i` is `nodes[i]`, and children are /// node indices inside each operator. -fn export(roots: &[QueryRoot]) -> Result { - for root in roots { - root.validate_structure() - .map_err(|e| format!("validation: {e}"))?; - } - Ok(flatten(roots).0) -} - -/// The first query whose plan reaches each target (enumeration order). -fn target_owners(inventory: &PerComputationCandidates) -> Vec { - inventory - .targets - .iter() - .map(|target| { - inventory - .roots - .iter() - .position(|(_, root)| { - let operators = match root { - QueryRoot::Operator(node) => vec![node], - QueryRoot::Scalar(expr) => expr.operator_refs(), - }; - operators.into_iter().any(|node| { - OperatorNode::reachable(node) - .iter() - .any(|n| Rc::ptr_eq(n, &target.target)) - }) - }) - .expect("every target is reached from a root") - }) - .collect() -} - -/// E.g. "Q1 exact · Q2 CMS+heap"; exact accumulators are listed in parentheses. -fn label( - inventory: &PerComputationCandidates, - owners: &[usize], - choice: &[usize], -) -> String { - (0..inventory.roots.len()) - .map(|query| { - let mut sketches = Vec::new(); - let mut accumulators = Vec::new(); - for (target, (&owner, &index)) in - inventory.targets.iter().zip(owners.iter().zip(choice)) - { - if owner != query { - continue; - } - match &target.choices.candidates[index] { - ComputationCandidate::Exact => {} - ComputationCandidate::ExactAccumulator { kind, .. } => { - accumulators.push(format!("{kind:?} acc")) - } - ComputationCandidate::AvgAsSumCount => { - accumulators.push("Sum/Count acc".into()) - } - ComputationCandidate::Summary(algorithm) => sketches.push(match algorithm { - SketchAlgorithm::CmsWithHeap => "CMS+heap".to_string(), - SketchAlgorithm::CountSketchWithHeap => "CountSketch+heap".to_string(), - other => format!("{other:?}"), - }), - } - } - let mut text = format!("Q{} ", query + 1); - text += &if sketches.is_empty() { - "exact".to_string() - } else { - sketches.join(" + ") - }; - if !accumulators.is_empty() { - text += &format!(" ({})", accumulators.join(", ")); - } - text - }) - .collect::>() - .join(" · ") +fn export(roots: &[QueryRoot]) -> FlatDag { + flatten(roots).0 } fn workload_queries(workload: &PlanningWorkload) -> Vec { @@ -349,14 +220,6 @@ fn workload_queries(workload: &PlanningWorkload) -> Vec { .collect() } -fn declared(value: T) -> Evidence { - Evidence { - value: Some(value), - source: EvidenceSource::Declared, - ..Default::default() - } -} - fn promql_batch( queries: &[String], accuracy: AccuracyTarget, @@ -390,143 +253,6 @@ fn promql_batch( } } -/// #509 Example 1 (docs/design_docs/proposals/planner-layering.md) over its -/// shared data workload; matches the acceptance spec's test workload. -fn planner_layering_example1() -> PlanningWorkload { - let panel = |query: &str, accuracy, response_latency| RepeatingEntry { - query: Query(query.into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(10_000)), - requirements: QueryRequirements { - accuracy: AccuracyRequirement::Explicit(accuracy), - response_latency, - }, - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection { - scope: QueryTimeScope::RealTime, - lookback: Some(DurationMs(60_000)), - as_of: None, - }, - }; - PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![ - panel( - "sum by (job) (rate(http_requests_total[1m]))", - AccuracyTarget::Exact, - LatencyRequirement::Unspecified, - ), - panel( - "topk by (job) (10, sum_over_time(http_requests_total[1m]))", - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.001, - }, - LatencyRequirement::ExplicitMaxMs(100.0), - ), - ]), - }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), - } -} - -/// The shared data workload of #509 with `arrival`. -fn shared_data_workload(arrival: DataArrival) -> DataWorkload { - DataWorkload { - arrival, - data_ingestion_interval: declared(DurationMs(15_000)), - ingestion_volume: Evidence::default(), - ingestion_rate: declared(Rate(1_000_000.0 / 15.0)), - input_cardinality: declared(1_000_000), - distribution: declared(DataDistribution::Zipf), - } -} - -/// #509 Example 3, Pattern A: an ad hoc batch of five p99 reports over -/// historical intervals, run once at T (2026-01-01), over mixed data. -fn planner_layering_example3a() -> PlanningWorkload { - const YEAR_MS: u64 = 365 * 24 * 3_600_000; - const T_MS: u64 = 1_767_225_600_000; - let queries = [ - ("quantile_over_time(0.99, latency_ms[5y])", 5 * YEAR_MS, 0), - ("quantile_over_time(0.99, latency_ms[1y])", YEAR_MS, 0), - ( - "quantile_over_time(0.99, latency_ms[1y] offset 1y)", - YEAR_MS, - YEAR_MS, - ), - ( - "quantile_over_time(0.99, latency_ms[1y] offset 2y)", - YEAR_MS, - 2 * YEAR_MS, - ), - ( - "quantile_over_time(0.99, latency_ms[3y] offset 2y)", - 3 * YEAR_MS, - 2 * YEAR_MS, - ), - ]; - PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some( - queries - .into_iter() - .map(|(query, lookback, before_t)| BatchEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { - epsilon: 0.005, - delta: 0.01, - }), - response_latency: LatencyRequirement::Unspecified, - }, - predictability: Predictability::AdHoc, - invocations: 1, - execute_at: Some(TimestampMs(T_MS)), - time_selection: TimeSelection { - scope: QueryTimeScope::Longitudinal, - lookback: Some(DurationMs(lookback)), - as_of: Some(TimestampMs(T_MS - before_t)), - }, - }) - .collect(), - ), - repeating_queries: None, - }, - data_workload: Some(shared_data_workload(DataArrival::Mixed)), - } -} - -/// #509 Example 3, Pattern B: a p99 panel over the last 5 min, every minute. -fn planner_layering_example3b() -> PlanningWorkload { - PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![RepeatingEntry { - query: Query("quantile_over_time(0.99, latency_ms[5m])".into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(60_000)), - requirements: QueryRequirements { - accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }), - response_latency: LatencyRequirement::ExplicitMaxMs(200.0), - }, - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection { - scope: QueryTimeScope::RealTime, - lookback: Some(DurationMs(300_000)), - as_of: None, - }, - }]), - }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/devtools/src/lib.rs b/crates/devtools/src/lib.rs index 0316622dc..3a44b6cee 100644 --- a/crates/devtools/src/lib.rs +++ b/crates/devtools/src/lib.rs @@ -11,6 +11,13 @@ //! matching front-end crate directly — [`asap_frontend_promql`] (PromQL parser //! only) or [`asap_frontend_sql`] (DataFusion only) — so it never compiles the //! other's parser. +//! +//! [`planning_stages`] and [`planner_layering_examples`] hold the one +//! pipeline and the #509 example workloads that `stage_pipeline` and the +//! planner-layering acceptance tests share. + +pub mod planner_layering_examples; +pub mod planning_stages; pub use asap_frontend_promql::{ lower_promql_workload, lower_promql_workload_with_histograms, PromqlError, diff --git a/crates/devtools/src/planner_layering_examples.rs b/crates/devtools/src/planner_layering_examples.rs new file mode 100644 index 000000000..594693d46 --- /dev/null +++ b/crates/devtools/src/planner_layering_examples.rs @@ -0,0 +1,233 @@ +//! The workloads of #509 Examples 1, 3 and 4 +//! (`docs/design_docs/proposals/planner-layering.md`), shared by +//! `stage_pipeline` and the planner-layering acceptance tests. + +use asap_types::types::AccuracyTarget; +use asap_types::workload::{ + AccuracyRequirement, BatchEntry, DataArrival, DataDistribution, DataWorkload, DurationMs, + Evidence, EvidenceSource, LatencyRequirement, PlanningWorkload, Predictability, Query, + QueryLanguage, QueryRequirements, QueryTimeScope, QueryWorkload, Rate, RepeatedDemand, + RepeatingEntry, RepetitionInterval, TimeSelection, TimestampMs, +}; + +pub fn declared(value: T) -> Evidence { + Evidence { + value: Some(value), + source: EvidenceSource::Declared, + ..Default::default() + } +} + +pub const MINUTE_MS: u64 = 60_000; +/// PromQL's `y` is 365 days. +pub const YEAR_MS: u64 = 365 * 24 * 60 * MINUTE_MS; +/// Pattern A's batch execution time T (2026-01-01T00:00:00Z). +pub const T_MS: u64 = 1_767_225_600_000; + +/// The #509 shared data workload with `arrival`. +pub fn shared_data_workload(arrival: DataArrival) -> DataWorkload { + DataWorkload { + data_ingestion_interval: declared(DurationMs(15_000)), + ingestion_volume: Evidence::default(), + // Data at rest has no ingestion (`validate` rejects a positive rate). + ingestion_rate: match arrival { + DataArrival::AtRest => Evidence::default(), + _ => declared(Rate(1_000_000.0 / 15.0)), + }, + input_cardinality: declared(1_000_000), + distribution: declared(DataDistribution::Zipf), + arrival, + } +} + +pub const EXAMPLE1_Q1: &str = "sum by (job) (rate(http_requests_total[1m]))"; +pub const EXAMPLE1_Q2: &str = "topk by (job) (10, sum_over_time(http_requests_total[1m]))"; + +/// Example 1: two dashboard panels, every 10 s over the last minute. +pub fn example1() -> PlanningWorkload { + let panel = |query: &str, accuracy, response_latency| RepeatingEntry { + query: Query(query.into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval(10_000)), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(accuracy), + response_latency, + }, + predictability: Predictability::Predictable { known_at: None }, + time_selection: TimeSelection { + scope: QueryTimeScope::RealTime, + lookback: Some(DurationMs(MINUTE_MS)), + as_of: None, + }, + }; + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: None, + repeating_queries: Some(vec![ + panel( + EXAMPLE1_Q1, + AccuracyTarget::Exact, + LatencyRequirement::Unspecified, + ), + panel( + EXAMPLE1_Q2, + AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.001, + }, + LatencyRequirement::ExplicitMaxMs(100.0), + ), + ]), + }, + data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + } +} + +/// Pattern A's five queries: (PromQL, `lookback`, T − `as_of`). +pub const PATTERN_A: [(&str, u64, u64); 5] = [ + ("quantile_over_time(0.99, latency_ms[5y])", 5 * YEAR_MS, 0), + ("quantile_over_time(0.99, latency_ms[1y])", YEAR_MS, 0), + ( + "quantile_over_time(0.99, latency_ms[1y] offset 1y)", + YEAR_MS, + YEAR_MS, + ), + ( + "quantile_over_time(0.99, latency_ms[1y] offset 2y)", + YEAR_MS, + 2 * YEAR_MS, + ), + ( + "quantile_over_time(0.99, latency_ms[3y] offset 2y)", + 3 * YEAR_MS, + 2 * YEAR_MS, + ), +]; + +/// How Pattern A's batch recurs (Example 4 varies it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatternARecurrence { + /// As given: one `ad_hoc` batch run once at T. + OnceAdHoc, + /// Example 4: repeated monthly and `Predictable { known_at }`; each run + /// reads the intervals ending at its own evaluation time. + MonthlyPredictable, +} + +fn pattern_a_requirements() -> QueryRequirements { + QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon: 0.005, + delta: 0.01, + }), + response_latency: LatencyRequirement::Unspecified, + } +} + +/// Example 3, Pattern A: p99 over five historical intervals. +pub fn pattern_a(recurrence: PatternARecurrence, arrival: DataArrival) -> PlanningWorkload { + let (batch, repeating) = match recurrence { + PatternARecurrence::OnceAdHoc => ( + Some( + PATTERN_A + .iter() + .map(|&(query, lookback, before_t)| BatchEntry { + query: Query(query.into()), + requirements: pattern_a_requirements(), + predictability: Predictability::AdHoc, + invocations: 1, + execute_at: Some(TimestampMs(T_MS)), + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(lookback)), + as_of: Some(TimestampMs(T_MS - before_t)), + }, + }) + .collect(), + ), + None, + ), + PatternARecurrence::MonthlyPredictable => ( + None, + Some( + PATTERN_A + .iter() + .map(|&(query, lookback, _)| RepeatingEntry { + query: Query(query.into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval( + (30 * 24 * 60 * MINUTE_MS) as u32, + )), + requirements: pattern_a_requirements(), + predictability: Predictability::Predictable { + known_at: Some(TimestampMs(T_MS)), + }, + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(lookback)), + as_of: None, + }, + }) + .collect(), + ), + ), + }; + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: batch, + repeating_queries: repeating, + }, + data_workload: Some(shared_data_workload(arrival)), + } +} + +pub const PATTERN_B: &str = "quantile_over_time(0.99, latency_ms[5m])"; +pub const PATTERN_B_WINDOW_MS: u64 = 5 * MINUTE_MS; +pub const PATTERN_B_INTERVAL_MS: u64 = MINUTE_MS; + +/// Example 3, Pattern B: a p99 panel over the last 5 min, every minute. +pub fn pattern_b() -> PlanningWorkload { + PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: None, + repeating_queries: Some(vec![RepeatingEntry { + query: Query(PATTERN_B.into()), + demand: RepeatedDemand::FixedInterval(RepetitionInterval( + PATTERN_B_INTERVAL_MS as u32, + )), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }), + response_latency: LatencyRequirement::ExplicitMaxMs(200.0), + }, + predictability: Predictability::Predictable { known_at: None }, + time_selection: TimeSelection { + scope: QueryTimeScope::RealTime, + lookback: Some(DurationMs(PATTERN_B_WINDOW_MS)), + as_of: None, + }, + }]), + }, + data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Data at rest has no ingestion rate, so the at-rest shared data + /// workload validates. + #[test] + fn at_rest_shared_data_workload_is_valid() { + shared_data_workload(DataArrival::AtRest) + .validate() + .unwrap(); + pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::AtRest) + .validate() + .unwrap(); + } +} diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs new file mode 100644 index 000000000..bfdeb8b2b --- /dev/null +++ b/crates/devtools/src/planning_stages.rs @@ -0,0 +1,231 @@ +//! The four planner stages of #509 run end to end over one workload: the +//! one pipeline behind `stage_pipeline` and the planner-layering acceptance +//! tests, so both see the same candidates, ids and selection. + +use std::rc::Rc; + +use asap_aware_mapping::per_computation_candidates::{ + compose_logical_candidate, generate_per_computation_candidates, ComputationCandidate, + PerComputationCandidates, +}; +use asap_aware_mapping::physical_asap_optimization::{optimize_physical_asap, PhysicalCandidate}; +use asap_aware_mapping::plan_selection::{select_plan, Selection, SelectionError}; +use asap_aware_mapping::PlanningModels; +use asap_types::ir::schema_support::with_promql_series_identity; +use asap_types::ir::{OperatorNode, QueryRoot}; +use asap_types::post_asap::SketchAlgorithm; +use asap_types::workload::PlanningWorkload; + +/// One whole-workload logical candidate: a choice of a per-computation +/// candidate for every Pass 1 target. +#[derive(Debug)] +pub struct LogicalCandidate { + /// `L`, numbered in enumeration order from 1. + pub id: String, + /// E.g. "Q1 exact · Q2 CMS+heap". + pub label: String, + /// One root per workload entry, or why composition cannot substitute + /// this choice yet. + pub roots: Result, String>, +} + +/// A composed logical candidate physical ASAP-aware optimization cannot +/// implement. +#[derive(Debug)] +pub struct PrunedCandidate { + pub from_logical: String, + pub label: String, + pub reason: String, +} + +#[derive(Debug)] +pub struct PlanningStagesRun { + /// The language-specific frontends' roots, one per workload entry. + pub frontends: Vec, + /// Size of the Cartesian product of per-computation candidates; + /// `logical` holds its first `max_candidates`. + pub combinations: usize, + pub logical: Vec, + /// `P` implements `L`. + pub physical: Vec, + pub pruned: Vec, + pub selection: Result, +} + +impl PlanningStagesRun { + pub fn capped(&self) -> bool { + self.logical.len() < self.combinations + } +} + +/// Lower a PromQL workload, each series' identity as a column (the row +/// representation per-series state needs at runtime). +pub fn lower_promql_roots(workload: &PlanningWorkload) -> Result, String> { + asap_frontend_promql::lower_promql_query_workload(workload, 0) + .map_err(|e| format!("lowering: {e}"))? + .into_iter() + .map(|root| match root { + QueryRoot::Operator(node) => with_promql_series_identity(&node) + .map(QueryRoot::Operator) + .map_err(|e| format!("series identity: {e}")), + scalar => Ok(scalar), + }) + .collect() +} + +/// Logical ASAP-aware optimization (Pass 1 and composition; no Pass 2), +/// physical ASAP-aware optimization and plan selection over `roots`, the +/// lowered entries of `workload`. Logical candidates are every choice of a +/// per-computation candidate for every target, the last target varying +/// fastest, cut at `max_candidates` (`None`: all). Plan selection reads only +/// the accuracy targets and the data workload. +pub fn run_planning_stages( + workload: &PlanningWorkload, + roots: Vec, + max_candidates: Option, +) -> Result { + validate(&roots)?; + let inventory = + generate_per_computation_candidates(roots.iter().cloned().enumerate().collect()) + .map_err(|e| format!("Pass 1: {e}"))?; + let combinations = inventory + .targets + .iter() + .map(|target| target.choices.candidates.len()) + .product::(); + let owners = target_owners(&inventory); + let mut logical = Vec::new(); + let mut physical = Vec::new(); + let mut pruned = Vec::new(); + let mut choice = vec![0; inventory.targets.len()]; + for index in 0..combinations.min(max_candidates.unwrap_or(usize::MAX)) { + let (id, label) = ( + format!("L{}", index + 1), + label(&inventory, &owners, &choice), + ); + let composed = compose_logical_candidate(&inventory, &choice) + .map(|roots| roots.into_iter().map(|(_, root)| root).collect::>()) + .map_err(|e| e.to_string()); + if let Ok(roots) = &composed { + validate(roots)?; + match optimize_physical_asap(&id, roots) { + Ok(mut candidate) => { + candidate.id = format!("P{}", index + 1); + candidate.label = label.clone(); + physical.push(candidate); + } + Err(e) => pruned.push(PrunedCandidate { + from_logical: id.clone(), + label: label.clone(), + reason: e.to_string(), + }), + } + } + logical.push(LogicalCandidate { + id, + label, + roots: composed, + }); + // Mixed-radix increment: the last target varies fastest. + for (digit, target) in choice.iter_mut().zip(&inventory.targets).rev() { + *digit += 1; + if *digit < target.choices.candidates.len() { + break; + } + *digit = 0; + } + } + let targets: Vec<_> = workload + .query_workload + .entries() + .map(|entry| Some(entry.requirements.accuracy.target())) + .collect(); + let data = workload.data_workload.clone().unwrap_or_default(); + let selection = select_plan(&physical, &targets, &data, PlanningModels::builtin()); + Ok(PlanningStagesRun { + frontends: roots, + combinations, + logical, + physical, + pruned, + selection, + }) +} + +fn validate(roots: &[QueryRoot]) -> Result<(), String> { + roots + .iter() + .try_for_each(|root| root.validate_structure()) + .map_err(|e| format!("validation: {e}")) +} + +/// The first query whose plan reaches each target (enumeration order). +fn target_owners(inventory: &PerComputationCandidates) -> Vec { + inventory + .targets + .iter() + .map(|target| { + inventory + .roots + .iter() + .position(|(_, root)| { + let operators = match root { + QueryRoot::Operator(node) => vec![node], + QueryRoot::Scalar(expr) => expr.operator_refs(), + }; + operators.into_iter().any(|node| { + OperatorNode::reachable(node) + .iter() + .any(|n| Rc::ptr_eq(n, &target.target)) + }) + }) + .expect("every target is reached from a root") + }) + .collect() +} + +/// E.g. "Q1 exact · Q2 CMS+heap"; exact accumulators are listed in parentheses. +fn label( + inventory: &PerComputationCandidates, + owners: &[usize], + choice: &[usize], +) -> String { + (0..inventory.roots.len()) + .map(|query| { + let mut sketches = Vec::new(); + let mut accumulators = Vec::new(); + for (target, (&owner, &index)) in + inventory.targets.iter().zip(owners.iter().zip(choice)) + { + if owner != query { + continue; + } + match &target.choices.candidates[index] { + ComputationCandidate::Exact => {} + ComputationCandidate::ExactAccumulator { kind, .. } => { + accumulators.push(format!("{kind:?} acc")) + } + ComputationCandidate::AvgAsSumCount => { + accumulators.push("Sum/Count acc".into()) + } + ComputationCandidate::Summary(algorithm) => sketches.push(match algorithm { + SketchAlgorithm::CmsWithHeap => "CMS+heap".to_string(), + SketchAlgorithm::CountSketchWithHeap => "CountSketch+heap".to_string(), + other => format!("{other:?}"), + }), + } + } + let mut text = format!("Q{} ", query + 1); + text += &if sketches.is_empty() { + "exact".to_string() + } else { + sketches.join(" + ") + }; + if !accumulators.is_empty() { + text += &format!(" ({})", accumulators.join(", ")); + } + text + }) + .collect::>() + .join(" · ") +} diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index afa7559b4..dcd2a1e05 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -10,6 +10,7 @@ asap-frontend-sql = { path = "../frontend-sql" } asap-aware-mapping = { path = "../asap-aware-mapping" } [dev-dependencies] +asap-devtools = { path = "../devtools" } asap-planner = { path = "../planner" } asap_sketchlib = { workspace = true } serde_json = "1" diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index 3ccad26e8..bc4a15ca0 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -2,9 +2,10 @@ //! (`planner_layering_example{1,2,3,4}.rs`). Specs: //! `docs/design_docs/proposals/planner-layering-example{1,2,3,4}-acceptance.md`. //! -//! [`run_stages`] runs the real stages: logical ASAP-aware optimization -//! (Pass 1 and composition), physical ASAP-aware optimization and plan -//! selection. The functions under "Pending adapters" read properties the IR +//! [`run_stages`] runs the real stages through +//! `asap_devtools::planning_stages::run_planning_stages`, the pipeline +//! `stage_pipeline` also runs, uncapped; the example workloads come from +//! `asap_devtools::planner_layering_examples`. The functions under "Pending adapters" read properties the IR //! cannot express yet (window summaries, retention). They return today's //! only possible answer; the implementer of each feature replaces the body //! with a read of the new IR. Tests that depend on them are `#[ignore]`d @@ -13,191 +14,20 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use asap_aware_mapping::per_computation_candidates::{ - compose_logical_candidate, generate_per_computation_candidates, -}; -use asap_aware_mapping::physical_asap_optimization::{optimize_physical_asap, PhysicalCandidate}; -use asap_aware_mapping::plan_selection::{select_plan, Selection}; -use asap_aware_mapping::PlanningModels; +use asap_aware_mapping::physical_asap_optimization::PhysicalCandidate; +use asap_aware_mapping::plan_selection::Selection; +use asap_devtools::planning_stages::{lower_promql_roots, run_planning_stages}; use asap_types::ir::flat::{flatten, FlatDag, NodeId}; use asap_types::ir::physical_export::PhysicalASAPDAG; use asap_types::ir::{ASAPOp, Operator, QueryRoot}; use asap_types::post_asap::{ FieldDataType, Materialization, SketchAlgorithm, SketchParams, SketchStatistic, }; -use asap_types::types::AccuracyTarget; -use asap_types::workload::{ - AccuracyRequirement, BatchEntry, DataArrival, DataDistribution, DataWorkload, DurationMs, - Evidence, EvidenceSource, LatencyRequirement, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRecurrence, QueryRequirements, QueryTimeScope, QueryWorkload, Rate, - RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, TimestampMs, -}; - -pub type Payload = Operator; - -pub fn declared(value: T) -> Evidence { - Evidence { - value: Some(value), - source: EvidenceSource::Declared, - ..Default::default() - } -} - -// ── Workloads shared by Examples 3 and 4 ──────────────────────────────── - -pub const MINUTE_MS: u64 = 60_000; -/// PromQL's `y` is 365 days. -pub const YEAR_MS: u64 = 365 * 24 * 60 * MINUTE_MS; -/// Pattern A's batch execution time T (2026-01-01T00:00:00Z). -pub const T_MS: u64 = 1_767_225_600_000; - -/// The #509 shared data workload with `arrival`. -pub fn shared_data_workload(arrival: DataArrival) -> DataWorkload { - DataWorkload { - data_ingestion_interval: declared(DurationMs(15_000)), - ingestion_volume: Evidence::default(), - // Data at rest has no ingestion rate (`validate` rejects one). - ingestion_rate: match arrival { - DataArrival::AtRest => Evidence::default(), - _ => declared(Rate(1_000_000.0 / 15.0)), - }, - input_cardinality: declared(1_000_000), - distribution: declared(DataDistribution::Zipf), - arrival, - } -} +use asap_types::workload::{PlanningWorkload, QueryRecurrence, RepeatedDemand}; -/// Pattern A's five queries: (PromQL, `lookback`, `as_of` − T). -pub const PATTERN_A: [(&str, u64, u64); 5] = [ - ("quantile_over_time(0.99, latency_ms[5y])", 5 * YEAR_MS, 0), - ("quantile_over_time(0.99, latency_ms[1y])", YEAR_MS, 0), - ( - "quantile_over_time(0.99, latency_ms[1y] offset 1y)", - YEAR_MS, - YEAR_MS, - ), - ( - "quantile_over_time(0.99, latency_ms[1y] offset 2y)", - YEAR_MS, - 2 * YEAR_MS, - ), - ( - "quantile_over_time(0.99, latency_ms[3y] offset 2y)", - 3 * YEAR_MS, - 2 * YEAR_MS, - ), -]; - -/// How Pattern A's batch recurs (Example 4 varies it). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PatternARecurrence { - /// As given: one `ad_hoc` batch run once at T. - OnceAdHoc, - /// Example 4: repeated monthly and `Predictable { known_at }`; each run - /// reads the intervals ending at its own evaluation time. - MonthlyPredictable, -} - -fn pattern_a_requirements() -> QueryRequirements { - QueryRequirements { - accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { - epsilon: 0.005, - delta: 0.01, - }), - response_latency: LatencyRequirement::Unspecified, - } -} - -/// Example 3, Pattern A: p99 over five historical intervals. -pub fn pattern_a(recurrence: PatternARecurrence, arrival: DataArrival) -> PlanningWorkload { - let (batch, repeating) = match recurrence { - PatternARecurrence::OnceAdHoc => ( - Some( - PATTERN_A - .iter() - .map(|&(query, lookback, before_t)| BatchEntry { - query: Query(query.into()), - requirements: pattern_a_requirements(), - predictability: Predictability::AdHoc, - invocations: 1, - execute_at: Some(TimestampMs(T_MS)), - time_selection: TimeSelection { - scope: QueryTimeScope::Longitudinal, - lookback: Some(DurationMs(lookback)), - as_of: Some(TimestampMs(T_MS - before_t)), - }, - }) - .collect(), - ), - None, - ), - PatternARecurrence::MonthlyPredictable => ( - None, - Some( - PATTERN_A - .iter() - .map(|&(query, lookback, _)| RepeatingEntry { - query: Query(query.into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval( - (30 * 24 * 60 * MINUTE_MS) as u32, - )), - requirements: pattern_a_requirements(), - predictability: Predictability::Predictable { - known_at: Some(TimestampMs(T_MS)), - }, - time_selection: TimeSelection { - scope: QueryTimeScope::Longitudinal, - lookback: Some(DurationMs(lookback)), - as_of: None, - }, - }) - .collect(), - ), - ), - }; - PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: batch, - repeating_queries: repeating, - }, - data_workload: Some(shared_data_workload(arrival)), - } -} +pub use asap_devtools::planner_layering_examples::*; -pub const PATTERN_B: &str = "quantile_over_time(0.99, latency_ms[5m])"; -pub const PATTERN_B_WINDOW_MS: u64 = 5 * MINUTE_MS; -pub const PATTERN_B_INTERVAL_MS: u64 = MINUTE_MS; - -/// Example 3, Pattern B: a p99 panel over the last 5 min, every minute. -pub fn pattern_b() -> PlanningWorkload { - PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![RepeatingEntry { - query: Query(PATTERN_B.into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval( - PATTERN_B_INTERVAL_MS as u32, - )), - requirements: QueryRequirements { - accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }), - response_latency: LatencyRequirement::ExplicitMaxMs(200.0), - }, - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection { - scope: QueryTimeScope::RealTime, - lookback: Some(DurationMs(PATTERN_B_WINDOW_MS)), - as_of: None, - }, - }]), - }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), - } -} +pub type Payload = Operator; /// Lower and plan a PromQL workload. pub fn run_promql(workload: &PlanningWorkload) -> Run { @@ -215,20 +45,9 @@ pub fn needs_merge(form: WindowForm, query_window_ms: u64) -> bool { // ── Language-specific frontends ────────────────────────────────────────── -/// PromQL workload roots, each series' identity as a column (the row -/// representation per-series state needs at runtime). +/// PromQL workload roots, each series' identity as a column. pub fn lower_promql(workload: &PlanningWorkload) -> Vec { - asap_frontend_promql::lower_promql_query_workload(workload, 0) - .expect("workload lowers") - .into_iter() - .map(|root| match root { - QueryRoot::Operator(node) => QueryRoot::Operator( - asap_types::ir::schema_support::with_promql_series_identity(&node) - .expect("series identity"), - ), - scalar => scalar, - }) - .collect() + lower_promql_roots(workload).expect("workload lowers") } /// The non-ASAP operator's kind or the aggregate's first measure as @@ -326,9 +145,6 @@ impl Run { } fn logical(id: String, label: String, roots: Vec) -> Logical { - for root in &roots { - root.validate_structure().expect("valid DAG"); - } let dag = flatten(&roots).0; let query_roots = dag .roots @@ -347,82 +163,42 @@ fn logical(id: String, label: String, roots: Vec) -> Logical { } } -/// Every choice of a per-computation candidate for every target, the last -/// target varying fastest. -fn choices(counts: &[usize]) -> Vec> { - counts.iter().fold(vec![vec![]], |prefixes, &count| { - prefixes - .into_iter() - .flat_map(|prefix| { - (0..count).map(move |i| { - let mut choice = prefix.clone(); - choice.push(i); - choice - }) - }) - .collect() - }) -} - -/// The frontends' DAG, then logical ASAP-aware optimization (Pass 1 and -/// composition; Pass 2 is not implemented), physical ASAP-aware -/// optimization and plan selection over `roots`, the lowered entries of -/// `workload`. Plan selection reads only the accuracy targets and the data -/// workload; recurrence and predictability (which materialization needs, -/// Example 4) are not passed. +/// Every stage over `roots`, the lowered entries of `workload`, with every +/// logical candidate (no cap). Every candidate of the examples composes and +/// plan selection selects one. Recurrence and predictability (which +/// materialization needs, Example 4) are not passed to plan selection. pub fn run_stages(workload: &PlanningWorkload, roots: Vec) -> Run { - let language_specific_frontends = logical("S0".into(), "frontends".into(), roots.clone()); - let inventory = generate_per_computation_candidates(roots.into_iter().enumerate().collect()) - .expect("Pass 1"); - let counts: Vec<_> = inventory - .targets - .iter() - .map(|target| target.choices.candidates.len()) + let run = run_planning_stages(workload, roots, None).expect("planning stages run"); + let logical_candidates = run + .logical + .into_iter() + .map(|c| { + let roots = c + .roots + .unwrap_or_else(|e| panic!("{} ({}) composes: {e}", c.id, c.label)); + logical(c.id, c.label, roots) + }) .collect(); - let mut logical_candidates = Vec::new(); - let mut physical = Vec::new(); - let mut pruned = Vec::new(); - for (index, choice) in choices(&counts).into_iter().enumerate() { - let id = format!("L{}", index + 1); - let roots: Vec<_> = compose_logical_candidate(&inventory, &choice) - .unwrap_or_else(|e| panic!("{id} {choice:?} composes: {e}")) - .into_iter() - .map(|(_, root)| root) - .collect(); - match optimize_physical_asap(&id, &roots) { - Ok(mut candidate) => { - candidate.id = format!("P{}", index + 1); - candidate.label = format!("{choice:?}"); - physical.push(Physical { - id: candidate.id.clone(), - from_logical: id.clone(), - query_roots: candidate.dag.roots.clone(), - candidate, - }); - } - Err(e) => pruned.push((id.clone(), e.to_string())), - } - logical_candidates.push(logical(id, format!("{choice:?}"), roots)); - } - let targets: Vec<_> = workload - .query_workload - .entries() - .map(|entry| Some(entry.requirements.accuracy.target())) + let physical = run + .physical + .into_iter() + .map(|candidate| Physical { + id: candidate.id.clone(), + from_logical: candidate.from_logical.clone(), + query_roots: candidate.dag.roots.clone(), + candidate, + }) .collect(); - let candidates: Vec<_> = physical.iter().map(|p| p.candidate.clone()).collect(); - let selection = select_plan( - &candidates, - &targets, - workload.data_workload.as_ref().expect("data workload"), - PlanningModels::builtin(), - ) - .expect("plan selection selects"); Run { - language_specific_frontends, + language_specific_frontends: logical("frontends".into(), "frontends".into(), run.frontends), logical: logical_candidates, physical, - pruned, - selection, + pruned: run + .pruned + .into_iter() + .map(|p| (p.from_logical, p.reason)) + .collect(), + selection: run.selection.expect("plan selection selects"), } } @@ -721,7 +497,7 @@ pub fn evaluation_interval_ms(workload: &PlanningWorkload, query: usize) -> Opti pub fn lookback_ms(workload: &PlanningWorkload, query: usize) -> u64 { let entry = workload.query_workload.entries().nth(query).expect("entry"); - let DurationMs(ms) = entry.time_selection.lookback.expect("lookback"); + let asap_types::workload::DurationMs(ms) = entry.time_selection.lookback.expect("lookback"); ms } diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index 0e9a39b54..aa61c0c78 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -17,65 +17,12 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use asap_types::ir::flat::NodeId; use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot, ScalarExpr, SortKey}; use asap_types::post_asap::{FieldDataType, GroupingStrategy, HydraKind, SketchAlgorithm}; -use asap_types::types::AccuracyTarget; -use asap_types::workload::{ - AccuracyRequirement, DataArrival, DurationMs, LatencyRequirement, PlanningWorkload, - Predictability, Query, QueryLanguage, QueryRequirements, QueryTimeScope, QueryWorkload, - RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, -}; use planner_layering_common::*; // ── Example 1 workload ─────────────────────────────────────────────────── -const Q1: &str = "sum by (job) (rate(http_requests_total[1m]))"; -const Q2: &str = "topk by (job) (10, sum_over_time(http_requests_total[1m]))"; - -fn dashboard_panel(query: &str, requirements: QueryRequirements) -> RepeatingEntry { - RepeatingEntry { - query: Query(query.into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(10_000)), - requirements, - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection { - scope: QueryTimeScope::RealTime, - lookback: Some(DurationMs(60_000)), - as_of: None, - }, - } -} - -/// Example 1 queries over the shared data workload of #509. -fn example1_workload() -> PlanningWorkload { - PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![ - dashboard_panel( - Q1, - QueryRequirements { - accuracy: AccuracyRequirement::Explicit(AccuracyTarget::Exact), - response_latency: LatencyRequirement::Unspecified, - }, - ), - dashboard_panel( - Q2, - QueryRequirements { - accuracy: AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.001, - }), - response_latency: LatencyRequirement::ExplicitMaxMs(100.0), - }, - ), - ]), - }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), - } -} - fn pipeline() -> Run { - run_promql(&example1_workload()) + run_promql(&example1()) } // ── Helpers ────────────────────────────────────────────────────────────── @@ -157,12 +104,12 @@ fn physical_with(run: &Run, option: Q2Option) -> &Physical { /// The encoded workload is valid and normalizes to Q1 then Q2. #[test] fn workload_encodes_example1() { - let workload = example1_workload(); + let workload = example1(); workload.validate().expect("valid workload"); let entries: Vec<_> = workload.query_workload.entries().collect(); assert_eq!(entries.len(), 2); - assert_eq!(entries[0].query.0, Q1); - assert_eq!(entries[1].query.0, Q2); + assert_eq!(entries[0].query.0, EXAMPLE1_Q1); + assert_eq!(entries[1].query.0, EXAMPLE1_Q2); } // ── Language-specific frontends ────────────────────────────────────────── @@ -170,7 +117,7 @@ fn workload_encodes_example1() { /// The PromQL frontend lowers each query to the doc's chain. #[test] fn language_specific_frontends_lower_each_query_to_doc_chain() { - let roots = lower_promql(&example1_workload()); + let roots = lower_promql(&example1()); let chains: Vec<_> = roots.iter().map(frontend_operations).collect(); assert_eq!( chains, From e2187ecf9778a50bf9831a88f0f99efcde4ecd05 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:00:37 +0000 Subject: [PATCH 09/19] fix(devtools): select over every candidate; --max-displayed limits only the document stage_pipeline capped enumeration at 64, so Example 3a (243 candidates) selected P41 instead of the cheapest P122 (KLL for all five queries). The pipeline now enumerates, optimizes and prices every candidate; the document shows the first --max-displayed (default 64) plus the selected one, with `combinations` and `capped`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/devtools/src/bin/stage_pipeline.rs | 102 ++++++++++++------ crates/devtools/src/planning_stages.rs | 18 +--- crates/devtools/tests/stage_pipeline.rs | 48 +++++++-- .../tests/planner_layering_common/mod.rs | 6 +- .../planner-layering-example3-acceptance.md | 13 ++- .../planner-layering-example4-acceptance.md | 3 +- docs/user_guide_docs/stage-viewer.md | 2 +- 7 files changed, 132 insertions(+), 60 deletions(-) diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 6e895b043..8be3c6d97 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -1,7 +1,7 @@ // cargo run -p asap-devtools --bin stage_pipeline -- \ // --example planner-layering-1 --out planner-layering-example1.json // (also planner-layering-3a and planner-layering-3b: #509 Example 3, -// Patterns A and B) +// Patterns A and B; 3a has 243 candidates, all priced, 64 shown by default) // cargo run -p asap-devtools --bin stage_pipeline -- \ // --promql "topk by (job) (10, rate(x[1m]))" --epsilon 0.01 --delta 0.001 --out run.json // @@ -10,26 +10,30 @@ // - language_specific_frontends: the frontends' workload DAG, one root per query; // - logical_asap_optimization: Pass 1 whole-workload candidates, one per // choice of a per-computation candidate for every target (the Cartesian -// product), in enumeration order and capped by `--max-candidates` -// (default 64). A candidate that cannot be composed carries `error` -// instead of `dag`; +// product), in enumeration order. Every candidate is optimized and +// priced; the document shows the first `--max-displayed` (default 64) +// and the selected one, with `combinations` (how many exist) and +// `capped` (whether some are not shown). A candidate that cannot be +// composed carries `error` instead of `dag`; // - physical_asap_optimization: one physical candidate per composed logical // candidate (operator implementation; every sub-DAG NotMaterialized), no // cost; a composed logical candidate Stage 2 cannot implement is listed // under `pruned` with the reason; -// - plan_selection: per-candidate costs, the selected candidate, and -// every other candidate as rejected (`valid: false`) or costlier. Each -// node's `cost` is one computation's price times `multiplier`, the -// times it is computed: once if materialized, else once per query (or -// materialized node) that reads it, so a shared NotMaterialized node is -// charged once per query. When -// selection fails, `selected` is null, with the rejections, or with -// `error` for any other failure. +// - plan_selection: the selected candidate, chosen among all candidates; +// the shown candidates' costs, and every other shown candidate as +// rejected (`valid: false`) or costlier. Each node's `cost` is one +// computation's price times `multiplier`, the times it is computed: +// once if materialized, else once per query (or materialized node) +// that reads it, so a shared NotMaterialized node is charged once per +// query. When selection fails, `selected` is null, with the rejections, +// or with `error` for any other failure. // // `--promql` may repeat. `--epsilon`/`--delta` apply to every `--promql` // query; without them the queries are exact. `--interval-ms` is the source // cadence PromQL needs (default 15000). +use std::collections::HashSet; + use asap_aware_mapping::plan_selection::{Rejection, Selection, SelectionError}; use asap_devtools::planner_layering_examples::{ declared, example1, pattern_a, pattern_b, PatternARecurrence, @@ -47,7 +51,7 @@ use serde_json::{json, Value}; const USAGE: &str = "usage: stage_pipeline (--example planner-layering-{1,3a,3b} | --promql ... \ -[--epsilon --delta ] [--interval-ms ]) [--max-candidates ] --out "; +[--epsilon --delta ] [--interval-ms ]) [--max-displayed ] --out "; fn main() { if let Err(message) = run(std::env::args().skip(1).collect()) { @@ -60,7 +64,7 @@ fn run(args: Vec) -> Result<(), String> { let mut example = None; let mut queries = Vec::new(); let (mut epsilon, mut delta, mut interval_ms) = (None, None, 15_000u64); - let mut max_candidates = 64usize; + let mut max_displayed = 64usize; let mut out = None; let mut args = args.into_iter(); while let Some(flag) = args.next() { @@ -72,7 +76,7 @@ fn run(args: Vec) -> Result<(), String> { "--epsilon" => epsilon = Some(number(value()?)?), "--delta" => delta = Some(number(value()?)?), "--interval-ms" => interval_ms = value()?.parse().map_err(|e| format!("{e}"))?, - "--max-candidates" => max_candidates = value()?.parse().map_err(|e| format!("{e}"))?, + "--max-displayed" => max_displayed = value()?.parse().map_err(|e| format!("{e}"))?, "--out" => out = Some(value()?), other => return Err(format!("unknown argument {other}")), } @@ -96,19 +100,41 @@ fn run(args: Vec) -> Result<(), String> { _ => return Err("give exactly one of --example or --promql".into()), }; let out = out.ok_or("--out is required")?; - let document = stage_pipeline(&workload, max_candidates)?; + let document = stage_pipeline(&workload, max_displayed)?; let text = serde_json::to_string_pretty(&document).map_err(|e| e.to_string())? + "\n"; std::fs::write(&out, text).map_err(|e| format!("{out}: {e}")) } -fn stage_pipeline(workload: &PlanningWorkload, max_candidates: usize) -> Result { +fn stage_pipeline(workload: &PlanningWorkload, max_displayed: usize) -> Result { let roots = lower_promql_roots(workload)?; - let run = run_planning_stages(workload, roots, Some(max_candidates))?; + let run = run_planning_stages(workload, roots)?; + // The display keeps the first `max_displayed` candidates and the + // selected one; plan selection has already ranked all of them. + let selected_logical = run.selection.as_ref().ok().and_then(|selection| { + run.physical + .iter() + .find(|p| p.id == selection.selected) + .map(|p| p.from_logical.as_str()) + }); + let shown: HashSet<&str> = run + .logical + .iter() + .enumerate() + .filter(|(index, c)| *index < max_displayed || Some(c.id.as_str()) == selected_logical) + .map(|(_, c)| c.id.as_str()) + .collect(); + let shown_physical: HashSet<&str> = run + .physical + .iter() + .filter(|p| shown.contains(p.from_logical.as_str())) + .map(|p| p.id.as_str()) + .collect(); // A choice that composition cannot substitute yet is listed with its // error rather than ending the run. let candidates: Vec<_> = run .logical .iter() + .filter(|c| shown.contains(c.id.as_str())) .map(|c| match &c.roots { Ok(roots) => json!({ "id": c.id, "label": c.label, "dag": export(roots) }), Err(error) => json!({ "id": c.id, "label": c.label, "error": error }), @@ -117,11 +143,13 @@ fn stage_pipeline(workload: &PlanningWorkload, max_candidates: usize) -> Result< let physical: Vec<_> = run .physical .iter() + .filter(|p| shown_physical.contains(p.id.as_str())) .map(|p| json!({ "id": p.id, "from_logical": p.from_logical, "label": p.label, "dag": p.dag })) .collect(); let pruned: Vec<_> = run .pruned .iter() + .filter(|p| shown.contains(p.from_logical.as_str())) .map(|p| json!({ "from_logical": p.from_logical, "label": p.label, "reason": p.reason })) .collect(); Ok(json!({ @@ -129,16 +157,24 @@ fn stage_pipeline(workload: &PlanningWorkload, max_candidates: usize) -> Result< "workload": { "queries": workload_queries(workload) }, "language_specific_frontends": { "dag": export(&run.frontends) }, "logical_asap_optimization": { - "combinations": run.combinations, - "capped": run.capped(), + "combinations": run.logical.len(), + "capped": candidates.len() < run.logical.len(), "candidates": candidates, }, "physical_asap_optimization": { "candidates": physical, "pruned": pruned }, - "plan_selection": plan_selection_json(&run.selection), + "plan_selection": plan_selection_json(&run.selection, |id| shown_physical.contains(id)), })) } -fn plan_selection_json(selection: &Result) -> Value { +/// Plan selection over every candidate, listing the costs and rejections +/// of the `shown` ones. +fn plan_selection_json( + selection: &Result, + shown: impl Fn(&str) -> bool, +) -> Value { + let rejections_json = |rejected: &[Rejection]| -> Vec { + rejections_json(rejected.iter().filter(|r| shown(&r.id))) + }; let selection = match selection { Ok(selection) => selection, Err(SelectionError::NoValidCandidate(rejected)) => { @@ -151,6 +187,7 @@ fn plan_selection_json(selection: &Result) -> Value { let costs: serde_json::Map<_, _> = selection .costs .iter() + .filter(|(id, _)| shown(id)) .map(|(id, cost)| { let per_node: serde_json::Map<_, _> = cost .per_node @@ -175,9 +212,8 @@ fn plan_selection_json(selection: &Result) -> Value { }) } -fn rejections_json(rejected: &[Rejection]) -> Vec { +fn rejections_json<'a>(rejected: impl Iterator) -> Vec { rejected - .iter() .map(|r| json!({ "id": r.id, "valid": r.valid, "reason": r.reason })) .collect() } @@ -266,7 +302,8 @@ mod tests { valid: false, reason: "q1: misses target".into(), }]; - let document = plan_selection_json(&Err(SelectionError::NoValidCandidate(rejected))); + let document = + plan_selection_json(&Err(SelectionError::NoValidCandidate(rejected)), |_| true); assert_eq!( document, json!({ @@ -277,14 +314,19 @@ mod tests { ); } - /// A run with no physical candidate (here, none enumerated) still emits - /// every stage, with no selection. + /// With nothing else displayed, the document still shows the selected + /// candidate and its cost, chosen among all candidates. #[test] - fn run_without_candidates_emits_plan_selection() { + fn display_cap_keeps_the_selected_candidate() { let workload = promql_batch(&["sum(m)".into()], AccuracyTarget::Exact, 15_000); let document = stage_pipeline(&workload, 0).unwrap(); - assert_eq!(document["physical_asap_optimization"]["pruned"], json!([])); - assert_eq!(document["plan_selection"]["selected"], Value::Null); + let selected = document["plan_selection"]["selected"].as_str().unwrap(); + let physical = document["physical_asap_optimization"]["candidates"] + .as_array() + .unwrap(); + assert_eq!(physical.len(), 1); + assert_eq!(physical[0]["id"], selected); + assert!(document["plan_selection"]["costs"].get(selected).is_some()); assert_eq!(document["plan_selection"]["rejected"], json!([])); } } diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index bfdeb8b2b..d108e831d 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -42,9 +42,6 @@ pub struct PrunedCandidate { pub struct PlanningStagesRun { /// The language-specific frontends' roots, one per workload entry. pub frontends: Vec, - /// Size of the Cartesian product of per-computation candidates; - /// `logical` holds its first `max_candidates`. - pub combinations: usize, pub logical: Vec, /// `P` implements `L`. pub physical: Vec, @@ -52,12 +49,6 @@ pub struct PlanningStagesRun { pub selection: Result, } -impl PlanningStagesRun { - pub fn capped(&self) -> bool { - self.logical.len() < self.combinations - } -} - /// Lower a PromQL workload, each series' identity as a column (the row /// representation per-series state needs at runtime). pub fn lower_promql_roots(workload: &PlanningWorkload) -> Result, String> { @@ -77,12 +68,12 @@ pub fn lower_promql_roots(workload: &PlanningWorkload) -> Result, /// physical ASAP-aware optimization and plan selection over `roots`, the /// lowered entries of `workload`. Logical candidates are every choice of a /// per-computation candidate for every target, the last target varying -/// fastest, cut at `max_candidates` (`None`: all). Plan selection reads only -/// the accuracy targets and the data workload. +/// fastest. Every candidate is enumerated and priced, so the selection is +/// the cheapest of all; a caller that shows fewer cuts only its display. +/// Plan selection reads only the accuracy targets and the data workload. pub fn run_planning_stages( workload: &PlanningWorkload, roots: Vec, - max_candidates: Option, ) -> Result { validate(&roots)?; let inventory = @@ -98,7 +89,7 @@ pub fn run_planning_stages( let mut physical = Vec::new(); let mut pruned = Vec::new(); let mut choice = vec![0; inventory.targets.len()]; - for index in 0..combinations.min(max_candidates.unwrap_or(usize::MAX)) { + for index in 0..combinations { let (id, label) = ( format!("L{}", index + 1), label(&inventory, &owners, &choice), @@ -144,7 +135,6 @@ pub fn run_planning_stages( let selection = select_plan(&physical, &targets, &data, PlanningModels::builtin()); Ok(PlanningStagesRun { frontends: roots, - combinations, logical, physical, pruned, diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index 897ab193f..fc79dccef 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -3,21 +3,28 @@ use std::collections::HashSet; use std::path::PathBuf; use std::process::Command; +use asap_devtools::planner_layering_examples::{pattern_a, PatternARecurrence}; +use asap_devtools::planning_stages::{lower_promql_roots, run_planning_stages}; use asap_types::ir::flat::FlatDag; use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPDAGDocument}; use asap_types::ir::QueryRoot; +use asap_types::workload::DataArrival; use serde_json::Value; const COMMITTED: &str = "../../tools/dag-viewer/examples/planner-layering-example1.json"; fn generate(extra: &[&str]) -> Value { + generate_example("planner-layering-1", extra) +} + +fn generate_example(example: &str, extra: &[&str]) -> Value { let out = std::env::temp_dir().join(format!( - "stage_pipeline_{}_{}.json", + "stage_pipeline_{}_{example}_{}.json", std::process::id(), extra.len() )); let status = Command::new(env!("CARGO_BIN_EXE_stage_pipeline")) - .args(["--example", "planner-layering-1", "--out"]) + .args(["--example", example, "--out"]) .arg(&out) .args(extra) .status() @@ -122,14 +129,20 @@ fn example1_document_is_valid_and_committed_fixture_is_current() { assert_eq!(accounted, all); } -/// The Cartesian product is cut at `--max-candidates` and says so. +/// `--max-displayed` cuts only what the document shows, says so, and keeps +/// the selection made over all candidates. #[test] -fn candidate_cap_is_recorded() { - let document = generate(&["--max-candidates", "2"]); +fn display_cap_is_recorded_and_keeps_the_selection() { + let full = generate(&[]); + let document = generate(&["--max-displayed", "1"]); let logical = &document["logical_asap_optimization"]; assert_eq!(logical["capped"], true); - assert_eq!(logical["candidates"].as_array().unwrap().len(), 2); - assert!(logical["combinations"].as_u64().unwrap() > 2); + assert!(logical["candidates"].as_array().unwrap().len() < 3); + assert_eq!(logical["combinations"], 3); + assert_eq!( + document["plan_selection"]["selected"], + full["plan_selection"]["selected"] + ); } /// A choice composition cannot substitute is listed with its error, and the @@ -169,3 +182,24 @@ fn uncomposable_candidate_is_recorded_with_its_error() { validated_dag(&candidate["dag"], 1); } } + +/// Example 3a (243 candidates) selects what the planning stages select over +/// every candidate, which the acceptance tests also run: the document's +/// display cap does not change the selection, and the selected plan is +/// displayed with its cost. +#[test] +fn example3a_selects_the_cheapest_of_all_candidates() { + let workload = pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed); + let run = run_planning_stages(&workload, lower_promql_roots(&workload).unwrap()).unwrap(); + assert_eq!(run.logical.len(), 243); + let expected = run.selection.unwrap().selected; + + let document = generate_example("planner-layering-3a", &[]); + let selection = &document["plan_selection"]; + assert_eq!(selection["selected"], expected.as_str()); + assert!(selection["costs"].get(&expected).is_some()); + let physical = document["physical_asap_optimization"]["candidates"] + .as_array() + .unwrap(); + assert!(physical.iter().any(|p| p["id"] == expected.as_str())); +} diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index bc4a15ca0..638420dfd 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -4,7 +4,7 @@ //! //! [`run_stages`] runs the real stages through //! `asap_devtools::planning_stages::run_planning_stages`, the pipeline -//! `stage_pipeline` also runs, uncapped; the example workloads come from +//! `stage_pipeline` also runs; the example workloads come from //! `asap_devtools::planner_layering_examples`. The functions under "Pending adapters" read properties the IR //! cannot express yet (window summaries, retention). They return today's //! only possible answer; the implementer of each feature replaces the body @@ -164,11 +164,11 @@ fn logical(id: String, label: String, roots: Vec) -> Logical { } /// Every stage over `roots`, the lowered entries of `workload`, with every -/// logical candidate (no cap). Every candidate of the examples composes and +/// logical candidate. Every candidate of the examples composes and /// plan selection selects one. Recurrence and predictability (which /// materialization needs, Example 4) are not passed to plan selection. pub fn run_stages(workload: &PlanningWorkload, roots: Vec) -> Run { - let run = run_planning_stages(workload, roots, None).expect("planning stages run"); + let run = run_planning_stages(workload, roots).expect("planning stages run"); let logical_candidates = run .logical .into_iter() diff --git a/docs/design_docs/proposals/planner-layering-example3-acceptance.md b/docs/design_docs/proposals/planner-layering-example3-acceptance.md index 512958824..bc45f479d 100644 --- a/docs/design_docs/proposals/planner-layering-example3-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example3-acceptance.md @@ -4,12 +4,17 @@ Audience: planner designers and the implementer of Pass 2's window-composition rule. Source: [planner-layering.md](planner-layering.md), "Example 3: Aggregation over windows" and the Pass 2 section. Tests: -`crates/integration-tests/tests/planner_layering_example3.rs` (helpers and the -shared Example 3/4 workloads in `tests/planner_layering_common/`). Devtool: -`stage_pipeline --example planner-layering-3a` and `planner-layering-3b`. +`crates/integration-tests/tests/planner_layering_example3.rs` (helpers in +`tests/planner_layering_common/`; the Example 3/4 workloads and the stage +pipeline in `asap_devtools::{planner_layering_examples, planning_stages}`). +Devtool: `stage_pipeline --example planner-layering-3a` and +`planner-layering-3b`. Both run every candidate (243 for 3a) through plan +selection; `--max-displayed` (default 64) limits only how many the document +shows, so 3a selects P122, KLL for all five queries. This spec was written by a test designer who does not implement the stages. -Tests run the stages through `planner_layering_common::run_stages`. A test +Tests run the stages through `planner_layering_common::run_stages`, which +calls `run_planning_stages`, the pipeline `stage_pipeline` runs. A test that needs a missing feature is `#[ignore]`d with the PR that delivers it. ## Workload diff --git a/docs/design_docs/proposals/planner-layering-example4-acceptance.md b/docs/design_docs/proposals/planner-layering-example4-acceptance.md index f318b4501..5a44de24e 100644 --- a/docs/design_docs/proposals/planner-layering-example4-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example4-acceptance.md @@ -5,7 +5,8 @@ physical ASAP-aware optimization and its pricing in plan selection. Source: [planner-layering.md](planner-layering.md), "Example 4: Materialization of window summaries in physical planning" and the Materialization section. Tests: `crates/integration-tests/tests/planner_layering_example4.rs` (helpers -and workloads in `tests/planner_layering_common/`). +in `tests/planner_layering_common/`; workloads in +`asap_devtools::planner_layering_examples`). This spec was written by a test designer who does not implement the stages. Tests run the stages through `planner_layering_common::run_stages`. A test diff --git a/docs/user_guide_docs/stage-viewer.md b/docs/user_guide_docs/stage-viewer.md index 2f2852aff..2ca4addfb 100644 --- a/docs/user_guide_docs/stage-viewer.md +++ b/docs/user_guide_docs/stage-viewer.md @@ -208,7 +208,7 @@ cargo run -p asap-devtools --bin stage_pipeline -- \ | `--epsilon`, `--delta` | Accuracy for every query; without them the queries are exact | | `--interval-ms ` | Sample interval (default 15000) | | `--example ` | A built-in example instead of `--promql`: `planner-layering-1`, `-2`, `-3a`, `-3b`, `-4a` or `-4b` | -| `--max-candidates ` | How many plans the document carries, cheapest first (default 64). Stage 3 still prices every plan. | +| `--max-displayed ` | How many plans the document shows (default 64), in enumeration order, plus the selected plan. The planner still optimizes and prices every plan, so the selection does not depend on it. | | `--out ` | Where to write the document | Open the document with **Open stage document…**, by dropping the file on the From a13361e60b3da593015de9f1654d6a49ca04d0b3 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:01:01 +0000 Subject: [PATCH 10/19] docs: Example 2's SQL candidates compose; the SQL test waits only on #598 Running the ignored SQL test composes every candidate and fails only on Q2's options (per-source counts until #598), so #603 is not a blocker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_example2.rs | 2 +- .../planner-layering-example2-acceptance.md | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/integration-tests/tests/planner_layering_example2.rs b/crates/integration-tests/tests/planner_layering_example2.rs index 2da07e5aa..8c974c4c8 100644 --- a/crates/integration-tests/tests/planner_layering_example2.rs +++ b/crates/integration-tests/tests/planner_layering_example2.rs @@ -254,7 +254,7 @@ async fn language_specific_frontends_sql_q2_q3_lower_to_entropy_and_l2() { /// Once the SQL forms are recognized, the SQL workload has the stand-in's Pass 1 options. #[tokio::test] -#[ignore = "needs SQL frontend recognition of the Entropy and L2 idioms (#598) and SQL COUNT(*) candidates that compose on flows(ts, src_ip) (#603)"] +#[ignore = "needs SQL frontend recognition of the Entropy and L2 idioms (#598)"] async fn logical_asap_optimization_sql_has_the_standin_options() { let workload = sql_workload(); let sql = run_stages(&workload, lower_sql(&workload).await); diff --git a/docs/design_docs/proposals/planner-layering-example2-acceptance.md b/docs/design_docs/proposals/planner-layering-example2-acceptance.md index bfe40f750..64234871e 100644 --- a/docs/design_docs/proposals/planner-layering-example2-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example2-acceptance.md @@ -61,7 +61,7 @@ with Q1–Q3's targets and a 15-s ingestion interval (PromQL needs one). | Invariant | Test | Status | |---|---|---| -| Once recognized, the SQL workload has the stand-in's Pass 1 options | `logical_asap_optimization_sql_has_the_standin_options` | ignored: #598, and #603 (ambiguity 9) | +| Once recognized, the SQL workload has the stand-in's Pass 1 options | `logical_asap_optimization_sql_has_the_standin_options` | ignored: #598 (ambiguity 9) | | Each statistic has an exact and a UnivMon option | `logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic` | passes | | Q1 has a specialized distinct-count summary (HLL, Theta or KMV) | `logical_asap_optimization_pass1_offers_a_distinct_count_summary` | passes | | Q2 and Q3 have a specialized entropy and norm summary | `logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries` | ignored: no PR yet | @@ -132,11 +132,11 @@ strictest size. option set. 8. **Data workload.** Only `input_cardinality` and `data_ingestion_interval` change, so the ingestion rate stays about 66,667/s with 10,000,000 keys. -9. **SQL candidates that fail to compose.** On `flows(ts, src_ip)`, sketch - candidates for `COUNT(*) … GROUP BY src_ip` reference - `ColumnRef::SampleValue`, and the schema has no `value` column, so - composition fails (#603). This is a Pass 1 defect independent of this - example. +9. **SQL candidates compose.** On `flows(ts, src_ip)`, every logical + candidate of the SQL workload composes, including the CMS and CountSketch + candidates for Q2's and Q3's `COUNT(*) … GROUP BY src_ip`. Until #598 + recognizes the Entropy and L2 idioms, those per-source counts are what + Pass 1 sees, so the SQL options differ from the stand-in's. ## Devtool @@ -144,6 +144,5 @@ No `--example planner-layering-2` yet. It needs: * SQL `Entropy` and `L2` recognition (#598), so the demo shows the doc's workload rather than exact relational plans; -* the Pass 1 fix in ambiguity 9 (#603); * a SQL lowering path with the `flows` catalog in `stage_pipeline` (devtool only; it lowers PromQL only today). From 1c3b6a20c75484e6c0bb9f487d56ed6b4c9e4d32 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:02:09 +0000 Subject: [PATCH 11/19] test: name stages by number and name; say query-language frontends The frontends' Run field, id and locals become stage0_query_language_frontends, and test names carry stage0..stage3 with the stage name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/devtools/src/bin/stage_pipeline.rs | 2 +- crates/devtools/src/planning_stages.rs | 6 +- .../tests/planner_layering_common/mod.rs | 10 ++- .../tests/planner_layering_example1.rs | 64 ++++++++++--------- .../tests/planner_layering_example2.rs | 64 +++++++++++-------- .../tests/planner_layering_example3.rs | 58 +++++++++-------- .../tests/planner_layering_example4.rs | 30 ++++----- .../planner-layering-example1-acceptance.md | 4 +- .../planner-layering-example2-acceptance.md | 50 +++++++-------- .../planner-layering-example3-acceptance.md | 38 +++++------ .../planner-layering-example4-acceptance.md | 30 ++++----- 11 files changed, 190 insertions(+), 166 deletions(-) diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 8be3c6d97..1c076eef0 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -155,7 +155,7 @@ fn stage_pipeline(workload: &PlanningWorkload, max_displayed: usize) -> Result, + /// The query-language frontends' roots, one per workload entry. + pub stage0_query_language_frontends: Vec, pub logical: Vec, /// `P` implements `L`. pub physical: Vec, @@ -134,7 +134,7 @@ pub fn run_planning_stages( let data = workload.data_workload.clone().unwrap_or_default(); let selection = select_plan(&physical, &targets, &data, PlanningModels::builtin()); Ok(PlanningStagesRun { - frontends: roots, + stage0_query_language_frontends: roots, logical, physical, pruned, diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index 638420dfd..5b922f2a0 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -43,7 +43,7 @@ pub fn needs_merge(form: WindowForm, query_window_ms: u64) -> bool { } } -// ── Language-specific frontends ────────────────────────────────────────── +// ── Query-language frontends ────────────────────────────────────────── /// PromQL workload roots, each series' identity as a column. pub fn lower_promql(workload: &PlanningWorkload) -> Vec { @@ -110,7 +110,7 @@ impl Physical { #[derive(Debug, Clone)] pub struct Run { - pub language_specific_frontends: Logical, + pub stage0_query_language_frontends: Logical, pub logical: Vec, pub physical: Vec, /// Logical candidates physical ASAP-aware optimization cannot implement: @@ -190,7 +190,11 @@ pub fn run_stages(workload: &PlanningWorkload, roots: Vec) -> Run { }) .collect(); Run { - language_specific_frontends: logical("frontends".into(), "frontends".into(), run.frontends), + stage0_query_language_frontends: logical( + "stage0-query-language-frontends".into(), + "query-language frontends".into(), + run.stage0_query_language_frontends, + ), logical: logical_candidates, physical, pruned: run diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index aa61c0c78..1dce182c5 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -112,11 +112,11 @@ fn workload_encodes_example1() { assert_eq!(entries[1].query.0, EXAMPLE1_Q2); } -// ── Language-specific frontends ────────────────────────────────────────── +// ── Query-language frontends ────────────────────────────────────────── /// The PromQL frontend lowers each query to the doc's chain. #[test] -fn language_specific_frontends_lower_each_query_to_doc_chain() { +fn stage0_query_language_frontends_lower_each_query_to_doc_chain() { let roots = lower_promql(&example1()); let chains: Vec<_> = roots.iter().map(frontend_operations).collect(); assert_eq!( @@ -130,24 +130,28 @@ fn language_specific_frontends_lower_each_query_to_doc_chain() { /// The frontends yield one workload DAG with one root per query and no summaries. #[test] -fn language_specific_frontends_one_summary_free_workload_dag() { - let frontends = pipeline().language_specific_frontends; - roots(&frontends.query_roots); - assert!(frontends.dag.nodes.iter().all(|n| !is_summary(&n.operator))); +fn stage0_query_language_frontends_one_summary_free_workload_dag() { + let stage0_query_language_frontends = pipeline().stage0_query_language_frontends; + roots(&stage0_query_language_frontends.query_roots); + assert!(stage0_query_language_frontends + .dag + .nodes + .iter() + .all(|n| !is_summary(&n.operator))); } /// The frontends keep Q1 and Q2 separate; sharing is a logical ASAP-aware optimization decision. #[test] -fn language_specific_frontends_queries_do_not_share_nodes() { - let frontends = pipeline().language_specific_frontends; - assert!(!frontends.shared_input()); +fn stage0_query_language_frontends_queries_do_not_share_nodes() { + let stage0_query_language_frontends = pipeline().stage0_query_language_frontends; + assert!(!stage0_query_language_frontends.shared_input()); } // ── Logical ASAP-aware optimization ────────────────────────────────────── /// Pass 1 gives 1 × 3 = 3 candidates: Q2's top-k is exact, Count-Min + heap or CountSketch + heap. #[test] -fn logical_asap_optimization_has_three_pass1_candidates() { +fn stage1_logical_asap_optimization_has_three_pass1_candidates() { let run = pipeline(); assert_eq!(run.logical.len(), 3); let found: BTreeSet<_> = run @@ -161,7 +165,7 @@ fn logical_asap_optimization_has_three_pass1_candidates() { /// Q1 is exact in every candidate: no summary is reachable from its root. #[test] -fn logical_asap_optimization_q1_is_always_exact() { +fn stage1_logical_asap_optimization_q1_is_always_exact() { let run = pipeline(); for c in &run.logical { let (q1, _) = roots(&c.query_roots); @@ -177,7 +181,7 @@ fn logical_asap_optimization_q1_is_always_exact() { /// Every Pass 1 target's exact option is its exact accumulator: Q1's rate and sum and Q2's sum_over_time. #[test] -fn logical_asap_optimization_exact_targets_use_accumulators() { +fn stage1_logical_asap_optimization_exact_targets_use_accumulators() { let run = pipeline(); for c in &run.logical { let accumulators = c @@ -197,7 +201,7 @@ fn logical_asap_optimization_exact_targets_use_accumulators() { /// Q2's summary families are Count-Min + heap and CountSketch + heap, one per job. #[test] -fn logical_asap_optimization_q2_summary_families() { +fn stage1_logical_asap_optimization_q2_summary_families() { let run = pipeline(); let families: BTreeSet<_> = run .logical @@ -219,7 +223,7 @@ fn logical_asap_optimization_q2_summary_families() { /// Pass 1 also offers Hydra (HydraCms over all (job, series) keys, a heap per job) for Q2's top-k. #[test] #[ignore = "needs Pass 1 Hydra for grouped approximate counts (#605)"] -fn logical_asap_optimization_q2_offers_hydra() { +fn stage1_logical_asap_optimization_q2_offers_hydra() { let run = pipeline(); assert!(run .logical @@ -230,7 +234,7 @@ fn logical_asap_optimization_q2_offers_hydra() { /// Pass 2's identical-expression rule adds a shared-input variant of every Q2 option and keeps the independent one. #[test] #[ignore = "needs the Pass 2 identical-expression rule (#588)"] -fn logical_asap_optimization_keeps_independent_and_shared_variants() { +fn stage1_logical_asap_optimization_keeps_independent_and_shared_variants() { let run = pipeline(); let found: Vec<_> = run .logical @@ -245,7 +249,7 @@ fn logical_asap_optimization_keeps_independent_and_shared_variants() { /// Only the raw input is ever shared between Q1 and Q2; no summary is shared. #[test] -fn logical_asap_optimization_shares_input_but_never_a_summary() { +fn stage1_logical_asap_optimization_shares_input_but_never_a_summary() { let run = pipeline(); for c in &run.logical { for id in cross_query_nodes(&c.dag, &c.query_roots) { @@ -261,7 +265,7 @@ fn logical_asap_optimization_shares_input_but_never_a_summary() { /// Logical candidates are valid DAGs with unique ids. #[test] -fn logical_asap_optimization_candidates_are_valid_and_uniquely_named() { +fn stage1_logical_asap_optimization_candidates_are_valid_and_uniquely_named() { assert_valid_and_uniquely_named(&pipeline()); } @@ -269,7 +273,7 @@ fn logical_asap_optimization_candidates_are_valid_and_uniquely_named() { /// No candidate is discarded before plan selection: the 3 logical candidates map one-to-one. #[test] -fn physical_asap_optimization_keeps_every_logical_candidate() { +fn stage2_physical_asap_optimization_keeps_every_logical_candidate() { let run = pipeline(); assert_one_physical_candidate_per_logical(&run); assert_eq!(run.physical.len(), 3); @@ -277,7 +281,7 @@ fn physical_asap_optimization_keeps_every_logical_candidate() { /// Each physical candidate keeps its logical Q2 option and input sharing. #[test] -fn physical_asap_optimization_preserves_logical_choices() { +fn stage2_physical_asap_optimization_preserves_logical_choices() { let run = pipeline(); for p in &run.physical { let source = run.logical.iter().find(|c| c.id == p.from_logical).unwrap(); @@ -293,7 +297,7 @@ fn physical_asap_optimization_preserves_logical_choices() { /// Exact TopK is implemented as a sort followed by a limit. #[test] -fn physical_asap_optimization_exact_topk_is_sort_then_limit() { +fn stage2_physical_asap_optimization_exact_topk_is_sort_then_limit() { let run = pipeline(); let p = physical_with(&run, Q2Option::Exact); let sort_then_limit = p.dag().edges.iter().any(|e| { @@ -306,7 +310,7 @@ fn physical_asap_optimization_exact_topk_is_sort_then_limit() { /// Exact Q2 sorts each job's series by the ranked value, descending, and a /// `Project` over the limit restores the logical `[job, topk_10]` columns. #[test] -fn physical_asap_optimization_exact_topk_returns_job_and_topk_10() { +fn stage2_physical_asap_optimization_exact_topk_returns_job_and_topk_10() { let run = pipeline(); let p = physical_with(&run, Q2Option::Exact); let QueryRoot::Operator(root) = &p.candidate.roots[1] else { @@ -347,7 +351,7 @@ fn physical_asap_optimization_exact_topk_returns_job_and_topk_10() { /// A summary Q2 is a build node feeding a top-10 estimation node, with no merge. #[test] -fn physical_asap_optimization_summary_topk_is_build_then_estimate() { +fn stage2_physical_asap_optimization_summary_topk_is_build_then_estimate() { let run = pipeline(); for p in &run.physical { if classify(p.dag(), &p.query_roots).0 == Q2Option::Exact { @@ -376,7 +380,7 @@ fn physical_asap_optimization_summary_topk_is_build_then_estimate() { /// Every sub-DAG is NotMaterialized, so every node runs at query time. #[test] -fn physical_asap_optimization_nothing_is_materialized() { +fn stage2_physical_asap_optimization_nothing_is_materialized() { let run = pipeline(); for p in &run.physical { for n in &p.dag().nodes { @@ -425,7 +429,7 @@ fn compile_in_runtime(p: &Physical) -> Result<(), String> { #[ignore = "runtime gaps: CMS+heap needs a non-negative weight contract (#593); \ CountSketch+heap's SummaryEstimate{TopK} output is not the keyed-evaluation \ shape the physical planner builds (#579)"] -fn physical_asap_optimization_every_candidate_compiles_in_the_physical_planner() { +fn stage2_physical_asap_optimization_every_candidate_compiles_in_the_physical_planner() { let run = pipeline(); let failures: Vec<_> = run .physical @@ -439,7 +443,7 @@ fn physical_asap_optimization_every_candidate_compiles_in_the_physical_planner() /// Plan selection selects one candidate and gives every other one a reason. #[test] -fn plan_selection_selects_one_and_explains_the_rest() { +fn stage3_plan_selection_selects_one_and_explains_the_rest() { assert_selects_one_and_explains_the_rest(&pipeline()); } @@ -447,7 +451,7 @@ fn plan_selection_selects_one_and_explains_the_rest() { /// are not proven non-negative; CountSketch + heap is invalid because its /// top-k output has no guarantee and the deployment has no evidence for it. #[test] -fn plan_selection_selects_exact_q2_and_rejects_both_heaps() { +fn stage3_plan_selection_selects_exact_q2_and_rejects_both_heaps() { let run = pipeline(); assert_eq!( run.selection.selected, @@ -469,20 +473,20 @@ fn plan_selection_selects_exact_q2_and_rejects_both_heaps() { /// The selected plan is the cheapest valid candidate for the whole workload. #[test] -fn plan_selection_selects_cheapest_valid() { +fn stage3_plan_selection_selects_cheapest_valid() { assert_selects_cheapest_valid(&pipeline()); } /// Every node of a valid candidate is charged once per query that reads it: nothing is materialized, so a shared node is recomputed for each. #[test] -fn plan_selection_charges_each_node_per_consuming_query() { +fn stage3_plan_selection_charges_each_node_per_consuming_query() { assert_each_node_charged_per_consuming_query(&pipeline()); } /// The plan selection picks compiles in the physical planner (added by the /// implementer, not part of the spec). #[test] -fn plan_selection_selected_plan_compiles_in_the_physical_planner() { +fn stage3_plan_selection_selected_plan_compiles_in_the_physical_planner() { let run = pipeline(); compile_in_runtime(run.physical(&run.selection.selected)).unwrap(); } @@ -490,7 +494,7 @@ fn plan_selection_selected_plan_compiles_in_the_physical_planner() { /// Sharing the input never costs more than reading it separately. #[test] #[ignore = "needs the Pass 2 identical-expression rule (#588): there are no shared-input variants to compare"] -fn plan_selection_shared_input_is_not_costlier() { +fn stage3_plan_selection_shared_input_is_not_costlier() { let run = pipeline(); let by_combo: BTreeMap<_, _> = run .physical diff --git a/crates/integration-tests/tests/planner_layering_example2.rs b/crates/integration-tests/tests/planner_layering_example2.rs index 8c974c4c8..78e013bec 100644 --- a/crates/integration-tests/tests/planner_layering_example2.rs +++ b/crates/integration-tests/tests/planner_layering_example2.rs @@ -215,7 +215,7 @@ fn own_univmon(run: &Run, query: usize) -> SketchParams { .expect("Pass 1 offers UnivMon") } -// ── Workload and language-specific frontends ───────────────────────────────────────────────── +// ── Workload and query-language frontends ───────────────────────────────────────────────── /// The encoded SQL workload is valid and normalizes to Q1, Q2, Q3. #[test] @@ -233,7 +233,7 @@ fn workload_encodes_example2() { /// The SQL frontend lowers Q1 to a distinct count over `flows`. #[tokio::test] -async fn language_specific_frontends_sql_q1_lowers_to_a_distinct_count() { +async fn stage0_query_language_frontends_sql_q1_lowers_to_a_distinct_count() { let roots = lower_sql(&sql_workload()).await; let ops = frontend_operations(&roots[0]); assert!(ops.contains(&"scan".to_string()), "{ops:?}"); @@ -246,7 +246,7 @@ async fn language_specific_frontends_sql_q1_lowers_to_a_distinct_count() { /// The SQL frontend recognizes Q2 as `Entropy(src_ip)` and Q3 as `L2(src_ip)`. #[tokio::test] #[ignore = "needs SQL frontend recognition of the Entropy and L2 idioms (#598)"] -async fn language_specific_frontends_sql_q2_q3_lower_to_entropy_and_l2() { +async fn stage0_query_language_frontends_sql_q2_q3_lower_to_entropy_and_l2() { let roots = lower_sql(&sql_workload()).await; assert!(frontend_operations(&roots[1]).contains(&"aggregate:frequency_entropy".to_string())); assert!(frontend_operations(&roots[2]).contains(&"aggregate:frequency_l2".to_string())); @@ -255,7 +255,7 @@ async fn language_specific_frontends_sql_q2_q3_lower_to_entropy_and_l2() { /// Once the SQL forms are recognized, the SQL workload has the stand-in's Pass 1 options. #[tokio::test] #[ignore = "needs SQL frontend recognition of the Entropy and L2 idioms (#598)"] -async fn logical_asap_optimization_sql_has_the_standin_options() { +async fn stage1_logical_asap_optimization_sql_has_the_standin_options() { let workload = sql_workload(); let sql = run_stages(&workload, lower_sql(&workload).await); let standin = pipeline(); @@ -266,7 +266,7 @@ async fn logical_asap_optimization_sql_has_the_standin_options() { /// The stand-in lowers to the three statistics, each over a 1-min range of one input. #[test] -fn language_specific_frontends_standin_lowers_to_three_statistics_of_one_input() { +fn stage0_query_language_frontends_standin_lowers_to_three_statistics_of_one_input() { let roots = lower_promql(&standin_workload()); let ops: Vec<_> = roots.iter().map(frontend_operations).collect(); for (ops, statistic) in ops.iter().zip([ @@ -282,19 +282,27 @@ fn language_specific_frontends_standin_lowers_to_three_statistics_of_one_input() /// The frontends give one summary-free workload DAG whose queries share no node. #[test] -fn language_specific_frontends_one_summary_free_dag_without_sharing() { +fn stage0_query_language_frontends_one_summary_free_dag_without_sharing() { let run = pipeline(); - let s0 = &run.language_specific_frontends; - assert_eq!(s0.query_roots.len(), 3); - assert!(s0.dag.nodes.iter().all(|n| !is_summary(&n.operator))); - assert!(cross_query_nodes(&s0.dag, &s0.query_roots).is_empty()); + let stage0_query_language_frontends = &run.stage0_query_language_frontends; + assert_eq!(stage0_query_language_frontends.query_roots.len(), 3); + assert!(stage0_query_language_frontends + .dag + .nodes + .iter() + .all(|n| !is_summary(&n.operator))); + assert!(cross_query_nodes( + &stage0_query_language_frontends.dag, + &stage0_query_language_frontends.query_roots + ) + .is_empty()); } // ── Logical ASAP-aware optimization ────────────────────────────────────── /// Pass 1 offers an exact and a UnivMon option for each of the three statistics. #[test] -fn logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic() { +fn stage1_logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic() { let run = pipeline(); for q in 0..3 { let found = options(&run, q); @@ -305,7 +313,7 @@ fn logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic() /// Pass 1 offers a specialized distinct-count summary for Q1. #[test] -fn logical_asap_optimization_pass1_offers_a_distinct_count_summary() { +fn stage1_logical_asap_optimization_pass1_offers_a_distinct_count_summary() { let found = options(&pipeline(), 0); assert!( found @@ -318,7 +326,7 @@ fn logical_asap_optimization_pass1_offers_a_distinct_count_summary() { /// Pass 1 offers a specialized entropy summary for Q2 and a norm summary for Q3. #[test] #[ignore = "needs Pass 1 specialized entropy and L2 summary families (no PR yet)"] -fn logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries() { +fn stage1_logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries() { let run = pipeline(); for q in [1, 2] { let found = options(&run, q); @@ -332,7 +340,7 @@ fn logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries() /// Every combination of the three queries' own options is kept as an independent candidate. #[test] -fn logical_asap_optimization_keeps_every_independent_combination() { +fn stage1_logical_asap_optimization_keeps_every_independent_combination() { let run = pipeline(); let found: BTreeSet<_> = run .logical @@ -351,7 +359,7 @@ fn logical_asap_optimization_keeps_every_independent_combination() { /// The summary-capability rule adds one UnivMon build feeding a distinct-count, an entropy and an L2 estimate. #[test] #[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] -fn logical_asap_optimization_summary_capability_adds_one_univmon_for_all_three() { +fn stage1_logical_asap_optimization_summary_capability_adds_one_univmon_for_all_three() { let run = pipeline(); let shared = run.logical.iter().find(|c| { let ums = univmons(c); @@ -371,7 +379,7 @@ fn logical_asap_optimization_summary_capability_adds_one_univmon_for_all_three() /// For each pair, a candidate shares one UnivMon between the two while the third keeps each of its own options. #[test] #[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] -fn logical_asap_optimization_summary_capability_adds_pairwise_shared_univmons() { +fn stage1_logical_asap_optimization_summary_capability_adds_pairwise_shared_univmons() { let run = pipeline(); for (pair, third) in [([0, 1], 2), ([0, 2], 1), ([1, 2], 0)] { let thirds: BTreeSet<_> = run @@ -387,7 +395,7 @@ fn logical_asap_optimization_summary_capability_adds_pairwise_shared_univmons() /// A UnivMon shared by several statistics is sized for the strictest consumer. #[test] #[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] -fn logical_asap_optimization_shared_univmon_is_sized_for_the_strictest_consumer() { +fn stage1_logical_asap_optimization_shared_univmon_is_sized_for_the_strictest_consumer() { let run = pipeline(); let strictest = |queries: &[usize]| { *queries @@ -410,14 +418,14 @@ fn logical_asap_optimization_shared_univmon_is_sized_for_the_strictest_consumer( /// Pass 1 sizes UnivMon for its query's target: ε = 0.01 (Q3) needs a larger state than ε = 0.05 (Q2). #[test] #[ignore = "needs UnivMon sizing for an accuracy target (#621); its shape is fixed"] -fn logical_asap_optimization_univmon_is_sized_per_accuracy_target() { +fn stage1_logical_asap_optimization_univmon_is_sized_per_accuracy_target() { let run = pipeline(); assert!(footprint(&own_univmon(&run, 2)) > footprint(&own_univmon(&run, 1))); } /// Only a UnivMon, never a specialized summary, is shared across the statistics. #[test] -fn logical_asap_optimization_only_univmon_is_shared_across_statistics() { +fn stage1_logical_asap_optimization_only_univmon_is_shared_across_statistics() { let run = pipeline(); for c in &run.logical { for (id, algorithm, _) in sketch_builds(&c.dag) { @@ -430,7 +438,7 @@ fn logical_asap_optimization_only_univmon_is_shared_across_statistics() { /// Logical candidates are valid DAGs with unique ids. #[test] -fn logical_asap_optimization_candidates_are_valid_and_uniquely_named() { +fn stage1_logical_asap_optimization_candidates_are_valid_and_uniquely_named() { assert_valid_and_uniquely_named(&pipeline()); } @@ -438,14 +446,14 @@ fn logical_asap_optimization_candidates_are_valid_and_uniquely_named() { /// No candidate is discarded before plan selection: logical candidates map one-to-one onto physical ones. #[test] -fn physical_asap_optimization_keeps_every_logical_candidate() { +fn stage2_physical_asap_optimization_keeps_every_logical_candidate() { assert_one_physical_candidate_per_logical(&pipeline()); } /// Physical ASAP-aware optimization keeps the shared UnivMon as one build node read by all three queries. #[test] #[ignore = "needs the Pass 2 summary-capability rule for frequency moments (#595, #596)"] -fn physical_asap_optimization_keeps_the_shared_univmon_as_one_build() { +fn stage2_physical_asap_optimization_keeps_the_shared_univmon_as_one_build() { let run = pipeline(); let mut seen = 0; for c in run @@ -470,26 +478,26 @@ fn physical_asap_optimization_keeps_the_shared_univmon_as_one_build() { /// Plan selection selects one candidate and gives every other one a reason. #[test] -fn plan_selection_selects_one_and_explains_the_rest() { +fn stage3_plan_selection_selects_one_and_explains_the_rest() { assert_selects_one_and_explains_the_rest(&pipeline()); } /// The selected plan is the cheapest valid candidate for the whole workload. #[test] -fn plan_selection_selects_cheapest_valid() { +fn stage3_plan_selection_selects_cheapest_valid() { assert_selects_cheapest_valid(&pipeline()); } /// Every node of a valid candidate is charged once per query that reads it: nothing is materialized, so a shared node is recomputed for each. #[test] -fn plan_selection_charges_each_node_per_consuming_query() { +fn stage3_plan_selection_charges_each_node_per_consuming_query() { assert_each_node_charged_per_consuming_query(&pipeline()); } /// A UnivMon estimate has a guarantee, so plan selection judges it against its target instead of rejecting it for having none. #[test] #[ignore = "needs a UnivMon accuracy model: L2 in #621; distinct count and entropy have none yet"] -fn plan_selection_judges_univmon_with_an_accuracy_model() { +fn stage3_plan_selection_judges_univmon_with_an_accuracy_model() { let run = pipeline(); let invalid = run.invalid(); for c in &run.logical { @@ -510,7 +518,7 @@ fn plan_selection_judges_univmon_with_an_accuracy_model() { /// One materialized UnivMon for all three costs no more than three separate UnivMons. #[test] #[ignore = "needs one UnivMon for all three (#595, #596), a UnivMon accuracy model (#621) and materializing it (#604): not materialized, it is charged once per consuming query"] -fn plan_selection_shared_univmon_costs_no_more_than_three() { +fn stage3_plan_selection_shared_univmon_costs_no_more_than_three() { let run = pipeline(); let cost = |c: &Logical| run.cost(&run.physical_of(c).next().unwrap().id); let separate: Vec<_> = run @@ -543,7 +551,7 @@ fn plan_selection_shared_univmon_costs_no_more_than_three() { /// The selected plan updates at most one UnivMon per flow record: separate UnivMons are dominated by the shared one. #[test] -fn plan_selection_selected_plan_has_at_most_one_univmon() { +fn stage3_plan_selection_selected_plan_has_at_most_one_univmon() { let run = pipeline(); let selected = run.physical(&run.selection.selected); let count = sketch_builds(selected.dag()) diff --git a/crates/integration-tests/tests/planner_layering_example3.rs b/crates/integration-tests/tests/planner_layering_example3.rs index 24a1d07a9..96a75dab7 100644 --- a/crates/integration-tests/tests/planner_layering_example3.rs +++ b/crates/integration-tests/tests/planner_layering_example3.rs @@ -66,11 +66,11 @@ fn workload_encodes_example3() { assert_eq!(lookback_ms(&b, 0), PATTERN_B_WINDOW_MS); } -// ── Pattern A: language-specific frontends ─────────────────────────────── +// ── Pattern A: query-language frontends ─────────────────────────────── /// Each Pattern A query lowers to scan → (time shift) → range → quantile. #[test] -fn language_specific_frontends_a_lowers_each_query_to_its_interval() { +fn stage0_query_language_frontends_a_lowers_each_query_to_its_interval() { let roots = lower_promql(&pattern_a( PatternARecurrence::OnceAdHoc, DataArrival::Mixed, @@ -97,19 +97,27 @@ fn language_specific_frontends_a_lowers_each_query_to_its_interval() { /// The frontends give one summary-free DAG whose five queries share no node. #[test] -fn language_specific_frontends_a_one_summary_free_dag_without_sharing() { +fn stage0_query_language_frontends_a_one_summary_free_dag_without_sharing() { let run = run_a(); - let s0 = &run.language_specific_frontends; - assert_eq!(s0.query_roots.len(), 5); - assert!(s0.dag.nodes.iter().all(|n| !is_summary(&n.operator))); - assert!(cross_query_nodes(&s0.dag, &s0.query_roots).is_empty()); + let stage0_query_language_frontends = &run.stage0_query_language_frontends; + assert_eq!(stage0_query_language_frontends.query_roots.len(), 5); + assert!(stage0_query_language_frontends + .dag + .nodes + .iter() + .all(|n| !is_summary(&n.operator))); + assert!(cross_query_nodes( + &stage0_query_language_frontends.dag, + &stage0_query_language_frontends.query_roots + ) + .is_empty()); } // ── Pattern A: logical ASAP-aware optimization ─────────────────────────── /// Pass 1 offers each query an exact and a KLL candidate over its own interval. #[test] -fn logical_asap_optimization_a_pass1_offers_exact_and_kll_per_query() { +fn stage1_logical_asap_optimization_a_pass1_offers_exact_and_kll_per_query() { let run = run_a(); for q in 0..5 { let found = options(&run, q); @@ -123,7 +131,7 @@ fn logical_asap_optimization_a_pass1_offers_exact_and_kll_per_query() { /// The five independent KLLs: one per query, each read by its query only, all sized for ε = 0.005. #[test] -fn logical_asap_optimization_a_keeps_five_independent_klls() { +fn stage1_logical_asap_optimization_a_keeps_five_independent_klls() { let run = run_a(); let all_kll = run .logical @@ -152,7 +160,7 @@ fn logical_asap_optimization_a_keeps_five_independent_klls() { /// The identical-expression rule shares only raw input (scan, range, shift), never a quantile or summary, and keeps the unshared variant. #[test] #[ignore = "needs the Pass 2 identical-expression rule (#588)"] -fn logical_asap_optimization_a_identical_expression_rule_shares_only_raw_input() { +fn stage1_logical_asap_optimization_a_identical_expression_rule_shares_only_raw_input() { let run = run_a(); assert!(run.logical.iter().any(|c| c.shared_input())); assert!(run.logical.iter().any(|c| !c.shared_input())); @@ -171,7 +179,7 @@ fn logical_asap_optimization_a_identical_expression_rule_shares_only_raw_input() /// One Exponential Histogram of KLLs over [T − 5y, T] serves all five queries, each through its own merge and p99 estimate. #[test] #[ignore = "needs Pass 2 window composition across queries: one window summary for all five (#628, #634)"] -fn logical_asap_optimization_a_window_composition_adds_one_eh_for_all_five() { +fn stage1_logical_asap_optimization_a_window_composition_adds_one_eh_for_all_five() { let run = run_a(); let found = run.logical.iter().find(|c| { windowed_builds(c).iter().any(|(_, a, form, readers)| { @@ -205,7 +213,7 @@ fn logical_asap_optimization_a_window_composition_adds_one_eh_for_all_five() { /// The shared EH candidate is added next to the independent KLL candidates, not instead of them. #[test] #[ignore = "needs Pass 2 window composition across queries: one window summary for all five (#628, #634)"] -fn logical_asap_optimization_a_keeps_independent_and_shared_window_summaries() { +fn stage1_logical_asap_optimization_a_keeps_independent_and_shared_window_summaries() { let run = run_a(); let shared = run.logical.iter().any(|c| { windowed_builds(c).iter().any(|(_, _, f, r)| { @@ -225,7 +233,7 @@ fn logical_asap_optimization_a_keeps_independent_and_shared_window_summaries() { /// Pass 2 adds a candidate for each way of grouping two queries onto one shared window summary. #[test] #[ignore = "needs Pass 2 window composition across queries: partial groupings (#628, #634)"] -fn logical_asap_optimization_a_window_composition_groups_every_pair() { +fn stage1_logical_asap_optimization_a_window_composition_groups_every_pair() { let run = run_a(); let groups: BTreeSet<_> = run .logical @@ -250,7 +258,7 @@ fn logical_asap_optimization_a_window_composition_groups_every_pair() { /// Plan selection selects one Pattern A candidate, explains the rest, and picks the cheapest valid one. #[test] -fn plan_selection_a_selects_cheapest_valid() { +fn stage3_plan_selection_a_selects_cheapest_valid() { let run = run_a(); assert_selects_one_and_explains_the_rest(&run); assert_selects_cheapest_valid(&run); @@ -258,14 +266,14 @@ fn plan_selection_a_selects_cheapest_valid() { /// Every node of a valid candidate is charged once per query that reads it: nothing is materialized, so a shared node is recomputed for each. #[test] -fn plan_selection_a_charges_each_node_per_consuming_query() { +fn stage3_plan_selection_a_charges_each_node_per_consuming_query() { assert_each_node_charged_per_consuming_query(&run_a()); } /// Sharing the scan never costs more than scanning separately, for the same per-computation choices. #[test] #[ignore = "needs the Pass 2 identical-expression rule (#588) and plan selection row estimates for time-shifted scans and narrower time ranges (#632)"] -fn plan_selection_a_shared_scan_is_not_costlier() { +fn stage3_plan_selection_a_shared_scan_is_not_costlier() { let run = run_a(); let key = |c: &Logical| { (0..5) @@ -288,11 +296,11 @@ fn plan_selection_a_shared_scan_is_not_costlier() { assert!(compared > 0); } -// ── Pattern B: language-specific frontends and Pass 1 ──────────────────── +// ── Pattern B: query-language frontends and Pass 1 ──────────────────── /// Pattern B lowers to scan → range 5m → quantile, with no summary. #[test] -fn language_specific_frontends_b_lowers_to_one_range_quantile() { +fn stage0_query_language_frontends_b_lowers_to_one_range_quantile() { let run = run_b(); let roots = lower_promql(&pattern_b()); assert_eq!( @@ -300,7 +308,7 @@ fn language_specific_frontends_b_lowers_to_one_range_quantile() { [op("aggregate:quantile"), op("scan"), op("time_range")] ); assert!(run - .language_specific_frontends + .stage0_query_language_frontends .dag .nodes .iter() @@ -309,7 +317,7 @@ fn language_specific_frontends_b_lowers_to_one_range_quantile() { /// Pass 1 offers an exact and a KLL candidate for the 5-min window. #[test] -fn logical_asap_optimization_b_pass1_offers_exact_and_kll() { +fn stage1_logical_asap_optimization_b_pass1_offers_exact_and_kll() { let found = options(&run_b(), 0); assert!( found.contains("exact") && found.contains("Kll"), @@ -322,7 +330,7 @@ fn logical_asap_optimization_b_pass1_offers_exact_and_kll() { /// Each Pass 1 option gets three window forms: none, a 5-min sliding window with a 1-min slide, and 1-min tumbling windows. #[test] #[ignore = "needs Pass 2 window composition: tumbling windows (#601) and sliding windows (no PR yet)"] -fn logical_asap_optimization_b_window_composition_adds_sliding_and_tumbling_per_option() { +fn stage1_logical_asap_optimization_b_window_composition_adds_sliding_and_tumbling_per_option() { let run = run_b(); let summaries: BTreeSet<_> = options(&run, 0) .into_iter() @@ -353,7 +361,7 @@ fn logical_asap_optimization_b_window_composition_adds_sliding_and_tumbling_per_ /// Window parameters are legal: L divides W, s divides L and the evaluation interval, a tumbling length divides both. #[test] -fn logical_asap_optimization_b_window_parameters_are_legal() { +fn stage1_logical_asap_optimization_b_window_parameters_are_legal() { let (w, every) = (PATTERN_B_WINDOW_MS, PATTERN_B_INTERVAL_MS); for c in &run_b().logical { for (_, _, form, _) in windowed_builds(c) { @@ -382,7 +390,7 @@ fn logical_asap_optimization_b_window_parameters_are_legal() { /// A sliding window with L = W reads one completed window (no merge); tumbling windows merge W / length of them. #[test] #[ignore = "needs Pass 2 window composition: tumbling windows (#601) and sliding windows (no PR yet)"] -fn logical_asap_optimization_b_merge_only_where_the_window_form_needs_it() { +fn stage1_logical_asap_optimization_b_merge_only_where_the_window_form_needs_it() { let run = run_b(); let mut seen = BTreeSet::new(); for c in &run.logical { @@ -413,7 +421,7 @@ fn logical_asap_optimization_b_merge_only_where_the_window_form_needs_it() { /// A window form that merges is used only with a summary whose states merge (KLL, DDSketch). #[test] -fn logical_asap_optimization_window_merges_use_mergeable_summaries() { +fn stage1_logical_asap_optimization_window_merges_use_mergeable_summaries() { for (run, window) in [(run_a(), 5 * YEAR_MS), (run_b(), PATTERN_B_WINDOW_MS)] { for c in &run.logical { for (_, algorithm, form, _) in windowed_builds(c) { @@ -433,7 +441,7 @@ fn logical_asap_optimization_window_merges_use_mergeable_summaries() { /// Plan selection selects one Pattern B candidate, explains the rest, and picks the cheapest valid one. #[test] -fn plan_selection_b_selects_cheapest_valid() { +fn stage3_plan_selection_b_selects_cheapest_valid() { let run = run_b(); assert_selects_one_and_explains_the_rest(&run); assert_selects_cheapest_valid(&run); diff --git a/crates/integration-tests/tests/planner_layering_example4.rs b/crates/integration-tests/tests/planner_layering_example4.rs index b9a293e7f..ef0db7346 100644 --- a/crates/integration-tests/tests/planner_layering_example4.rs +++ b/crates/integration-tests/tests/planner_layering_example4.rs @@ -113,7 +113,7 @@ use MaterializationOption::{MatAtIngestion, MatAtQuery, NotMaterialized}; /// Every node upstream of an ingestion-time node also runs at ingestion time, in every workload variant. #[test] -fn physical_asap_optimization_ingestion_time_upstream_is_ingestion_time() { +fn stage2_physical_asap_optimization_ingestion_time_upstream_is_ingestion_time() { for workload in [once(), monthly(), at_rest(), pattern_b()] { assert_ingestion_upstream_is_ingestion(&run_promql(&workload)); } @@ -121,7 +121,7 @@ fn physical_asap_optimization_ingestion_time_upstream_is_ingestion_time() { /// Data at rest has no ingestion, so no candidate runs anything at ingestion time. #[test] -fn physical_asap_optimization_at_rest_runs_nothing_at_ingestion_time() { +fn stage2_physical_asap_optimization_at_rest_runs_nothing_at_ingestion_time() { let run = run_promql(&at_rest()); for p in &run.physical { for n in &p.dag().nodes { @@ -132,7 +132,7 @@ fn physical_asap_optimization_at_rest_runs_nothing_at_ingestion_time() { /// A materialized output is kept for as long as its consumers read: it covers every reader's lookback and offset. #[test] -fn physical_asap_optimization_materialized_output_covers_its_consumers() { +fn stage2_physical_asap_optimization_materialized_output_covers_its_consumers() { let offsets: Vec = PATTERN_A.iter().map(|(_, _, before_t)| *before_t).collect(); for (workload, offsets) in [ (once(), offsets.clone()), @@ -165,7 +165,7 @@ fn physical_asap_optimization_materialized_output_covers_its_consumers() { /// The shared EH candidate yields A1 (query time, kept), A2 (ingestion time) and A3 (not materialized). #[test] #[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] -fn physical_asap_optimization_a_shared_eh_has_three_materialization_options() { +fn stage2_physical_asap_optimization_a_shared_eh_has_three_materialization_options() { let found: BTreeSet<_> = eh_options(&run_promql(&once())).into_keys().collect(); assert_eq!( found, @@ -176,7 +176,7 @@ fn physical_asap_optimization_a_shared_eh_has_three_materialization_options() { /// With data at rest, A2 is not generated; A1 and A3 remain. #[test] #[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] -fn physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option() { +fn stage2_physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option() { let found: BTreeSet<_> = eh_options(&run_promql(&at_rest())).into_keys().collect(); assert_eq!(found, BTreeSet::from([MatAtQuery, NotMaterialized])); } @@ -184,7 +184,7 @@ fn physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option() { /// A materialized shared EH is one build node read by all five queries, charged once. #[test] #[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] -fn physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers() { +fn stage2_physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers() { let run = run_promql(&once()); for (m, (id, build)) in eh_options(&run) { if m == NotMaterialized { @@ -214,7 +214,7 @@ fn physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers( /// which builds it once. #[test] #[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] -fn plan_selection_a_rebuilding_per_query_costs_more_than_building_once() { +fn stage3_plan_selection_a_rebuilding_per_query_costs_more_than_building_once() { let run = run_promql(&once()); let options = eh_options(&run); let (a1, a3) = (&options[&MatAtQuery].0, &options[&NotMaterialized].0); @@ -229,7 +229,7 @@ fn plan_selection_a_rebuilding_per_query_costs_more_than_building_once() { /// As given (run once, ad hoc), A1 is the cheapest of the three: A2 maintains years of history for one batch. #[test] #[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] -fn plan_selection_a_once_adhoc_prefers_the_query_time_eh() { +fn stage3_plan_selection_a_once_adhoc_prefers_the_query_time_eh() { let run = run_promql(&once()); let options = eh_options(&run); let a1 = cost(&run, &options[&MatAtQuery].0); @@ -241,7 +241,7 @@ fn plan_selection_a_once_adhoc_prefers_the_query_time_eh() { /// Repeated monthly and predictable, A2's maintenance is shared by many batches, so A2 gains on A1. #[test] #[ignore = "needs Pass 2 window segments shared across queries (#628, #634), materialization enumeration (#604) and MatAtQuery (#625)"] -fn plan_selection_a_monthly_amortizes_ingestion_time_maintenance() { +fn stage3_plan_selection_a_monthly_amortizes_ingestion_time_maintenance() { let ratio = |workload: PlanningWorkload| { let run = run_promql(&workload); let options = eh_options(&run); @@ -256,7 +256,7 @@ fn plan_selection_a_monthly_amortizes_ingestion_time_maintenance() { /// The 1-min tumbling KLL candidate yields B1 (ingestion time), B2 (not materialized) and B3 (query time, kept). #[test] #[ignore = "needs tumbling windows (#601), materialization enumeration (#604) and MatAtQuery panes (#625)"] -fn physical_asap_optimization_b_tumbling_kll_has_three_materialization_options() { +fn stage2_physical_asap_optimization_b_tumbling_kll_has_three_materialization_options() { let found: BTreeSet<_> = options_of(&run_promql(&pattern_b()), tumbling(), 1) .into_keys() .collect(); @@ -269,7 +269,7 @@ fn physical_asap_optimization_b_tumbling_kll_has_three_materialization_options() /// B1 builds the tumbling KLLs at ingestion time and merges and estimates at query time. #[test] #[ignore = "needs tumbling windows (#601) and materialization enumeration (#604)"] -fn physical_asap_optimization_b_b1_builds_at_ingestion_and_merges_at_query_time() { +fn stage2_physical_asap_optimization_b_b1_builds_at_ingestion_and_merges_at_query_time() { let run = run_promql(&pattern_b()); let (id, build) = options_of(&run, tumbling(), 1)[&MatAtIngestion].clone(); let p = run.physical(&id); @@ -294,7 +294,7 @@ fn physical_asap_optimization_b_b1_builds_at_ingestion_and_merges_at_query_time( /// B3 runs nothing at ingestion time; it keeps the tumbling KLLs it builds at query time. #[test] #[ignore = "needs tumbling windows (#601) and MatAtQuery panes (#625)"] -fn physical_asap_optimization_b_b3_keeps_query_time_windows() { +fn stage2_physical_asap_optimization_b_b3_keeps_query_time_windows() { let run = run_promql(&pattern_b()); let (id, _) = options_of(&run, tumbling(), 1)[&MatAtQuery].clone(); let p = run.physical(&id); @@ -307,7 +307,7 @@ fn physical_asap_optimization_b_b3_keeps_query_time_windows() { /// The sliding-window KLL is kept from ingestion time or from query time; not materializing it is the no-window plan. #[test] #[ignore = "needs sliding windows (no PR yet) and materialization enumeration (#604)"] -fn physical_asap_optimization_b_sliding_kll_has_two_materialization_options() { +fn stage2_physical_asap_optimization_b_sliding_kll_has_two_materialization_options() { let found: BTreeSet<_> = options_of(&run_promql(&pattern_b()), sliding(), 1) .into_keys() .collect(); @@ -317,7 +317,7 @@ fn physical_asap_optimization_b_sliding_kll_has_two_materialization_options() { /// B2 rebuilds all five tumbling KLLs at every evaluation, so it costs at least B1 and B3. #[test] #[ignore = "needs tumbling windows (#601), materialization enumeration (#604) and MatAtQuery panes (#625)"] -fn plan_selection_b_rebuilding_every_window_costs_most() { +fn stage3_plan_selection_b_rebuilding_every_window_costs_most() { let run = run_promql(&pattern_b()); let options = options_of(&run, tumbling(), 1); let b2 = cost(&run, &options[&NotMaterialized].0); @@ -329,7 +329,7 @@ fn plan_selection_b_rebuilding_every_window_costs_most() { /// Repeating over arriving data, the built-in models pick B1 among the tumbling options. #[test] #[ignore = "needs tumbling windows (#601), materialization enumeration (#604) and MatAtQuery panes (#625)"] -fn plan_selection_b_prefers_ingestion_time_tumbling_windows() { +fn stage3_plan_selection_b_prefers_ingestion_time_tumbling_windows() { let run = run_promql(&pattern_b()); let options = options_of(&run, tumbling(), 1); let b1 = cost(&run, &options[&MatAtIngestion].0); diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index fb4181c6f..a1838f83a 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -15,7 +15,7 @@ whose expectation needs a feature that is not in the stack yet is | Stage | Doc (#509) | MVP (this spec) | |---|---|---| -| Language-specific frontends | 1 workload `LogicalDAG` | same | +| Query-language frontends | 1 workload `LogicalDAG` | same | | Logical ASAP-aware optimization | Pass 1 (3) × identical-expression rule (2) × window-composition rule (3 × 3) = **54** | Pass 1 only = **3**. Pass 2 is not implemented: the identical-expression rule is #588, window composition #601. | | Physical ASAP-aware optimization | Materialization options per window form = **156** | Physical operator implementation, every sub-DAG `NotMaterialized` = **3** (the doc's Raw plans). Materialization enumeration is #604. | | Plan selection | 1 plan | 1 plan | @@ -35,7 +35,7 @@ Shared data workload: `continuously_ingesting`, 15 s ingestion interval, | Q1 `sum by (job) (rate(http_requests_total[1m]))` | 10 s | 1 m | evaluation time | exact | none | | Q2 `topk by (job) (10, sum_over_time(http_requests_total[1m]))` | 10 s | 1 m | evaluation time | ε = 0.01, δ = 0.001 | ≤ 100 ms | -## Language-specific frontends: 1 candidate +## Query-language frontends: 1 candidate | Query | Chain | |---|---| diff --git a/docs/design_docs/proposals/planner-layering-example2-acceptance.md b/docs/design_docs/proposals/planner-layering-example2-acceptance.md index 64234871e..26cd2b826 100644 --- a/docs/design_docs/proposals/planner-layering-example2-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example2-acceptance.md @@ -39,7 +39,7 @@ with Q1–Q3's targets and a 15-s ingestion interval (PromQL needs one). | Stage | Doc | Today (stand-in) | |---|---|---| -| Language-specific frontends | One summary-free DAG: `Distinct`, `Entropy`, `L2` over `flows.src_ip`, last 1 m | Same for the stand-in. SQL: Q1 is `aggregate:cardinality`; Q2 and Q3 are `count` per `src_ip` → window function / projection → `sum` (not recognized). | +| Query-language frontends | One summary-free DAG: `Distinct`, `Entropy`, `L2` over `flows.src_ip`, last 1 m | Same for the stand-in. SQL: Q1 is `aggregate:cardinality`; Q2 and Q3 are `count` per `src_ip` → window function / projection → `sum` (not recognized). | | Logical ASAP-aware optimization, Pass 1 | Each computation: exact, a specialized summary, UnivMon. 3 × 3 × 3 = **27** | Q1: exact, HLL, Theta, KMV, UnivMon. Q2, Q3: exact, UnivMon (no specialized entropy or norm summary). 5 × 2 × 2 = **20** | | Logical ASAP-aware optimization, Pass 2 | Summary-capability rule: + 1 (one UnivMon for all three, sized for ε = 0.01) + 9 (3 pairs share a UnivMon × 3 options of the third). Independent kept. **37**. Window composition left out. | Not implemented: **20**, no candidate shares a node. | | Physical ASAP-aware optimization | Same options as Example 1: no window form → rebuilt from raw samples each refresh. | One physical candidate per logical candidate, every sub-DAG `NotMaterialized`: **20**. | @@ -47,38 +47,38 @@ with Q1–Q3's targets and a 15-s ingestion interval (PromQL needs one). ## Invariants and tests -### Language-specific frontends +### Query-language frontends | Invariant | Test | Status | |---|---|---| | The SQL workload is valid, in entry order | `workload_encodes_example2` | passes | -| SQL Q1 lowers to a distinct count over `flows` | `language_specific_frontends_sql_q1_lowers_to_a_distinct_count` | passes | -| SQL Q2 and Q3 lower to `FrequencyEntropy` and `FrequencyL2` | `language_specific_frontends_sql_q2_q3_lower_to_entropy_and_l2` | ignored: #598 | -| The stand-in lowers to the three statistics over one 1-min range | `language_specific_frontends_standin_lowers_to_three_statistics_of_one_input` | passes | -| One summary-free DAG; the queries share no node | `language_specific_frontends_one_summary_free_dag_without_sharing` | passes | +| SQL Q1 lowers to a distinct count over `flows` | `stage0_query_language_frontends_sql_q1_lowers_to_a_distinct_count` | passes | +| SQL Q2 and Q3 lower to `FrequencyEntropy` and `FrequencyL2` | `stage0_query_language_frontends_sql_q2_q3_lower_to_entropy_and_l2` | ignored: #598 | +| The stand-in lowers to the three statistics over one 1-min range | `stage0_query_language_frontends_standin_lowers_to_three_statistics_of_one_input` | passes | +| One summary-free DAG; the queries share no node | `stage0_query_language_frontends_one_summary_free_dag_without_sharing` | passes | ### Logical ASAP-aware optimization | Invariant | Test | Status | |---|---|---| -| Once recognized, the SQL workload has the stand-in's Pass 1 options | `logical_asap_optimization_sql_has_the_standin_options` | ignored: #598 (ambiguity 9) | -| Each statistic has an exact and a UnivMon option | `logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic` | passes | -| Q1 has a specialized distinct-count summary (HLL, Theta or KMV) | `logical_asap_optimization_pass1_offers_a_distinct_count_summary` | passes | -| Q2 and Q3 have a specialized entropy and norm summary | `logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries` | ignored: no PR yet | -| Every combination of the queries' own options is kept, with no shared summary | `logical_asap_optimization_keeps_every_independent_combination` | passes | -| A candidate has one UnivMon build read by all three queries, feeding a distinct-count, an entropy and an L2 estimate | `logical_asap_optimization_summary_capability_adds_one_univmon_for_all_three` | ignored: #595, #596 | -| For each pair, a candidate shares one UnivMon between the two while the third takes each of its own options | `logical_asap_optimization_summary_capability_adds_pairwise_shared_univmons` | ignored: #595, #596 | -| A shared UnivMon has the parameters Pass 1 gives its strictest consumer alone | `logical_asap_optimization_shared_univmon_is_sized_for_the_strictest_consumer` | ignored: #595, #596 | -| UnivMon is sized per target: Q3's (ε = 0.01) is larger than Q2's (ε = 0.05) | `logical_asap_optimization_univmon_is_sized_per_accuracy_target` | ignored: #621 | -| Only a UnivMon is ever shared across statistics | `logical_asap_optimization_only_univmon_is_shared_across_statistics` | passes (vacuously today) | -| Candidates are valid DAGs with unique ids | `logical_asap_optimization_candidates_are_valid_and_uniquely_named` | passes | +| Once recognized, the SQL workload has the stand-in's Pass 1 options | `stage1_logical_asap_optimization_sql_has_the_standin_options` | ignored: #598 (ambiguity 9) | +| Each statistic has an exact and a UnivMon option | `stage1_logical_asap_optimization_pass1_offers_exact_and_univmon_for_each_statistic` | passes | +| Q1 has a specialized distinct-count summary (HLL, Theta or KMV) | `stage1_logical_asap_optimization_pass1_offers_a_distinct_count_summary` | passes | +| Q2 and Q3 have a specialized entropy and norm summary | `stage1_logical_asap_optimization_pass1_offers_specialized_entropy_and_l2_summaries` | ignored: no PR yet | +| Every combination of the queries' own options is kept, with no shared summary | `stage1_logical_asap_optimization_keeps_every_independent_combination` | passes | +| A candidate has one UnivMon build read by all three queries, feeding a distinct-count, an entropy and an L2 estimate | `stage1_logical_asap_optimization_summary_capability_adds_one_univmon_for_all_three` | ignored: #595, #596 | +| For each pair, a candidate shares one UnivMon between the two while the third takes each of its own options | `stage1_logical_asap_optimization_summary_capability_adds_pairwise_shared_univmons` | ignored: #595, #596 | +| A shared UnivMon has the parameters Pass 1 gives its strictest consumer alone | `stage1_logical_asap_optimization_shared_univmon_is_sized_for_the_strictest_consumer` | ignored: #595, #596 | +| UnivMon is sized per target: Q3's (ε = 0.01) is larger than Q2's (ε = 0.05) | `stage1_logical_asap_optimization_univmon_is_sized_per_accuracy_target` | ignored: #621 | +| Only a UnivMon is ever shared across statistics | `stage1_logical_asap_optimization_only_univmon_is_shared_across_statistics` | passes (vacuously today) | +| Candidates are valid DAGs with unique ids | `stage1_logical_asap_optimization_candidates_are_valid_and_uniquely_named` | passes | ### Physical ASAP-aware optimization | Invariant | Test | Status | |---|---|---| -| One physical candidate per logical candidate, none pruned | `physical_asap_optimization_keeps_every_logical_candidate` | passes | -| The shared UnivMon stays one build read by all three queries | `physical_asap_optimization_keeps_the_shared_univmon_as_one_build` | ignored: #595, #596 | +| One physical candidate per logical candidate, none pruned | `stage2_physical_asap_optimization_keeps_every_logical_candidate` | passes | +| The shared UnivMon stays one build read by all three queries | `stage2_physical_asap_optimization_keeps_the_shared_univmon_as_one_build` | ignored: #595, #596 | Materialization and window variants are out of scope: the doc leaves out the window-composition variants for this example, and a summary with no window @@ -88,12 +88,12 @@ form is rebuilt at every refresh (Example 1's table). | Invariant | Test | Status | |---|---|---| -| One selected id; every other candidate rejected once with a reason | `plan_selection_selects_one_and_explains_the_rest` | passes | -| The selected plan is the cheapest valid one | `plan_selection_selects_cheapest_valid` | passes | -| Each node of a valid candidate is charged once per consuming query while `NotMaterialized` (all today), once when materialized | `plan_selection_charges_each_node_per_consuming_query` | passes | -| A UnivMon estimate has a guarantee, so it is judged against its target instead of rejected for having none | `plan_selection_judges_univmon_with_an_accuracy_model` | ignored: L2 in #621; distinct count and entropy have no PR | -| One materialized UnivMon for all three costs no more than three separate UnivMons | `plan_selection_shared_univmon_costs_no_more_than_three` | ignored: #595, #596, #621, #604 | -| Selection direction: the selected plan has at most one UnivMon, because one shared UnivMon dominates several over the same input | `plan_selection_selected_plan_has_at_most_one_univmon` | passes (vacuously today: no UnivMon is valid) | +| One selected id; every other candidate rejected once with a reason | `stage3_plan_selection_selects_one_and_explains_the_rest` | passes | +| The selected plan is the cheapest valid one | `stage3_plan_selection_selects_cheapest_valid` | passes | +| Each node of a valid candidate is charged once per consuming query while `NotMaterialized` (all today), once when materialized | `stage3_plan_selection_charges_each_node_per_consuming_query` | passes | +| A UnivMon estimate has a guarantee, so it is judged against its target instead of rejected for having none | `stage3_plan_selection_judges_univmon_with_an_accuracy_model` | ignored: L2 in #621; distinct count and entropy have no PR | +| One materialized UnivMon for all three costs no more than three separate UnivMons | `stage3_plan_selection_shared_univmon_costs_no_more_than_three` | ignored: #595, #596, #621, #604 | +| Selection direction: the selected plan has at most one UnivMon, because one shared UnivMon dominates several over the same input | `stage3_plan_selection_selected_plan_has_at_most_one_univmon` | passes (vacuously today: no UnivMon is valid) | The doc's "the shared candidate usually wins" depends on the cost model, so it is tested only as the dominance above: one build sized for the strictest diff --git a/docs/design_docs/proposals/planner-layering-example3-acceptance.md b/docs/design_docs/proposals/planner-layering-example3-acceptance.md index bc45f479d..18bf8ad38 100644 --- a/docs/design_docs/proposals/planner-layering-example3-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example3-acceptance.md @@ -39,7 +39,7 @@ Shared data workload, except Pattern A's `arrival` is `mixed`. | Stage | Doc | Today | |---|---|---| -| Language-specific frontends | A: five scan → range → quantile chains. B: one. | Same; q3–q5 also have a `time_shift`. | +| Query-language frontends | A: five scan → range → quantile chains. B: one. | Same; q3–q5 also have a `time_shift`. | | Logical ASAP-aware optimization, Pass 1 | A: exact or KLL over its own interval per query, 2⁵ = 32. B: exact, KLL. | A: exact, KLL (k = 547), DDSketch per query, 3⁵ = **243**. B: exact, KLL (k = 269), DDSketch, **3**. | | Logical ASAP-aware optimization, Pass 2 | A: window composition adds one EH of KLLs over [T − 5 y, T], one merge + p99 estimate per query, plus a candidate for every grouping of two or more queries onto shared window summaries ("hundreds"). B: each Pass 1 option × {none, sliding L = 5 min s = 1 min (5 active windows), 1-min tumbling (merge 5)} = 6. Independent kept. | Not implemented: no shared input, no window summaries. | | Physical ASAP-aware optimization | See Example 4. | One physical candidate per logical candidate, every sub-DAG `NotMaterialized`. | @@ -52,29 +52,29 @@ Shared data workload, except Pattern A's `arrival` is `mixed`. | Invariant | Test | Status | |---|---|---| | Both workloads are valid; Pattern B repeats every 1 min over 5 min | `workload_encodes_example3` | passes | -| Each query lowers to scan → (time shift) → range → quantile | `language_specific_frontends_a_lowers_each_query_to_its_interval` | passes | -| One summary-free frontend DAG; the queries share no node | `language_specific_frontends_a_one_summary_free_dag_without_sharing` | passes | -| Each query has an exact and a KLL option | `logical_asap_optimization_a_pass1_offers_exact_and_kll_per_query` | passes | -| Five independent KLLs, each read by one query, all the same size | `logical_asap_optimization_a_keeps_five_independent_klls` | passes | -| The identical-expression rule shares only raw input (scan, range, shift) and keeps the unshared variant | `logical_asap_optimization_a_identical_expression_rule_shares_only_raw_input` | ignored: #588 | -| One EH of KLLs over ≥ 5 y is read by all five queries; each has its own merge feeding a p99 estimate | `logical_asap_optimization_a_window_composition_adds_one_eh_for_all_five` | ignored: #628, #634 | -| The EH candidate coexists with the five independent KLLs | `logical_asap_optimization_a_keeps_independent_and_shared_window_summaries` | ignored: #628, #634 | -| Every pair of queries shares a window summary in some candidate | `logical_asap_optimization_a_window_composition_groups_every_pair` | ignored: #628, #634 | -| One selected id, the rest explained; the selected plan is the cheapest valid one | `plan_selection_a_selects_cheapest_valid` | passes | -| Each node of a valid candidate is charged once per consuming query while `NotMaterialized` (all today), once when materialized | `plan_selection_a_charges_each_node_per_consuming_query` | passes | -| For the same per-computation choices, sharing the scan costs no more than separate scans | `plan_selection_a_shared_scan_is_not_costlier` | ignored: #588, #632 (see ambiguity 7) | +| Each query lowers to scan → (time shift) → range → quantile | `stage0_query_language_frontends_a_lowers_each_query_to_its_interval` | passes | +| One summary-free frontend DAG; the queries share no node | `stage0_query_language_frontends_a_one_summary_free_dag_without_sharing` | passes | +| Each query has an exact and a KLL option | `stage1_logical_asap_optimization_a_pass1_offers_exact_and_kll_per_query` | passes | +| Five independent KLLs, each read by one query, all the same size | `stage1_logical_asap_optimization_a_keeps_five_independent_klls` | passes | +| The identical-expression rule shares only raw input (scan, range, shift) and keeps the unshared variant | `stage1_logical_asap_optimization_a_identical_expression_rule_shares_only_raw_input` | ignored: #588 | +| One EH of KLLs over ≥ 5 y is read by all five queries; each has its own merge feeding a p99 estimate | `stage1_logical_asap_optimization_a_window_composition_adds_one_eh_for_all_five` | ignored: #628, #634 | +| The EH candidate coexists with the five independent KLLs | `stage1_logical_asap_optimization_a_keeps_independent_and_shared_window_summaries` | ignored: #628, #634 | +| Every pair of queries shares a window summary in some candidate | `stage1_logical_asap_optimization_a_window_composition_groups_every_pair` | ignored: #628, #634 | +| One selected id, the rest explained; the selected plan is the cheapest valid one | `stage3_plan_selection_a_selects_cheapest_valid` | passes | +| Each node of a valid candidate is charged once per consuming query while `NotMaterialized` (all today), once when materialized | `stage3_plan_selection_a_charges_each_node_per_consuming_query` | passes | +| For the same per-computation choices, sharing the scan costs no more than separate scans | `stage3_plan_selection_a_shared_scan_is_not_costlier` | ignored: #588, #632 (see ambiguity 7) | ### Pattern B | Invariant | Test | Status | |---|---|---| -| The query lowers to scan → range 5m → quantile, no summary | `language_specific_frontends_b_lowers_to_one_range_quantile` | passes | -| Exact and KLL options | `logical_asap_optimization_b_pass1_offers_exact_and_kll` | passes | -| Each summary option appears with no window, a 5-min sliding window with a 1-min slide, and 1-min tumbling windows | `logical_asap_optimization_b_window_composition_adds_sliding_and_tumbling_per_option` | ignored: tumbling #601, sliding no PR yet | -| Window parameters are legal: L divides W; s divides L and the evaluation interval; a tumbling length divides W and the interval | `logical_asap_optimization_b_window_parameters_are_legal` | passes (vacuously today) | -| Sliding with L = W has no merge; tumbling merges before the estimate | `logical_asap_optimization_b_merge_only_where_the_window_form_needs_it` | ignored: tumbling #601, sliding no PR yet | -| A window form that merges uses a mergeable summary (KLL, DDSketch), in both patterns | `logical_asap_optimization_window_merges_use_mergeable_summaries` | passes (vacuously today) | -| One selected, cheapest valid, each node charged once | `plan_selection_b_selects_cheapest_valid` | passes | +| The query lowers to scan → range 5m → quantile, no summary | `stage0_query_language_frontends_b_lowers_to_one_range_quantile` | passes | +| Exact and KLL options | `stage1_logical_asap_optimization_b_pass1_offers_exact_and_kll` | passes | +| Each summary option appears with no window, a 5-min sliding window with a 1-min slide, and 1-min tumbling windows | `stage1_logical_asap_optimization_b_window_composition_adds_sliding_and_tumbling_per_option` | ignored: tumbling #601, sliding no PR yet | +| Window parameters are legal: L divides W; s divides L and the evaluation interval; a tumbling length divides W and the interval | `stage1_logical_asap_optimization_b_window_parameters_are_legal` | passes (vacuously today) | +| Sliding with L = W has no merge; tumbling merges before the estimate | `stage1_logical_asap_optimization_b_merge_only_where_the_window_form_needs_it` | ignored: tumbling #601, sliding no PR yet | +| A window form that merges uses a mergeable summary (KLL, DDSketch), in both patterns | `stage1_logical_asap_optimization_window_merges_use_mergeable_summaries` | passes (vacuously today) | +| One selected, cheapest valid, each node charged once | `stage3_plan_selection_b_selects_cheapest_valid` | passes | **Reading window summaries.** The IR has no window summary yet, so tests read it through `planner_layering_common::window_form(dag, build)`, which returns diff --git a/docs/design_docs/proposals/planner-layering-example4-acceptance.md b/docs/design_docs/proposals/planner-layering-example4-acceptance.md index 5a44de24e..8468e68f3 100644 --- a/docs/design_docs/proposals/planner-layering-example4-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example4-acceptance.md @@ -66,31 +66,31 @@ the IR records retention. | Invariant | Test | Status | |---|---|---| -| Every node upstream of an ingestion-time node also runs at ingestion time (all variants) | `physical_asap_optimization_ingestion_time_upstream_is_ingestion_time` | passes (vacuously today) | -| With data at rest, no node runs at ingestion time | `physical_asap_optimization_at_rest_runs_nothing_at_ingestion_time` | passes (vacuously today) | -| A materialized output covers its consumers: retention ≥ max over its readers of `lookback` + offset | `physical_asap_optimization_materialized_output_covers_its_consumers` | passes (vacuously today) | +| Every node upstream of an ingestion-time node also runs at ingestion time (all variants) | `stage2_physical_asap_optimization_ingestion_time_upstream_is_ingestion_time` | passes (vacuously today) | +| With data at rest, no node runs at ingestion time | `stage2_physical_asap_optimization_at_rest_runs_nothing_at_ingestion_time` | passes (vacuously today) | +| A materialized output covers its consumers: retention ≥ max over its readers of `lookback` + offset | `stage2_physical_asap_optimization_materialized_output_covers_its_consumers` | passes (vacuously today) | ### Pattern A | Invariant | Test | Status | |---|---|---| -| The shared EH yields exactly A1, A2, A3 | `physical_asap_optimization_a_shared_eh_has_three_materialization_options` | ignored: #628, #634, #604, #625 | -| At rest, A2 is not generated; A1 and A3 remain | `physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option` | ignored: #628, #634, #604, #625 | -| A materialized EH is one build node read by all five queries, priced once | `physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers` | ignored: #628, #634, #604, #625 | -| A3 costs strictly more than A1 (five builds instead of one: a `NotMaterialized` node is charged per consuming query) | `plan_selection_a_rebuilding_per_query_costs_more_than_building_once` | ignored: #628, #634, #604, #625 | -| As given, A1 is the cheapest of the three | `plan_selection_a_once_adhoc_prefers_the_query_time_eh` | ignored: #628, #634, #604, #625 | -| Monthly, A2's cost relative to A1 drops: cost(A2)/cost(A1) monthly < as given | `plan_selection_a_monthly_amortizes_ingestion_time_maintenance` | ignored: #628, #634, #604, #625 | +| The shared EH yields exactly A1, A2, A3 | `stage2_physical_asap_optimization_a_shared_eh_has_three_materialization_options` | ignored: #628, #634, #604, #625 | +| At rest, A2 is not generated; A1 and A3 remain | `stage2_physical_asap_optimization_a_at_rest_drops_the_ingestion_time_option` | ignored: #628, #634, #604, #625 | +| A materialized EH is one build node read by all five queries, priced once | `stage2_physical_asap_optimization_a_materialized_eh_is_built_once_for_all_consumers` | ignored: #628, #634, #604, #625 | +| A3 costs strictly more than A1 (five builds instead of one: a `NotMaterialized` node is charged per consuming query) | `stage3_plan_selection_a_rebuilding_per_query_costs_more_than_building_once` | ignored: #628, #634, #604, #625 | +| As given, A1 is the cheapest of the three | `stage3_plan_selection_a_once_adhoc_prefers_the_query_time_eh` | ignored: #628, #634, #604, #625 | +| Monthly, A2's cost relative to A1 drops: cost(A2)/cost(A1) monthly < as given | `stage3_plan_selection_a_monthly_amortizes_ingestion_time_maintenance` | ignored: #628, #634, #604, #625 | ### Pattern B | Invariant | Test | Status | |---|---|---| -| The tumbling KLL candidate yields exactly B1, B2, B3 | `physical_asap_optimization_b_tumbling_kll_has_three_materialization_options` | ignored: #601, #604, #625 | -| B1 builds at ingestion time; its merge and estimate run at query time | `physical_asap_optimization_b_b1_builds_at_ingestion_and_merges_at_query_time` | ignored: #601, #604 | -| B3 runs nothing at ingestion time | `physical_asap_optimization_b_b3_keeps_query_time_windows` | ignored: #601, #625 | -| The sliding KLL yields exactly ingestion time and query time, kept | `physical_asap_optimization_b_sliding_kll_has_two_materialization_options` | ignored: sliding windows (no PR yet), #604 | -| B2 costs at least B1 and B3 | `plan_selection_b_rebuilding_every_window_costs_most` | ignored: #601, #604, #625 | -| With the built-in models, B1 is the cheapest of the three | `plan_selection_b_prefers_ingestion_time_tumbling_windows` | ignored: #601, #604, #625 | +| The tumbling KLL candidate yields exactly B1, B2, B3 | `stage2_physical_asap_optimization_b_tumbling_kll_has_three_materialization_options` | ignored: #601, #604, #625 | +| B1 builds at ingestion time; its merge and estimate run at query time | `stage2_physical_asap_optimization_b_b1_builds_at_ingestion_and_merges_at_query_time` | ignored: #601, #604 | +| B3 runs nothing at ingestion time | `stage2_physical_asap_optimization_b_b3_keeps_query_time_windows` | ignored: #601, #625 | +| The sliding KLL yields exactly ingestion time and query time, kept | `stage2_physical_asap_optimization_b_sliding_kll_has_two_materialization_options` | ignored: sliding windows (no PR yet), #604 | +| B2 costs at least B1 and B3 | `stage3_plan_selection_b_rebuilding_every_window_costs_most` | ignored: #601, #604, #625 | +| With the built-in models, B1 is the cheapest of the three | `stage3_plan_selection_b_prefers_ingestion_time_tumbling_windows` | ignored: #601, #604, #625 | ### Selection direction, relative to the cost model From 922bb7bbd5f3a684321e86ad15e26559711d400f Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:04:06 +0000 Subject: [PATCH 12/19] test: charging helper follows plan selection's per-consumer rule With a materialized node the helper accepted any multiplier >= 1. It now computes each node's computations as plan selection does (walks from query roots and materialized nodes' inputs, stopping at materialized nodes), checked on a hand-built candidate with a materialized node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_common/mod.rs | 89 +++++++++++------- .../tests/planner_layering_example1.rs | 90 +++++++++++++++++++ .../planner-layering-example1-acceptance.md | 9 +- 3 files changed, 154 insertions(+), 34 deletions(-) diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index 5b922f2a0..4309b2669 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -55,21 +55,21 @@ pub fn lower_promql(workload: &PlanningWorkload) -> Vec { /// (node ids are post-order; compare as a set of operations). pub fn frontend_operations(root: &QueryRoot) -> Vec { let dag = flatten(std::slice::from_ref(root)).0; - let mut ops: Vec = dag - .nodes - .iter() - .map(|n| { - let json = serde_json::to_value(&n.operator).expect("serializes"); - match json["NonASAP"]["Aggregate"]["measures"][0]["kind"].as_str() { - Some(measure) => format!("aggregate:{measure}"), - None => relational(&n.operator).unwrap_or_else(|| "?".into()), - } - }) - .collect(); + let mut ops: Vec = dag.nodes.iter().map(|n| operation(&n.operator)).collect(); ops.sort(); ops } +/// The aggregate's first measure as `aggregate:`, else the operator's +/// kind in snake case. +pub fn operation(payload: &Payload) -> String { + let json = serde_json::to_value(payload).expect("serializes"); + match json["NonASAP"]["Aggregate"]["measures"][0]["kind"].as_str() { + Some(measure) => format!("aggregate:{measure}"), + None => snake_case(payload.kind_name()), + } +} + // ── Pipeline ───────────────────────────────────────────────────────────── /// One logical candidate. `query_roots` has one root per workload entry, in @@ -448,12 +448,9 @@ pub fn assert_selects_cheapest_valid(run: &Run) { } } -/// Each valid candidate's cost has one entry per DAG node and `total` is -/// their sum. A materialized node is charged once; a `NotMaterialized` node -/// once per consuming query, since it is recomputed for each (#509). With no -/// materialized node in the DAG (every candidate until #604), a node's -/// multiplier is the number of queries that read it. Invalid candidates are -/// not priced. +/// Each valid candidate's cost has one entry per DAG node, `total` is their +/// sum, and each node's multiplier is [`computations`]. Invalid candidates +/// are not priced. pub fn assert_each_node_charged_per_consuming_query(run: &Run) { let invalid = run.invalid(); for p in &run.physical { @@ -471,23 +468,53 @@ pub fn assert_each_node_charged_per_consuming_query(run: &Run) { "{}", p.id ); - let none_materialized = nodes - .iter() - .all(|&n| materialization(p, n) == MaterializationOption::NotMaterialized); - for (&id, c) in &cost.per_node { - let expected = if materialization(p, id) != MaterializationOption::NotMaterialized { - Some(1) - } else if none_materialized { - Some(readers(p.dag(), &p.query_roots, id).len() as u64) - } else { - None - }; - match expected { - Some(times) => assert_eq!(c.multiplier, times, "{}: {id:?}", p.id), - None => assert!(c.multiplier >= 1, "{}: {id:?}", p.id), + let times = computations(p.dag()); + for (id, c) in &cost.per_node { + assert_eq!(c.multiplier, times[id], "{}: multiplier of {id:?}", p.id); + } + } +} + +/// How many times each node is computed per workload evaluation (#509; the +/// rule of plan selection's private `computations`): 1 for a materialized +/// node; for a `NotMaterialized` one, the number of consumers that reach +/// it. A consumer is a query (operator or scalar root) or a materialized +/// node, whose walk starts at its inputs; a walk stops at materialized +/// nodes and counts each node once. +pub fn computations(dag: &PhysicalASAPDAG) -> BTreeMap { + let materialized: BTreeSet<_> = dag + .nodes + .iter() + .filter(|n| n.materialization != Materialization::NotMaterialized) + .map(|n| n.id) + .collect(); + let starts = dag + .roots + .iter() + .map(|&root| vec![root]) + .chain( + dag.scalar_roots + .iter() + .map(|(_, expr)| expr.operator_refs().into_iter().copied().collect()), + ) + .chain(materialized.iter().map(|&id| dag.producers(id))); + let mut times: BTreeMap<_, _> = dag + .nodes + .iter() + .map(|n| (n.id, u64::from(materialized.contains(&n.id)))) + .collect(); + for start in starts { + let mut seen = HashSet::new(); + let mut stack = start; + while let Some(id) = stack.pop() { + if materialized.contains(&id) || !seen.insert(id) { + continue; } + *times.get_mut(&id).expect("node") += 1; + stack.extend(dag.producers(id)); } } + times } /// The evaluation interval of entry `query`, for a fixed-interval repeating diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index 1dce182c5..a33664800 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -14,9 +14,13 @@ mod planner_layering_common; use std::collections::{BTreeMap, BTreeSet, HashSet}; +use asap_aware_mapping::plan_selection::select_plan; +use asap_aware_mapping::PlanningModels; use asap_types::ir::flat::NodeId; use asap_types::ir::{ASAPOp, NonASAPOp, Operator, QueryRoot, ScalarExpr, SortKey}; use asap_types::post_asap::{FieldDataType, GroupingStrategy, HydraKind, SketchAlgorithm}; +use asap_types::post_asap::{Materialization, Medium}; +use asap_types::types::AccuracyTarget; use planner_layering_common::*; // ── Example 1 workload ─────────────────────────────────────────────────── @@ -483,6 +487,92 @@ fn stage3_plan_selection_charges_each_node_per_consuming_query() { assert_each_node_charged_per_consuming_query(&pipeline()); } +/// A hand-built candidate with a materialized node: Q1's plan read by two +/// queries, its `time_range` kept `MatAtQuery`. Plan selection charges the +/// two accumulators (rate, sum) and their finalizations twice, once per query; the walks from +/// both roots stop at the materialized `time_range`, which is computed once +/// and reads the scan once. +fn materialized_case() -> Run { + let mut workload = example1(); + let repeating = workload.query_workload.repeating_queries.as_mut().unwrap(); + repeating.truncate(1); + let run = run_promql(&workload); + let mut p = run.physical[0].clone(); + p.candidate.roots = vec![p.candidate.roots[0].clone(); 2]; + let dag = &mut p.candidate.dag; + dag.roots = vec![dag.roots[0]; 2]; + p.query_roots = dag.roots.clone(); + let time_range = dag + .nodes + .iter_mut() + .find(|n| relational(&n.payload).as_deref() == Some("time_range")) + .unwrap(); + time_range.materialization = Materialization::MatAtQuery(Medium::Memory); + let selection = select_plan( + std::slice::from_ref(&p.candidate), + &[Some(AccuracyTarget::Exact), Some(AccuracyTarget::Exact)], + workload.data_workload.as_ref().unwrap(), + PlanningModels::builtin(), + ) + .unwrap(); + Run { + physical: vec![p], + selection, + ..run + } +} + +/// The charging helper agrees with plan selection when a node is +/// materialized. +#[test] +fn charging_helper_follows_plan_selection_with_a_materialized_node() { + let run = materialized_case(); + let p = &run.physical[0]; + let mut by_kind: Vec<_> = run.selection.costs[&p.id] + .per_node + .iter() + .map(|(id, c)| (operation(p.dag().payload(*id)), c.multiplier)) + .collect(); + by_kind.sort(); + let expected = [ + ("finalize_exact_accumulator", 2), + ("finalize_exact_accumulator", 2), + ("scan", 1), + ("summary_agg", 2), + ("summary_agg", 2), + ("time_range", 1), + ]; + assert_eq!( + by_kind, + expected.map(|(kind, times)| (kind.to_string(), times)) + ); + assert_each_node_charged_per_consuming_query(&run); +} + +/// Charging the scan under a materialized node once per query is caught. +#[test] +#[should_panic(expected = "multiplier of")] +fn charging_helper_rejects_recounting_below_a_materialized_node() { + let mut run = materialized_case(); + let p = run.physical[0].clone(); + let scan = p + .dag() + .nodes + .iter() + .find(|n| relational(&n.payload).as_deref() == Some("scan")) + .unwrap() + .id; + run.selection + .costs + .get_mut(&p.id) + .unwrap() + .per_node + .get_mut(&scan) + .unwrap() + .multiplier = 2; + assert_each_node_charged_per_consuming_query(&run); +} + /// The plan selection picks compiles in the physical planner (added by the /// implementer, not part of the spec). #[test] diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index a1838f83a..2a0f07618 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -131,9 +131,12 @@ Invariants: * One selected id. Every other candidate is listed once as rejected, with a reason, and marked invalid or valid but costlier. * Only valid candidates are priced. Each valid candidate's cost has one entry - per DAG node, and `total` is their sum. A `NotMaterialized` node is charged - once per query that reads it (its multiplier), since it is recomputed for - each; a materialized node once. Every node is `NotMaterialized` today. + per DAG node, and `total` is their sum. A materialized node is charged + once. A `NotMaterialized` node is charged once per consumer that reads it + (its multiplier), where a consumer is a query or a materialized node and + a read stops at materialized nodes. Every node is `NotMaterialized` today; + `charging_helper_*` check the helper on a hand-built candidate with a + materialized node. * The selected plan is no more expensive than any valid candidate. * The selected plan compiles in the physical planner. From 9f2989771f9144cd8ab09d8b982ac541bfbac5da Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:04:32 +0000 Subject: [PATCH 13/19] test: Example 1 picks Q2 candidates by (option, no shared input) physical_with and the invalid-candidate check no longer assume one candidate per Q2 option, so #588's shared-input variants need no change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_example1.rs | 14 ++++++++++++-- .../planner-layering-example1-acceptance.md | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index a33664800..3303d8342 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -93,11 +93,13 @@ fn classify(dag: &impl ExportedDag, query_roots: &[NodeId]) -> (Q2Option, bool) (option, !c1.is_disjoint(&c2)) } +/// The candidate with Q2 `option` whose queries share no node (#588 adds a +/// shared-input variant of each). fn physical_with(run: &Run, option: Q2Option) -> &Physical { let mut found = run .physical .iter() - .filter(|p| classify(p.dag(), &p.query_roots).0 == option); + .filter(|p| classify(p.dag(), &p.query_roots) == (option, false)); let p = found.next().unwrap_or_else(|| panic!("no {option:?}")); assert!(found.next().is_none(), "one {option:?} candidate"); p @@ -462,7 +464,15 @@ fn stage3_plan_selection_selects_exact_q2_and_rejects_both_heaps() { physical_with(&run, Q2Option::Exact).id ); let invalid = run.invalid(); - assert_eq!(invalid.len(), 2, "{invalid:?}"); + // Only heap candidates are invalid (with or without a shared input). + for id in invalid.keys() { + let p = run.physical(id); + assert_ne!( + classify(p.dag(), &p.query_roots).0, + Q2Option::Exact, + "{invalid:?}" + ); + } let cms = &physical_with(&run, Q2Option::CountMinHeapPerJob).id; assert!( invalid[cms.as_str()].contains("non-negative"), diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index 2a0f07618..8a26d4dc9 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -143,6 +143,11 @@ Invariants: Ignored until Pass 2 (#588): for each Q2 option, the shared-input candidate costs no more than its separate counterpart. +When #588's shared-input candidates land, each Q2 option has two +candidates. The tests already pick a Q2 option's candidate by (option, +queries share no node) and check only that every invalid candidate is a +heap one, not that exactly two are invalid, so they need no change. + The doc's typical winners need window forms or materialization, and its "Raw with a shared input" needs Pass 2. Latency bounds are not checked, so whether an exact Q2 misses 100 ms is not tested. From dc8a7541503291b8ac0c67f19be64cb3f748caa0 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:04:37 +0000 Subject: [PATCH 14/19] docs: Example 3 Pattern B charges each node once per consuming query Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../proposals/planner-layering-example3-acceptance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design_docs/proposals/planner-layering-example3-acceptance.md b/docs/design_docs/proposals/planner-layering-example3-acceptance.md index 18bf8ad38..73cf4877c 100644 --- a/docs/design_docs/proposals/planner-layering-example3-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example3-acceptance.md @@ -74,7 +74,7 @@ Shared data workload, except Pattern A's `arrival` is `mixed`. | Window parameters are legal: L divides W; s divides L and the evaluation interval; a tumbling length divides W and the interval | `stage1_logical_asap_optimization_b_window_parameters_are_legal` | passes (vacuously today) | | Sliding with L = W has no merge; tumbling merges before the estimate | `stage1_logical_asap_optimization_b_merge_only_where_the_window_form_needs_it` | ignored: tumbling #601, sliding no PR yet | | A window form that merges uses a mergeable summary (KLL, DDSketch), in both patterns | `stage1_logical_asap_optimization_window_merges_use_mergeable_summaries` | passes (vacuously today) | -| One selected, cheapest valid, each node charged once | `stage3_plan_selection_b_selects_cheapest_valid` | passes | +| One selected, cheapest valid, each node charged once per consuming query | `stage3_plan_selection_b_selects_cheapest_valid` | passes | **Reading window summaries.** The IR has no window summary yet, so tests read it through `planner_layering_common::window_form(dag, build)`, which returns From a04fe813045841a4427254f3f36d77d2a2e06c5e Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:05:42 +0000 Subject: [PATCH 15/19] docs(stage-viewer): rows 2, 3a and 3b describe the planner's current output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read from uncapped runs: Example 2's sketch plans are rejected for having no (ε, δ) guarantee, 3a selects P122 (first of 108 tied cheapest plans, nothing shared yet) and 3b selects query-time KLL; panes and the latency check come with the window-summary PRs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- docs/user_guide_docs/stage-viewer.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user_guide_docs/stage-viewer.md b/docs/user_guide_docs/stage-viewer.md index 2ca4addfb..615e75a0a 100644 --- a/docs/user_guide_docs/stage-viewer.md +++ b/docs/user_guide_docs/stage-viewer.md @@ -69,9 +69,9 @@ plan wins. | Tab | Workload | What to look for | | --- | --- | --- | | 1 · dashboard panels | Two PromQL panels over 1M counter series every 10 s: an exact per-job rate, and the approximate top-10 series per job | The exact query stays exact; the CMS+heap and CountSketch+heap top-k plans are rejected with their reasons, so the all-exact plan wins; each query reads its own scan until cross-query sharing lands | -| 2 · SQL flow statistics | Distinct sources, entropy and L2 of per-source counts over the last minute of flows | Every UnivMon plan is invalid with the reason "no accuracy model for UnivMon", so an exact plan wins | -| 3a · historical p99 batch | Five p99 reports over 1–5 years, run once | The cheapest plan shares one input scan across the five KLL sketches | -| 3b · live p99 panel | p99 over the last 5 min every minute, 1M series | Keeping KLL panes from ingestion time costs more memory than building the sketch at each evaluation, and rebuilding all panes at query time is over the 200 ms latency bound | +| 2 · SQL flow statistics | Distinct sources, entropy and L2 of per-source counts over the last minute of flows | All 80 plans are priced; every plan with a sketch is invalid. HLL for Q1 is rejected because its bound "failure probability None … misses target"; the other sketches (UnivMon, and CMS or CountSketch for the per-source counts of Q2 and Q3, which the SQL frontend does not yet recognize as entropy and L2) because "the output has no guarantee, and the deployment has no evidence that it meets target". The all-exact plan P1 (exact accumulators for Q2 and Q3) is selected | +| 3a · historical p99 batch | Five p99 reports over 1–5 years, run once | All 243 plans are priced. Nothing is shared yet: each query scans its own interval and builds its own sketch at query time. P122, KLL for all five queries, is selected: it is the first of 108 plans at the lowest cost, because KLL and DDSketch cost the same and, for the three offset intervals, so does exact. The all-exact plan costs about 16% more | +| 3b · live p99 panel | p99 over the last 5 min every minute, 1M series | P2, KLL built from the window's raw samples at each evaluation, is selected; DDSketch costs the same and exact about 14% more. Everything runs at query time and nothing is materialized. KLL panes kept from ingestion time and the 200 ms latency check come with the window-summary PRs | | 4a · monthly p99 reports | Example 3a repeated monthly | The same plan, with its cost amortized over a month | | 4b · panes that pay off | p99 over the last hour every 10 min, 1k series sampled every second, raw data not kept by the deployment | Maintaining six 10-min KLL panes at ingestion time wins | From 62d4dd2b1a408d8f79b2a26583b1e072f8e12bdf Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:13:31 +0000 Subject: [PATCH 16/19] docs(stage-viewer): row 2 from the SQL run; 3a ties 32 plans, not 108 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- docs/user_guide_docs/stage-viewer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user_guide_docs/stage-viewer.md b/docs/user_guide_docs/stage-viewer.md index 615e75a0a..b13891cdc 100644 --- a/docs/user_guide_docs/stage-viewer.md +++ b/docs/user_guide_docs/stage-viewer.md @@ -69,8 +69,8 @@ plan wins. | Tab | Workload | What to look for | | --- | --- | --- | | 1 · dashboard panels | Two PromQL panels over 1M counter series every 10 s: an exact per-job rate, and the approximate top-10 series per job | The exact query stays exact; the CMS+heap and CountSketch+heap top-k plans are rejected with their reasons, so the all-exact plan wins; each query reads its own scan until cross-query sharing lands | -| 2 · SQL flow statistics | Distinct sources, entropy and L2 of per-source counts over the last minute of flows | All 80 plans are priced; every plan with a sketch is invalid. HLL for Q1 is rejected because its bound "failure probability None … misses target"; the other sketches (UnivMon, and CMS or CountSketch for the per-source counts of Q2 and Q3, which the SQL frontend does not yet recognize as entropy and L2) because "the output has no guarantee, and the deployment has no evidence that it meets target". The all-exact plan P1 (exact accumulators for Q2 and Q3) is selected | -| 3a · historical p99 batch | Five p99 reports over 1–5 years, run once | All 243 plans are priced. Nothing is shared yet: each query scans its own interval and builds its own sketch at query time. P122, KLL for all five queries, is selected: it is the first of 108 plans at the lowest cost, because KLL and DDSketch cost the same and, for the three offset intervals, so does exact. The all-exact plan costs about 16% more | +| 2 · SQL flow statistics | Distinct sources, entropy and L2 of per-source counts over the last minute of flows | All 80 plans compose and are priced; none is pruned. The SQL frontend does not yet recognize entropy and L2, so Q2 and Q3 are per-source counts with exact, Count-Min, CountSketch or UnivMon options. 68 plans are invalid: HLL for Q1 because its bound has "failure probability None", and UnivMon for Q1 or Count-Min or CountSketch for Q2 and Q3 because "the output has no guarantee, and the deployment has no evidence that it meets target". 12 are valid: Q1 exact, Theta or KMV with Q2 and Q3 each exact or UnivMon. P1 (all exact) is selected as the first of four plans tied at 52.80111 ms (with UnivMon on Q2, Q3 or both); Theta or KMV on Q1 costs 2·10⁻⁶ ms more | +| 3a · historical p99 batch | Five p99 reports over 1–5 years, run once | All 243 plans are priced. Nothing is shared yet: each query scans its own interval and builds its own sketch at query time. P122, KLL for all five queries, is selected: it is the first of 32 plans tied at the lowest cost, because KLL and DDSketch cost the same for every query; exact on any query costs more. The all-exact plan costs about 16% more | | 3b · live p99 panel | p99 over the last 5 min every minute, 1M series | P2, KLL built from the window's raw samples at each evaluation, is selected; DDSketch costs the same and exact about 14% more. Everything runs at query time and nothing is materialized. KLL panes kept from ingestion time and the 200 ms latency check come with the window-summary PRs | | 4a · monthly p99 reports | Example 3a repeated monthly | The same plan, with its cost amortized over a month | | 4b · panes that pay off | p99 over the last hour every 10 min, 1k series sampled every second, raw data not kept by the deployment | Maintaining six 10-min KLL panes at ingestion time wins | From cad0f7e0b7ed0ff4974d20033581031d8a7dc3c0 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:13:31 +0000 Subject: [PATCH 17/19] test: Example 1 counts only unshared candidates; stage3_ charging-helper names The 3-logical and 3-physical counts now hold when #588 adds shared-input variants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/planner_layering_example1.rs | 28 +++++++++++++------ .../planner-layering-example1-acceptance.md | 7 +++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index 3303d8342..7f41c3654 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -155,18 +155,22 @@ fn stage0_query_language_frontends_queries_do_not_share_nodes() { // ── Logical ASAP-aware optimization ────────────────────────────────────── -/// Pass 1 gives 1 × 3 = 3 candidates: Q2's top-k is exact, Count-Min + heap or CountSketch + heap. +/// Pass 1 gives 1 × 3 = 3 candidates without a shared input: Q2's top-k is +/// exact, Count-Min + heap or CountSketch + heap. (#588 adds a shared-input +/// variant of each.) #[test] fn stage1_logical_asap_optimization_has_three_pass1_candidates() { let run = pipeline(); - assert_eq!(run.logical.len(), 3); - let found: BTreeSet<_> = run + let found: Vec<_> = run .logical .iter() .map(|c| classify(&c.dag, &c.query_roots)) + .filter(|(_, shared)| !shared) .collect(); - let expected: BTreeSet<_> = PASS1_Q2_OPTIONS.into_iter().map(|o| (o, false)).collect(); - assert_eq!(found, expected, "each Q2 option exactly once"); + let expected: Vec<_> = PASS1_Q2_OPTIONS.into_iter().map(|o| (o, false)).collect(); + let mut sorted = found.clone(); + sorted.sort(); + assert_eq!(sorted, expected, "each Q2 option exactly once: {found:?}"); } /// Q1 is exact in every candidate: no summary is reachable from its root. @@ -277,12 +281,18 @@ fn stage1_logical_asap_optimization_candidates_are_valid_and_uniquely_named() { // ── Physical ASAP-aware optimization ───────────────────────────────────── -/// No candidate is discarded before plan selection: the 3 logical candidates map one-to-one. +/// No candidate is discarded before plan selection: the logical candidates +/// map one-to-one, 3 of them without a shared input. #[test] fn stage2_physical_asap_optimization_keeps_every_logical_candidate() { let run = pipeline(); assert_one_physical_candidate_per_logical(&run); - assert_eq!(run.physical.len(), 3); + let unshared = run + .physical + .iter() + .filter(|p| !classify(p.dag(), &p.query_roots).1) + .count(); + assert_eq!(unshared, 3); } /// Each physical candidate keeps its logical Q2 option and input sharing. @@ -535,7 +545,7 @@ fn materialized_case() -> Run { /// The charging helper agrees with plan selection when a node is /// materialized. #[test] -fn charging_helper_follows_plan_selection_with_a_materialized_node() { +fn stage3_plan_selection_charging_helper_follows_plan_selection_with_a_materialized_node() { let run = materialized_case(); let p = &run.physical[0]; let mut by_kind: Vec<_> = run.selection.costs[&p.id] @@ -562,7 +572,7 @@ fn charging_helper_follows_plan_selection_with_a_materialized_node() { /// Charging the scan under a materialized node once per query is caught. #[test] #[should_panic(expected = "multiplier of")] -fn charging_helper_rejects_recounting_below_a_materialized_node() { +fn stage3_plan_selection_charging_helper_rejects_recounting_below_a_materialized_node() { let mut run = materialized_case(); let p = run.physical[0].clone(); let scan = p diff --git a/docs/design_docs/proposals/planner-layering-example1-acceptance.md b/docs/design_docs/proposals/planner-layering-example1-acceptance.md index 8a26d4dc9..d54b47e47 100644 --- a/docs/design_docs/proposals/planner-layering-example1-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example1-acceptance.md @@ -135,7 +135,7 @@ Invariants: once. A `NotMaterialized` node is charged once per consumer that reads it (its multiplier), where a consumer is a query or a materialized node and a read stops at materialized nodes. Every node is `NotMaterialized` today; - `charging_helper_*` check the helper on a hand-built candidate with a + `stage3_plan_selection_charging_helper_*` check the helper on a hand-built candidate with a materialized node. * The selected plan is no more expensive than any valid candidate. * The selected plan compiles in the physical planner. @@ -144,8 +144,9 @@ Ignored until Pass 2 (#588): for each Q2 option, the shared-input candidate costs no more than its separate counterpart. When #588's shared-input candidates land, each Q2 option has two -candidates. The tests already pick a Q2 option's candidate by (option, -queries share no node) and check only that every invalid candidate is a +candidates. The tests already count only candidates whose queries share no +node (Pass 1's 3 logical and 3 physical), pick a Q2 option's candidate by +(option, no shared input), and check only that every invalid candidate is a heap one, not that exactly two are invalid, so they need no change. The doc's typical winners need window forms or materialization, and its From e2afcd021c7914abcbd5df31502934ad32ae47db Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:13:31 +0000 Subject: [PATCH 18/19] refactor(devtools): PlanningStagesRun fields carry stage number and name Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/devtools/src/bin/stage_pipeline.rs | 32 +++++++++++-------- crates/devtools/src/planning_stages.rs | 16 +++++----- crates/devtools/tests/stage_pipeline.rs | 4 +-- .../tests/planner_layering_common/mod.rs | 8 ++--- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 1c076eef0..1018b4014 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -110,21 +110,25 @@ fn stage_pipeline(workload: &PlanningWorkload, max_displayed: usize) -> Result = run - .logical + .stage1_logical_asap_optimization .iter() .enumerate() .filter(|(index, c)| *index < max_displayed || Some(c.id.as_str()) == selected_logical) .map(|(_, c)| c.id.as_str()) .collect(); let shown_physical: HashSet<&str> = run - .physical + .stage2_physical_asap_optimization .iter() .filter(|p| shown.contains(p.from_logical.as_str())) .map(|p| p.id.as_str()) @@ -132,7 +136,7 @@ fn stage_pipeline(workload: &PlanningWorkload, max_displayed: usize) -> Result = run - .logical + .stage1_logical_asap_optimization .iter() .filter(|c| shown.contains(c.id.as_str())) .map(|c| match &c.roots { @@ -141,13 +145,13 @@ fn stage_pipeline(workload: &PlanningWorkload, max_displayed: usize) -> Result = run - .physical + .stage2_physical_asap_optimization .iter() .filter(|p| shown_physical.contains(p.id.as_str())) .map(|p| json!({ "id": p.id, "from_logical": p.from_logical, "label": p.label, "dag": p.dag })) .collect(); let pruned: Vec<_> = run - .pruned + .stage2_pruned .iter() .filter(|p| shown.contains(p.from_logical.as_str())) .map(|p| json!({ "from_logical": p.from_logical, "label": p.label, "reason": p.reason })) @@ -157,12 +161,12 @@ fn stage_pipeline(workload: &PlanningWorkload, max_displayed: usize) -> Result, - pub logical: Vec, + pub stage1_logical_asap_optimization: Vec, /// `P` implements `L`. - pub physical: Vec, - pub pruned: Vec, - pub selection: Result, + pub stage2_physical_asap_optimization: Vec, + pub stage2_pruned: Vec, + pub stage3_plan_selection: Result, } /// Lower a PromQL workload, each series' identity as a column (the row @@ -135,10 +135,10 @@ pub fn run_planning_stages( let selection = select_plan(&physical, &targets, &data, PlanningModels::builtin()); Ok(PlanningStagesRun { stage0_query_language_frontends: roots, - logical, - physical, - pruned, - selection, + stage1_logical_asap_optimization: logical, + stage2_physical_asap_optimization: physical, + stage2_pruned: pruned, + stage3_plan_selection: selection, }) } diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index fc79dccef..0d0820b60 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -191,8 +191,8 @@ fn uncomposable_candidate_is_recorded_with_its_error() { fn example3a_selects_the_cheapest_of_all_candidates() { let workload = pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed); let run = run_planning_stages(&workload, lower_promql_roots(&workload).unwrap()).unwrap(); - assert_eq!(run.logical.len(), 243); - let expected = run.selection.unwrap().selected; + assert_eq!(run.stage1_logical_asap_optimization.len(), 243); + let expected = run.stage3_plan_selection.unwrap().selected; let document = generate_example("planner-layering-3a", &[]); let selection = &document["plan_selection"]; diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index 4309b2669..913584f0b 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -170,7 +170,7 @@ fn logical(id: String, label: String, roots: Vec) -> Logical { pub fn run_stages(workload: &PlanningWorkload, roots: Vec) -> Run { let run = run_planning_stages(workload, roots).expect("planning stages run"); let logical_candidates = run - .logical + .stage1_logical_asap_optimization .into_iter() .map(|c| { let roots = c @@ -180,7 +180,7 @@ pub fn run_stages(workload: &PlanningWorkload, roots: Vec) -> Run { }) .collect(); let physical = run - .physical + .stage2_physical_asap_optimization .into_iter() .map(|candidate| Physical { id: candidate.id.clone(), @@ -198,11 +198,11 @@ pub fn run_stages(workload: &PlanningWorkload, roots: Vec) -> Run { logical: logical_candidates, physical, pruned: run - .pruned + .stage2_pruned .into_iter() .map(|p| (p.from_logical, p.reason)) .collect(), - selection: run.selection.expect("plan selection selects"), + selection: run.stage3_plan_selection.expect("plan selection selects"), } } From f669b69e4504b55c7eaca416e78ae1daba964100 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:38:55 +0000 Subject: [PATCH 19/19] test(devtools): unique temp file per stage_pipeline run cargo test runs the tests as threads of one process, so two runs of the same example shared a file name and one deleted the other's output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/devtools/tests/stage_pipeline.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index 0d0820b60..7fb1f092b 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -18,10 +18,12 @@ fn generate(extra: &[&str]) -> Value { } fn generate_example(example: &str, extra: &[&str]) -> Value { + // Tests run as threads of one process, so the name needs a per-call counter. + static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let call = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let out = std::env::temp_dir().join(format!( - "stage_pipeline_{}_{example}_{}.json", + "stage_pipeline_{}_{call}_{example}.json", std::process::id(), - extra.len() )); let status = Command::new(env!("CARGO_BIN_EXE_stage_pipeline")) .args(["--example", example, "--out"])