From a177a0a32254079100d4efa86727f44d9dcf6115 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:07:51 +0000 Subject: [PATCH 1/5] refactor: one crate per stage (#1207 PR 0b) Move-only. Split asap-logical-asap-optimization into asap-stage0-memo, asap-stage1-pass1-per-computation-candidates and asap-stage1-pass2-reuse-candidates; rename asap-physical-asap-optimization to asap-stage2-physical-optimization and asap-plan-selection to asap-stage3-plan-selection. DeploymentCapabilities moves to asap-types, and the old test_support splits by caller. The crate-graph test checks the new crates; no stage crate depends on another. --- Cargo.lock | 95 +++-- Cargo.toml | 8 +- crates/cost/Cargo.toml | 5 - crates/cost/src/lib.rs | 2 +- crates/cost/src/usage.rs | 2 +- crates/devtools/Cargo.toml | 12 +- crates/devtools/src/bin/asap_planner.rs | 4 +- crates/devtools/src/bin/sketch_coverage.rs | 4 +- crates/devtools/src/candidate_labels.rs | 12 +- crates/devtools/src/planner_fuzz/mod.rs | 2 +- crates/devtools/src/planning_stages.rs | 17 +- crates/devtools/tests/asap_planner.rs | 2 +- crates/devtools/tests/viewer_contract.rs | 2 +- crates/executor/Cargo.toml | 3 +- crates/executor/tests/common/mod.rs | 4 +- crates/executor/tests/current_series_heap.rs | 6 +- .../executor/tests/deployment_computation.rs | 5 +- crates/executor/tests/physical_semantics.rs | 2 +- crates/frontend-common/src/canonicalize.rs | 2 +- crates/frontend-metricsql/Cargo.toml | 3 +- crates/frontend-promql/Cargo.toml | 5 +- crates/frontend-promql/tests/support.rs | 4 +- crates/frontend-sql/Cargo.toml | 5 +- crates/frontend-sql/tests/frequency_l2.rs | 4 +- crates/integration-tests/Cargo.toml | 9 +- crates/integration-tests/src/lib.rs | 8 +- crates/integration-tests/tests/crate_graph.rs | 51 +-- .../tests/joint_search_equivalence.rs | 2 +- .../tests/per_computation_candidates_sql.rs | 6 +- .../tests/planner_layering_common/mod.rs | 2 +- .../tests/stage1_logical_asap_optimization.rs | 32 +- crates/logical-asap-optimization/src/lib.rs | 90 ----- crates/logical-asap-optimization/src/pass1.rs | 20 - crates/logical-asap-optimization/src/pass2.rs | 6 - .../logical-asap-optimization/src/stage0.rs | 16 - .../src/test_support.rs | 349 ------------------ .../tests/pass_layering.rs | 59 --- crates/planner/Cargo.toml | 21 +- crates/planner/src/installable.rs | 4 +- crates/planner/src/lib.rs | 6 +- crates/planner/src/pass.rs | 6 +- crates/planner/src/run.rs | 14 +- crates/stage0-memo/Cargo.toml | 10 + crates/stage0-memo/src/lib.rs | 21 ++ .../Cargo.toml | 20 +- .../src/lib.rs | 51 +++ .../src}/logical_memo_candidates.rs | 4 +- .../src}/per_computation_candidates.rs | 31 +- .../src}/per_computation_candidates_tests.rs | 14 +- .../src}/realization.rs | 6 +- .../src/test_fixtures.rs | 129 +++++++ .../src/test_support.rs | 48 +++ .../stage1-pass2-reuse-candidates/Cargo.toml | 12 + .../stage1-pass2-reuse-candidates/src/lib.rs | 21 ++ .../src}/logical_memo_candidates.rs | 4 +- .../src}/memo.rs | 2 +- .../src}/memo/rules.rs | 0 .../src}/memo/rules/finer_aggregate.rs | 4 +- .../src}/memo/rules/rollup.rs | 2 +- .../src}/memo/rules/scan_labels.rs | 2 +- .../src}/memo/rules/summary_inputs.rs | 2 +- .../src}/memo/rules/topk.rs | 2 +- .../src}/memo/window_candidates.rs | 2 +- .../Cargo.toml | 2 +- .../src/catalog/mod.rs | 0 .../src/catalog/realize.rs | 0 .../src/lib.rs | 0 .../Cargo.toml | 2 +- .../src/lib.rs | 0 .../src/milp/mod.rs | 0 .../src/milp/model.rs | 0 .../src/models.rs | 0 .../src/report.rs | 0 .../src/select.rs | 0 .../tests/cost_inputs_json.rs | 0 .../src/deployment_capabilities.rs | 8 +- crates/types/src/ir/schema/schema_support.rs | 2 +- crates/types/src/ir/schema/sketch.rs | 2 +- crates/types/src/lib.rs | 4 + crates/types/src/test_support.rs | 75 +++- crates/types/tests/planner_vocabulary.rs | 2 +- docs/design_docs/architecture/README.md | 7 +- ...d_interface_with_pluggable_optimization.md | 10 +- .../decisions/concat-unique-keys.md | 2 +- docs/design_docs/decisions/cse-cost-model.md | 2 +- .../proposals/asap-primitive-schema.md | 4 +- .../joint-planning-implementation-plan.md | 16 +- .../proposals/joint-planning-search.md | 2 +- .../planner-layering-example2-acceptance.md | 2 +- .../design_docs/proposals/planner-layering.md | 14 +- .../asap-aware-mapping-architecture.md | 53 +-- .../asap-aware-mapping-contracts.md | 6 +- .../develop_docs/extend-asap-aware-mapping.md | 8 +- docs/develop_docs/library-api.md | 22 +- docs/develop_docs/pre-asap-ir.md | 2 +- .../target-candidate-api-migration.md | 2 +- 96 files changed, 703 insertions(+), 844 deletions(-) delete mode 100644 crates/logical-asap-optimization/src/lib.rs delete mode 100644 crates/logical-asap-optimization/src/pass1.rs delete mode 100644 crates/logical-asap-optimization/src/pass2.rs delete mode 100644 crates/logical-asap-optimization/src/stage0.rs delete mode 100644 crates/logical-asap-optimization/src/test_support.rs delete mode 100644 crates/logical-asap-optimization/tests/pass_layering.rs create mode 100644 crates/stage0-memo/Cargo.toml create mode 100644 crates/stage0-memo/src/lib.rs rename crates/{logical-asap-optimization => stage1-pass1-per-computation-candidates}/Cargo.toml (51%) create mode 100644 crates/stage1-pass1-per-computation-candidates/src/lib.rs rename crates/{logical-asap-optimization/src/pass1 => stage1-pass1-per-computation-candidates/src}/logical_memo_candidates.rs (95%) rename crates/{logical-asap-optimization/src/pass1 => stage1-pass1-per-computation-candidates/src}/per_computation_candidates.rs (99%) rename crates/{logical-asap-optimization/src/pass1 => stage1-pass1-per-computation-candidates/src}/per_computation_candidates_tests.rs (98%) rename crates/{logical-asap-optimization/src/pass1 => stage1-pass1-per-computation-candidates/src}/realization.rs (98%) create mode 100644 crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs create mode 100644 crates/stage1-pass1-per-computation-candidates/src/test_support.rs create mode 100644 crates/stage1-pass2-reuse-candidates/Cargo.toml create mode 100644 crates/stage1-pass2-reuse-candidates/src/lib.rs rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/logical_memo_candidates.rs (95%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo.rs (87%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo/rules.rs (100%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo/rules/finer_aggregate.rs (76%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo/rules/rollup.rs (93%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo/rules/scan_labels.rs (90%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo/rules/summary_inputs.rs (91%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo/rules/topk.rs (90%) rename crates/{logical-asap-optimization/src/pass2 => stage1-pass2-reuse-candidates/src}/memo/window_candidates.rs (93%) rename crates/{physical-asap-optimization => stage2-physical-optimization}/Cargo.toml (90%) rename crates/{physical-asap-optimization => stage2-physical-optimization}/src/catalog/mod.rs (100%) rename crates/{physical-asap-optimization => stage2-physical-optimization}/src/catalog/realize.rs (100%) rename crates/{physical-asap-optimization => stage2-physical-optimization}/src/lib.rs (100%) rename crates/{plan-selection => stage3-plan-selection}/Cargo.toml (95%) rename crates/{plan-selection => stage3-plan-selection}/src/lib.rs (100%) rename crates/{plan-selection => stage3-plan-selection}/src/milp/mod.rs (100%) rename crates/{plan-selection => stage3-plan-selection}/src/milp/model.rs (100%) rename crates/{plan-selection => stage3-plan-selection}/src/models.rs (100%) rename crates/{plan-selection => stage3-plan-selection}/src/report.rs (100%) rename crates/{plan-selection => stage3-plan-selection}/src/select.rs (100%) rename crates/{plan-selection => stage3-plan-selection}/tests/cost_inputs_json.rs (100%) rename crates/{logical-asap-optimization => types}/src/deployment_capabilities.rs (97%) diff --git a/Cargo.lock b/Cargo.lock index 933b01efb..a541ab6b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,8 +294,6 @@ name = "asap-cost" version = "0.1.0" dependencies = [ "asap-executor", - "asap-logical-asap-optimization", - "asap-physical-asap-optimization", "asap-types", "asap_sketchlib", "serde", @@ -312,10 +310,12 @@ dependencies = [ "asap-executor", "asap-frontend-promql", "asap-frontend-sql", - "asap-logical-asap-optimization", - "asap-physical-asap-optimization", - "asap-plan-selection", "asap-planner", + "asap-stage0-memo", + "asap-stage1-pass1-per-computation-candidates", + "asap-stage1-pass2-reuse-candidates", + "asap-stage2-physical-optimization", + "asap-stage3-plan-selection", "asap-types", "serde", "serde_json", @@ -331,8 +331,9 @@ dependencies = [ "arrow", "asap-executor", "asap-frontend-promql", - "asap-logical-asap-optimization", "asap-planner", + "asap-stage0-memo", + "asap-stage1-pass1-per-computation-candidates", "asap-types", "asap_sketchlib", "chrono", @@ -360,7 +361,8 @@ dependencies = [ "asap-frontend-common", "asap-frontend-metricsql", "asap-frontend-promql", - "asap-logical-asap-optimization", + "asap-stage0-memo", + "asap-stage1-pass1-per-computation-candidates", "asap-types", "metricsql_parser", "thiserror 2.0.18", @@ -372,8 +374,9 @@ version = "0.1.0" dependencies = [ "asap-accuracy", "asap-frontend-common", - "asap-logical-asap-optimization", "asap-planner", + "asap-stage0-memo", + "asap-stage1-pass1-per-computation-candidates", "asap-types", "promql-parser", ] @@ -384,9 +387,10 @@ version = "0.1.0" dependencies = [ "asap-frontend-common", "asap-frontend-sql", - "asap-logical-asap-optimization", "asap-planner", "asap-sql-function-catalog", + "asap-stage0-memo", + "asap-stage1-pass1-per-computation-candidates", "asap-types", "datafusion", "serde", @@ -405,10 +409,11 @@ dependencies = [ "asap-executor", "asap-frontend-promql", "asap-frontend-sql", - "asap-logical-asap-optimization", - "asap-physical-asap-optimization", - "asap-plan-selection", "asap-planner", + "asap-stage0-memo", + "asap-stage1-pass1-per-computation-candidates", + "asap-stage2-physical-optimization", + "asap-stage3-plan-selection", "asap-types", "asap_sketchlib", "futures", @@ -417,67 +422,81 @@ dependencies = [ ] [[package]] -name = "asap-logical-asap-optimization" +name = "asap-planner" version = "0.1.0" dependencies = [ + "arrow", "asap-accuracy", + "asap-cost", + "asap-executor", + "asap-frontend-metricsql", "asap-frontend-promql", + "asap-frontend-sql", + "asap-stage0-memo", + "asap-stage1-pass1-per-computation-candidates", + "asap-stage1-pass2-reuse-candidates", + "asap-stage2-physical-optimization", + "asap-stage3-plan-selection", "asap-types", - "asap_sketchlib", - "rmp-serde", + "futures", "serde", "serde_json", "thiserror 2.0.18", + "tokio", ] [[package]] -name = "asap-physical-asap-optimization" +name = "asap-sql-function-catalog" +version = "0.1.0" + +[[package]] +name = "asap-stage0-memo" version = "0.1.0" dependencies = [ "asap-types", - "thiserror 2.0.18", ] [[package]] -name = "asap-plan-selection" +name = "asap-stage1-pass1-per-computation-candidates" version = "0.1.0" dependencies = [ "asap-accuracy", - "asap-cost", + "asap-frontend-promql", + "asap-stage0-memo", "asap-types", - "good_lp", - "highs", - "serde", - "serde_json", "thiserror 2.0.18", ] [[package]] -name = "asap-planner" +name = "asap-stage1-pass2-reuse-candidates" +version = "0.1.0" +dependencies = [ + "asap-types", + "thiserror 2.0.18", +] + +[[package]] +name = "asap-stage2-physical-optimization" +version = "0.1.0" +dependencies = [ + "asap-types", + "thiserror 2.0.18", +] + +[[package]] +name = "asap-stage3-plan-selection" version = "0.1.0" dependencies = [ - "arrow", "asap-accuracy", "asap-cost", - "asap-executor", - "asap-frontend-metricsql", - "asap-frontend-promql", - "asap-frontend-sql", - "asap-logical-asap-optimization", - "asap-physical-asap-optimization", - "asap-plan-selection", "asap-types", - "futures", + "good_lp", + "highs", "serde", "serde_json", "thiserror 2.0.18", - "tokio", ] -[[package]] -name = "asap-sql-function-catalog" -version = "0.1.0" - [[package]] name = "asap-types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4204bb33c..7b29da192 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,13 @@ members = [ "crates/types", "crates/frontend-common", "crates/sql-function-catalog", - "crates/logical-asap-optimization", - "crates/physical-asap-optimization", + "crates/stage0-memo", + "crates/stage1-pass1-per-computation-candidates", + "crates/stage1-pass2-reuse-candidates", + "crates/stage2-physical-optimization", "crates/accuracy", "crates/cost", - "crates/plan-selection", + "crates/stage3-plan-selection", "crates/frontend-promql", "crates/frontend-metricsql", "crates/metricsql-common-parser-support", diff --git a/crates/cost/Cargo.toml b/crates/cost/Cargo.toml index 8a8e35fa9..5bbdc691c 100644 --- a/crates/cost/Cargo.toml +++ b/crates/cost/Cargo.toml @@ -18,8 +18,3 @@ serde_json = "1" # The executor's charge for each summary state, checked against the bytes # the state byte models give for it plus the accumulator's own fields (#947). asap-executor = { path = "../executor" } -# Physical plans of PromQL queries for the plan cost tests (tests/), built -# by the stages above this crate; `tests/held_memory.rs` builds them with -# the reference pricer's `optimize_physical_asap`. -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } -asap-physical-asap-optimization = { path = "../physical-asap-optimization" } diff --git a/crates/cost/src/lib.rs b/crates/cost/src/lib.rs index 27e7f5ade..9b5cca54b 100644 --- a/crates/cost/src/lib.rs +++ b/crates/cost/src/lib.rs @@ -16,7 +16,7 @@ //! [`NodeUsage`]; [`plan_cost`] the whole plan's [`PlanCost`]. Stage 3 //! computes no cost of its own. //! -//! Plan selection (Stage 3, `asap-plan-selection`) documents how it prices +//! Plan selection (Stage 3, `asap-stage3-plan-selection`) documents how it prices //! a candidate with these. use std::collections::BTreeMap; diff --git a/crates/cost/src/usage.rs b/crates/cost/src/usage.rs index 7601756e5..6047d5fb2 100644 --- a/crates/cost/src/usage.rs +++ b/crates/cost/src/usage.rs @@ -1,6 +1,6 @@ //! Costing a physical plan DAG node by node: each node's resource usage //! ([`node_usage`]), then the plan's usage, latency, retention, retained tumbling window -//! chains and cost ([`plan_cost`]). Moved from Stage 3 (`asap-plan-selection`, +//! chains and cost ([`plan_cost`]). Moved from Stage 3 (`asap-stage3-plan-selection`, //! whose module doc, "Cost model", describes the model), so Stage 3 computes //! no cost of its own. diff --git a/crates/devtools/Cargo.toml b/crates/devtools/Cargo.toml index bb24e585b..1e6c69df1 100644 --- a/crates/devtools/Cargo.toml +++ b/crates/devtools/Cargo.toml @@ -12,10 +12,12 @@ edition = "2021" asap-frontend-promql = { path = "../frontend-promql" } asap-frontend-sql = { path = "../frontend-sql" } asap-accuracy = { path = "../accuracy" } -asap-logical-asap-optimization = { path = "../logical-asap-optimization" } -asap-physical-asap-optimization = { path = "../physical-asap-optimization" } +asap-stage0-memo = { path = "../stage0-memo" } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates" } +asap-stage1-pass2-reuse-candidates = { path = "../stage1-pass2-reuse-candidates" } +asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } asap-cost = { path = "../cost" } -asap-plan-selection = { path = "../plan-selection" } +asap-stage3-plan-selection = { path = "../stage3-plan-selection" } # The installable plan of the selected candidate (`installable_plan_of`), # whose stored outputs `asap_planner` writes. asap-planner = { path = "../planner" } @@ -34,9 +36,9 @@ tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } -asap-physical-asap-optimization = { path = "../physical-asap-optimization" } +asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } # Pass 1's target list (`per_computation_candidates`) for the tests. -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } [[test]] diff --git a/crates/devtools/src/bin/asap_planner.rs b/crates/devtools/src/bin/asap_planner.rs index 2e9d883b5..9dbde8e69 100644 --- a/crates/devtools/src/bin/asap_planner.rs +++ b/crates/devtools/src/bin/asap_planner.rs @@ -108,9 +108,9 @@ use asap_devtools::planning_stages::{ use asap_devtools::planning_stages_text::planning_stages_text; use asap_devtools::SqlCatalog; use asap_frontend_promql::{metric_type_warnings, promql_metric_names}; -use asap_logical_asap_optimization::DeploymentCapabilities; -use asap_plan_selection::{large_peak_memory_warning, AcceptedAccuracy, Selection}; use asap_planner::{installable_plan_of, PlanningModels, SelectionError}; +use asap_stage3_plan_selection::{large_peak_memory_warning, AcceptedAccuracy, Selection}; +use asap_types::deployment_capabilities::DeploymentCapabilities; use asap_types::ir::flat::{flatten, FlatDag, FlatNode}; use asap_types::ir::properties::{default_metric_for_root, AccuracyTarget}; use asap_types::ir::QueryRoot; diff --git a/crates/devtools/src/bin/sketch_coverage.rs b/crates/devtools/src/bin/sketch_coverage.rs index e11557d33..6b573f020 100644 --- a/crates/devtools/src/bin/sketch_coverage.rs +++ b/crates/devtools/src/bin/sketch_coverage.rs @@ -20,8 +20,8 @@ use asap_devtools::corpora::{ }; use asap_devtools::{lower_promql_with_sample_interval, SqlCatalog}; use asap_frontend_sql::lower_sql_dialect; -use asap_logical_asap_optimization::memo_of_queries; -use asap_logical_asap_optimization::pass1_memo; +use asap_stage0_memo::memo_of_queries; +use asap_stage1_pass1_per_computation_candidates::pass1_memo; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::schema::FieldDataType; use asap_types::ir::{ASAPOp, Operator, OperatorNode, QueryRoot}; diff --git a/crates/devtools/src/candidate_labels.rs b/crates/devtools/src/candidate_labels.rs index 8cf029620..94cc6e651 100644 --- a/crates/devtools/src/candidate_labels.rs +++ b/crates/devtools/src/candidate_labels.rs @@ -390,7 +390,7 @@ mod tests { use super::*; use crate::planner_layering_examples::example1; use crate::planning_stages::lower_promql_roots; - use asap_logical_asap_optimization::test_support::{ + use asap_stage1_pass1_per_computation_candidates::test_support::{ compose_logical_candidate, generate_per_computation_candidates, }; @@ -399,10 +399,7 @@ mod tests { fn connected_labels_describe_selected_states() { let workload = example1(); let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries( - &lower_promql_roots(&workload).unwrap(), - ) - .unwrap(), + &asap_stage0_memo::memo_of_queries(&lower_promql_roots(&workload).unwrap()).unwrap(), ) .unwrap(); let choice = vec![0; inventory.targets.len()]; @@ -417,10 +414,7 @@ mod tests { fn connected_labels_name_actual_shared_state() { let workload = example1(); let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries( - &lower_promql_roots(&workload).unwrap(), - ) - .unwrap(), + &asap_stage0_memo::memo_of_queries(&lower_promql_roots(&workload).unwrap()).unwrap(), ) .unwrap(); let roots = diff --git a/crates/devtools/src/planner_fuzz/mod.rs b/crates/devtools/src/planner_fuzz/mod.rs index c40a2ef4a..ad5df92fe 100644 --- a/crates/devtools/src/planner_fuzz/mod.rs +++ b/crates/devtools/src/planner_fuzz/mod.rs @@ -41,8 +41,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use asap_cost::CostInputs; -use asap_logical_asap_optimization::DeploymentCapabilities; use asap_planner::{PlanSearchSettings, PlanningModels}; +use asap_types::deployment_capabilities::DeploymentCapabilities; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::schema::sketch::SketchAlgorithm; use asap_types::ir::QueryRoot; diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index 9efdd4fd9..8cdc6772c 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -3,17 +3,18 @@ //! [`run_stages_until`] for `asap_planner`. use asap_frontend_sql::SqlCatalog; -use asap_logical_asap_optimization::{ - memo_of_queries, pass1_memo, pass2_after_local_window_composition, - pass2_local_window_composition, Pass1Error, -}; -use asap_physical_asap_optimization::{ - generate_physical_asap_memo_candidates, Stage2AssignmentRealizer, -}; -use asap_plan_selection::select_plan; use asap_planner::{ run_planning, PlanSearchSettings, PlanningModels, PlanningReport, PlanningRun, SelectionError, }; +use asap_stage0_memo::memo_of_queries; +use asap_stage1_pass1_per_computation_candidates::{pass1_memo, Pass1Error}; +use asap_stage1_pass2_reuse_candidates::{ + pass2_after_local_window_composition, pass2_local_window_composition, +}; +use asap_stage2_physical_optimization::{ + generate_physical_asap_memo_candidates, Stage2AssignmentRealizer, +}; +use asap_stage3_plan_selection::select_plan; use asap_types::ir::schema::schema_support::with_promql_series_identity; use asap_types::ir::schema::{DataType, Field, Schema}; use asap_types::ir::{OperatorNode, QueryRoot}; diff --git a/crates/devtools/tests/asap_planner.rs b/crates/devtools/tests/asap_planner.rs index c09f75a24..98f51f3da 100644 --- a/crates/devtools/tests/asap_planner.rs +++ b/crates/devtools/tests/asap_planner.rs @@ -1131,7 +1131,7 @@ fn workload_set_index_and_files_validate() { ); } if let Some(capabilities) = set.get("deployment_capabilities") { - serde_json::from_value::( + serde_json::from_value::( capabilities.clone(), ) .unwrap(); diff --git a/crates/devtools/tests/viewer_contract.rs b/crates/devtools/tests/viewer_contract.rs index 559223d2e..fc7d6c89f 100644 --- a/crates/devtools/tests/viewer_contract.rs +++ b/crates/devtools/tests/viewer_contract.rs @@ -326,8 +326,8 @@ fn wire_name(value: T) -> String { /// `RejectionCause::failed_a_check` does. #[test] fn viewer_knows_every_view_refresh_and_rejection_cause() { - use asap_plan_selection::RejectionCause as C; use asap_planner::MaterializedViewRefresh as R; + use asap_stage3_plan_selection::RejectionCause as C; let refreshes = [R::AtIngestion, R::OnFirstRead]; for refresh in refreshes { // A new variant fails to compile here until it is listed above. diff --git a/crates/executor/Cargo.toml b/crates/executor/Cargo.toml index 231b6ed1d..7661ad112 100644 --- a/crates/executor/Cargo.toml +++ b/crates/executor/Cargo.toml @@ -27,7 +27,8 @@ test-support = ["planner-types/test-support"] [dev-dependencies] asap-executor = { path = ".", features = ["test-support"] } rmp-serde = "1" -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } +asap-stage0-memo = { path = "../stage0-memo" } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } # The planning stages through the facade (a dev-dependency cycle, for # integration tests only). asap-planner = { path = "../planner" } diff --git a/crates/executor/tests/common/mod.rs b/crates/executor/tests/common/mod.rs index 31187af65..8a226a05a 100644 --- a/crates/executor/tests/common/mod.rs +++ b/crates/executor/tests/common/mod.rs @@ -25,11 +25,11 @@ pub fn selected_dag( /// computations at their default choice. A bounded solver archive need not /// contain every algorithm that executor binding tests need to exercise. pub fn computation_candidates(root: Rc) -> Vec> { - use asap_logical_asap_optimization::test_support::{ + use asap_stage1_pass1_per_computation_candidates::test_support::{ compose_logical_candidate, generate_per_computation_candidates, }; let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .expect("Pass 1 accepts the query"); let mut choices = vec![vec![0; inventory.targets.len()]]; diff --git a/crates/executor/tests/current_series_heap.rs b/crates/executor/tests/current_series_heap.rs index 1368b006f..26d727944 100644 --- a/crates/executor/tests/current_series_heap.rs +++ b/crates/executor/tests/current_series_heap.rs @@ -335,11 +335,7 @@ fn population_fixture_compiles_with_dynamic_identity() { let topk = planner_types::ir::operator::maintained_population::PopulationStatistic::TopK { k: 1 }; let root = Rc::new(with_series_identity(&original).unwrap()); - let selected = asap_logical_asap_optimization::test_support::population_over_selector( - &root, - topk, - &["job"], - ); + let selected = planner_types::test_support::population_over_selector(&root, topk, &["job"]); let logical = planner_types::test_support::physical_asap_dag_at_query_time(&selected).unwrap(); let (source, raw_schema) = logical .nodes diff --git a/crates/executor/tests/deployment_computation.rs b/crates/executor/tests/deployment_computation.rs index 080454c79..9fe132ffd 100644 --- a/crates/executor/tests/deployment_computation.rs +++ b/crates/executor/tests/deployment_computation.rs @@ -60,9 +60,8 @@ fn population_dag( grouping: &[&str], ) -> PhysicalASAPDAG { let root = promql_rows::with_series_identity(&lower(query)).unwrap(); - let selected = asap_logical_asap_optimization::test_support::population_over_selector( - &root, statistic, grouping, - ); + let selected = + planner_types::test_support::population_over_selector(&root, statistic, grouping); planner_types::test_support::physical_asap_dag_at_query_time(&selected).unwrap() } diff --git a/crates/executor/tests/physical_semantics.rs b/crates/executor/tests/physical_semantics.rs index 78bca3e36..37d826fbe 100644 --- a/crates/executor/tests/physical_semantics.rs +++ b/crates/executor/tests/physical_semantics.rs @@ -647,7 +647,7 @@ fn compacted_kll_tumbling_windows_merge_within_the_calibrated_rank_error() { const ROWS_PER_TUMBLING_WINDOW: u64 = 1000; const K: u32 = 200; // The planner's empirical 99% KLL rank error, `2.296 / k^0.9723` - // (`asap_logical_asap_optimization`'s `kll_rank_error_99`). + // (`asap_accuracy`'s `kll_rank_error_99`). let bound = 2.296 / f64::from(K).powf(0.9723); let n = (TUMBLING_WINDOW_COUNT * ROWS_PER_TUMBLING_WINDOW) as f64; let input = schema(&[("v", DataType::Float64, false)]); diff --git a/crates/frontend-common/src/canonicalize.rs b/crates/frontend-common/src/canonicalize.rs index 43495769f..68a1eb47c 100644 --- a/crates/frontend-common/src/canonicalize.rs +++ b/crates/frontend-common/src/canonicalize.rs @@ -1,7 +1,7 @@ //! Post-lowering canonicalization of the operator DAG. //! //! Rewrites equivalent spellings of a query into one shape, so that common -//! sub-DAG sharing (hash-consing, `Memo::find_or_add_group`) can match +//! sub-DAG sharing (Stage 0 hash-consing, `cse` in `asap_stage0_memo`) can match //! them by structural equality, whichever front end produced them. //! //! ## Heavy-hitter promotion diff --git a/crates/frontend-metricsql/Cargo.toml b/crates/frontend-metricsql/Cargo.toml index 719d49a5e..caa418db2 100644 --- a/crates/frontend-metricsql/Cargo.toml +++ b/crates/frontend-metricsql/Cargo.toml @@ -24,4 +24,5 @@ asap-types = { path = "../types", features = ["test-support"] } asap-frontend-metricsql = { path = ".", features = ["test-support"] } asap-frontend-promql = { path = "../frontend-promql" } # `support.rs` (shared with frontend-promql's tests) binds through Pass 1. -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } +asap-stage0-memo = { path = "../stage0-memo" } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } diff --git a/crates/frontend-promql/Cargo.toml b/crates/frontend-promql/Cargo.toml index b7bc12ad6..4a5871204 100644 --- a/crates/frontend-promql/Cargo.toml +++ b/crates/frontend-promql/Cargo.toml @@ -19,8 +19,9 @@ promql-parser = { git = "https://github.com/ProjectASAP/promql-parser", rev = "1 [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-accuracy = { path = "../accuracy", features = ["test-support"] } -# Pass 1's target list (`per_computation_candidates`) for the tests. -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } +# Stage 0's memo and Pass 1's target list (`per_computation_candidates`) for the tests. +asap-stage0-memo = { path = "../stage0-memo" } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } # The planning stages through the facade (a dev-dependency cycle, for # integration tests only). asap-planner = { path = "../planner" } diff --git a/crates/frontend-promql/tests/support.rs b/crates/frontend-promql/tests/support.rs index d6a3912e1..2acd52396 100644 --- a/crates/frontend-promql/tests/support.rs +++ b/crates/frontend-promql/tests/support.rs @@ -144,12 +144,12 @@ pub fn logical_candidates(root: Rc) -> Vec> { } fn local_computation_candidates(root: Rc) -> Result>, String> { - use asap_logical_asap_optimization::test_support::{ + use asap_stage1_pass1_per_computation_candidates::test_support::{ compose_logical_candidate, generate_per_computation_candidates, }; use asap_types::ir::QueryRoot; let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .map_err(|error| error.to_string())?; let mut choices = vec![vec![0; inventory.targets.len()]]; diff --git a/crates/frontend-sql/Cargo.toml b/crates/frontend-sql/Cargo.toml index fba3eb0c7..0e8818b7d 100644 --- a/crates/frontend-sql/Cargo.toml +++ b/crates/frontend-sql/Cargo.toml @@ -36,8 +36,9 @@ test-support = [] [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-frontend-sql = { path = ".", features = ["test-support"] } -# Pass 1's target list (`per_computation_candidates`) for the tests. -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } +# Stage 0's memo and Pass 1's target list (`per_computation_candidates`) for the tests. +asap-stage0-memo = { path = "../stage0-memo" } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } # The planning stages through the facade (a dev-dependency cycle, for # integration tests only). asap-planner = { path = "../planner" } diff --git a/crates/frontend-sql/tests/frequency_l2.rs b/crates/frontend-sql/tests/frequency_l2.rs index 2cdbead77..6152a9ba0 100644 --- a/crates/frontend-sql/tests/frequency_l2.rs +++ b/crates/frontend-sql/tests/frequency_l2.rs @@ -124,14 +124,14 @@ async fn frequency_empty_input_guard_uses_an_exact_count() { /// recognized L2 intent. #[tokio::test] async fn stage1_offers_exact_and_summary_l2_candidates() { - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; + use asap_stage1_pass1_per_computation_candidates::test_support::generate_per_computation_candidates; use asap_types::ir::QueryRoot; let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); let root = lower_sql(CAST_COUNT, &catalog(false), target) .await .unwrap(); let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap(); let l2 = inventory diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index c8ee40b02..dbe28ea89 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -9,11 +9,12 @@ autotests = false asap-types = { path = "../types", features = ["test-support"] } asap-frontend-promql = { path = "../frontend-promql" } asap-frontend-sql = { path = "../frontend-sql" } -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } -asap-physical-asap-optimization = { path = "../physical-asap-optimization" } +asap-stage0-memo = { path = "../stage0-memo" } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } +asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } asap-accuracy = { path = "../accuracy" } asap-cost = { path = "../cost" } -asap-plan-selection = { path = "../plan-selection" } +asap-stage3-plan-selection = { path = "../stage3-plan-selection" } # The fixtures plan through the facade and the developer tools' reference # enumeration. asap-planner = { path = "../planner" } @@ -22,7 +23,7 @@ asap-devtools = { path = "../devtools" } [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-accuracy = { path = "../accuracy", features = ["test-support"] } -asap-physical-asap-optimization = { path = "../physical-asap-optimization" } +asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } asap-devtools = { path = "../devtools" } # The tests plan PromQL and SQL workloads through the facade. asap-planner = { path = "../planner" } diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index d145c8228..fce296805 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -196,11 +196,11 @@ pub mod planning { /// Exact computation and each explicit single-target rule application. /// This fixture catalog is independent of the bounded MILP's visited plans. pub fn explicit_local_candidates(root: Rc) -> Vec> { - use asap_logical_asap_optimization::test_support::{ + use asap_stage1_pass1_per_computation_candidates::test_support::{ compose_logical_candidate, generate_per_computation_candidates, }; let local = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap(); let mut choices = std::collections::BTreeSet::from([vec![0; local.targets.len()]]); @@ -231,12 +231,12 @@ pub mod planning { root: Rc, family: asap_types::ir::schema::SketchAlgorithm, ) -> Rc { - use asap_logical_asap_optimization::test_support::{ + use asap_stage1_pass1_per_computation_candidates::test_support::{ compose_logical_candidate, generate_per_computation_candidates, }; use asap_types::stages::HowToComputeAggregateMeasure; let local = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap(); let choices = local.targets.iter().map(|target| target.ways_to_compute.iter() diff --git a/crates/integration-tests/tests/crate_graph.rs b/crates/integration-tests/tests/crate_graph.rs index 2eca8fe81..1efd73395 100644 --- a/crates/integration-tests/tests/crate_graph.rs +++ b/crates/integration-tests/tests/crate_graph.rs @@ -5,7 +5,8 @@ //! build-dependencies are not checked. Each crate has a role ([`role_of`]), and //! [`rule_violated`] says which role may depend on which: //! -//! - **stage crates** (the frontends, logical, physical, plan-selection) never +//! - **stage crates** (the frontends, Stage 0's memo, Stage 1's Pass 1 and +//! Pass 2, Stage 2's physical optimization, Stage 3's plan selection) never //! depend on each other; they may depend on the shared crates; //! - **asap-accuracy** and **asap-cost** depend only on asap-types; //! - **asap-types** depends on no workspace crate; @@ -31,8 +32,8 @@ enum Role { SharedModel, /// Helper crates of the frontends that are no stage themselves. FrontendHelper, - /// A stage crate: a frontend (Stage 0), logical (Stage 1), physical - /// (Stage 2) or plan-selection (Stage 3). + /// A stage crate: a frontend, Stage 0 (memo), Stage 1 (Pass 1 or Pass 2), + /// Stage 2 (physical optimization) or Stage 3 (plan selection). Stage, /// `asap-planner`: orchestrates the stages and owns the public API. Facade, @@ -53,9 +54,11 @@ fn role_of(name: &str) -> Option { "asap-frontend-promql" | "asap-frontend-sql" | "asap-frontend-metricsql" - | "asap-logical-asap-optimization" - | "asap-physical-asap-optimization" - | "asap-plan-selection" => Role::Stage, + | "asap-stage0-memo" + | "asap-stage1-pass1-per-computation-candidates" + | "asap-stage1-pass2-reuse-candidates" + | "asap-stage2-physical-optimization" + | "asap-stage3-plan-selection" => Role::Stage, "asap-planner" => Role::Facade, "asap-executor" => Role::Runtime, "asap-devtools" | "asap-integration-tests" => Role::Unrestricted, @@ -97,12 +100,12 @@ struct Exception { /// The forbidden edges the workspace keeps today. const EXCEPTIONS: &[Exception] = &[ - // Logical's `test-support` feature lowers PromQL test queries for other + // Pass 1's `test-support` feature lowers PromQL test queries for other // crates' tests. The dependency is optional and enabled only by that - // feature, so no production build links Stage 0 into Stage 1. Made + // feature, so no production build links a frontend into Stage 1. Made // non-optional, the edge fails the test. Exception { - from: "asap-logical-asap-optimization", + from: "asap-stage1-pass1-per-computation-candidates", to: "asap-frontend-promql", only_if_optional: true, }, @@ -209,19 +212,21 @@ fn checker_rejects_an_edge_between_stage_crates() { let metadata = serde_json::json!({"packages": [ package("asap-types", &[], &[]), package("asap-cost", &[("asap-types", false)], &[]), - package("asap-physical-asap-optimization", &[("asap-types", false)], &[]), + package("asap-stage2-physical-optimization", &[("asap-types", false)], &[]), // Forbidden: Stage 3 on Stage 2. package( - "asap-plan-selection", - &[("asap-cost", false), ("asap-physical-asap-optimization", false)], + "asap-stage3-plan-selection", + &[("asap-cost", false), ("asap-stage2-physical-optimization", false)], &[], ), ]}); assert_eq!( crate_graph_violations(&metadata), - vec!["forbidden [dependencies] edge asap-plan-selection -> \ - asap-physical-asap-optimization: stage crates never depend on each other (#1054)" - .to_string()] + vec![ + "forbidden [dependencies] edge asap-stage3-plan-selection -> \ + asap-stage2-physical-optimization: stage crates never depend on each other (#1054)" + .to_string() + ] ); } @@ -229,11 +234,11 @@ fn checker_rejects_an_edge_between_stage_crates() { fn checker_ignores_dev_dependencies_and_external_crates() { let metadata = serde_json::json!({"packages": [ package("asap-types", &[("serde", false)], &[]), - package("asap-logical-asap-optimization", &[("asap-types", false)], &[]), + package("asap-stage1-pass1-per-computation-candidates", &[("asap-types", false)], &[]), package( - "asap-plan-selection", + "asap-stage3-plan-selection", &[("asap-types", false)], - &["asap-logical-asap-optimization"], + &["asap-stage1-pass1-per-computation-candidates"], ), ]}); assert!(crate_graph_violations(&metadata).is_empty()); @@ -246,7 +251,7 @@ fn optional_only_exception_rejects_the_edge_made_non_optional() { package("asap-types", &[], &[]), package("asap-frontend-promql", &[("asap-types", false)], &[]), package( - "asap-logical-asap-optimization", + "asap-stage1-pass1-per-computation-candidates", &[("asap-types", false), ("asap-frontend-promql", optional)], &[], ), @@ -256,7 +261,7 @@ fn optional_only_exception_rejects_the_edge_made_non_optional() { assert_eq!( crate_graph_violations(&metadata(false)), vec![ - "forbidden [dependencies] edge asap-logical-asap-optimization -> \ + "forbidden [dependencies] edge asap-stage1-pass1-per-computation-candidates -> \ asap-frontend-promql: stage crates never depend on each other (#1054)" .to_string() ] @@ -267,14 +272,14 @@ fn optional_only_exception_rejects_the_edge_made_non_optional() { fn checker_rejects_an_unclassified_crate_and_shared_model_edges() { let metadata = serde_json::json!({"packages": [ package("asap-types", &[], &[]), - package("asap-logical-asap-optimization", &[("asap-types", false)], &[]), - package("asap-accuracy", &[("asap-logical-asap-optimization", false)], &[]), + package("asap-stage1-pass1-per-computation-candidates", &[("asap-types", false)], &[]), + package("asap-accuracy", &[("asap-stage1-pass1-per-computation-candidates", false)], &[]), package("asap-new-crate", &[], &[]), ]}); assert_eq!( crate_graph_violations(&metadata), vec![ - "forbidden [dependencies] edge asap-accuracy -> asap-logical-asap-optimization: \ + "forbidden [dependencies] edge asap-accuracy -> asap-stage1-pass1-per-computation-candidates: \ asap-accuracy and asap-cost depend only on asap-types (#1054)" .to_string(), "workspace crate `asap-new-crate` has no role: add it to `role_of` in \ diff --git a/crates/integration-tests/tests/joint_search_equivalence.rs b/crates/integration-tests/tests/joint_search_equivalence.rs index edd0dd2a3..600521edc 100644 --- a/crates/integration-tests/tests/joint_search_equivalence.rs +++ b/crates/integration-tests/tests/joint_search_equivalence.rs @@ -10,8 +10,8 @@ use crate::planner_layering_common; use asap_accuracy::{AccuracyModel, BuiltinAccuracyModel}; use asap_cost::{ClusterLimits, ClusterObjective, CostInputs, CostModel}; use asap_devtools::planning_stages::{lower_promql_roots, lower_sql_roots}; -use asap_logical_asap_optimization::DeploymentCapabilities; use asap_planner::{run_planning, PlanSearchSettings, PlanningModels, PlanningRun}; +use asap_types::deployment_capabilities::DeploymentCapabilities; use asap_types::ir::properties::{AccuracyGuarantee, AccuracyTarget}; use asap_types::ir::QueryRoot; use asap_types::workload::{ diff --git a/crates/integration-tests/tests/per_computation_candidates_sql.rs b/crates/integration-tests/tests/per_computation_candidates_sql.rs index 564381f0a..bd6d2d4a5 100644 --- a/crates/integration-tests/tests/per_computation_candidates_sql.rs +++ b/crates/integration-tests/tests/per_computation_candidates_sql.rs @@ -3,7 +3,7 @@ //! ranks. use asap_frontend_sql::{lower_sql, SqlCatalog}; -use asap_logical_asap_optimization::test_support::{ +use asap_stage1_pass1_per_computation_candidates::test_support::{ compose_logical_candidate, generate_per_computation_candidates, }; use asap_types::ir::properties::AccuracyTarget; @@ -26,7 +26,7 @@ async fn targets(sql: &str) -> Vec { .await .unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}")); generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap() .targets @@ -212,7 +212,7 @@ async fn heap_updates_over(fields: Vec, sql: &str) -> Vec<(String, String let accuracy = AccuracyTarget::max_error(0.01).failure_probability(0.01); let root = lower_sql(sql, &catalog, accuracy).await.unwrap(); let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap(); let name = |expr: &SummaryInputExpr| match expr { diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index 283b0f173..20681c7ac 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -17,7 +17,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use asap_cost::CostInputs; use asap_devtools::planning_stages::{lower_promql_roots, run_planning_stages_over}; -use asap_logical_asap_optimization::DeploymentCapabilities; +use asap_types::deployment_capabilities::DeploymentCapabilities; use asap_types::ir::flat::{flatten, FlatDag, NodeId}; use asap_types::ir::physical_export::PhysicalASAPDAG; use asap_types::ir::properties::ExecutionTiming; diff --git a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs index fab612462..090cea7b7 100644 --- a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs +++ b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs @@ -12,7 +12,7 @@ use asap_executor::{ values::{Batch, Value}, }; use asap_frontend_sql::{lower_sql, SqlCatalog}; -use asap_logical_asap_optimization::test_support::{ +use asap_stage1_pass1_per_computation_candidates::test_support::{ compose_logical_candidate, generate_per_computation_candidates, }; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; @@ -97,10 +97,9 @@ fn output_columns(root: &QueryRoot) -> Vec<(String, String, bool)> { /// Compose every candidate of `root`; returns how many there were. fn compose_all(label: &str, root: QueryRoot) -> usize { let expected = output_columns(&root); - let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[root]).unwrap(), - ) - .unwrap(); + let inventory = + generate_per_computation_candidates(&asap_stage0_memo::memo_of_queries(&[root]).unwrap()) + .unwrap(); let radix: Vec<_> = inventory .targets .iter() @@ -176,10 +175,9 @@ fn promql_candidates_compose() { /// The summary estimates of `root`'s candidates that vary one target's /// choice, every other target taking its first. fn composed_estimates(root: QueryRoot) -> Vec> { - let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[root]).unwrap(), - ) - .unwrap(); + let inventory = + generate_per_computation_candidates(&asap_stage0_memo::memo_of_queries(&[root]).unwrap()) + .unwrap(); let targets = inventory.targets.len(); let choices: Vec<_> = inventory .targets @@ -267,7 +265,7 @@ async fn subquery_in_a_measure_filter_is_substituted() { let query = "SELECT src, count(*) FILTER (WHERE bytes > (SELECT approx_percentile_cont(bytes, 0.5) FROM flows)) \ FROM flows GROUP BY src"; let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[sql(query).await]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[sql(query).await]).unwrap(), ) .unwrap(); assert_eq!(inventory.targets.len(), 2); @@ -401,8 +399,7 @@ async fn avg_as_sum_and_count( ) -> (Rc, Rc) { let exact = lower_sql(query, catalog, accuracy()).await.unwrap(); let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(exact.clone())]) - .unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(exact.clone())]).unwrap(), ) .unwrap(); let choice: Vec<_> = inventory @@ -536,7 +533,7 @@ async fn filtered_sql_avg_is_null_without_matching_rows() { #[test] fn promql_avg_divides_sum_and_count_vectors() { let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[promql("avg by (job) (m)")]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[promql("avg by (job) (m)")]).unwrap(), ) .unwrap(); assert_eq!( @@ -574,11 +571,14 @@ async fn every_candidate( query: &str, ) -> Vec<( HowToComputeAggregateMeasure, - Result, asap_logical_asap_optimization::PerComputationCandidateError>, + Result< + Vec, + asap_stage1_pass1_per_computation_candidates::PerComputationCandidateError, + >, )> { let root = lower_sql(query, catalog, accuracy()).await.unwrap(); let inventory = generate_per_computation_candidates( - &asap_logical_asap_optimization::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap(); assert_eq!(inventory.targets.len(), 1, "{query}"); @@ -699,7 +699,7 @@ async fn sql_count_star_without_a_non_null_column_is_unsupported() { every_candidate(&catalog, "SELECT k, count(*) FROM t GROUP BY k").await { match composed { - Err(asap_logical_asap_optimization::PerComputationCandidateError::Unsupported( + Err(asap_stage1_pass1_per_computation_candidates::PerComputationCandidateError::Unsupported( _, )) => {} other => panic!("{columns:?} {candidate:?}: {other:?}"), diff --git a/crates/logical-asap-optimization/src/lib.rs b/crates/logical-asap-optimization/src/lib.rs deleted file mode 100644 index a94771df1..000000000 --- a/crates/logical-asap-optimization/src/lib.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! `asap-logical-asap-optimization` — #509 Stage 0 hash-consing and -//! Stage 1, logical ASAP-aware optimization: from the query-language -//! frontends' logical DAGs to the memo of logical candidates -//! (`asap_types::stages::LogicalASAPMemoCandidates`) that Stage 2 implements. -//! -//! Identical-sub-DAG sharing over the logical DAG (Stage 0, the `stage0` -//! module, behind [`memo_of_queries`]) runs before the -//! stages; Stage 1 adds the ASAP-aware sharing that is invisible at the -//! logical level (Pass 2, the `asap_aware_cse` module), e.g. -//! `Quantile(x, 0.99)` and `Quantile(x, 0.95)` reading one built sketch. -//! -//! It depends only on `asap-types` and the shared accuracy model -//! (`asap-accuracy`). It depends on no other stage crate, and no other -//! stage crate depends on it except in their tests (CI checks the crate -//! graph): the stage-boundary types (its input -//! `Memo`, Pass 1's `Memo` and Pass 2's `LogicalASAPMemoCandidates`) are in `asap_types::stages`, and -//! the planner facade calls each stage in turn. It depends on no -//! query-language frontend either, except with the `test-support` feature, -//! whose test helpers (`test_support`) lower PromQL test queries through -//! `asap-frontend-promql`; other crates enable that feature only for their -//! tests. -//! -//! Post-lowering **canonicalization** is *not* here: it lives in -//! `asap_frontend_common::canonicalize`, run inside that crate's shared -//! `resolve_root` so every front end normalizes before the IR leaves resolution -//! (issue #34, closed). -//! -//! ## Components -//! -//! The modules are crate-private; the crate root re-exports the items other -//! crates use, one path each, and `test_support` (feature `test-support`) -//! re-exports the internals other crates' tests call. -//! -//! - `stage0` — Stage 0: hash-consing the workload's logical DAG into memo -//! groups through `Memo::find_or_add_group`, behind [`memo_of_queries`]. -//! - `pass1::per_computation_candidates` — Pass 1: per-computation candidate -//! generation and composition, behind -//! [`pass1_memo`]. -//! - `pass2::memo` — Pass 2: the rules over groups, behind -//! [`generate_logical_asap_memo_candidates`]. -//! - `pass1::realization` — the summary families that can serve an aggregate -//! intent, the intent's accuracy target and `(ε, δ)` budget, and default -//! sketch sizing, shared by Pass 1 and Pass 2. -//! - `deployment_capabilities` — the deployment's supported summaries -//! ([`DeploymentCapabilities`]). -//! -//! Accuracy guarantees come from the built-in analytical accuracy rules in -//! `asap-accuracy` (per-family estimators, composition and budget -//! allocation). They derive a machine-readable -//! [`AccuracyGuarantee`](asap_types::ir::properties::AccuracyGuarantee) -//! for every finalized value and propagate it through -//! approximate-over-approximate compositions under conservative rules (no -//! independence assumptions, unknown statistics stay unknown). A -//! composition with no rule leaves the guarantee `None` and keeps the -//! candidate. Pass 1 takes the deployment's `AccuracyModel` and evidence -//! only to prune summaries no query reading them can use (when they cannot -//! accept an output without its guarantee) and sizes every summary for its -//! consumers; plan selection (Stage 3) checks each selected candidate's -//! guarantee against its query's `AccuracyTarget` and never resizes. - -/// The deployment's capabilities: supported summaries and what it retains. -mod deployment_capabilities; -mod pass1; -mod pass2; -/// Stage 0: the memo groups of the workload's query roots. -mod stage0; -/// Test helpers shared by the stage crates' tests, and the Stage 1 -/// internals those tests call (feature `test-support`). -#[cfg(any(test, feature = "test-support"))] -pub mod test_support; - -// Stage 1's public API: one path per item, for the planner facade and the -// developer tools. Every other item is crate-private; the ones other crates' -// tests call are re-exported by `test_support`. - -pub use deployment_capabilities::{ - DeploymentCapabilities, DeploymentCapabilitiesError, SupportedSummary, -}; -// Stage 0: the memo groups of the workload's query roots, without -// candidates (`asap_types::stages::Memo`). -pub use stage0::memo_of_queries; -// Pass 1: its entry and its errors. Its output and the types the output -// holds are in `asap_types::stages`. -pub use pass1::{pass1_memo, Pass1Error, PerComputationCandidateError}; -// Pass 2: its entry and its error. -pub use pass2::logical_memo_candidates::{ - generate_logical_asap_memo_candidates, pass2_after_local_window_composition, - pass2_local_window_composition, -}; -pub use pass2::memo::rules::Pass2RuleError; diff --git a/crates/logical-asap-optimization/src/pass1.rs b/crates/logical-asap-optimization/src/pass1.rs deleted file mode 100644 index 6c2f87b43..000000000 --- a/crates/logical-asap-optimization/src/pass1.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Stage 1, Pass 1: per-computation candidate generation and composition, -//! and the summary realizations it offers. Its one entry is -//! [`pass1_memo`], whose output -//! memo Pass 2 extends. - -/// Stage 1, Pass 1's entry: the initial candidates of the memo groups. -pub(crate) mod logical_memo_candidates; -/// Stage 1, Pass 1's computation targets, their candidates and their -/// composition. No execution timing is assigned: that is a Stage 2 -/// materialization decision. Its target list -/// (`PerComputationCandidates`) is Pass 1's working form, public only to -/// tests (`crate::test_support`, feature `test-support`). -pub(crate) mod per_computation_candidates; -/// Frontend-to-Pass-1 acceptance tests over Pass 1's working form. -#[cfg(test)] -mod per_computation_candidates_tests; -pub(crate) mod realization; - -pub use logical_memo_candidates::{pass1_memo, Pass1Error}; -pub use per_computation_candidates::PerComputationCandidateError; diff --git a/crates/logical-asap-optimization/src/pass2.rs b/crates/logical-asap-optimization/src/pass2.rs deleted file mode 100644 index dbdc6f273..000000000 --- a/crates/logical-asap-optimization/src/pass2.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Stage 1, Pass 2: the rules over groups and the logical edges. - -/// Stage 1, Pass 2's entry, producing [`asap_types::stages::LogicalASAPMemoCandidates`]. -pub(crate) mod logical_memo_candidates; -/// The rules over groups. -pub(crate) mod memo; diff --git a/crates/logical-asap-optimization/src/stage0.rs b/crates/logical-asap-optimization/src/stage0.rs deleted file mode 100644 index 5764cfeaf..000000000 --- a/crates/logical-asap-optimization/src/stage0.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Stage 0: the memo groups of the workload's query roots (a [`Memo`] -//! without candidates). Each node of the queries' DAGs becomes the group of -//! its expression, found or added by [`Memo::find_or_add_group`], so -//! identical sub-expressions are one group. Stage 1 starts from these groups. - -use asap_types::ir::{QueryRoot, SchemaDerivationError}; -use asap_types::stages::Memo; - -/// Stage 0: the memo groups of the workload's query roots, query `i` -/// being `roots[i]`, every group without candidates. Fails with the first -/// node that breaks the node rules over its children's groups -/// ([`Memo::find_or_add_group`]). -pub fn memo_of_queries(roots: &[QueryRoot]) -> Result { - let _ = roots; - todo!("#1207 PR 2: Stage 0 over Memo::find_or_add_group") -} diff --git a/crates/logical-asap-optimization/src/test_support.rs b/crates/logical-asap-optimization/src/test_support.rs deleted file mode 100644 index 7eceb36ee..000000000 --- a/crates/logical-asap-optimization/src/test_support.rs +++ /dev/null @@ -1,349 +0,0 @@ -// Shared fixture helpers for the stage crates' tests (feature -// `test-support`); not every test module uses every helper. -#![allow(dead_code)] - -use std::rc::Rc; - -// Stage 1 internals other crates' tests call: Pass 1's working form, -// and single-candidate composition. -pub use crate::pass1::per_computation_candidates::{ - compose_logical_candidate, generate_per_computation_candidates, PerComputationCandidates, -}; - -use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::OperatorNode; -use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, - QueryRequirements, QueryWorkload, -}; - -/// The statistic classic HLL's failure probability at `precision` and -/// `relative_error` over `source` is left as, for Stage 3 tests that bind -/// it. The estimator keeps the format crate-private. -pub fn hll_classic_failure_statistic(precision: u8, relative_error: f64, source: &str) -> String { - asap_accuracy::estimators::hll::classic_failure_statistic(precision, relative_error, source) -} - -pub fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc { - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy, - ..Default::default() - }, - predictability: Predictability::Unknown, - invocations: 1, - execute_at: None, - range_evaluation: None, - }]), - repeating_queries: None, - }, - data_workload: Some(DataWorkload::sampled_every( - asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), - DurationMs(1_000), - )), - }; - asap_frontend_promql::lower_promql_query_workload(&workload) - .unwrap() - .remove(0) - .as_operator() - .cloned() - .expect("operator root") -} - -// ── Shared pre-ASAP fixture builders ───────────────────────────────────── -// -// Every builder returns an `Rc` whose schema is derived by -// `OperatorNode::new_shared`, so a fixture is exactly what a front end -// would hand the planner. Added by the test migration; only add here, never -// rename or remove (several test modules share these). - -use std::time::Duration; - -use asap_types::ir::operator::agg_intent::AggIntent; -use asap_types::ir::operator::operator_properties::{GroupKeys, Reduction, Source}; -use asap_types::ir::schema::{ColumnId, DataType, Field, Schema}; -use asap_types::ir::{NonASAPOp, Predicate, ScalarExpr, TimeRangeKind}; - -/// A `TimeSeries("m")` scan over `[ts(0), value(1), labels...]`, time index 0, -/// no unique key. -pub fn metric_scan(labels: &[&str]) -> Rc { - metric_scan_with_keys(labels, vec![]) -} - -/// [`metric_scan`] with explicit `unique_keys` (a `[[0]]` key makes CSE -/// willing to hoist the scan). -pub fn metric_scan_with_keys(labels: &[&str], unique_keys: Vec>) -> Rc { - let mut columns = vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ]; - columns.extend( - labels - .iter() - .map(|n| Field::plain(*n, DataType::Utf8, true)), - ); - scan("m", Schema::with_time_index(columns, 0, unique_keys)) -} - -/// A predicate-free `TimeSeries(metric)` scan with the given schema. -pub fn scan(metric: &str, schema: Schema) -> Rc { - scan_from( - Source::TimeSeries { - metric: metric.into(), - }, - schema, - ) -} - -pub fn scan_from(source: Source, schema: Schema) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source, - predicates: vec![], - schema, - })) - .unwrap() -} - -/// A general aggregate node. -pub fn aggregate( - reduction: Reduction, - measures: Vec, - output_names: Vec, - having: Option, - child: Rc, -) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction, - measures, - output_names, - filters: vec![], - having, - child, - })) - .unwrap() -} - -/// `intent by (by)` — a single-measure, `HAVING`-free grouped aggregate. -pub fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { - aggregate(Reduction::by(by), vec![intent], vec![], None, child) -} - -/// `intent without (excluded)`. -pub fn without_agg( - excluded: Vec, - intent: AggIntent, - child: Rc, -) -> Rc { - aggregate( - Reduction::Reduce(GroupKeys::without(excluded)), - vec![intent], - vec![], - None, - child, - ) -} - -pub fn filter(pred: ScalarExpr, child: Rc) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { - pred: Predicate(pred), - child, - })) - .unwrap() -} - -pub fn dedup(cols: Vec, child: Rc) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { - cols, - child, - })) - .unwrap() -} - -/// An explicit range selector `child[range]`. -pub fn time_range(range: Duration, child: Rc) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { - range, - kind: TimeRangeKind::Range, - child, - })) - .unwrap() -} - -/// The Count-Min summary of a Stage 1 candidate of `query`. -pub fn cms_summary(query: &str) -> OperatorNode { - use crate::pass1::per_computation_candidates::compose_logical_candidate; - use crate::pass1::per_computation_candidates::generate_per_computation_candidates; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; - use asap_types::ir::{ASAPOp, QueryRoot}; - let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); - let root = lower_promql(query, target); - // Inspect each local computation choice under the built-in deployment. - let per_computation = generate_per_computation_candidates( - &crate::stage0::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap() - .without_unsupported_summaries(None); - let summary = per_computation - .targets - .iter() - .enumerate() - .flat_map(|(target, choices)| { - let inventory = &per_computation; - (0..choices.choice_count()).filter_map(move |candidate| { - let mut assignment = vec![0; inventory.targets.len()]; - assignment[target] = candidate; - compose_logical_candidate(inventory, &assignment).ok() - }) - }) - .flat_map(|roots| match &roots[0] { - QueryRoot::Operator(node) => OperatorNode::reachable(node), - QueryRoot::Scalar(_) => vec![], - }) - .find(|node| { - matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) if *kind == SketchAlgorithm::Cms - ) - }) - .map(|node| (*node).clone()) - .unwrap_or_else(|| panic!("{query}: a Count-Min summary")); - summary -} - -/// `summary` with its update weight replaced by `weight`. -pub fn weighted( - mut summary: OperatorNode, - weight: asap_types::ir::schema::SummaryInputExpr, -) -> Rc { - use asap_types::ir::{ASAPOp, Operator}; - if let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &mut summary.operator { - input.weight = weight; - } - Rc::new(summary) -} - -/// A point-count estimate over `summary` carrying `guarantee`. -pub fn estimate_over( - summary: Rc, - guarantee: asap_types::ir::properties::AccuracyGuarantee, -) -> Rc { - use asap_types::ir::scalar::ColumnRef; - use asap_types::ir::schema::SketchStatistic; - use asap_types::ir::{ASAPOp, Operator}; - Rc::new( - OperatorNode::new(Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: summary, - query: SketchStatistic::PointCount { - key: ColumnRef::SampleValue, - value: None, - }, - })) - .unwrap() - .with_guarantee(Some(guarantee)), - ) -} - -/// The workload entry of a query evaluated as `recurrence` says, exact and -/// with no latency bound. -pub fn entry( - recurrence: asap_types::workload::QueryRecurrence, -) -> asap_types::workload::QueryWorkloadEntry { - entry_with(AccuracyTarget::exact(), recurrence) -} - -/// The workload entry of a query with accuracy target `accuracy`, evaluated -/// as `recurrence` says, with no latency bound. -pub fn entry_with( - accuracy: AccuracyTarget, - recurrence: asap_types::workload::QueryRecurrence, -) -> asap_types::workload::QueryWorkloadEntry { - asap_types::workload::QueryWorkloadEntry { - query: Default::default(), - requirements: QueryRequirements { - accuracy, - max_latency_ms: None, - }, - predictability: Default::default(), - recurrence, - range_evaluation: None, - } -} - -/// The maintained-population sub-DAG for executor tests: `root` (below its -/// projections, which are kept) replaced by `EvaluatePopulation(statistic)` -/// over a current-series population of the single selector under it, -/// grouped by `grouping`, with no matchers. The caller states the statistic -/// and grouping; nothing is recognized. No planner pass offers this sub-DAG -/// yet (#681). -pub fn population_over_selector( - root: &Rc, - statistic: asap_types::ir::operator::maintained_population::PopulationStatistic, - grouping: &[&str], -) -> Rc { - use asap_types::ir::operator::maintained_population::*; - use asap_types::ir::{ASAPOp, Operator}; - if let Some(NonASAPOp::Project { - cols, - qualifier, - child, - }) = root.non_asap() - { - let child = population_over_selector(child, statistic.clone(), grouping); - let project = Operator::NonASAP(NonASAPOp::Project { - cols: cols.clone(), - qualifier: qualifier.clone(), - child, - }); - return Rc::new(OperatorNode::with_schema(project, root.schema.clone())); - } - let selectors: Vec<_> = OperatorNode::reachable(root) - .into_iter() - .filter(|node| matches!(node.non_asap(), Some(NonASAPOp::TimeRange { .. }))) - .collect(); - let [selector] = selectors.as_slice() else { - panic!("one selector under {root:?}"); - }; - let Some(NonASAPOp::TimeRange { range, child, .. }) = selector.non_asap() else { - unreachable!() - }; - let Some(NonASAPOp::Scan { - source: Source::TimeSeries { metric }, - .. - }) = child.non_asap() - else { - panic!("a time-series selector, got {child:?}"); - }; - let population = MaintainedPopulation { - input: PopulationInput::CurrentSeries(CurrentSeriesInput { - metric: metric.clone(), - matchers: Vec::new(), - grouping: grouping.iter().map(|label| label.to_string()).collect(), - without: false, - lookback_ms: range.as_millis() as u64, - }), - max_k: match statistic { - PopulationStatistic::TopK { k } => k, - _ => 0, - }, - quantiles: matches!(statistic, PopulationStatistic::Quantile { .. }), - }; - let maintained = OperatorNode::with_schema( - Operator::ASAP(ASAPOp::MaintainPopulation { - child: selector.clone(), - population, - }), - selector.schema.clone(), - ); - let evaluation = Operator::ASAP(ASAPOp::EvaluatePopulation { - child: Rc::new(maintained), - evaluation: statistic, - }); - Rc::new(OperatorNode::with_schema(evaluation, root.schema.clone())) -} diff --git a/crates/logical-asap-optimization/tests/pass_layering.rs b/crates/logical-asap-optimization/tests/pass_layering.rs deleted file mode 100644 index daf0595a7..000000000 --- a/crates/logical-asap-optimization/tests/pass_layering.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Stage 1's passes are independent modules (#1096, #1191): neither pass's -//! production code imports the other. Test modules may use the other pass to -//! build fixtures. IR helpers both passes need live in `asap-types`. - -use std::path::{Path, PathBuf}; - -/// Production lines under `src/.rs` and `src//` that name a path -/// through `needle`'s module. -fn production_lines_mentioning(pass: &str, needle: &str) -> Vec { - fn walk(dir: &Path, files: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(&path, files); - // `*_tests.rs` files are `#[cfg(test)]` modules. - } else if !path.to_string_lossy().ends_with("_tests.rs") { - files.push(path); - } - } - } - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = vec![src.join(format!("{pass}.rs"))]; - walk(&src.join(pass), &mut files); - let mut offending = Vec::new(); - for file in files { - let text = std::fs::read_to_string(&file).unwrap(); - // ponytail: a file's production code ends at its `#[cfg(test)] mod tests`; - // parse with `syn` if inline `#[cfg(test)]` items ever need excluding. - let production = text.split("#[cfg(test)]\nmod tests").next().unwrap(); - for (n, line) in production.lines().enumerate() { - if line.contains(needle) { - offending.push(format!("{}:{}: {}", file.display(), n + 1, line.trim())); - } - } - } - offending -} - -/// Pass 1's production source does not reference `pass2`. -#[test] -fn pass1_does_not_import_pass2() { - let offending = production_lines_mentioning("pass1", "pass2::"); - assert!( - offending.is_empty(), - "Pass 1 imports Pass 2:\n{}", - offending.join("\n") - ); -} - -/// Pass 2's production source does not reference `pass1`. -#[test] -fn pass2_does_not_import_pass1() { - let offending = production_lines_mentioning("pass2", "pass1::"); - assert!( - offending.is_empty(), - "Pass 2 imports Pass 1:\n{}", - offending.join("\n") - ); -} diff --git a/crates/planner/Cargo.toml b/crates/planner/Cargo.toml index 63802bcf6..bec778efc 100644 --- a/crates/planner/Cargo.toml +++ b/crates/planner/Cargo.toml @@ -8,21 +8,24 @@ edition = "2021" # returns the selected DAG. It is the only crate that depends on every # frontend — before it, the sole facade re-exporting more than one was `asap-devtools`, # a developer-tools crate. Everything below the frontend boundary lives in the -# stage crates (`asap-logical-asap-optimization`, `asap-physical-asap-optimization`, -# `asap-plan-selection`), so a third party writing an optimization pass (issue -# #430) depends on `asap-plan-selection` (plus `asap-accuracy` to supply its own +# stage crates (`asap-stage0-memo`, `asap-stage1-pass1-per-computation-candidates`, +# `asap-stage1-pass2-reuse-candidates`, `asap-stage2-physical-optimization`, +# `asap-stage3-plan-selection`), so a third party writing an optimization pass (issue +# #430) depends on `asap-stage3-plan-selection` (plus `asap-accuracy` to supply its own # accuracy model or evidence) and never pulls in DataFusion. [dependencies] asap-types = { path = "../types" } asap-cost = { path = "../cost" } -asap-plan-selection = { path = "../plan-selection" } +asap-stage3-plan-selection = { path = "../stage3-plan-selection" } asap-frontend-sql = { path = "../frontend-sql" } asap-frontend-promql = { path = "../frontend-promql" } asap-frontend-metricsql = { path = "../frontend-metricsql" } -asap-logical-asap-optimization = { path = "../logical-asap-optimization" } +asap-stage0-memo = { path = "../stage0-memo" } +asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates" } +asap-stage1-pass2-reuse-candidates = { path = "../stage1-pass2-reuse-candidates" } # The facade runs every stage: Stage 2's catalog and realizer, Stage 3's # `select_plan`, with the deployment's accuracy model. -asap-physical-asap-optimization = { path = "../physical-asap-optimization" } +asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } asap-accuracy = { path = "../accuracy" } # The Planner's own executor: `plan_installable` offers only the summary # families it runs when the caller leaves them unrestricted. @@ -32,10 +35,8 @@ serde_json = "1" thiserror = "2" [dev-dependencies] -asap-plan-selection = { path = "../plan-selection" } -asap-physical-asap-optimization = { path = "../physical-asap-optimization" } -# Pass 1's target list (`per_computation_candidates`) for the tests. -asap-logical-asap-optimization = { path = "../logical-asap-optimization", features = ["test-support"] } +asap-stage3-plan-selection = { path = "../stage3-plan-selection" } +asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } # The deployment accuracy model tests implement `AccuracyModel`. asap-accuracy = { path = "../accuracy", features = ["test-support"] } # The SQL frontend plans through DataFusion, which is async; `e2e_plan` is diff --git a/crates/planner/src/installable.rs b/crates/planner/src/installable.rs index a2dcf5f48..858d575a7 100644 --- a/crates/planner/src/installable.rs +++ b/crates/planner/src/installable.rs @@ -26,8 +26,8 @@ use std::collections::{HashMap, HashSet}; use asap_cost::{NodePriceSource, OperatorKind}; -use asap_logical_asap_optimization::DeploymentCapabilities; -use asap_plan_selection::{SearchReport, Selection}; +use asap_stage3_plan_selection::{SearchReport, Selection}; +use asap_types::deployment_capabilities::DeploymentCapabilities; use asap_types::ir::materialized_view::{ IngestionRefreshGroup, InstallableDagError, MaterializedView, RawDataRetention, }; diff --git a/crates/planner/src/lib.rs b/crates/planner/src/lib.rs index 3aa06b879..db7d1f891 100644 --- a/crates/planner/src/lib.rs +++ b/crates/planner/src/lib.rs @@ -8,7 +8,7 @@ //! The default pass, [`Stage1ToStage3Pass`], orchestrates the stages //! ([`run_planning`]): Stage 0, Pass 1, Pass 2 over every tumbling-window width, //! Stage 2 (`generate_physical_asap_memo_candidates`) and Stage 3 -//! (`asap_plan_selection::select_plan`), and builds the selected plan through +//! (`asap_stage3_plan_selection::select_plan`), and builds the selected plan through //! Stage 2's realizer. The stages never call each other. //! //! [`e2e_plan`] runs both steps. [`plan_installable`] runs them for a @@ -51,10 +51,10 @@ pub use asap_cost::{ ResourceSpeed, ResourceUnitCost, SummaryOperation, }; pub use asap_frontend_sql::SqlCatalog; -pub use asap_logical_asap_optimization::{DeploymentCapabilities, SupportedSummary}; -pub use asap_plan_selection::{ +pub use asap_stage3_plan_selection::{ AcceptedAccuracy, ErrorSelectingPlan, RejectionCause, SearchReport, SearchStatus, Selection, }; +pub use asap_types::deployment_capabilities::{DeploymentCapabilities, SupportedSummary}; pub use asap_types::ir::materialized_view::{ IngestionRefreshGroup, InstallableDagError, MaterializedView, MaterializedViewInputs, MaterializedViewRefresh, RawDataRetention, RawDataWindow, RefreshWindow, ViewCoverage, diff --git a/crates/planner/src/pass.rs b/crates/planner/src/pass.rs index 539ac72e8..e02265f92 100644 --- a/crates/planner/src/pass.rs +++ b/crates/planner/src/pass.rs @@ -26,9 +26,9 @@ use asap_accuracy::{ NoAccuracyEvidence, }; use asap_cost::CostInputs; -use asap_logical_asap_optimization::DeploymentCapabilities; -use asap_logical_asap_optimization::PerComputationCandidateError; -use asap_plan_selection::{AccuracyCheckInputs, Selection}; +use asap_stage1_pass1_per_computation_candidates::PerComputationCandidateError; +use asap_stage3_plan_selection::{AccuracyCheckInputs, Selection}; +use asap_types::deployment_capabilities::DeploymentCapabilities; use asap_types::ir::physical_export::PhysicalASAPNodeId; static BUILTIN_ACCURACY_MODEL: BuiltinAccuracyModel = BuiltinAccuracyModel; diff --git a/crates/planner/src/run.rs b/crates/planner/src/run.rs index 269ba9405..65c1666bf 100644 --- a/crates/planner/src/run.rs +++ b/crates/planner/src/run.rs @@ -3,19 +3,21 @@ //! finally the selected plan built through Stage 2's realizer. No stage //! calls another, and none has a deadline: each runs to completion. -use asap_logical_asap_optimization::memo_of_queries; +use asap_stage0_memo::memo_of_queries; use std::collections::HashSet; use std::rc::Rc; use std::time::Instant; -use asap_logical_asap_optimization::generate_logical_asap_memo_candidates; -use asap_logical_asap_optimization::DeploymentCapabilitiesError; -use asap_logical_asap_optimization::{pass1_memo, Pass1Error, PerComputationCandidateError}; -use asap_physical_asap_optimization::{ +use asap_stage1_pass1_per_computation_candidates::{ + pass1_memo, Pass1Error, PerComputationCandidateError, +}; +use asap_stage1_pass2_reuse_candidates::generate_logical_asap_memo_candidates; +use asap_stage2_physical_optimization::{ generate_physical_asap_memo_candidates, Stage2AssignmentRealizer, }; -use asap_plan_selection::{select_plan, ErrorSelectingPlan, SearchReport, Selection}; +use asap_stage3_plan_selection::{select_plan, ErrorSelectingPlan, SearchReport, Selection}; +use asap_types::deployment_capabilities::DeploymentCapabilitiesError; use asap_types::ir::schema::schema_support::with_promql_series_identity; use asap_types::ir::{OperatorNode, QueryRoot}; use asap_types::stages::{ diff --git a/crates/stage0-memo/Cargo.toml b/crates/stage0-memo/Cargo.toml new file mode 100644 index 000000000..40804bc41 --- /dev/null +++ b/crates/stage0-memo/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "asap-stage0-memo" +version = "0.1.0" +edition = "2021" + +# #509 Stage 0: hash-consing the workload's logical DAGs into the memo groups +# (`asap_types::stages::Memo`, without candidates) that Stage 1 fills. Depends +# only on asap-types; no other stage crate depends on it except in their tests. +[dependencies] +asap-types = { path = "../types" } diff --git a/crates/stage0-memo/src/lib.rs b/crates/stage0-memo/src/lib.rs new file mode 100644 index 000000000..e827d908c --- /dev/null +++ b/crates/stage0-memo/src/lib.rs @@ -0,0 +1,21 @@ +//! `asap-stage0-memo` — #509 Stage 0: the memo groups of the workload's +//! query roots (a [`Memo`] without candidates). Each node of the queries' +//! DAGs becomes the group of its expression, found or added by +//! [`Memo::find_or_add_group`], so identical sub-expressions are one group. +//! Stage 1 starts from these groups. +//! +//! It depends only on `asap-types`, where its output (`Memo`) lives; no +//! other stage crate depends on it except in their tests, and the planner +//! facade calls it first. + +use asap_types::ir::{QueryRoot, SchemaDerivationError}; +use asap_types::stages::Memo; + +/// Stage 0: the memo groups of the workload's query roots, query `i` +/// being `roots[i]`, every group without candidates. Fails with the first +/// node that breaks the node rules over its children's groups +/// ([`Memo::find_or_add_group`]). +pub fn memo_of_queries(roots: &[QueryRoot]) -> Result { + let _ = roots; + todo!("#1207 PR 2: Stage 0 over Memo::find_or_add_group") +} diff --git a/crates/logical-asap-optimization/Cargo.toml b/crates/stage1-pass1-per-computation-candidates/Cargo.toml similarity index 51% rename from crates/logical-asap-optimization/Cargo.toml rename to crates/stage1-pass1-per-computation-candidates/Cargo.toml index 3154d7233..9cd188ba4 100644 --- a/crates/logical-asap-optimization/Cargo.toml +++ b/crates/stage1-pass1-per-computation-candidates/Cargo.toml @@ -1,29 +1,27 @@ [package] -name = "asap-logical-asap-optimization" +name = "asap-stage1-pass1-per-computation-candidates" version = "0.1.0" edition = "2021" -# #509 Stage 1, logical ASAP-aware optimization: Pass 1 per-computation -# candidates and Pass 2 ASAP-aware CSE over asap-types' operator DAG, with -# the shared analytical accuracy model (`asap-accuracy`). Depends on no -# later stage, and on no query-language frontend except through the -# `test-support` feature. +# #509 Stage 1, Pass 1: the initial candidates of each memo group (exact, +# exact accumulator, summaries, fused builds), with the shared analytical +# accuracy model (`asap-accuracy`). Reads Stage 0's memo through +# `asap_types::stages`; depends on no other stage crate, and on no +# query-language frontend except through the `test-support` feature. [dependencies] -asap_sketchlib = { workspace = true } asap-types = { path = "../types" } asap-accuracy = { path = "../accuracy" } thiserror = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -rmp-serde = "1" # Only for `test-support`: lowering PromQL test queries. asap-frontend-promql = { path = "../frontend-promql", optional = true } [features] -# The test helpers (`test_support`) the later stage crates' tests share. +# The test helpers (`test_support`) other crates' tests share. test-support = ["dep:asap-frontend-promql", "asap-types/test-support"] [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-accuracy = { path = "../accuracy", features = ["test-support"] } asap-frontend-promql = { path = "../frontend-promql" } +# Stage 0's memo of the test queries. +asap-stage0-memo = { path = "../stage0-memo" } diff --git a/crates/stage1-pass1-per-computation-candidates/src/lib.rs b/crates/stage1-pass1-per-computation-candidates/src/lib.rs new file mode 100644 index 000000000..3a1de5794 --- /dev/null +++ b/crates/stage1-pass1-per-computation-candidates/src/lib.rs @@ -0,0 +1,51 @@ +//! `asap-stage1-pass1-per-computation-candidates` — #509 Stage 1, Pass 1: +//! per-computation candidate generation and composition, and the summary +//! realizations it offers. Its one entry is [`pass1_memo`], which fills the +//! memo groups of Stage 0's [`Memo`](asap_types::stages::Memo) with their +//! initial candidates; Pass 2 extends that memo. +//! +//! It depends only on `asap-types` (its input and output memo are in +//! `asap_types::stages`) and the shared accuracy model (`asap-accuracy`). +//! It depends on no other stage crate, and the planner facade calls it +//! after Stage 0. It depends on no query-language frontend either, except +//! with the `test-support` feature, whose test helpers (`test_support`) +//! lower PromQL test queries through `asap-frontend-promql`; other crates +//! enable that feature only for their tests. +//! +//! Accuracy guarantees come from the built-in analytical accuracy rules in +//! `asap-accuracy` (per-family estimators, composition and budget +//! allocation). They derive a machine-readable +//! [`AccuracyGuarantee`](asap_types::ir::properties::AccuracyGuarantee) +//! for every finalized value and propagate it through +//! approximate-over-approximate compositions under conservative rules (no +//! independence assumptions, unknown statistics stay unknown). A +//! composition with no rule leaves the guarantee `None` and keeps the +//! candidate. Pass 1 takes the deployment's `AccuracyModel` and evidence +//! only to prune summaries no query reading them can use (when they cannot +//! accept an output without its guarantee) and sizes every summary for its +//! consumers; plan selection (Stage 3) checks each selected candidate's +//! guarantee against its query's `AccuracyTarget` and never resizes. + +/// Stage 1, Pass 1's entry: the initial candidates of the memo groups. +mod logical_memo_candidates; +/// Stage 1, Pass 1's computation targets, their candidates and their +/// composition. No execution timing is assigned: that is a Stage 2 +/// materialization decision. Its target list +/// (`PerComputationCandidates`) is Pass 1's working form, public only to +/// tests (`crate::test_support`, feature `test-support`). +mod per_computation_candidates; +/// Frontend-to-Pass-1 acceptance tests over Pass 1's working form. +#[cfg(test)] +mod per_computation_candidates_tests; +/// The summary families that can serve an aggregate intent, the input a +/// summary consumes and the column a top-k ranks. +mod realization; +/// Fixtures this crate's own tests share. +#[cfg(test)] +mod test_fixtures; +/// Test helpers other crates' tests call (feature `test-support`). +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + +pub use logical_memo_candidates::{pass1_memo, Pass1Error}; +pub use per_computation_candidates::PerComputationCandidateError; diff --git a/crates/logical-asap-optimization/src/pass1/logical_memo_candidates.rs b/crates/stage1-pass1-per-computation-candidates/src/logical_memo_candidates.rs similarity index 95% rename from crates/logical-asap-optimization/src/pass1/logical_memo_candidates.rs rename to crates/stage1-pass1-per-computation-candidates/src/logical_memo_candidates.rs index 066f41724..428094cb6 100644 --- a/crates/logical-asap-optimization/src/pass1/logical_memo_candidates.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/logical_memo_candidates.rs @@ -8,10 +8,10 @@ use asap_types::workload::DataWorkload; use asap_accuracy::{AccuracyEvidenceProvider, AccuracyModel}; -use crate::deployment_capabilities::SupportedSummary; -use crate::pass1::per_computation_candidates::{ +use crate::per_computation_candidates::{ generate_per_computation_candidates, PerComputationCandidateError, }; +use asap_types::ir::schema::SupportedSummary; use asap_types::stages::Memo; /// Why [`pass1_memo`] failed. diff --git a/crates/logical-asap-optimization/src/pass1/per_computation_candidates.rs b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs similarity index 99% rename from crates/logical-asap-optimization/src/pass1/per_computation_candidates.rs rename to crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs index 96c7f469d..207707c1a 100644 --- a/crates/logical-asap-optimization/src/pass1/per_computation_candidates.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs @@ -85,7 +85,7 @@ //! per output value; attached to the memo groups, each candidate's //! approximate inputs are sized for the values its consumers' targets cover //! (`δ / (n · V)`, -//! [`pass1_memo`](crate::pass1::logical_memo_candidates::pass1_memo)), +//! [`pass1_memo`](crate::logical_memo_candidates::pass1_memo)), //! their final sizes. Pass 2 inherits them or takes the strictest shared //! consumer's, and Stage 3 only checks the composed accuracy. //! @@ -129,8 +129,9 @@ use asap_types::ir::properties::AccuracyGuarantee; use asap_types::workload::bounds::{declared_non_negative, series_read, values_per_result}; use asap_types::workload::DataWorkload; -use crate::deployment_capabilities::{summary_supported, SupportedSummary}; -use crate::pass1::realization::summary_candidates; +use crate::realization::summary_candidates; +use asap_types::deployment_capabilities::summary_supported; +use asap_types::ir::schema::SupportedSummary; use asap_types::stages::{ ComputationTarget, FusedComputation, HavingEntry, HavingPlacement, HowToComputeAggregateMeasure, Memo, MemoGroupId, @@ -175,7 +176,7 @@ fn prune_own( /// Compact Pass 1 inventory over the memo groups it walked: one target per /// measure of each aggregate group, however many queries read the group. /// Pass 1's working form, not its output: -/// [`pass1_memo`](crate::pass1::pass1_memo) +/// [`pass1_memo`](crate::pass1_memo) /// builds, prunes and attaches it to the memo groups. Public only to tests /// (feature `test-support`). #[derive(Debug, Clone)] @@ -1111,7 +1112,7 @@ fn reads_only_time_series(node: &OperatorNode) -> bool { fn whole_expression_input( target: &OperatorNode, ) -> Option<(Rc, SummaryUpdate, Reduction)> { - use crate::pass1::realization::realize_keyed_additive_summary_input; + use crate::realization::realize_keyed_additive_summary_input; let Some(NonASAPOp::Aggregate { child, reduction, @@ -1989,13 +1990,13 @@ fn measure_input( } // A top-k ranks its child's aggregate output, not a column of its own. AggIntent::TopK { .. } => SummaryInputExpr::Column( - crate::pass1::realization::ranked_value(input) + crate::realization::ranked_value(input) .map_err(|_| { PerComputationCandidateError::Unsupported("Top-K has no ranked column") })? .1, ), - _ => crate::pass1::realization::summarised_input(intent, schema).ok_or( + _ => crate::realization::summarised_input(intent, schema).ok_or( PerComputationCandidateError::Unsupported("input column outside child schema"), )?, }; @@ -2355,9 +2356,9 @@ mod tests { let count_sketch = with_bound(AccuracyMetric::ABSOLUTE_STREAM_L2, 0.02, 0.002); // Summaries of each source's raw value column. let raw = |query: &str, guarantee: AccuracyGuarantee| { - ScalarExpr::PromqlScalarFromVector(crate::test_support::estimate_over( - crate::test_support::weighted( - crate::test_support::cms_summary(query), + ScalarExpr::PromqlScalarFromVector(crate::test_fixtures::estimate_over( + crate::test_fixtures::weighted( + crate::test_fixtures::cms_summary(query), SummaryInputExpr::Column(ColumnRef::SampleValue), ), guarantee, @@ -2808,7 +2809,7 @@ mod tests { /// Which stream norm bounds a Count-Min or Count Sketch estimate (#669). mod stream_norms { use super::*; - use crate::test_support::{cms_summary, weighted}; + use crate::test_fixtures::{cms_summary, weighted}; use asap_accuracy::composition::STREAM_L1_NORM; use asap_accuracy::test_support::{source_norm_statistic, STREAM_ROW_COUNT}; @@ -2852,7 +2853,7 @@ mod tests { vec![], ); let estimate = |summary: Rc| { - operand_guarantee(&crate::test_support::estimate_over( + operand_guarantee(&crate::test_fixtures::estimate_over( summary, frequency.clone(), )) @@ -2898,7 +2899,7 @@ mod tests { let root = asap_types::ir::schema::schema_support::with_promql_series_identity(&root) .unwrap_or(root); generate_per_computation_candidates( - &crate::stage0::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap() } @@ -3014,7 +3015,7 @@ mod tests { AccuracyTarget::max_error(0.1), ); let inventory = generate_per_computation_candidates( - &crate::stage0::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap(); let topk = topk_target(&inventory); @@ -3786,7 +3787,7 @@ mod tests { fn inventory_at(query: &str, accuracy: AccuracyTarget) -> PerComputationCandidates { let root = crate::test_support::lower_promql(query, accuracy); generate_per_computation_candidates( - &crate::stage0::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), ) .unwrap() } diff --git a/crates/logical-asap-optimization/src/pass1/per_computation_candidates_tests.rs b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs similarity index 98% rename from crates/logical-asap-optimization/src/pass1/per_computation_candidates_tests.rs rename to crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs index bcc2a625d..f53d2b551 100644 --- a/crates/logical-asap-optimization/src/pass1/per_computation_candidates_tests.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs @@ -1,9 +1,9 @@ //! Frontend-to-Pass-1 acceptance: candidate discovery precedes empirical selection. -use crate::pass1::per_computation_candidates::{ +use crate::per_computation_candidates::{ computation_candidates_for_intent, generate_per_computation_candidates, PerComputationCandidateError, }; -use crate::stage0::memo_of_queries; +use asap_stage0_memo::memo_of_queries; use asap_types::ir::operator::operator_properties::{Reduction, Source}; use asap_types::ir::operator::AggIntent; use asap_types::ir::properties::AccuracyTarget; @@ -378,7 +378,7 @@ fn filtered_measure_keeps_candidates_and_records_its_filter() { /// Target `target`'s own ways to compute. fn choices_of( - inventory: &crate::pass1::per_computation_candidates::PerComputationCandidates, + inventory: &crate::per_computation_candidates::PerComputationCandidates, target: usize, ) -> Vec { inventory.targets[target].ways_to_compute.clone() @@ -630,7 +630,7 @@ fn global_aggregate_having_stays_above() { /// The `HAVING` entries of the target whose measure satisfies `is`. fn having_of( - inventory: &crate::pass1::per_computation_candidates::PerComputationCandidates, + inventory: &crate::per_computation_candidates::PerComputationCandidates, is: impl Fn(&AggIntent) -> bool, ) -> Vec { let target = inventory @@ -799,7 +799,7 @@ fn without_and_per_entity_having_stays_above() { /// with an exact guarantee. #[test] fn composed_candidate_replaces_chosen_target_with_summary_evaluation() { - use crate::pass1::per_computation_candidates::compose_logical_candidate; + use crate::per_computation_candidates::compose_logical_candidate; let producer = aggregate(AggIntent::Cardinality { cols: vec![0], accuracy: approximate(), @@ -830,7 +830,7 @@ fn composed_candidate_replaces_chosen_target_with_summary_evaluation() { /// The one root of the workload candidate composed from `roots` and `choice`. fn composed(roots: Vec>, choice: &[usize]) -> Vec> { - use crate::pass1::per_computation_candidates::compose_logical_candidate; + use crate::per_computation_candidates::compose_logical_candidate; let inventory = generate_per_computation_candidates( &memo_of_queries( &roots @@ -1078,7 +1078,7 @@ fn composed_guarantees() { /// a subquery in it reads the subquery's substitution. #[test] fn own_having_subquery_is_substituted() { - use crate::pass1::per_computation_candidates::compose_logical_candidate; + use crate::per_computation_candidates::compose_logical_candidate; let inner = aggregate(quantile()); let outer = grouped_quantile(Some(above_scalar(1, inner.clone()))); let inventory = generate_per_computation_candidates( diff --git a/crates/logical-asap-optimization/src/pass1/realization.rs b/crates/stage1-pass1-per-computation-candidates/src/realization.rs similarity index 98% rename from crates/logical-asap-optimization/src/pass1/realization.rs rename to crates/stage1-pass1-per-computation-candidates/src/realization.rs index f510bdf65..114cba7ab 100644 --- a/crates/logical-asap-optimization/src/pass1/realization.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/realization.rs @@ -1,5 +1,5 @@ -//! Summary realization helpers shared by Pass 1 (`per_computation_candidates`) -//! and Pass 2 (`asap_aware_cse`): which sketch kinds can serve an intent, +//! Summary realization helpers of Pass 1 (`per_computation_candidates`): +//! which sketch kinds can serve an intent, //! the column/tuple a summary consumes, the column a top-k ranks, and the //! keyed-additive rule for a heap sketch that reads a larger sub-DAG. @@ -352,7 +352,7 @@ fn summed_per_series_samples(grouped: &OperatorNode) -> Option<(Rc #[cfg(test)] mod tests { use super::*; - use crate::test_support::metric_scan; + use crate::test_fixtures::metric_scan; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::scalar::{CompareOpKind, ScalarValue}; use asap_types::ir::schema::GroupingStrategy; diff --git a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs new file mode 100644 index 000000000..922692bb6 --- /dev/null +++ b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs @@ -0,0 +1,129 @@ +//! Fixtures this crate's own tests share. Every builder returns an +//! `Rc` whose schema is derived by `OperatorNode::new_shared`, +//! so a fixture is exactly what a front end would hand the planner. + +use std::rc::Rc; + +use asap_types::ir::operator::operator_properties::Source; +use asap_types::ir::properties::AccuracyTarget; +use asap_types::ir::schema::{ColumnId, DataType, Field, Schema}; +use asap_types::ir::{NonASAPOp, OperatorNode}; + +use crate::test_support::lower_promql; + +/// A `TimeSeries("m")` scan over `[ts(0), value(1), labels...]`, time index 0, +/// no unique key. +pub fn metric_scan(labels: &[&str]) -> Rc { + metric_scan_with_keys(labels, vec![]) +} + +/// [`metric_scan`] with explicit `unique_keys` (a `[[0]]` key makes CSE +/// willing to hoist the scan). +pub fn metric_scan_with_keys(labels: &[&str], unique_keys: Vec>) -> Rc { + let mut columns = vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + ]; + columns.extend( + labels + .iter() + .map(|n| Field::plain(*n, DataType::Utf8, true)), + ); + scan("m", Schema::with_time_index(columns, 0, unique_keys)) +} + +/// A predicate-free `TimeSeries(metric)` scan with the given schema. +pub fn scan(metric: &str, schema: Schema) -> Rc { + scan_from( + Source::TimeSeries { + metric: metric.into(), + }, + schema, + ) +} + +pub fn scan_from(source: Source, schema: Schema) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { + source, + predicates: vec![], + schema, + })) + .unwrap() +} + +/// The Count-Min summary of a Stage 1 candidate of `query`. +pub fn cms_summary(query: &str) -> OperatorNode { + use crate::per_computation_candidates::compose_logical_candidate; + use crate::per_computation_candidates::generate_per_computation_candidates; + use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; + use asap_types::ir::{ASAPOp, QueryRoot}; + let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); + let root = lower_promql(query, target); + // Inspect each local computation choice under the built-in deployment. + let per_computation = generate_per_computation_candidates( + &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), + ) + .unwrap() + .without_unsupported_summaries(None); + let summary = per_computation + .targets + .iter() + .enumerate() + .flat_map(|(target, choices)| { + let inventory = &per_computation; + (0..choices.choice_count()).filter_map(move |candidate| { + let mut assignment = vec![0; inventory.targets.len()]; + assignment[target] = candidate; + compose_logical_candidate(inventory, &assignment).ok() + }) + }) + .flat_map(|roots| match &roots[0] { + QueryRoot::Operator(node) => OperatorNode::reachable(node), + QueryRoot::Scalar(_) => vec![], + }) + .find(|node| { + matches!( + node.asap(), + Some(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) if *kind == SketchAlgorithm::Cms + ) + }) + .map(|node| (*node).clone()) + .unwrap_or_else(|| panic!("{query}: a Count-Min summary")); + summary +} + +/// `summary` with its update weight replaced by `weight`. +pub fn weighted( + mut summary: OperatorNode, + weight: asap_types::ir::schema::SummaryInputExpr, +) -> Rc { + use asap_types::ir::{ASAPOp, Operator}; + if let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &mut summary.operator { + input.weight = weight; + } + Rc::new(summary) +} + +/// A point-count estimate over `summary` carrying `guarantee`. +pub fn estimate_over( + summary: Rc, + guarantee: asap_types::ir::properties::AccuracyGuarantee, +) -> Rc { + use asap_types::ir::scalar::ColumnRef; + use asap_types::ir::schema::SketchStatistic; + use asap_types::ir::{ASAPOp, Operator}; + Rc::new( + OperatorNode::new(Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: summary, + query: SketchStatistic::PointCount { + key: ColumnRef::SampleValue, + value: None, + }, + })) + .unwrap() + .with_guarantee(Some(guarantee)), + ) +} diff --git a/crates/stage1-pass1-per-computation-candidates/src/test_support.rs b/crates/stage1-pass1-per-computation-candidates/src/test_support.rs new file mode 100644 index 000000000..be5f6f699 --- /dev/null +++ b/crates/stage1-pass1-per-computation-candidates/src/test_support.rs @@ -0,0 +1,48 @@ +//! Test helpers: the Pass 1 internals and the PromQL lowering other crates' +//! tests call (feature `test-support`), and the fixtures this crate's own +//! tests share (`cfg(test)`). + +use std::rc::Rc; + +// Pass 1 internals other crates' tests call: Pass 1's working form and +// single-candidate composition. +pub use crate::per_computation_candidates::{ + compose_logical_candidate, generate_per_computation_candidates, +}; + +use asap_types::ir::properties::AccuracyTarget; +use asap_types::ir::OperatorNode; +use asap_types::workload::{ + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, +}; + +pub fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc { + let workload = PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(vec![BatchEntry { + query: Query(query.into()), + requirements: QueryRequirements { + accuracy, + ..Default::default() + }, + predictability: Predictability::Unknown, + invocations: 1, + execute_at: None, + range_evaluation: None, + }]), + repeating_queries: None, + }, + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1_000), + )), + }; + asap_frontend_promql::lower_promql_query_workload(&workload) + .unwrap() + .remove(0) + .as_operator() + .cloned() + .expect("operator root") +} diff --git a/crates/stage1-pass2-reuse-candidates/Cargo.toml b/crates/stage1-pass2-reuse-candidates/Cargo.toml new file mode 100644 index 000000000..6320ee25e --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "asap-stage1-pass2-reuse-candidates" +version = "0.1.0" +edition = "2021" + +# #509 Stage 1, Pass 2: the reuse rules over memo groups and the logical +# edges (`asap_types::stages::LogicalASAPMemoCandidates`) Stage 2 expands. +# Reads Pass 1's memo through `asap_types::stages`; depends on no other +# stage crate. +[dependencies] +asap-types = { path = "../types" } +thiserror = "2" diff --git a/crates/stage1-pass2-reuse-candidates/src/lib.rs b/crates/stage1-pass2-reuse-candidates/src/lib.rs new file mode 100644 index 000000000..970b77f40 --- /dev/null +++ b/crates/stage1-pass2-reuse-candidates/src/lib.rs @@ -0,0 +1,21 @@ +//! `asap-stage1-pass2-reuse-candidates` — #509 Stage 1, Pass 2: the reuse +//! rules over memo groups and the logical edges. Its entry, +//! [`generate_logical_asap_memo_candidates`], extends Pass 1's memo into +//! the [`LogicalASAPMemoCandidates`](asap_types::stages::LogicalASAPMemoCandidates) +//! Stage 2 expands, e.g. `Quantile(x, 0.99)` and `Quantile(x, 0.95)` reading +//! one built sketch. +//! +//! It depends only on `asap-types`, where its input and output are. It +//! depends on no other stage crate, and the planner facade calls it after +//! Pass 1. + +/// Stage 1, Pass 2's entry, producing [`asap_types::stages::LogicalASAPMemoCandidates`]. +mod logical_memo_candidates; +/// The rules over groups. +mod memo; + +pub use logical_memo_candidates::{ + generate_logical_asap_memo_candidates, pass2_after_local_window_composition, + pass2_local_window_composition, +}; +pub use memo::rules::Pass2RuleError; diff --git a/crates/logical-asap-optimization/src/pass2/logical_memo_candidates.rs b/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs similarity index 95% rename from crates/logical-asap-optimization/src/pass2/logical_memo_candidates.rs rename to crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs index 02fa8f6c5..8088177ca 100644 --- a/crates/logical-asap-optimization/src/pass2/logical_memo_candidates.rs +++ b/crates/stage1-pass2-reuse-candidates/src/logical_memo_candidates.rs @@ -2,10 +2,10 @@ //! rules over groups, and the logical edges //! ([`asap_types::stages::LogicalASAPMemoCandidates`]), which Stage 2 expands. -use crate::pass2::memo::rules::{ +use crate::memo::rules::{ finer_aggregate, rollup, scan_labels, summary_inputs, topk, Pass2RuleError, }; -use crate::pass2::memo::window_candidates::add_local_window_composition; +use crate::memo::window_candidates::add_local_window_composition; use asap_types::stages::{LogicalASAPMemoCandidates, Memo}; use asap_types::workload::QueryWorkloadEntry; diff --git a/crates/logical-asap-optimization/src/pass2/memo.rs b/crates/stage1-pass2-reuse-candidates/src/memo.rs similarity index 87% rename from crates/logical-asap-optimization/src/pass2/memo.rs rename to crates/stage1-pass2-reuse-candidates/src/memo.rs index 754a06a5c..2e507211e 100644 --- a/crates/logical-asap-optimization/src/pass2/memo.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo.rs @@ -1,5 +1,5 @@ //! The logical memo's rules over the memo groups Stage 0 builds -//! (`crate::stage0`). +//! (`asap_stage0_memo`). pub(crate) mod rules; /// Rule 1: tumbling-window layouts of a summary state group. diff --git a/crates/logical-asap-optimization/src/pass2/memo/rules.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules.rs similarity index 100% rename from crates/logical-asap-optimization/src/pass2/memo/rules.rs rename to crates/stage1-pass2-reuse-candidates/src/memo/rules.rs diff --git a/crates/logical-asap-optimization/src/pass2/memo/rules/finer_aggregate.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs similarity index 76% rename from crates/logical-asap-optimization/src/pass2/memo/rules/finer_aggregate.rs rename to crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs index 95c8c7c0a..fee9f0bfd 100644 --- a/crates/logical-asap-optimization/src/pass2/memo/rules/finer_aggregate.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/finer_aggregate.rs @@ -1,8 +1,8 @@ //! Read a SQL aggregate from a finer aggregate's result -//! ([`match_finer`](crate::pass2::finer_aggregate::match_finer)): filter the finer +//! ([`match_finer`](crate::memo::rules::finer_aggregate::match_finer)): filter the finer //! rows, then merge them into the query's groups with each measure's rollup. -use crate::pass2::memo::rules::Pass2RuleError; +use crate::memo::rules::Pass2RuleError; use asap_types::stages::Memo; /// Rule 5, finer aggregate: a candidate of a coarse group that filters and rolls up a finer group. diff --git a/crates/logical-asap-optimization/src/pass2/memo/rules/rollup.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs similarity index 93% rename from crates/logical-asap-optimization/src/pass2/memo/rules/rollup.rs rename to crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs index de56a825b..2f5b8a77f 100644 --- a/crates/logical-asap-optimization/src/pass2/memo/rules/rollup.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/rollup.rs @@ -1,6 +1,6 @@ //! Producer-level exact and sketch rollups, using the existing capability rules. -use crate::pass2::memo::rules::Pass2RuleError; +use crate::memo::rules::Pass2RuleError; use asap_types::stages::Memo; /// Rule 6, rollup: a candidate of a coarse aggregate or summary group reading diff --git a/crates/logical-asap-optimization/src/pass2/memo/rules/scan_labels.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs similarity index 90% rename from crates/logical-asap-optimization/src/pass2/memo/rules/scan_labels.rs rename to crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs index a808eb48f..345e7b5ec 100644 --- a/crates/logical-asap-optimization/src/pass2/memo/rules/scan_labels.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/scan_labels.rs @@ -1,7 +1,7 @@ //! Scan-label widening for explicit memo state sharing. Reuses the existing //! Pass 2 widening rule; only its source-chain boundaries are interned here. -use crate::pass2::memo::rules::Pass2RuleError; +use crate::memo::rules::Pass2RuleError; use asap_types::stages::Memo; /// Rule 2, scan widening: a group for the widened scan, and in each original scan group a candidate `Project(widened)`. diff --git a/crates/logical-asap-optimization/src/pass2/memo/rules/summary_inputs.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/summary_inputs.rs similarity index 91% rename from crates/logical-asap-optimization/src/pass2/memo/rules/summary_inputs.rs rename to crates/stage1-pass2-reuse-candidates/src/memo/rules/summary_inputs.rs index 956141fbf..2bea6eaf2 100644 --- a/crates/logical-asap-optimization/src/pass2/memo/rules/summary_inputs.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/summary_inputs.rs @@ -2,7 +2,7 @@ //! This preserves their full expressions and schema, including opaque series //! identity, rather than assuming that a projection is an identity operation. -use crate::pass2::memo::rules::Pass2RuleError; +use crate::memo::rules::Pass2RuleError; use asap_types::stages::Memo; /// Rule 3, summary inputs: a value projection or filter below a summary build as its own input group. diff --git a/crates/logical-asap-optimization/src/pass2/memo/rules/topk.rs b/crates/stage1-pass2-reuse-candidates/src/memo/rules/topk.rs similarity index 90% rename from crates/logical-asap-optimization/src/pass2/memo/rules/topk.rs rename to crates/stage1-pass2-reuse-candidates/src/memo/rules/topk.rs index e3b1b22dc..0a82547ba 100644 --- a/crates/logical-asap-optimization/src/pass2/memo/rules/topk.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/rules/topk.rs @@ -1,7 +1,7 @@ //! Exact ranking reuse with explicit exact-input groups. No global selection //! flags are required: only exact implementations can supply these inputs. -use crate::pass2::memo::rules::Pass2RuleError; +use crate::memo::rules::Pass2RuleError; use asap_types::stages::Memo; /// Rule 4, top-k reuse: a candidate of each top-k group reading a larger-`k` group, with an exact-input requirement. diff --git a/crates/logical-asap-optimization/src/pass2/memo/window_candidates.rs b/crates/stage1-pass2-reuse-candidates/src/memo/window_candidates.rs similarity index 93% rename from crates/logical-asap-optimization/src/pass2/memo/window_candidates.rs rename to crates/stage1-pass2-reuse-candidates/src/memo/window_candidates.rs index 62594119f..4ecb7e5d3 100644 --- a/crates/logical-asap-optimization/src/pass2/memo/window_candidates.rs +++ b/crates/stage1-pass2-reuse-candidates/src/memo/window_candidates.rs @@ -1,7 +1,7 @@ //! Rule 1, local window composition: tumbling-window layouts $\omega_v$ as //! candidates of a summary state group (#1160). -use crate::pass2::memo::rules::Pass2RuleError; +use crate::memo::rules::Pass2RuleError; use asap_types::stages::Memo; use asap_types::workload::QueryWorkloadEntry; diff --git a/crates/physical-asap-optimization/Cargo.toml b/crates/stage2-physical-optimization/Cargo.toml similarity index 90% rename from crates/physical-asap-optimization/Cargo.toml rename to crates/stage2-physical-optimization/Cargo.toml index dc4220f5e..db1f94888 100644 --- a/crates/physical-asap-optimization/Cargo.toml +++ b/crates/stage2-physical-optimization/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "asap-physical-asap-optimization" +name = "asap-stage2-physical-optimization" version = "0.1.0" edition = "2021" diff --git a/crates/physical-asap-optimization/src/catalog/mod.rs b/crates/stage2-physical-optimization/src/catalog/mod.rs similarity index 100% rename from crates/physical-asap-optimization/src/catalog/mod.rs rename to crates/stage2-physical-optimization/src/catalog/mod.rs diff --git a/crates/physical-asap-optimization/src/catalog/realize.rs b/crates/stage2-physical-optimization/src/catalog/realize.rs similarity index 100% rename from crates/physical-asap-optimization/src/catalog/realize.rs rename to crates/stage2-physical-optimization/src/catalog/realize.rs diff --git a/crates/physical-asap-optimization/src/lib.rs b/crates/stage2-physical-optimization/src/lib.rs similarity index 100% rename from crates/physical-asap-optimization/src/lib.rs rename to crates/stage2-physical-optimization/src/lib.rs diff --git a/crates/plan-selection/Cargo.toml b/crates/stage3-plan-selection/Cargo.toml similarity index 95% rename from crates/plan-selection/Cargo.toml rename to crates/stage3-plan-selection/Cargo.toml index ab85b889e..22f140ef0 100644 --- a/crates/plan-selection/Cargo.toml +++ b/crates/stage3-plan-selection/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "asap-plan-selection" +name = "asap-stage3-plan-selection" version = "0.1.0" edition = "2021" diff --git a/crates/plan-selection/src/lib.rs b/crates/stage3-plan-selection/src/lib.rs similarity index 100% rename from crates/plan-selection/src/lib.rs rename to crates/stage3-plan-selection/src/lib.rs diff --git a/crates/plan-selection/src/milp/mod.rs b/crates/stage3-plan-selection/src/milp/mod.rs similarity index 100% rename from crates/plan-selection/src/milp/mod.rs rename to crates/stage3-plan-selection/src/milp/mod.rs diff --git a/crates/plan-selection/src/milp/model.rs b/crates/stage3-plan-selection/src/milp/model.rs similarity index 100% rename from crates/plan-selection/src/milp/model.rs rename to crates/stage3-plan-selection/src/milp/model.rs diff --git a/crates/plan-selection/src/models.rs b/crates/stage3-plan-selection/src/models.rs similarity index 100% rename from crates/plan-selection/src/models.rs rename to crates/stage3-plan-selection/src/models.rs diff --git a/crates/plan-selection/src/report.rs b/crates/stage3-plan-selection/src/report.rs similarity index 100% rename from crates/plan-selection/src/report.rs rename to crates/stage3-plan-selection/src/report.rs diff --git a/crates/plan-selection/src/select.rs b/crates/stage3-plan-selection/src/select.rs similarity index 100% rename from crates/plan-selection/src/select.rs rename to crates/stage3-plan-selection/src/select.rs diff --git a/crates/plan-selection/tests/cost_inputs_json.rs b/crates/stage3-plan-selection/tests/cost_inputs_json.rs similarity index 100% rename from crates/plan-selection/tests/cost_inputs_json.rs rename to crates/stage3-plan-selection/tests/cost_inputs_json.rs diff --git a/crates/logical-asap-optimization/src/deployment_capabilities.rs b/crates/types/src/deployment_capabilities.rs similarity index 97% rename from crates/logical-asap-optimization/src/deployment_capabilities.rs rename to crates/types/src/deployment_capabilities.rs index add7d3ddd..0ba1be478 100644 --- a/crates/logical-asap-optimization/src/deployment_capabilities.rs +++ b/crates/types/src/deployment_capabilities.rs @@ -33,9 +33,9 @@ // The executor lists the families it builds as `SupportedSummary`s, so the // type lives with the readout vocabulary in `asap-types`. -pub use asap_types::ir::schema::SupportedSummary; -use asap_types::ir::schema::{SketchAlgorithm, StatisticKind}; -use asap_types::workload::{DataArrival, DataWorkload}; +pub use crate::ir::schema::SupportedSummary; +use crate::ir::schema::{SketchAlgorithm, StatisticKind}; +use crate::workload::{DataArrival, DataWorkload}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -93,7 +93,7 @@ impl DeploymentCapabilities { /// family and readout the planner can build) can build `family` and answer /// `readout` from it. Pass 1 reads only this field of /// [`DeploymentCapabilities`]. -pub(crate) fn summary_supported( +pub fn summary_supported( supported_summaries: Option<&[SupportedSummary]>, family: &SketchAlgorithm, readout: StatisticKind, diff --git a/crates/types/src/ir/schema/schema_support.rs b/crates/types/src/ir/schema/schema_support.rs index 61bc53e69..94e997390 100644 --- a/crates/types/src/ir/schema/schema_support.rs +++ b/crates/types/src/ir/schema/schema_support.rs @@ -64,7 +64,7 @@ pub fn with_promql_series_identity( // A series has at most one sample per timestamp, so the full // identity and the timestamp key each row. Pass 2's // identical-expression rule reads this key: only a keyed - // node is legal to share (hash-consing, `Memo::find_or_add_group`). + // node is legal to share (Stage 0 hash-consing, `cse` in `asap_stage0_memo`). if let Some(time) = schema.time_index { schema.add_unique_key(vec![schema.fields.len() - 1, time]); } diff --git a/crates/types/src/ir/schema/sketch.rs b/crates/types/src/ir/schema/sketch.rs index b4c4ace79..b5b80cb7c 100644 --- a/crates/types/src/ir/schema/sketch.rs +++ b/crates/types/src/ir/schema/sketch.rs @@ -233,7 +233,7 @@ impl PartialEq for SketchParams { /// ([`FamilyCapability`](crate::ir::schema::FamilyCapability)). /// /// Construct through [`SketchKind::new`], which rejects parameters of a -/// different algorithm. See `asap_logical_asap_optimization::summary_candidates` for the +/// different algorithm. See `asap_stage1_pass1_per_computation_candidates`'s `summary_candidates` for the /// `AggIntent -> [SketchAlgorithm]` candidate list. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SketchKind { diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index e219b78f5..0f8987310 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -9,6 +9,9 @@ //! [`ir::properties`] (§2.2–2.3 accuracy, guarantee, execution timing and //! summary coverage), the flat DAG //! form ([`ir::flat`]) and the physical ASAP DAG ([`ir::physical_export`]). +//! - [`deployment_capabilities`] — what the deployment's executor can run and +//! keep ([`deployment_capabilities::DeploymentCapabilities`]), the deployment +//! input Stages 1–3 read. //! - [`workload`] — the planner inputs (#509): the query and data //! workloads and the parsed workload the query-language frontends produce //! ([`workload::parsed_workload`]). @@ -20,6 +23,7 @@ //! physical candidates ([`stages::PhysicalASAPMemoCandidates`]). //! //! No execution logic lives in this crate (issue #190). +pub mod deployment_capabilities; pub mod ir; pub mod physical; pub mod serde_f64; diff --git a/crates/types/src/test_support.rs b/crates/types/src/test_support.rs index 71879fdc5..720812263 100644 --- a/crates/types/src/test_support.rs +++ b/crates/types/src/test_support.rs @@ -1,7 +1,8 @@ //! Helpers that only tests call (this crate's integration tests and other //! crates' tests), exposed under the `test-support` feature so the normal //! public API stays as narrow as its production users need. Each forwards -//! to the crate-private function of the same name. +//! to the crate-private function of the same name, except the fixture +//! [`population_over_selector`], which the executor's tests build. use crate::ir::materialized_view::RawDataWindow; use crate::ir::operator::input::node_of_build; @@ -87,3 +88,75 @@ pub fn physical_asap_dag_at_query_time( .0, ) } + +/// The maintained-population sub-DAG for executor tests: `root` (below its +/// projections, which are kept) replaced by `EvaluatePopulation(statistic)` +/// over a current-series population of the single selector under it, +/// grouped by `grouping`, with no matchers. The caller states the statistic +/// and grouping; nothing is recognized. No planner pass offers this sub-DAG +/// yet (#681). +pub fn population_over_selector( + root: &Rc, + statistic: crate::ir::operator::maintained_population::PopulationStatistic, + grouping: &[&str], +) -> Rc { + use crate::ir::operator::maintained_population::*; + use crate::ir::operator::operator_properties::Source; + if let Some(NonASAPOp::Project { + cols, + qualifier, + child, + }) = root.non_asap() + { + let child = population_over_selector(child, statistic.clone(), grouping); + let project = Operator::NonASAP(NonASAPOp::Project { + cols: cols.clone(), + qualifier: qualifier.clone(), + child, + }); + return Rc::new(OperatorNode::with_schema(project, root.schema.clone())); + } + let selectors: Vec<_> = OperatorNode::reachable(root) + .into_iter() + .filter(|node| matches!(node.non_asap(), Some(NonASAPOp::TimeRange { .. }))) + .collect(); + let [selector] = selectors.as_slice() else { + panic!("one selector under {root:?}"); + }; + let Some(NonASAPOp::TimeRange { range, child, .. }) = selector.non_asap() else { + unreachable!() + }; + let Some(NonASAPOp::Scan { + source: Source::TimeSeries { metric }, + .. + }) = child.non_asap() + else { + panic!("a time-series selector, got {child:?}"); + }; + let population = MaintainedPopulation { + input: PopulationInput::CurrentSeries(CurrentSeriesInput { + metric: metric.clone(), + matchers: Vec::new(), + grouping: grouping.iter().map(|label| label.to_string()).collect(), + without: false, + lookback_ms: range.as_millis() as u64, + }), + max_k: match statistic { + PopulationStatistic::TopK { k } => k, + _ => 0, + }, + quantiles: matches!(statistic, PopulationStatistic::Quantile { .. }), + }; + let maintained = OperatorNode::with_schema( + Operator::ASAP(ASAPOp::MaintainPopulation { + child: selector.clone(), + population, + }), + selector.schema.clone(), + ); + let evaluation = Operator::ASAP(ASAPOp::EvaluatePopulation { + child: Rc::new(maintained), + evaluation: statistic, + }); + Rc::new(OperatorNode::with_schema(evaluation, root.schema.clone())) +} diff --git a/crates/types/tests/planner_vocabulary.rs b/crates/types/tests/planner_vocabulary.rs index 5f5a6ab7a..ebb441646 100644 --- a/crates/types/tests/planner_vocabulary.rs +++ b/crates/types/tests/planner_vocabulary.rs @@ -1,7 +1,7 @@ use asap_types::ir::physical_export::WindowEdgeCompatibility; // The renamed window-edge compatibility still reads and emits the deployed -// wire value. (Tumbling-window coverage names are checked in physical-asap-optimization.) +// wire value. (Tumbling-window coverage names are checked in asap-stage2-physical-optimization.) #[test] fn window_edge_compatibility_names_preserve_wire_values() { let compatibility = diff --git a/docs/design_docs/architecture/README.md b/docs/design_docs/architecture/README.md index f714afc1f..a521d7e3f 100644 --- a/docs/design_docs/architecture/README.md +++ b/docs/design_docs/architecture/README.md @@ -45,9 +45,10 @@ target. | Shared IR | `asap-types` | The unified operator IR (`ir`: one `OperatorNode` before and after ASAP optimization), schemas, workloads, guarantees, and exported plan data | | Front-end common | `frontend-common` | Name-based `UnresolvedOp` tree shared by the front ends, and `resolve_root` into the operator IR | | Query frontends | `frontend-sql`, `frontend-promql`, `frontend-metricsql` | Stage 0: parse source languages and produce canonical Pre-ASAP queries | -| Logical ASAP-aware optimization | `logical-asap-optimization` | Stage 1: per-computation candidates (Pass 1), ASAP-aware CSE (Pass 2), the analytical accuracy model | -| Physical ASAP-aware optimization | `physical-asap-optimization` | Stage 2: materialization and physical operator implementation | -| Plan selection | `plan-selection` | Stage 3: accuracy and latency checks, costing and selection; the planning models and the Stage 1 to Stage 3 driver | +| Stage 0 hash-consing | `stage0-memo` | The memo groups of the workload's query roots | +| Logical ASAP-aware optimization | `stage1-pass1-per-computation-candidates`, `stage1-pass2-reuse-candidates` | Stage 1: per-computation candidates (Pass 1), ASAP-aware CSE (Pass 2) | +| Physical ASAP-aware optimization | `stage2-physical-optimization` | Stage 2: materialization and physical operator implementation | +| Plan selection | `stage3-plan-selection` | Stage 3: accuracy and latency checks, costing and selection; the planning models and the Stage 1 to Stage 3 driver | | Planner facade | `planner` | `e2e_plan`: Stage 0 followed by Stages 1–3 | | Executor | `executor` | Stage 4: runs a `PhysicalASAPDAG` over `asap_sketchlib` summaries; no stage crate depends on it | | Developer inspection | `devtools` | `asap_planner` and the Stage Viewer documents, corpus coverage tools | diff --git a/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md b/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md index d8756480f..6573da6bb 100644 --- a/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md +++ b/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md @@ -233,14 +233,16 @@ pass no longer runs these steps; another pass need not run any of them. | Crate | What it holds | |---|---| | `asap-types` | `ParsedWorkload` | -| `asap-plan-selection` | Stage 3: `select_plan`, `Selection`, `SearchReport`, `AccuracyCheckInputs` | +| `asap-stage3-plan-selection` | Stage 3: `select_plan`, `Selection`, `SearchReport`, `AccuracyCheckInputs` | | `asap-planner` *(new)* | `e2e_plan`, `UserInput`, `FrontendInput`, lowering dispatch; `OptimizationPass`, `OptimizationInput`, `PlanOutput`, `PlanningModels`, `optimize`, `PassRegistry`, `Stage1ToStage3Pass`, `run_planning` | ```text asap-planner ──┬──> asap-frontend-{sql, promql, metricsql} - ├──> asap-logical-asap-optimization ──> asap-types - ├──> asap-physical-asap-optimization ──> asap-types - └──> asap-plan-selection ──> asap-types, asap-cost, asap-accuracy + ├──> asap-stage0-memo ──> asap-types + ├──> asap-stage1-pass1-per-computation-candidates ──> asap-types, asap-accuracy + ├──> asap-stage1-pass2-reuse-candidates ──> asap-types + ├──> asap-stage2-physical-optimization ──> asap-types + └──> asap-stage3-plan-selection ──> asap-types, asap-cost, asap-accuracy ``` `asap-planner` is separate because it is the only crate depending on every diff --git a/docs/design_docs/decisions/concat-unique-keys.md b/docs/design_docs/decisions/concat-unique-keys.md index 4820d2185..4e18cd28f 100644 --- a/docs/design_docs/decisions/concat-unique-keys.md +++ b/docs/design_docs/decisions/concat-unique-keys.md @@ -75,7 +75,7 @@ Both current `Concat`-constructing call sites, and every consumer of test fixtures) — none of them builds a fresh `Concat` with a `Dedup` on top that this feature could remove. - **Every consumer of `Schema::unique_keys`** in the DAG, to check for a - cost beyond "a literal `Dedup` node": `share_common_sub_dags` (crate-private in `asap_logical_asap_optimization`, `stage0/cse.rs`) + cost beyond "a literal `Dedup` node": `share_common_sub_dags` (crate-private in `asap_stage0_memo`, `cse.rs`) (gates CSE producer-sharing on `Schema::has_unique_key()`) and `asap_aware_mapping::rollup::is_legal_rollup_source` (gates rollup-source legality the same way, on an *`Aggregate`'s* own output schema). Neither diff --git a/docs/design_docs/decisions/cse-cost-model.md b/docs/design_docs/decisions/cse-cost-model.md index b7a87edba..45d757f58 100644 --- a/docs/design_docs/decisions/cse-cost-model.md +++ b/docs/design_docs/decisions/cse-cost-model.md @@ -16,7 +16,7 @@ elimination) to identify eligible, structurally identical computations. A shared logical node records an opportunity for reuse; selecting maintained state or independent execution is a separate planning decision. -[`share_common_sub_dags`](../../../crates/logical-asap-optimization/src/stage0/cse.rs) +[`share_common_sub_dags`](../../../crates/stage0-memo/src/cse.rs) (issue #223 stages 1-2, PR #235) already *detects* every structurally-identical, legally-shareable (`Schema::unique_keys`-gated) sub-DAG and shares it **unconditionally** — there is no cost gate on top of legality. This document diff --git a/docs/design_docs/proposals/asap-primitive-schema.md b/docs/design_docs/proposals/asap-primitive-schema.md index 4bf59e9ea..d6920d4e6 100644 --- a/docs/design_docs/proposals/asap-primitive-schema.md +++ b/docs/design_docs/proposals/asap-primitive-schema.md @@ -92,7 +92,7 @@ Based on our requirement, each field should contain the following information. | `FrequencyL2`, `FrequencyEntropy` | `Sketch`: `UnivMon` | `SummaryEstimate(FrequencyL2 \| FrequencyEntropy)` | | `Sum`, `Count`, `Min`, `Max`, `Rate`, `IRate`, `Increase` (exact) | `ExactAggregate(ExactKind, …)` | `FinalizeExactAccumulator` | - The sketch candidates are `summary_candidates(intent)` in `crates/logical-asap-optimization/src/pass1/realization.rs`; exact accumulators come from Pass 1's `ExactAccumulator` candidates in `pass1/per_computation_candidates.rs`; the evaluations are `SketchStatistic` ([§6.5](#65-update-input-and-evaluations-irschemasketchrs-iroperatormaintained_populationrs)). + The sketch candidates are `summary_candidates(intent)` in `crates/stage1-pass1-per-computation-candidates/src/realization.rs`; exact accumulators come from Pass 1's `ExactAccumulator` candidates in `pass1/per_computation_candidates.rs`; the evaluations are `SketchStatistic` ([§6.5](#65-update-input-and-evaluations-irschemasketchrs-iroperatormaintained_populationrs)). - **Time window aggregation intents**: whether states built over smaller windows can answer a larger one. This depends on how the family combines states: - **Merge** (`SummaryMerge`, §5.6): states over disjoint tumbling windows combine into the state of their union, e.g. two 1-minute KLL states answer a 2-minute quantile. Requires a mergeable family. @@ -1241,7 +1241,7 @@ This part of the code answers two questions about summary state: 1. **Update input** (`SummaryUpdate`): when a `SummaryAgg` reads one input row, what does it add to the state? It adds a **weight**, optionally under an **item** key. 2. **Evaluations**: once the state is built, what can be asked of it? `SketchStatistic` is what `SummaryEstimate` asks a sketch (§5.2). `MaintainedPopulation` and `PopulationStatistic` are the exact-population counterpart, used by `MaintainPopulation` and `EvaluatePopulation` (§5.4, §5.5). -**Update input examples** (as the planner builds them in Stage 1, `asap-logical-asap-optimization`): +**Update input examples** (as the planner builds them in Stage 1, `asap-stage1-pass1-per-computation-candidates`): | Query | Sketch | `item` | `weight` | `weight_domain` | |---|---|---|---|---| diff --git a/docs/design_docs/proposals/joint-planning-implementation-plan.md b/docs/design_docs/proposals/joint-planning-implementation-plan.md index 89b8dde7b..f87477210 100644 --- a/docs/design_docs/proposals/joint-planning-implementation-plan.md +++ b/docs/design_docs/proposals/joint-planning-implementation-plan.md @@ -12,14 +12,14 @@ Preserve supported accuracy semantics, physical eligibility, cost objectives, sc | Scope | Current implementation | Responsibility | | --- | --- | --- | -| Logical identity | `logical-asap-optimization/src/stage0.rs`; `types/src/stages/` | `DagOfLogicalMemoGroups`, `MemoGroup` (`asap_types::stages`; built by `memo_of_queries`), stable initial query/group references. | -| Computation and sharing rules | `logical-asap-optimization/src/pass2/memo/{candidates,rules,state_groups,shared_configurations,window_candidates}` | Populate groups, retain local candidates, expose shared states and compatible rewrites. | -| Physical legality | `physical-asap-optimization` | Executable operators, timing, materialization eligibility, and `PhysicalWorkloadPlan`. | -| Candidate dependency inventory | `physical-asap-optimization/src/catalog`; `types/src/stages/physical` | Local `PhysicalCandidate`, `ProducerRequirement` (`asap_types::stages`), compatible producers, structural pruning, the selected DAG (`DagOfSelectedCandidates`, one node per run of a candidate), and reconstruction. Stage 2 makes no execution-scope copies; today's Stage 3 engine makes its own (`plan-selection/src/search/candidates/scopes.rs`). | -| Solver | `plan-selection/src/search/milp` | Activation, producer/root assignment, reachability, acyclicity, resource constraints, and solver warm starts. | -| Search and validation | `plan-selection/src/search/refinement` | Actual evaluator checks, sound cuts, objective-specific ranking, and certified component splitting. | -| Pricing support | `plan-selection/src/search/costing` | Certified local bounds and consistent calibration. Production execution demand is owned by `cost/src/execution_demand.rs`; the search demand reference is test-only. | -| Pipeline and lifecycle | `plan-selection/src/search/{pipeline,route}` | Initial groups, generation accounting, seed/full search budgets, archives, reports, and selected workload outputs. | +| Logical identity | `stage0-memo/src/lib.rs`; `types/src/stages/` | `DagOfLogicalMemoGroups`, `MemoGroup` (`asap_types::stages`; built by `memo_of_queries`), stable initial query/group references. | +| Computation and sharing rules | `stage1-pass2-reuse-candidates/src/memo/{candidates,rules,state_groups,shared_configurations,window_candidates}` | Populate groups, retain local candidates, expose shared states and compatible rewrites. | +| Physical legality | `stage2-physical-optimization` | Executable operators, timing, materialization eligibility, and `PhysicalWorkloadPlan`. | +| Candidate dependency inventory | `stage2-physical-optimization/src/catalog`; `types/src/stages/physical` | Local `PhysicalCandidate`, `ProducerRequirement` (`asap_types::stages`), compatible producers, structural pruning, the selected DAG (`DagOfSelectedCandidates`, one node per run of a candidate), and reconstruction. Stage 2 makes no execution-scope copies; today's Stage 3 engine makes its own (`stage3-plan-selection/src/search/candidates/scopes.rs`). | +| Solver | `stage3-plan-selection/src/search/milp` | Activation, producer/root assignment, reachability, acyclicity, resource constraints, and solver warm starts. | +| Search and validation | `stage3-plan-selection/src/search/refinement` | Actual evaluator checks, sound cuts, objective-specific ranking, and certified component splitting. | +| Pricing support | `stage3-plan-selection/src/search/costing` | Certified local bounds and consistent calibration. Production execution demand is owned by `cost/src/execution_demand.rs`; the search demand reference is test-only. | +| Pipeline and lifecycle | `stage3-plan-selection/src/search/{pipeline,route}` | Initial groups, generation accounting, seed/full search budgets, archives, reports, and selected workload outputs. | `PhysicalCandidate` is a local selectable computation. `PhysicalWorkloadPlan` is a complete Stage 2 workload DAG; they are not interchangeable. Physical candidate configuration in selection invokes the authoritative Stage 2 legality code rather than duplicating its rules. Logical rules do not depend on the solver crate. diff --git a/docs/design_docs/proposals/joint-planning-search.md b/docs/design_docs/proposals/joint-planning-search.md index 2558a5341..937f21269 100644 --- a/docs/design_docs/proposals/joint-planning-search.md +++ b/docs/design_docs/proposals/joint-planning-search.md @@ -632,7 +632,7 @@ After selection, the planner reconstructs the DAG and evaluates accuracy, latenc The implementation uses `DagOfLogicalMemoGroups` and `MemoGroup` for initial identities, and `Memo` / `CandidateForMemoGroup` for logical expansion. Local `PhysicalCandidate` choices have explicit producer requirements and compatible connections. Selection reconstructs a `PhysicalWorkloadPlan` and validates its resulting graph through the configured evaluators. The sole production engine is `CandidateConnections`; bounded and exhaustive modes use the same candidate representation and solver. Existing supported runtime operators determine which configurations can be generated. -The candidate memo and its Pass 2 rules belong in `logical-asap-optimization`; physical eligibility remains authoritative in `physical-asap-optimization`. Within selection, candidate inventory/reconstruction, MILP construction, evaluator refinement, pricing support, and route orchestration have separate scopes. State-local choices belong in candidates; cross-candidate decisions use connections and constraints. The retired `CouplingSite` selector representation and legacy search routes are removed rather than retained under new names. +The candidate memo and its Pass 2 rules belong in `stage1-pass2-reuse-candidates`; physical eligibility remains authoritative in `stage2-physical-optimization`. Within selection, candidate inventory/reconstruction, MILP construction, evaluator refinement, pricing support, and route orchestration have separate scopes. State-local choices belong in candidates; cross-candidate decisions use connections and constraints. The retired `CouplingSite` selector representation and legacy search routes are removed rather than retained under new names. Evaluation must distinguish generation coverage from selection correctness. Small workloads are exhaustively enumerated over candidate subsets and compatible connections under the same inventory, then compared with MILP results. Cases include multiple selected candidates in one group, shared build costs, separate execution scopes, fusion outputs, and composed accuracy/resource requirements. Scaling experiments measure candidate and connection counts, generation and solve time, planner memory, and validated execution savings. Together these measurements establish whether the representation avoids repeated search work and whether joint selection justifies its planning cost. diff --git a/docs/design_docs/proposals/planner-layering-example2-acceptance.md b/docs/design_docs/proposals/planner-layering-example2-acceptance.md index 0ec29bd09..b0d35a810 100644 --- a/docs/design_docs/proposals/planner-layering-example2-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example2-acceptance.md @@ -10,7 +10,7 @@ several computations". Tests: 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 the whole-workload reference -pricer (`asap_plan_selection::reference_pricer::select_among_plans`). A test that +pricer (`asap_stage3_plan_selection::reference_pricer::select_among_plans`). 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.md b/docs/design_docs/proposals/planner-layering.md index 3c0678a33..93cb4441d 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -282,7 +282,7 @@ one. It makes no implementation choice, so this section counts it as Stage 0 (its output); the code runs it as Stage 1 starts, which is why [Stage inputs and outputs](#stage-inputs-and-outputs-whole-workload-candidates-and-memo) lists `memo_of_queries` with Pass 1. `memo_of_queries` runs -`share_common_sub_dags` (`crates/logical-asap-optimization/src/stage0/cse.rs`) over the frontends' +`share_common_sub_dags` (`crates/stage0-memo/src/cse.rs`) over the frontends' roots and numbers the shared nodes as groups. Two structurally equal nodes become one group only when sharing them is legal, that is, when every reader of the shared node reads the rows it would read alone. Equal nodes are @@ -515,11 +515,11 @@ equals the MILP's objective, and that every query's accuracy and latency hold. #### Memo code interface -The memo rules are in `crates/logical-asap-optimization/src/` (module -`pass2::memo`, crate-private: the crate root re-exports each entry point); the memo types Stage 1 hands Stage 2 are in +The memo rules are in `crates/stage1-pass2-reuse-candidates/src/` (module +`memo`, crate-private: the crate root re-exports each entry point); the memo types Stage 1 hands Stage 2 are in `crates/types/src/stages/` (`asap_types::stages`); the facade that runs the stages is in `crates/planner/src/run.rs`; the search over Stage 2's -candidates is in `crates/plan-selection/src/` (`select.rs`, `groups/`, `milp/`). +candidates is in `crates/stage3-plan-selection/src/` (`select.rs`, `groups/`, `milp/`). | Step | Entry point | Path | |---|---|---| @@ -528,7 +528,7 @@ candidates is in `crates/plan-selection/src/` (`select.rs`, `groups/`, `milp/`). | Memo types | `DagOfLogicalMemoGroups`, `PerComputationLogicalMemoCandidates`, `HowToComputeAggregateMeasure`, Pass 1's working types (`ComputationTarget`, `FusedComputation`, `HavingEntry`, `HavingPlacement`), `Memo`, `CandidateForMemoGroup`, `OriginOfCandidateForMemoGroup`, `LogicalASAPMemoCandidates`, `CandidateGenerationTimeout`, `root_groups`, `queries_rooted_at` | `crates/types/src/stages/` (`asap_types::stages`) | | Pass 2 | `generate_logical_asap_memo_candidates` → `LogicalASAPMemoCandidates`, from Pass 1's output. It calls in this order: 1. window composition for every legal tumbling-window width (`add_local_window_composition`); 2–9. the rules in `pass2/memo/rules/`: scan widening, summary inputs, top-k reuse, finer aggregate, rollup, window rollup, summary results, state configurations; 10. `add_shared_summary_state_groups`, the shared builds | `pass2/logical_memo_candidates.rs` (`LogicalASAPMemoCandidates` itself: `asap_types::stages`); `pass2/memo/window_candidates.rs`, `pass2/memo/rules/`, `pass2/memo/state_groups.rs`, `pass2/memo/shared_configurations.rs` | | Memo to candidate DAGs | `enumerate`, `build_logical_asap_dags_from_chosen_candidates`, `ChosenCandidatePerMemoGroup`, `AllChosenCandidatesPerMemoGroup`, `ErrorBuildingLogicalASAPDagsFromChosenCandidates` | `pass2/memo/enumeration.rs` (re-exported at the crate root) | -| Production memo build and search | The facade's `run_planning` builds the DAG of logical memo groups and runs Pass 1 (`pass1_memo`) once; then `generate_logical_asap_memo_candidates`, `generate_physical_asap_memo_candidates` and Stage 3's `select_plan`, once each, and builds the selected plan with Stage 2's `AssignmentRealizer::realize`. Behind `select_plan`: the group selection MILP (#1095), `groups/` and `milp/` | `crates/planner/src/run.rs`; `crates/plan-selection/src/select.rs` | +| Production memo build and search | The facade's `run_planning` builds the DAG of logical memo groups and runs Pass 1 (`pass1_memo`) once; then `generate_logical_asap_memo_candidates`, `generate_physical_asap_memo_candidates` and Stage 3's `select_plan`, once each, and builds the selected plan with Stage 2's `AssignmentRealizer::realize`. Behind `select_plan`: the group selection MILP (#1095), `groups/` and `milp/` | `crates/planner/src/run.rs`; `crates/stage3-plan-selection/src/select.rs` | ## ASAPPlanner Detailed Design: Planning Stages and their decisions/Strategies @@ -600,8 +600,8 @@ the memo path's entry points. | 1, Pass 1 | Logical roots; the deployment's supported summaries → the Pass 1 inventory, every computation's candidates (`PerComputationCandidates`, now Pass 1's crate-private working form) | Logical roots → `DagOfLogicalMemoGroups` (hash-consed `MemoGroup`s); with the Pass 1 inventory, less the summaries the deployment does not support and those no query reading them can use → `PerComputationLogicalMemoCandidates`: the `Memo` (each group's `CandidateForMemoGroup`s, with the accuracy-scoped sizes) and the pruned candidates (`PrunedCandidate`) | `memo_of_queries`, `pass1_memo` | | 1, Pass 2 | One Pass 1 choice per computation → that choice rewritten by each sharing rule, every subset of sharing choices (no longer in the code) | `Memo`; the entries' recurrence and range steps → the same memo with each rule's candidates added to the groups they implement | the memo rules (`pass2/memo/rules/`), `pass2::memo::window_candidates` | | 1, concrete summary-state groups | (part of Pass 2) | `Memo` → the memo with concrete summary-state groups: one group per shared size or tumbling-window layout that readers can read | `add_shared_summary_state_groups`, Pass 2's last step; the Pass 2 output is `LogicalASAPMemoCandidates` (`generate_logical_asap_memo_candidates`) | -| 2 | One whole-workload logical candidate; entries (recurrence, predictability), `DataWorkload`, query-time retention → its physical candidates, one per physical properties assignment | `LogicalASAPMemoCandidates`; entries, `DataWorkload`, query-time and raw-data retention → `PhysicalASAPMemoCandidates`: every operator node of every candidate with each allowed timing and lifetime (one physical candidate each, no copies), the producers of each input, reader constraints (each applied to every selected run of its candidate), and the pruned candidates (`PrunedCandidate`) | `generate_physical_asap_memo_candidates` (`asap_physical_asap_optimization`) | -| 3 | Every physical candidate; accuracy targets, accuracy model and evidence, cost model → one plan | `PhysicalASAPMemoCandidates`; entries, `DataWorkload`, `AccuracyCheckInputs`, `CostInputs`, Stage 2's realizer → `Selection`: the selected plan (`selected_dag`, a `DagOfSelectedCandidates`: one node per run of a candidate, so a candidate recomputed by two readers or shared as two instances over different inputs is two nodes, with an edge per input), its cost, the accepted accuracy per query and the search report | `select_plan` (`asap_plan_selection`); the facade (`run_planning`, output `PlanningRun`) runs it once and builds the selected `PhysicalWorkloadPlan` with `AssignmentRealizer::realize`, one plan node per selected node | +| 2 | One whole-workload logical candidate; entries (recurrence, predictability), `DataWorkload`, query-time retention → its physical candidates, one per physical properties assignment | `LogicalASAPMemoCandidates`; entries, `DataWorkload`, query-time and raw-data retention → `PhysicalASAPMemoCandidates`: every operator node of every candidate with each allowed timing and lifetime (one physical candidate each, no copies), the producers of each input, reader constraints (each applied to every selected run of its candidate), and the pruned candidates (`PrunedCandidate`) | `generate_physical_asap_memo_candidates` (`asap_stage2_physical_optimization`) | +| 3 | Every physical candidate; accuracy targets, accuracy model and evidence, cost model → one plan | `PhysicalASAPMemoCandidates`; entries, `DataWorkload`, `AccuracyCheckInputs`, `CostInputs`, Stage 2's realizer → `Selection`: the selected plan (`selected_dag`, a `DagOfSelectedCandidates`: one node per run of a candidate, so a candidate recomputed by two readers or shared as two instances over different inputs is two nodes, with an edge per input), its cost, the accepted accuracy per query and the search report | `select_plan` (`asap_stage3_plan_selection`); the facade (`run_planning`, output `PlanningRun`) runs it once and builds the selected `PhysicalWorkloadPlan` with `AssignmentRealizer::realize`, one plan node per selected node | | 4. Execution | The selected `PhysicalASAPDAG` → query results | Same: an `InstallablePlan` (`{dag, query_texts}`) → query results | The deployment runtime installs the `InstallablePlan` | What changed with the memo: diff --git a/docs/develop_docs/asap-aware-mapping-architecture.md b/docs/develop_docs/asap-aware-mapping-architecture.md index a1b24044c..b309f2cbb 100644 --- a/docs/develop_docs/asap-aware-mapping-architecture.md +++ b/docs/develop_docs/asap-aware-mapping-architecture.md @@ -19,22 +19,26 @@ in `asap-integration-tests` (`crate_graph`) checks these edges with | --- | --- | | `asap-types` (`crates/types`) | Shared vocabulary, by IR section: `ir::operator` (`OperatorNode`, `NonASAPOp`, `ASAPOp`, operator parameters, `AggIntent`), `ir::scalar` (`ScalarExpr`, `ColumnRef`), `ir::schema` (`Schema`, `FieldDataType`, summary-state types such as `SketchAlgorithm` and `FamilyCapability`), `ir::properties` (`AccuracyTarget`, `AccuracyMetric`, `AccuracyGuarantee`, execution timing, summary coverage), plus `ir::flat` and `ir::physical_export`; the planner inputs (`workload`); Stage 2 decision data (`physical`: `PhysicalProperties` (timing and `Lifetime`), tumbling windows) | | `asap-frontend-sql`, `asap-frontend-promql`, `asap-frontend-metricsql` (with `asap-frontend-common`) | Stage 0, the query-language frontends: lower each query into the logical DAG (`QueryRoot`s of `NonASAPOp` nodes, no summaries, no timing) | -| `asap-logical-asap-optimization` (`crates/logical-asap-optimization`) | Stage 1: Pass 1 and Pass 2 (the memo rules) | +| `asap-stage0-memo` (`crates/stage0-memo`) | Stage 0 hash-consing: the memo groups of the workload's query roots (`memo_of_queries`) | +| `asap-stage1-pass1-per-computation-candidates` (`crates/stage1-pass1-per-computation-candidates`) | Stage 1, Pass 1: the initial candidates of each memo group | +| `asap-stage1-pass2-reuse-candidates` (`crates/stage1-pass2-reuse-candidates`) | Stage 1, Pass 2: the reuse rules over memo groups and the logical edges | | `asap-accuracy` (`crates/accuracy`) | The accuracy model the stages share: the analytical rules (per-family estimators, composition, budget split, target check) and the deployment's `AccuracyModel` and `AccuracyEvidenceProvider`; depends only on `asap-types` | -| `asap-physical-asap-optimization` (`crates/physical-asap-optimization`) | Stage 2: materialization and physical operator implementation | +| `asap-stage2-physical-optimization` (`crates/stage2-physical-optimization`) | Stage 2: materialization and physical operator implementation | | `asap-cost` (`crates/cost`) | The cost model the stages share: resource-usage estimation (the analytical per-operator model, measured operation costs, state byte sizes) and the cost models (`ResourceUsage`, `CostModel`, `CostInputs`); depends only on `asap-types` | -| `asap-plan-selection` (`crates/plan-selection`) | Stage 3: `select_plan` over Stage 2's output; accuracy and latency checks, pricing candidates with `asap-cost` | +| `asap-stage3-plan-selection` (`crates/stage3-plan-selection`) | Stage 3: `select_plan` over Stage 2's output; accuracy and latency checks, pricing candidates with `asap-cost` | | `asap-planner` (`crates/planner`) | The facade: `e2e_plan` runs Stage 0, then the optimization pass (`Stage1ToStage3Pass` by default), which orchestrates Stages 1–3 (`run_planning`); the planning models (`PlanningModels`) and the `OptimizationPass` trait | | `asap-executor` (`crates/executor`) | Stage 4, the reference executor: operators and summary kernels over `asap_sketchlib`; it runs a `PhysicalASAPDAG` | | `asap-devtools` | `asap_planner` and the other inspection tools, the design-doc example workloads (`planner_layering_examples`) and the candidate labels | ```text asap-types ◄── frontends (Stage 0) -asap-types ◄── asap-cost (shared cost model) ◄── asap-physical-asap-optimization, asap-plan-selection -asap-types ◄── asap-accuracy (shared accuracy model) ◄── asap-logical-asap-optimization, asap-physical-asap-optimization, asap-plan-selection -asap-types ◄── asap-logical-asap-optimization (Stage 1) ◄── asap-planner -asap-types ◄── asap-physical-asap-optimization (Stage 2) ◄── asap-planner -asap-plan-selection (Stage 3) ◄── asap-planner (also depends on the frontends) +asap-types ◄── asap-cost (shared cost model) ◄── asap-stage2-physical-optimization, asap-stage3-plan-selection +asap-types ◄── asap-accuracy (shared accuracy model) ◄── asap-stage1-pass1-per-computation-candidates, asap-stage2-physical-optimization, asap-stage3-plan-selection +asap-types ◄── asap-stage0-memo (Stage 0 hash-consing) ◄── asap-planner +asap-types ◄── asap-stage1-pass1-per-computation-candidates (Stage 1, Pass 1) ◄── asap-planner +asap-types ◄── asap-stage1-pass2-reuse-candidates (Stage 1, Pass 2) ◄── asap-planner +asap-types ◄── asap-stage2-physical-optimization (Stage 2) ◄── asap-planner +asap-stage3-plan-selection (Stage 3) ◄── asap-planner (also depends on the frontends) asap-types ◄── asap-executor (Stage 4; no stage crate depends on it) ``` @@ -42,18 +46,18 @@ asap-types ◄── asap-executor (Stage 4; no stage crate depends on it) ```text QueryRoots (Stage 0) - │ memo_of_queries → DagOfLogicalMemoGroups, then - │ pass1_memo + │ asap_stage0_memo::memo_of_queries → DagOfLogicalMemoGroups, then + │ asap_stage1_pass1_per_computation_candidates::pass1_memo ▼ Stage 1, Pass 1 PerComputationLogicalMemoCandidates (Memo + pruned_candidates) - │ generate_logical_asap_memo_candidates - │ the memo rules (pass2/memo/rules/) over the candidate memo Stage 1, Pass 2 + │ asap_stage1_pass2_reuse_candidates::generate_logical_asap_memo_candidates + │ the memo rules (memo/rules/) over the candidate memo Stage 1, Pass 2 ▼ LogicalASAPMemoCandidates - │ asap_physical_asap_optimization::generate_physical_asap_memo_candidates Stage 2 + │ asap_stage2_physical_optimization::generate_physical_asap_memo_candidates Stage 2 ▼ PhysicalASAPMemoCandidates (operator nodes per timing/materialization + pruned_candidates) - │ asap_plan_selection::select_plan (with Stage 2's AssignmentRealizer) Stage 3 + │ asap_stage3_plan_selection::select_plan (with Stage 2's AssignmentRealizer) Stage 3 ▼ Selection (selected_dag, cost, accepted_accuracy_per_query, search_report) ``` @@ -66,17 +70,17 @@ same pipeline behind the `OptimizationPass` trait. | Concern | Where it lives | | --- | --- | -| Pass 1: which computations a query has (`ComputationTarget`), how each can be computed (`HowToComputeAggregateMeasure`: `Exact`, `ExactAccumulator`, `Summary`), fused computations (`FusedComputation`), pruning (`PruneReason`) and composing one choice into a logical DAG (`compose_logical_candidate`) | `asap-logical-asap-optimization`, `pass1/per_computation_candidates.rs` | -| Which summary families serve an intent (`summary_candidates`) | `asap-logical-asap-optimization`, `pass1/realization.rs` | -| Pass 2 memo rules: shared producers (identical expressions by memo group identity, one build at different sizes), scan-label widening, window composition (tumbling windows), rollup, exact top-k reuse and finer-aggregate reuse | `asap-logical-asap-optimization`, `pass2/memo/rules/` and `pass2/memo/window_candidates.rs`, over the helpers in `pass2/asap_aware_cse.rs` | -| Enumeration of the plans a memo stands for (reference pricer, DAG viewer) | `asap-logical-asap-optimization`, `pass2/memo/enumeration.rs` | +| Pass 1: which computations a query has (`ComputationTarget`), how each can be computed (`HowToComputeAggregateMeasure`: `Exact`, `ExactAccumulator`, `Summary`), fused computations (`FusedComputation`), pruning (`PruneReason`) and composing one choice into a logical DAG (`compose_logical_candidate`) | `asap-stage1-pass1-per-computation-candidates`, `per_computation_candidates.rs` | +| Which summary families serve an intent (`summary_candidates`) | `asap-stage1-pass1-per-computation-candidates`, `realization.rs` | +| Pass 2 memo rules: shared producers (identical expressions by memo group identity, one build at different sizes), scan-label widening, window composition (tumbling windows), rollup, exact top-k reuse and finer-aggregate reuse | `asap-stage1-pass2-reuse-candidates`, `memo/rules/` and `memo/window_candidates.rs` | +| Enumeration of the plans a memo stands for (reference pricer, DAG viewer) | none today (deleted in #1207 PR 0) | | What each summary family can merge and answer (`FamilyCapability::of`: `merge_inputs`, `readouts`) | `asap-types`, `ir/schema/family_capability.rs` | -| Stage 2: physical properties options per summary build (not materialized; materialized at ingestion time `AcrossEvaluations`; materialized at query time `OneEvaluation` or `AcrossEvaluations`), ingestion-time eligibility, physical operator implementation | `asap-physical-asap-optimization` | +| Stage 2: physical properties options per summary build (not materialized; materialized at ingestion time `AcrossEvaluations`; materialized at query time `OneEvaluation` or `AcrossEvaluations`), ingestion-time eligibility, physical operator implementation | `asap-stage2-physical-optimization` | | Timing and data-state edge rules applied from a `PhysicalPropertiesAssignment` | `asap-types`, `ir/properties/timing.rs` and `ir/properties/execution_data_state.rs`; `PhysicalProperties` and `Lifetime` in `physical/properties.rs` | -| Stage 3: accuracy check, latency check, resource usage (`ResourceUsage`), cost models 1–3 (`CostModel`), measured operation costs | `asap-plan-selection`, `lib.rs`; `asap-cost`, `lib.rs` and `measured.rs` | +| Stage 3: accuracy check, latency check, resource usage (`ResourceUsage`), cost models 1–3 (`CostModel`), measured operation costs | `asap-stage3-plan-selection`, `lib.rs`; `asap-cost`, `lib.rs` and `measured.rs` | | The analytical per-operator resource model Stage 3 measures with | `asap-cost`, `analytical.rs` and `operator_statistics.rs` | | The accuracy algebra: per-family guarantees, composition, budget split; guarantee propagation through a DAG; sizing a summary for its target, (ε, δ) budget and default sizes | `asap-accuracy` (`AccuracyModel`, `BuiltinAccuracyModel`, `estimators/`, `composition.rs`, `budget.rs`, `propagation.rs`, `sizing.rs`) | -| What the executor supports (`DeploymentCapabilities`) | `asap-logical-asap-optimization`, `deployment_capabilities.rs`; Pass 1 receives only `supported_summaries` and Stage 2 only `query_time_results_retained` and `raw_data_retained` (`bool` arguments, so Stage 2 does not depend on the Stage 1 crate); Stage 3 reads none of it | +| What the executor supports (`DeploymentCapabilities`) | `asap-types`, `deployment_capabilities.rs`; Pass 1 receives only `supported_summaries` and Stage 2 only `query_time_results_retained` and `raw_data_retained` (`bool` arguments); Stage 3 reads none of it | | The inputs every stage reads (`PlanningModels`: accuracy model, evidence, cost inputs, deployment capabilities) and the `OptimizationPass` trait | `asap-planner`, `pass.rs`; the facade gives Stage 3 only `AccuracyCheckInputs` and `CostInputs` | The current production search organizes these computations in memo result/state @@ -115,9 +119,10 @@ No stage caps the candidates it plans, and neither does `asap_planner`. ## Verification - Unit tests sit beside each module (`cargo nextest run -p - asap-logical-asap-optimization -p asap-physical-asap-optimization -p - asap-plan-selection -E 'test()'`). -- `crates/logical-asap-optimization/tests/per_computation_candidates.rs` covers + asap-stage0-memo -p asap-stage1-pass1-per-computation-candidates -p + asap-stage1-pass2-reuse-candidates -p asap-stage2-physical-optimization -p + asap-stage3-plan-selection -E 'test()'`). +- `crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs` covers Pass 1. - `crates/integration-tests/tests/planner_layering_example{1,2,3,4}.rs` pin the design document's examples through every stage; diff --git a/docs/develop_docs/asap-aware-mapping-contracts.md b/docs/develop_docs/asap-aware-mapping-contracts.md index 98c9219ce..7c82da604 100644 --- a/docs/develop_docs/asap-aware-mapping-contracts.md +++ b/docs/develop_docs/asap-aware-mapping-contracts.md @@ -5,8 +5,8 @@ the stage code relies on: what each stage's input and output may contain, and which component owns each decision. Read the [architecture](asap-aware-mapping-architecture.md) first; use the [extension guide](extend-asap-aware-mapping.md) when changing one. Field-level -details are in the rustdoc (`cargo doc -p asap-logical-asap-optimization -p -asap-physical-asap-optimization -p asap-plan-selection -p asap-types +details are in the rustdoc (`cargo doc -p asap-stage0-memo -p asap-stage1-pass1-per-computation-candidates -p +asap-stage1-pass2-reuse-candidates -p asap-stage2-physical-optimization -p asap-stage3-plan-selection -p asap-types --no-deps`). ## 1. IR at each stage @@ -18,7 +18,7 @@ asap-physical-asap-optimization -p asap-plan-selection -p asap-types | Stage 2 (`generate_physical_asap_memo_candidates`; a selected plan built by `Stage2AssignmentRealizer`) | the same operators, with a timing and a `Lifetime` on each summary build | every node timed from the candidate's `PhysicalPropertiesAssignment` | - Structurally equal sub-DAGs are interned before Stage 1 - (`stage0/cse.rs` in `asap_logical_asap_optimization`), so Stage 1 compares nodes by structure and Pass 2 + (`cse.rs` in `asap_stage0_memo`), so Stage 1 compares nodes by structure and Pass 2 matches readers structurally, never by pointer. - Timing is applied with `apply_physical_properties`, which validates every data-state edge. A violation is an `ExecutionDataStateError`: diff --git a/docs/develop_docs/extend-asap-aware-mapping.md b/docs/develop_docs/extend-asap-aware-mapping.md index f59566dcd..76e7ae510 100644 --- a/docs/develop_docs/extend-asap-aware-mapping.md +++ b/docs/develop_docs/extend-asap-aware-mapping.md @@ -24,7 +24,7 @@ A new sketch that Pass 1 can offer for an intent. (`crates/executor/src/capability.rs`); add the family's readouts there once the executor answers them. 3. **Offer and size.** Place it in `realization::FAMILY_PREFERENCE` - (`crates/logical-asap-optimization`): `summary_candidates` offers it, in + (`crates/stage1-pass1-per-computation-candidates`): `summary_candidates` offers it, in that order, for every intent whose statistic its capability row answers. Size it in `asap_accuracy::sizing::default_size_params`. 4. **Accuracy.** Add its error model under @@ -43,7 +43,7 @@ A new sketch that Pass 1 can offer for an intent. not with a local copy. 7. **Labels.** Name it in `asap_devtools::candidate_labels`. -Tests: a Pass 1 test in `crates/logical-asap-optimization/tests/per_computation_candidates.rs` +Tests: a Pass 1 test in `crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs` that the intent gets the new candidate (and that a deployment without it prunes it with `UnsupportedByDeployment`); an estimator test beside the estimator; an execution test in `crates/integration-tests/tests/` such as @@ -54,7 +54,7 @@ estimator; an execution test in `crates/integration-tests/tests/` such as A new way for computations of one Pass 1 candidate to share, split or reuse work. -1. Add a memo rule under `crates/logical-asap-optimization/src/pass2/memo/rules/` +1. Add a memo rule under `crates/stage1-pass2-reuse-candidates/src/memo/rules/` (for example `rollup.rs` or `topk.rs`): it adds candidates (and, when it needs them, groups) to the memo groups it implements, keeping every existing candidate. Read summary families only through @@ -80,7 +80,7 @@ contains the candidate and that Stage 3 prices it. (`crates/types/src/physical/properties.rs`, `PhysicalProperties::new` and `readable_at`) and its placement in `crates/types/src/ir/properties/timing.rs`, with its data-state edge rules. -2. Offer it in `crates/physical-asap-optimization/src/lib.rs` (the +2. Offer it in `crates/stage2-physical-optimization/src/lib.rs` (the `MaterializedOutputCandidate` options), after the existing options so default candidate ids stay the same, and gate it on the deployment input that allows it. diff --git a/docs/develop_docs/library-api.md b/docs/develop_docs/library-api.md index 122eae3b8..a738de28d 100644 --- a/docs/develop_docs/library-api.md +++ b/docs/develop_docs/library-api.md @@ -24,22 +24,23 @@ artifact, while preserving the checks required by its intended consumer. ## Dependencies Inside this workspace, depend on the frontend you need, the stage crates -(`asap-logical-asap-optimization` for Stage 1, `asap-physical-asap-optimization` -for Stage 2, `asap-plan-selection` for Stage 3), `asap-planner` for the facade that +(`asap-stage0-memo` for Stage 0 hash-consing, `asap-stage1-pass1-per-computation-candidates` and +`asap-stage1-pass2-reuse-candidates` for Stage 1, `asap-stage2-physical-optimization` +for Stage 2, `asap-stage3-plan-selection` for Stage 3), `asap-planner` for the facade that runs the stages, `asap-cost` for the cost model and its inputs (`CostInputs`), `asap-accuracy` for the accuracy model (`AccuracyModel`, `AccuracyEvidenceProvider`), and `asap-types`, or on `asap-planner`, which wraps every frontend and the planning stages. External users can use Git dependencies pinned to a compatible revision; use the same revision across these crates, and pick a revision at least as new as the APIs you call. Replace `REV` below with a -`main` revision that has the stage crates (`crates/plan-selection`). +`main` revision that has the stage crates (`crates/stage3-plan-selection`). For the examples below: ```toml [dependencies] asap-planner = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "REV" } asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "REV" } -asap-plan-selection = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "REV" } +asap-stage3-plan-selection = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "REV" } asap-cost = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "REV" } asap-accuracy = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "REV" } asap-types = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "REV" } @@ -183,7 +184,7 @@ asap_planner::run_planning( ) -> Result // Stage 3 alone, over Stage 2's output: -asap_plan_selection::select_plan( +asap_stage3_plan_selection::select_plan( physical: &PhysicalASAPMemoCandidates, entries: &[QueryWorkloadEntry], data: &DataWorkload, @@ -197,7 +198,7 @@ asap_plan_selection::select_plan( | --- | --- | | `PlanningModels::new(accuracy, evidence)` | Your `&dyn AccuracyModel` and `&dyn AccuracyEvidenceProvider`, with default cost inputs and deployment capabilities; `with_accuracy` and `with_evidence` replace one of them | | `PlanningModels::builtin()` | Built-in accuracy model (`BuiltinAccuracyModel`) and no extra evidence (`NoAccuracyEvidence`). `with_cost_inputs(&settings)` sets the deployment's `CostInputs`; `with_deployment_capabilities(&capabilities)` sets its `DeploymentCapabilities` (field `deployment_capabilities`) | -| `DeploymentCapabilities` (`asap_logical_asap_optimization`, re-exported as `asap_planner::DeploymentCapabilities`) | What the executor supports: `supported_summaries` (`None`, the default, supports every summary the planner builds; otherwise `SupportedSummary { family: SketchAlgorithm, readouts: Vec }` entries; Pass 1 prunes others with `PruneReason::UnsupportedByDeployment`), `raw_data_retained` (default `true`; when `false`, a candidate that scans raw data at query time is pruned by Stage 2, and stored data is refused with `asap_planner::SelectionError::InvalidDeploymentCapabilities`) and `query_time_results_retained` (default `false`; when `true`, Stage 2 offers query-time `AcrossEvaluations` for tumbling-window chains) | +| `DeploymentCapabilities` (`asap_types::deployment_capabilities`, re-exported as `asap_planner::DeploymentCapabilities`) | What the executor supports: `supported_summaries` (`None`, the default, supports every summary the planner builds; otherwise `SupportedSummary { family: SketchAlgorithm, readouts: Vec }` entries; Pass 1 prunes others with `PruneReason::UnsupportedByDeployment`), `raw_data_retained` (default `true`; when `false`, a candidate that scans raw data at query time is pruned by Stage 2, and stored data is refused with `asap_planner::SelectionError::InvalidDeploymentCapabilities`) and `query_time_results_retained` (default `false`; when `true`, Stage 2 offers query-time `AcrossEvaluations` for tumbling-window chains) | | `CostInputs` (`asap_cost`) | `cost_model`, `resource_speed` (`ms_per_cpu_op`, `ms_per_scanned_byte`), `non_repeating_query_horizon_seconds` (default 3,600) and `measured_operation_costs` (`MeasuredOperationCosts`, default empty: `operators`, one `OperatorCost { operator, phase, sketch / exact / measure, ns_per_unit, ns_per_invocation, memory_bytes_per_state }` per measured operator kind, phase and family; `ns_per_evaluation` per query request; `provenance` (executor version, machine). A node with an entry is priced from it, a sketch between the two nearest measured sizes; every other node from the formulas times one calibration factor fitted from the measured nodes; each node's `NodeCost::price_source` says which. The earlier JSON array of `MeasuredOperationCost { sketch, operation, cpu_ns_per_operation }` still loads) | | `CostModel::UsageOverTime(ResourceUnitCost)` | Cost model 1 (default, `ResourceUnitCost::illustrative()`): unit cost × usage per second; the cheapest valid candidate wins | | `CostModel::ProvisionedCapacity(ResourceUnitCost)` | Cost model 2: unit cost × peak memory, peak CPU cores and disk; the cheapest valid candidate wins | @@ -370,14 +371,15 @@ cost/guarantee evidence needed downstream instead of exporting only a bare DAG. For public symbol details, build local API documentation with: ```sh -cargo doc -p asap-logical-asap-optimization -p asap-physical-asap-optimization \ - -p asap-plan-selection -p asap-types --no-deps +cargo doc -p asap-stage0-memo -p asap-stage1-pass1-per-computation-candidates \ + -p asap-stage1-pass2-reuse-candidates -p asap-stage2-physical-optimization \ + -p asap-stage3-plan-selection -p asap-types --no-deps ``` ## Source references - [Frontend PromQL](../../crates/frontend-promql/src/lib.rs), [SQL](../../crates/frontend-sql/src/lib.rs), [MetricsQL](../../crates/frontend-metricsql/src/lib.rs) -- [Planner facade](../../crates/planner/src/lib.rs), [planning stages](../../crates/planner/src/run.rs), [plan selection and cost models 1–3](../../crates/plan-selection/src/lib.rs) -- [Deployment capabilities](../../crates/logical-asap-optimization/src/deployment_capabilities.rs) +- [Planner facade](../../crates/planner/src/lib.rs), [planning stages](../../crates/planner/src/run.rs), [plan selection and cost models 1–3](../../crates/stage3-plan-selection/src/lib.rs) +- [Deployment capabilities](../../crates/types/src/deployment_capabilities.rs) - [Workload types](../../crates/types/src/workload/mod.rs) - [Planner-runtime contract](../design_docs/architecture/planner-runtime-contract.md) diff --git a/docs/develop_docs/pre-asap-ir.md b/docs/develop_docs/pre-asap-ir.md index 9a2d23dc5..f3346279b 100644 --- a/docs/develop_docs/pre-asap-ir.md +++ b/docs/develop_docs/pre-asap-ir.md @@ -149,7 +149,7 @@ yet — and calls `asap_frontend_common::resolve_root`, which does three things The result is `Rc`. `lower_sql` / `lower_sql_dialect` and `lower_metricsql` return it; `lower_promql_query_workload` returns a `QueryRoot` per query. -Workload search then runs structural CSE (`share_common_sub_dags` (crate-private in `asap_logical_asap_optimization`, `stage0/cse.rs`)) once +Workload search then runs structural CSE (`share_common_sub_dags` (crate-private in `asap_stage0_memo`, `cse.rs`)) once across every root: bottom-up hash-consing where the structural hash is only a filter and the typed `PartialEq` decides sharing, following scalar references like any other input, and gated by `NonASAPOp::is_shareable_when_equal` for non-ASAP producers. diff --git a/docs/develop_docs/target-candidate-api-migration.md b/docs/develop_docs/target-candidate-api-migration.md index 7d89d4389..904f0f9db 100644 --- a/docs/develop_docs/target-candidate-api-migration.md +++ b/docs/develop_docs/target-candidate-api-migration.md @@ -49,7 +49,7 @@ or `Operator::ASAP(ASAPOp)`. Old public names are not kept as aliases. | `SummaryFamilyType` | `FieldDataType` (its non-`Plain` variants) | | Timing stored on post-ASAP nodes | `OperatorNode::timing`, `None` until `ir::timing::apply_physical_properties` writes it from a `PhysicalPropertiesAssignment` (default: all query time) | | `UnresolvedQueryExpr` + `asap_types::pre_asap::resolve_root` | `UnresolvedOp` / `UnresolvedScalar` + `asap_frontend_common::resolve_root` | -| `pre_asap::canonicalize`, `pre_asap::cse::share_common_sub_dags` | `asap_frontend_common` `canonicalize` (crate-private), `share_common_sub_dags` (crate-private in `asap_logical_asap_optimization`, `stage0/cse.rs`) | +| `pre_asap::canonicalize`, `pre_asap::cse::share_common_sub_dags` | `asap_frontend_common` `canonicalize` (crate-private), `share_common_sub_dags` (crate-private in `asap_stage0_memo`, `cse.rs`) | | `asap_types::post_asap::compile_post_asap_dag` (wire version 5, `Fallback`/`Binary`/`Value` payloads) | `asap_types::ir::physical_export::compile_physical_asap_dag` (`PhysicalASAPDAG`, carried in the plan file versioned by `asap_planner::INSTALLABLE_PLAN_WIRE_VERSION`: one node per operator, `Operator` payloads, `ScalarRef` edges); input must be timed | | Exported schema JSON `columns` | `fields` | From eca1f8ad64c3a030580eacb00142f7399aa1e239 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:52:35 +0000 Subject: [PATCH 2/5] test: delete the tests that cross stage crates (#1207 PR 0b) Pass 1's tests that build Stage 0's memo and Stage 0's tests that lower PromQL through Pass 1 are deleted, with the dev-dependencies and helpers only they used. lower_promql moves to Pass 1's test fixtures, so Pass 1 no longer depends on the PromQL frontend outside its own tests. The crate-graph test now also checks dev-dependencies between stage crates. --- Cargo.lock | 1 - crates/integration-tests/tests/crate_graph.rs | 162 +-- crates/stage0-memo/Cargo.toml | 2 +- crates/stage0-memo/src/lib.rs | 4 +- .../Cargo.toml | 10 +- .../src/lib.rs | 8 +- .../src/per_computation_candidates.rs | 1118 +---------------- .../src/per_computation_candidates_tests.rs | 1113 +--------------- .../src/test_fixtures.rs | 108 +- .../src/test_support.rs | 46 +- 10 files changed, 144 insertions(+), 2428 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a541ab6b2..686868b87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -462,7 +462,6 @@ version = "0.1.0" dependencies = [ "asap-accuracy", "asap-frontend-promql", - "asap-stage0-memo", "asap-types", "thiserror 2.0.18", ] diff --git a/crates/integration-tests/tests/crate_graph.rs b/crates/integration-tests/tests/crate_graph.rs index 1efd73395..3f4d90c3a 100644 --- a/crates/integration-tests/tests/crate_graph.rs +++ b/crates/integration-tests/tests/crate_graph.rs @@ -1,9 +1,11 @@ //! The workspace crate graph follows #1054's target graph (its rule 4). //! //! The test reads `cargo metadata --format-version 1 --no-deps` and checks every -//! `[dependencies]` edge between two workspace crates. Dev- and -//! build-dependencies are not checked. Each crate has a role ([`role_of`]), and -//! [`rule_violated`] says which role may depend on which: +//! `[dependencies]` edge between two workspace crates, and every +//! `[dev-dependencies]` edge between two stage crates (a test of one stage +//! never calls another stage). Build-dependencies are not checked. Each crate +//! has a role ([`role_of`]), and [`rule_violated`] says which role may depend +//! on which: //! //! - **stage crates** (the frontends, Stage 0's memo, Stage 1's Pass 1 and //! Pass 2, Stage 2's physical optimization, Stage 3's plan selection) never @@ -18,7 +20,7 @@ //! other crate may depend on them. //! //! The edges the rules forbid but the workspace has today are listed, each -//! with its reason, in [`EXCEPTIONS`]. A new workspace crate fails the test +//! with its reason, in [`EXCEPTIONS`] and [`DEV_EXCEPTIONS`]. A new workspace crate fails the test //! until it gets a role. use serde_json::Value; @@ -90,38 +92,51 @@ fn rule_violated(from: Role, to: Role) -> Option<&'static str> { } } -/// A forbidden edge the workspace keeps on purpose. -struct Exception { - from: &'static str, - to: &'static str, - /// The exception holds only while the dependency is `optional = true`. - only_if_optional: bool, -} +/// A forbidden edge the workspace keeps on purpose: (from, to). +type Exception = (&'static str, &'static str); -/// The forbidden edges the workspace keeps today. +/// The forbidden `[dependencies]` edges the workspace keeps today. const EXCEPTIONS: &[Exception] = &[ - // Pass 1's `test-support` feature lowers PromQL test queries for other - // crates' tests. The dependency is optional and enabled only by that - // feature, so no production build links a frontend into Stage 1. Made - // non-optional, the edge fails the test. - Exception { - from: "asap-stage1-pass1-per-computation-candidates", - to: "asap-frontend-promql", - only_if_optional: true, - }, // `plan_installable` offers only the summaries the Planner's own executor // implements. #1016 decides where that kernel list and the executor // belong; this exception goes away with it. - Exception { - from: "asap-planner", - to: "asap-executor", - only_if_optional: false, - }, + ("asap-planner", "asap-executor"), +]; + +/// The `[dev-dependencies]` edges between stage crates the workspace keeps +/// today: the query-language frontends' tests and Pass 1's tests. +const DEV_EXCEPTIONS: &[Exception] = &[ + // Pass 1's tests lower PromQL test queries (`test_fixtures::lower_promql`). + ( + "asap-stage1-pass1-per-computation-candidates", + "asap-frontend-promql", + ), + // The frontends' tests compose Pass 1 candidates of the lowered queries + // (`tests/support.rs`'s `logical_candidates`, `frequency_l2.rs`). + ("asap-frontend-promql", "asap-stage0-memo"), + ( + "asap-frontend-promql", + "asap-stage1-pass1-per-computation-candidates", + ), + ("asap-frontend-sql", "asap-stage0-memo"), + ( + "asap-frontend-sql", + "asap-stage1-pass1-per-computation-candidates", + ), + ("asap-frontend-metricsql", "asap-stage0-memo"), + ( + "asap-frontend-metricsql", + "asap-stage1-pass1-per-computation-candidates", + ), + // The MetricsQL tests share the PromQL tests' `support.rs` and compare + // with the PromQL frontend's lowering. + ("asap-frontend-metricsql", "asap-frontend-promql"), ]; -/// One message per `[dependencies]` edge of `metadata` (the JSON of +/// One message per edge of `metadata` (the JSON of /// `cargo metadata --format-version 1 --no-deps`) that breaks a rule, naming -/// the edge and the rule. +/// the edge and the rule: a `[dependencies]` edge that [`rule_violated`] +/// forbids, or a `[dev-dependencies]` edge between two stage crates. fn crate_graph_violations(metadata: &Value) -> Vec { let packages = metadata["packages"] .as_array() @@ -141,28 +156,35 @@ fn crate_graph_violations(metadata: &Value) -> Vec { continue; }; for dependency in package["dependencies"].as_array().into_iter().flatten() { - // `kind` is null for `[dependencies]`, "dev" or "build" otherwise. - if !dependency["kind"].is_null() { - continue; - } let to = dependency["name"].as_str().expect("dependency `name`"); - if !names.contains(&to) { + if !names.contains(&to) || to == from { continue; } let Some(to_role) = role_of(to) else { // Reported once, as its own package above. continue; }; - let Some(rule) = rule_violated(from_role, to_role) else { + // `kind` is null for `[dependencies]`, "dev" or "build" otherwise. + let (section, rule, exceptions) = match dependency["kind"].as_str() { + None => ( + "[dependencies]", + rule_violated(from_role, to_role), + EXCEPTIONS, + ), + Some("dev") => ( + "[dev-dependencies]", + (from_role == Role::Stage && to_role == Role::Stage) + .then_some("a stage crate's tests never call another stage crate"), + DEV_EXCEPTIONS, + ), + Some(_) => continue, + }; + let Some(rule) = rule else { continue; }; - let optional = dependency["optional"].as_bool().unwrap_or(false); - let excepted = EXCEPTIONS - .iter() - .any(|e| e.from == from && e.to == to && (optional || !e.only_if_optional)); - if !excepted { + if !exceptions.contains(&(from, to)) { violations.push(format!( - "forbidden [dependencies] edge {from} -> {to}: {rule} (#1054)" + "forbidden {section} edge {from} -> {to}: {rule} (#1054)" )); } } @@ -196,14 +218,14 @@ fn workspace_crate_graph_follows_the_target_graph() { } /// A package of synthetic `cargo metadata` JSON with `[dependencies]` on -/// `deps` (name, optional) and a dev-dependency on `dev_deps`. -fn package(name: &str, deps: &[(&str, bool)], dev_deps: &[&str]) -> Value { +/// `deps` and `[dev-dependencies]` on `dev_deps`. +fn package(name: &str, deps: &[&str], dev_deps: &[&str]) -> Value { let normal = deps .iter() - .map(|(d, optional)| serde_json::json!({"name": d, "kind": null, "optional": optional})); + .map(|d| serde_json::json!({"name": d, "kind": null})); let dev = dev_deps .iter() - .map(|d| serde_json::json!({"name": d, "kind": "dev", "optional": false})); + .map(|d| serde_json::json!({"name": d, "kind": "dev"})); serde_json::json!({"name": name, "dependencies": normal.chain(dev).collect::>()}) } @@ -211,12 +233,12 @@ fn package(name: &str, deps: &[(&str, bool)], dev_deps: &[&str]) -> Value { fn checker_rejects_an_edge_between_stage_crates() { let metadata = serde_json::json!({"packages": [ package("asap-types", &[], &[]), - package("asap-cost", &[("asap-types", false)], &[]), - package("asap-stage2-physical-optimization", &[("asap-types", false)], &[]), + package("asap-cost", &["asap-types"], &[]), + package("asap-stage2-physical-optimization", &["asap-types"], &[]), // Forbidden: Stage 3 on Stage 2. package( "asap-stage3-plan-selection", - &[("asap-cost", false), ("asap-stage2-physical-optimization", false)], + &["asap-cost", "asap-stage2-physical-optimization"], &[], ), ]}); @@ -231,38 +253,32 @@ fn checker_rejects_an_edge_between_stage_crates() { } #[test] -fn checker_ignores_dev_dependencies_and_external_crates() { +fn checker_ignores_external_crates_and_non_stage_dev_dependencies() { let metadata = serde_json::json!({"packages": [ - package("asap-types", &[("serde", false)], &[]), - package("asap-stage1-pass1-per-computation-candidates", &[("asap-types", false)], &[]), - package( - "asap-stage3-plan-selection", - &[("asap-types", false)], - &["asap-stage1-pass1-per-computation-candidates"], - ), + package("asap-types", &["serde"], &[]), + package("asap-stage1-pass1-per-computation-candidates", &["asap-types"], &[]), + package("asap-executor", &["asap-types"], &["asap-stage1-pass1-per-computation-candidates"]), ]}); assert!(crate_graph_violations(&metadata).is_empty()); } #[test] -fn optional_only_exception_rejects_the_edge_made_non_optional() { - let metadata = |optional| { - serde_json::json!({"packages": [ - package("asap-types", &[], &[]), - package("asap-frontend-promql", &[("asap-types", false)], &[]), - package( - "asap-stage1-pass1-per-computation-candidates", - &[("asap-types", false), ("asap-frontend-promql", optional)], - &[], - ), - ]}) - }; - assert!(crate_graph_violations(&metadata(true)).is_empty()); +fn checker_rejects_a_dev_dependency_between_stage_crates() { + let metadata = serde_json::json!({"packages": [ + package("asap-types", &[], &[]), + package("asap-stage0-memo", &["asap-types"], &[]), + // Forbidden: Pass 1's tests call Stage 0. + package( + "asap-stage1-pass1-per-computation-candidates", + &["asap-types"], + &["asap-stage0-memo"], + ), + ]}); assert_eq!( - crate_graph_violations(&metadata(false)), + crate_graph_violations(&metadata), vec![ - "forbidden [dependencies] edge asap-stage1-pass1-per-computation-candidates -> \ - asap-frontend-promql: stage crates never depend on each other (#1054)" + "forbidden [dev-dependencies] edge asap-stage1-pass1-per-computation-candidates -> \ + asap-stage0-memo: a stage crate's tests never call another stage crate (#1054)" .to_string() ] ); @@ -272,8 +288,8 @@ fn optional_only_exception_rejects_the_edge_made_non_optional() { fn checker_rejects_an_unclassified_crate_and_shared_model_edges() { let metadata = serde_json::json!({"packages": [ package("asap-types", &[], &[]), - package("asap-stage1-pass1-per-computation-candidates", &[("asap-types", false)], &[]), - package("asap-accuracy", &[("asap-stage1-pass1-per-computation-candidates", false)], &[]), + package("asap-stage1-pass1-per-computation-candidates", &["asap-types"], &[]), + package("asap-accuracy", &["asap-stage1-pass1-per-computation-candidates"], &[]), package("asap-new-crate", &[], &[]), ]}); assert_eq!( diff --git a/crates/stage0-memo/Cargo.toml b/crates/stage0-memo/Cargo.toml index 40804bc41..5fda801d9 100644 --- a/crates/stage0-memo/Cargo.toml +++ b/crates/stage0-memo/Cargo.toml @@ -5,6 +5,6 @@ edition = "2021" # #509 Stage 0: hash-consing the workload's logical DAGs into the memo groups # (`asap_types::stages::Memo`, without candidates) that Stage 1 fills. Depends -# only on asap-types; no other stage crate depends on it except in their tests. +# only on asap-types; no other stage crate depends on it. [dependencies] asap-types = { path = "../types" } diff --git a/crates/stage0-memo/src/lib.rs b/crates/stage0-memo/src/lib.rs index e827d908c..9311a60dd 100644 --- a/crates/stage0-memo/src/lib.rs +++ b/crates/stage0-memo/src/lib.rs @@ -5,8 +5,8 @@ //! Stage 1 starts from these groups. //! //! It depends only on `asap-types`, where its output (`Memo`) lives; no -//! other stage crate depends on it except in their tests, and the planner -//! facade calls it first. +//! other stage crate depends on it (the frontends' tests aside, listed in +//! `crate_graph.rs`), and the planner facade calls it first. use asap_types::ir::{QueryRoot, SchemaDerivationError}; use asap_types::stages::Memo; diff --git a/crates/stage1-pass1-per-computation-candidates/Cargo.toml b/crates/stage1-pass1-per-computation-candidates/Cargo.toml index 9cd188ba4..4af9be734 100644 --- a/crates/stage1-pass1-per-computation-candidates/Cargo.toml +++ b/crates/stage1-pass1-per-computation-candidates/Cargo.toml @@ -6,22 +6,18 @@ edition = "2021" # #509 Stage 1, Pass 1: the initial candidates of each memo group (exact, # exact accumulator, summaries, fused builds), with the shared analytical # accuracy model (`asap-accuracy`). Reads Stage 0's memo through -# `asap_types::stages`; depends on no other stage crate, and on no -# query-language frontend except through the `test-support` feature. +# `asap_types::stages`; depends on no other stage crate and on no +# query-language frontend. [dependencies] asap-types = { path = "../types" } asap-accuracy = { path = "../accuracy" } thiserror = "2" -# Only for `test-support`: lowering PromQL test queries. -asap-frontend-promql = { path = "../frontend-promql", optional = true } [features] # The test helpers (`test_support`) other crates' tests share. -test-support = ["dep:asap-frontend-promql", "asap-types/test-support"] +test-support = [] [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-accuracy = { path = "../accuracy", features = ["test-support"] } asap-frontend-promql = { path = "../frontend-promql" } -# Stage 0's memo of the test queries. -asap-stage0-memo = { path = "../stage0-memo" } diff --git a/crates/stage1-pass1-per-computation-candidates/src/lib.rs b/crates/stage1-pass1-per-computation-candidates/src/lib.rs index 3a1de5794..b8cc17b9e 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/lib.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/lib.rs @@ -7,10 +7,10 @@ //! It depends only on `asap-types` (its input and output memo are in //! `asap_types::stages`) and the shared accuracy model (`asap-accuracy`). //! It depends on no other stage crate, and the planner facade calls it -//! after Stage 0. It depends on no query-language frontend either, except -//! with the `test-support` feature, whose test helpers (`test_support`) -//! lower PromQL test queries through `asap-frontend-promql`; other crates -//! enable that feature only for their tests. +//! after Stage 0. It depends on no query-language frontend either; its own +//! tests lower PromQL through `asap-frontend-promql` (a dev-dependency). +//! The `test-support` feature exports the Pass 1 internals other crates' +//! tests call (`test_support`). //! //! Accuracy guarantees come from the built-in analytical accuracy rules in //! `asap-accuracy` (per-family estimators, composition and budget diff --git a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs index 207707c1a..dbec09f54 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs @@ -2149,7 +2149,7 @@ mod tests { #[test] fn aggregate_replacement_preserves_sql_field_identity() { let original = - crate::test_support::lower_promql("sum by (job) (m)", AccuracyTarget::exact()); + crate::test_fixtures::lower_promql("sum by (job) (m)", AccuracyTarget::exact()); let mut target = original.as_ref().clone(); target.schema.fields[0].table = Some("metrics".into()); let projected = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { @@ -2175,13 +2175,12 @@ mod tests { } use super::*; - use asap_accuracy::composition::{StreamNorm, PRODUCT_OPERAND_DOMAINS}; - use asap_accuracy::propagation::operand_guarantee; + use asap_accuracy::composition::PRODUCT_OPERAND_DOMAINS; + use asap_accuracy::test_support::{ composed_guarantee, estimate_bound_over_raw_data_samples, estimate_has_bound, - estimates_raw_data_samples_of, fold_stats, local_guarantee, - raw_data_samples_sign_statistic, scalar_guarantee, sign_statistic, - source_distinct_guarantee, stream_norm, QUOTIENT_OPERAND_DOMAINS, + local_guarantee, raw_data_samples_sign_statistic, scalar_guarantee, + source_distinct_guarantee, QUOTIENT_OPERAND_DOMAINS, }; use asap_types::ir::properties::guarantee::{ AccuracyGuarantee, AccuracyMetric, BoundExpr, ProbabilityExpr, @@ -2238,7 +2237,7 @@ mod tests { /// Guarantee composition through scalar and vector arithmetic (#669). mod guarantee_composition { use super::*; - use crate::test_support::lower_promql; + use crate::test_fixtures::lower_promql; use asap_types::ir::properties::AccuracyTarget; fn with_bound(metric: AccuracyMetric, bound: f64, delta: f64) -> AccuracyGuarantee { @@ -2346,51 +2345,6 @@ mod tests { } } - /// Count-Min and Count Sketch errors are additive: through - /// arithmetic they compose as absolute errors, each normalized bound - /// times the (symbolic) stream norm of its summary's source, here - /// over each source's raw value column, for plan selection to bind. - #[test] - fn normalized_additive_errors_compose_as_absolute_errors() { - let cms = with_bound(AccuracyMetric::ABSOLUTE_STREAM_L1, 0.01, 0.001); - let count_sketch = with_bound(AccuracyMetric::ABSOLUTE_STREAM_L2, 0.02, 0.002); - // Summaries of each source's raw value column. - let raw = |query: &str, guarantee: AccuracyGuarantee| { - ScalarExpr::PromqlScalarFromVector(crate::test_fixtures::estimate_over( - crate::test_fixtures::weighted( - crate::test_fixtures::cms_summary(query), - SummaryInputExpr::Column(ColumnRef::SampleValue), - ), - guarantee, - )) - }; - let g = of(&arith( - ArithmeticOpKind::Sub, - raw("count by (job) (m)", cms.clone()), - raw("count by (job) (n)", count_sketch), - )) - .unwrap(); - assert_eq!(g.spec.accuracy_metric, AccuracyMetric::ABSOLUTE); - assert!(close(delta(&g), 0.003)); - let norms = |statistic: &str| match statistic { - "stream_l1_norm[m]" => Some(100.0), - "stream_l2_norm[n]" => Some(10.0), - _ => None, - }; - assert!(g.spec.error.has_unknown()); - assert!(close(g.bind(&norms).spec.error.evaluate().unwrap(), 1.2)); - let doubled = of(&arith( - ArithmeticOpKind::Mul, - lit(2.0), - raw("count by (job) (m)", cms), - )) - .unwrap(); - assert!(close( - doubled.bind(&norms).spec.error.evaluate().unwrap(), - 2.0 - )); - } - /// A product of two estimates has an absolute bound /// `|x|·e_y + |y|·e_x + e_x·e_y`, and a quotient with an approximate /// denominator one that needs the denominator bounded away from @@ -2806,437 +2760,6 @@ mod tests { } } - /// Which stream norm bounds a Count-Min or Count Sketch estimate (#669). - mod stream_norms { - use super::*; - use crate::test_fixtures::{cms_summary, weighted}; - use asap_accuracy::composition::STREAM_L1_NORM; - use asap_accuracy::test_support::{source_norm_statistic, STREAM_ROW_COUNT}; - - /// A count (constant weight) is bounded by its weight times the - /// source's row count; a sum of the source's raw value column by - /// the source's norm; a sum of a computed value (`m * 1000`, - /// `rate(…)`) by nothing that can be bound. - #[test] - fn a_norm_follows_the_update_weight() { - let count = Rc::new(cms_summary("count by (job) (m)")); - assert_eq!( - stream_norm(&count), - Some(StreamNorm::ConstantWeight { - weight: 1.0, - source: "m".into() - }) - ); - let raw = weighted( - cms_summary("count by (job) (m)"), - SummaryInputExpr::Column(ColumnRef::SampleValue), - ); - assert_eq!(stream_norm(&raw), Some(StreamNorm::Source("m".into()))); - for computed in ["count by (job) (m * 1000)", "count by (job) (rate(m[1m]))"] { - let summary = weighted( - cms_summary(computed), - SummaryInputExpr::Column(ColumnRef::SampleValue), - ); - assert_eq!(stream_norm(&summary), None, "{computed}"); - } - } - - /// The absolute form of a Count-Min bound names the statistic its - /// update weight allows: the source's norm for its raw value - /// column, the weight times the source's row count for a count. - #[test] - fn the_absolute_bound_names_the_weighted_norm() { - let frequency = AccuracyGuarantee::new( - AccuracyMetric::ABSOLUTE_STREAM_L1, - BoundExpr::Constant { value: 0.01 }, - ProbabilityExpr::Constant { value: 0.001 }, - vec![], - ); - let estimate = |summary: Rc| { - operand_guarantee(&crate::test_fixtures::estimate_over( - summary, - frequency.clone(), - )) - .unwrap() - }; - let raw = estimate(weighted( - cms_summary("count by (job) (m)"), - SummaryInputExpr::Column(ColumnRef::SampleValue), - )); - let l1 = source_norm_statistic(STREAM_L1_NORM, "m"); - assert_eq!( - raw.bind(&|s| (s == l1).then_some(100.0)) - .spec - .error - .evaluate(), - Some(1.0) - ); - let count = estimate(Rc::new(cms_summary("count by (job) (m)"))); - assert!(count - .bind(&|s| (s == l1).then_some(100.0)) - .spec - .error - .has_unknown()); - let rows = source_norm_statistic(STREAM_ROW_COUNT, "m"); - assert_eq!( - count - .bind(&|s| (s == rows).then_some(100.0)) - .spec - .error - .evaluate(), - Some(1.0) - ); - } - } - - fn inventory(query: &str) -> PerComputationCandidates { - let root = crate::test_support::lower_promql( - query, - AccuracyTarget::max_error(0.01).failure_probability(0.001), - ); - // Per-series state ranks each series' identity, which the rows carry - // as a column; a query over aggregated rows is planned over labels. - let root = asap_types::ir::schema::schema_support::with_promql_series_identity(&root) - .unwrap_or(root); - generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap() - } - - /// Every candidate of target `t`, then of its fused computation, composed - /// with every other target at its first candidate, with the candidate. - fn composed_with( - inventory: &PerComputationCandidates, - t: usize, - ) -> Vec<(HowToComputeAggregateMeasure, Rc)> { - (0..inventory.targets[t].choice_count()) - .map(|c| { - let mut choice = vec![0; inventory.targets.len()]; - choice[t] = c; - let composed = compose_logical_candidate(inventory, &choice).unwrap(); - let QueryRoot::Operator(root) = &composed[0] else { - panic!("operator root") - }; - let (candidate, _) = inventory.targets[t].computation_for(c).unwrap(); - (candidate.clone(), root.clone()) - }) - .collect() - } - - fn heap_states(root: &Rc) -> Vec> { - OperatorNode::reachable(root) - .into_iter() - .filter(|node| { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), .. - }) if kind.capability().answers(StatisticKind::TopK)) - }) - .collect() - } - - fn topk_target(inventory: &PerComputationCandidates) -> usize { - inventory - .targets - .iter() - .position(|t| { - matches!( - inventory.aggregate(t).non_asap(), - Some(NonASAPOp::Aggregate { measures, .. }) - if matches!(measures.as_slice(), [AggIntent::TopK { .. }]) - ) - }) - .expect("a top-k target") - } - - /// Example 1's Q2: the top-k over a per-series `sum_over_time` keeps - /// only its exact candidate and gets a fused whole-expression - /// computation with a Count-Min and a CountSketch heap, which replace the - /// heaps over the exact sum. The fused computation absorbs the inner sum: composed, no - /// aggregate is computed and the heap reads the range selector's raw - /// samples. - #[test] - fn whole_expression_topk_replaces_the_heap_over_the_exact_sum() { - let inventory = inventory("topk by (job) (10, sum_over_time(m[1m]))"); - let topk = topk_target(&inventory); - let target = &inventory.targets[topk]; - assert_eq!( - target.ways_to_compute, - [HowToComputeAggregateMeasure::Exact] - ); - let fused = target - .fused_computation - .as_ref() - .expect("a fused computation"); - assert_eq!( - fused.ways_to_compute, - [ - HowToComputeAggregateMeasure::Summary(SketchAlgorithm::CmsWithHeap), - HowToComputeAggregateMeasure::Summary(SketchAlgorithm::CountSketchWithHeap), - ] - ); - let inner = fused.absorbed_targets[0]; - assert_eq!(inventory.targets[inner].ways_to_compute.len(), 1); - assert_eq!(inventory.combinations(), 3); - for (candidate, root) in composed_with(&inventory, topk) { - if candidate == HowToComputeAggregateMeasure::Exact { - continue; - } - assert!( - !OperatorNode::reachable(&root).iter().any(|n| matches!( - n.non_asap(), - Some(NonASAPOp::Aggregate { .. }) - ) || matches!( - n.asap(), - Some(ASAPOp::FinalizeExactAccumulator { .. }) - )), - "{candidate:?}: the inner sum is not computed" - ); - let [state] = &heap_states(&root)[..] else { - panic!("one heap") - }; - let Some(ASAPOp::SummaryAgg { child, .. }) = state.asap() else { - unreachable!() - }; - assert!(matches!( - child.non_asap(), - Some(NonASAPOp::TimeRange { .. }) - )); - } - } - - /// The whole-expression heap reads the inner sum's input, so the outer - /// top-k's partition keys (indices into the sum's output) are moved onto - /// that input by column. - #[test] - fn whole_expression_topk_partitions_index_the_heap_input() { - let root = crate::test_support::lower_promql( - "topk by (job) (2, sum by (service, job) (rate(m[1m])))", - AccuracyTarget::max_error(0.1), - ); - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap(); - let topk = topk_target(&inventory); - let mut heaps = 0; - for (candidate, root) in composed_with(&inventory, topk) { - // The top-k's own candidate is exact; its summaries are the - // fused computation's. - if !matches!(candidate, HowToComputeAggregateMeasure::Summary(_)) { - continue; - } - for state in heap_states(&root) { - let Some(ASAPOp::SummaryAgg { - child, reduction, .. - }) = state.asap() - else { - unreachable!() - }; - let names: Vec<_> = reduction - .group_keys() - .expect("partitioned by job") - .keys() - .iter() - .map(|&index| child.schema.fields[index].name.as_str()) - .collect(); - assert_eq!(names, ["job"]); - heaps += 1; - } - } - assert_eq!( - heaps, 2, - "a whole-expression Count-Min and CountSketch heap" - ); - } - - /// How many whole-workload choices enumeration keeps: every digit over - /// its target's `choice_count`, less those [`absorbed_choice_varies`] - /// skips. - /// - /// [`absorbed_choice_varies`]: PerComputationCandidates::absorbed_choice_varies - fn enumerated_choices(inventory: &PerComputationCandidates) -> usize { - let mut enumerated = 0; - let mut choice = vec![0; inventory.targets.len()]; - loop { - if !inventory.absorbed_choice_varies(&choice) { - enumerated += 1; - } - let mut carry = true; - for (digit, target) in choice.iter_mut().zip(&inventory.targets).rev() { - *digit += 1; - if *digit < target.choice_count() { - carry = false; - break; - } - *digit = 0; - } - if carry { - return enumerated; - } - } - } - - /// `topk(k, sum by (G) (sum_over_time(m[W])))` and its `count_over_time` - /// form rank the groups `G` by the sum (count) of their raw data samples: the - /// top-k keeps its exact candidate and gets the fused whole-expression - /// Count-Min and CountSketch heaps, which absorb both the grouped sum and - /// the per-series sum (count) beneath it. Composed, no aggregate is - /// computed: the heap reads the range selector's raw data samples, keyed by - /// `G` (less the top-k's own `by` labels, its partitions), weighted by - /// the sample value (a sum, an open requirement on `m`) or by one (a - /// count, proven non-negative). - #[test] - fn whole_expression_topk_over_a_grouped_sum_of_range_sums_reads_the_raw_data_samples() { - let samples = WeightDomain::RawDataSamplesOfMetric { metric: "m".into() }; - let unit = WeightDomain::NonNegative { - proof: NonNegativeWeightProof::UnitCount, - }; - let value = SummaryInputExpr::Column(ColumnRef::SampleValue); - let one = SummaryInputExpr::Constant(1.0); - for (query, weight, domain, partitions) in [ - ( - "topk(3, sum by (region) (sum_over_time(m[1m])))", - &value, - &samples, - &[][..], - ), - ( - "topk(3, sum by (region) (count_over_time(m[1m])))", - &one, - &unit, - &[], - ), - ( - "topk by (job) (3, sum by (job, region) (sum_over_time(m[1m])))", - &value, - &samples, - &["job"], - ), - ] { - let inventory = inventory(query); - let topk = topk_target(&inventory); - let target = &inventory.targets[topk]; - assert_eq!( - target.ways_to_compute, - [HowToComputeAggregateMeasure::Exact], - "{query}" - ); - let fused = target - .fused_computation - .as_ref() - .expect("a fused computation"); - assert_eq!( - fused.ways_to_compute, - [ - HowToComputeAggregateMeasure::Summary(SketchAlgorithm::CmsWithHeap), - HowToComputeAggregateMeasure::Summary(SketchAlgorithm::CountSketchWithHeap), - ], - "{query}" - ); - assert_eq!( - fused.absorbed_targets.len(), - 2, - "{query}: both aggregates beneath" - ); - assert_eq!( - inventory.combinations(), - enumerated_choices(&inventory), - "{query}" - ); - let mut heaps = 0; - for (candidate, root) in composed_with(&inventory, topk) { - if candidate == HowToComputeAggregateMeasure::Exact { - continue; - } - assert!( - !OperatorNode::reachable(&root).iter().any(|n| matches!( - n.non_asap(), - Some(NonASAPOp::Aggregate { .. }) - ) || matches!( - n.asap(), - Some(ASAPOp::FinalizeExactAccumulator { .. }) - )), - "{query} {candidate:?}: no sum is computed" - ); - let [state] = &heap_states(&root)[..] else { - panic!("{query}: one heap") - }; - let Some(ASAPOp::SummaryAgg { - child, - input, - reduction, - .. - }) = state.asap() - else { - unreachable!() - }; - assert!( - matches!(child.non_asap(), Some(NonASAPOp::TimeRange { .. })), - "{query}: the heap reads the raw data samples" - ); - assert_eq!( - input.item, - Some(SummaryInputExpr::Column(ColumnRef::Named("region".into()))), - "{query}" - ); - assert_eq!(&input.weight, weight, "{query}"); - assert_eq!(&input.weight_domain, domain, "{query}"); - let names: Vec<_> = reduction - .group_keys() - .map_or(&[][..], |keys| keys.keys()) - .iter() - .map(|&index| child.schema.fields[index].name.as_str()) - .collect(); - assert_eq!(names, partitions, "{query}"); - // The answer has the top-k's columns, `region` among them. - assert!( - root.schema.fields.iter().any(|f| f.name == "region"), - "{query}: {:?}", - root.schema - ); - heaps += 1; - } - assert_eq!(heaps, 2, "{query}"); - } - } - - /// The weight domain of every heap-sketch update in every candidate of - /// `query`'s top-k. - fn heap_weight_domains(query: &str) -> Vec { - let inventory = inventory(query); - let topk = topk_target(&inventory); - composed_with(&inventory, topk) - .iter() - .flat_map(|(_, root)| heap_states(root)) - .map(|state| match state.asap() { - Some(ASAPOp::SummaryAgg { input, .. }) => input.weight_domain.clone(), - _ => unreachable!(), - }) - .collect() - } - - /// Pass 1 does not read the metric's type: heap weights that are raw - /// samples of `m` carry the open requirement that `m` is a counter, under - /// any name (`m_total` included), for plan selection to confirm. - #[test] - fn heap_weights_over_raw_data_samples_record_an_open_requirement() { - for (query, metric) in [ - ("topk by (job) (10, sum_over_time(m[1m]))", "m"), - ("topk by (job) (10, sum_over_time(m_total[1m]))", "m_total"), - ] { - let domains = heap_weight_domains(query); - assert_eq!(domains.len(), 2, "{query}"); - assert!( - domains.iter().all(|d| *d - == WeightDomain::RawDataSamplesOfMetric { - metric: metric.into() - }), - "{query}: {domains:?}" - ); - } - } - /// Only raw data samples and plain sums of them: arithmetic, `rate` and other /// functions end the requirement. #[test] @@ -3249,7 +2772,7 @@ mod tests { ("rate(m[1m])", None), ("max_over_time(m[1m])", None), ] { - let root = crate::test_support::lower_promql(query, AccuracyTarget::exact()); + let root = crate::test_fixtures::lower_promql(query, AccuracyTarget::exact()); assert_eq!( raw_data_samples_of_metric(&root).as_deref(), metric, @@ -3258,409 +2781,6 @@ mod tests { } } - /// An avg's exact states are its sum then its count: any other order - /// is refused rather than divided the wrong way round. - #[test] - fn avg_states_out_of_order_are_refused() { - let mut inventory = inventory("avg by (job) (m)"); - assert_eq!( - inventory.targets[0].ways_to_compute, - [HowToComputeAggregateMeasure::ExactAccumulator(vec![ - ExactKind::Sum, - ExactKind::Count - ])] - ); - assert!(compose_logical_candidate(&inventory, &[0]).is_ok()); - inventory.targets[0].ways_to_compute = vec![ - HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Count, ExactKind::Sum]), - ]; - assert!(matches!( - compose_logical_candidate(&inventory, &[0]), - Err(PerComputationCandidateError::Unsupported(_)) - )); - } - - /// The target whose fused computation is a nested-sum merge. - fn nested_sum_target(inventory: &PerComputationCandidates) -> Option { - let merge = HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Sum]); - inventory.targets.iter().position(|t| { - t.fused_computation - .as_ref() - .is_some_and(|fused| fused.ways_to_compute == [merge.clone()]) - }) - } - - /// `sum by (job) (sum_over_time(m[5m]))` gets a fused nested sum - /// beside its own accumulator: one exact Sum accumulator over the range's - /// raw data samples, grouped by `job`, which absorbs the inner per-series sum. - /// Composed, no per-series state is built, the value is exact and the - /// output columns are the outer sum's. - #[test] - fn nested_sum_merges_into_one_exact_accumulator() { - let inventory = inventory("sum by (job) (sum_over_time(m[5m]))"); - let outer = nested_sum_target(&inventory).expect("a nested-sum merge"); - let target = &inventory.targets[outer]; - let sum = HowToComputeAggregateMeasure::ExactAccumulator(vec![ExactKind::Sum]); - assert_eq!(target.ways_to_compute, std::slice::from_ref(&sum)); - let fused = target - .fused_computation - .as_ref() - .expect("a fused computation"); - assert_eq!(fused.ways_to_compute, [sum]); - let inner = fused.absorbed_targets[0]; - assert_eq!(inventory.targets[inner].ways_to_compute.len(), 1); - assert_eq!(inventory.combinations(), 2); - let composed = composed_with(&inventory, outer); - let (_, separate) = &composed[0]; - let (_, merged) = &composed[1]; - let states: Vec<_> = OperatorNode::reachable(merged) - .into_iter() - .filter(|n| matches!(n.asap(), Some(ASAPOp::SummaryAgg { .. }))) - .collect(); - let [state] = states.as_slice() else { - panic!("one accumulator: {states:?}") - }; - let Some(ASAPOp::SummaryAgg { - child, - family, - reduction, - .. - }) = state.asap() - else { - unreachable!() - }; - assert_eq!( - *family, - FieldDataType::ExactAggregate(ExactKind::Sum, asap_types::ir::schema::ExactParams::Sum) - ); - assert!(matches!( - child.non_asap(), - Some(NonASAPOp::TimeRange { .. }) - )); - let Reduction::Reduce(keys) = reduction else { - panic!("grouped by job: {reduction:?}") - }; - assert_eq!( - keys.keys() - .iter() - .map(|&k| child.schema.fields[k].name.as_str()) - .collect::>(), - ["job"] - ); - assert!(merged - .guarantee - .as_ref() - .is_some_and(AccuracyGuarantee::is_exact)); - let names = |node: &OperatorNode| { - node.schema - .fields - .iter() - .map(|f| f.name.clone()) - .collect::>() - }; - assert_eq!(names(merged), names(separate)); - merged.validate_structure().unwrap(); - } - - /// The merge needs a sum over a sum's value: an outer sum over a rate, - /// an outer count, or an outer sum over an inner `max` gets none. - #[test] - fn nested_sum_merge_needs_a_sum_of_sums() { - for query in [ - "sum by (job) (rate(m[5m]))", - "count by (job) (sum_over_time(m[5m]))", - "sum by (job) (max_over_time(m[5m]))", - ] { - assert_eq!(nested_sum_target(&inventory(query)), None, "{query}"); - } - } - - /// A per-series sum drops the metric name, but the raw data samples keep it: - /// grouping the merge by `__name__` would answer the metric's name where - /// the nested sums answer none, so no merge is offered. - #[test] - fn nested_sum_merge_never_groups_by_the_dropped_metric_name() { - let query = "sum by (__name__) (sum_over_time(m[5m]))"; - assert_eq!(nested_sum_target(&inventory(query)), None); - } - - /// Merges do not stack: in `sum(sum by (job) (sum_over_time(m[5m])))` - /// a target with a fused computation is never absorbed, and `combinations` - /// counts exactly the choices enumeration keeps. - #[test] - fn nested_sum_merges_do_not_stack() { - let inventory = inventory("sum(sum by (job) (sum_over_time(m[5m])))"); - let absorbed: Vec = absorbed_targets(&inventory.targets).collect(); - assert_eq!(absorbed.len(), 1, "one merge"); - for &a in &absorbed { - assert!( - inventory.targets[a].fused_computation.is_none(), - "an absorbed target has a fused computation" - ); - } - let enumerated = enumerated_choices(&inventory); - assert_eq!(inventory.combinations(), enumerated); - } - - /// The shapes a heap over the raw data samples cannot answer do not get one: - /// `bottomk` stays a sort and limit (no top-k target), and a grouped sum - /// `without` labels or by the metric name (which the per-series sum drops) - /// gets no heap that absorbs the per-series sum and reads the samples. - #[test] - fn grouped_range_sums_a_raw_data_samples_heap_cannot_rank_get_no_fused_heap() { - let is_topk = |inventory: &PerComputationCandidates, t: &ComputationTarget| { - matches!( - inventory.aggregate(t).non_asap(), - Some(NonASAPOp::Aggregate { measures, .. }) - if matches!(measures.as_slice(), [AggIntent::TopK { .. }]) - ) - }; - let bottom = inventory("bottomk(3, sum by (region) (sum_over_time(m[1m])))"); - assert!( - !bottom.targets.iter().any(|t| is_topk(&bottom, t)), - "bottomk has no top-k target" - ); - for query in [ - "topk(3, sum without (instance) (sum_over_time(m[1m])))", - "topk(3, sum by (__name__) (sum_over_time(m[1m])))", - ] { - let inventory = inventory(query); - for target in inventory.targets.iter().filter(|t| is_topk(&inventory, t)) { - assert!( - target - .fused_computation - .as_ref() - .is_none_or(|f| f.absorbed_targets.len() == 1), - "{query}: {:?}", - target.fused_computation - ); - } - } - } - - /// A fused computation picked leaves every target beneath it uncomputed, - /// also one that an absorbed target's own merge absorbs: Pass 1 closes - /// `absorbed_targets` over those, so enumeration keeps only their first choice and - /// `combinations` still counts exactly the choices kept. Here the top-k - /// heap is made to absorb the grouped sum only, whose merge absorbs the - /// per-series sum. - #[test] - fn a_fused_computation_absorbs_what_its_absorbed_targets_merges_absorb() { - let mut inventory = inventory("topk(10, sum by (job) (sum_over_time(m[1m])))"); - let topk = topk_target(&inventory); - let fused = inventory.targets[topk] - .fused_computation - .as_mut() - .expect("a fused heap"); - let grouped = fused.absorbed_targets[0]; - fused.absorbed_targets.truncate(1); - let per_series = inventory.targets[grouped] - .fused_computation - .as_ref() - .expect("the grouped sum's merge") - .absorbed_targets[0]; - close_absorptions(&mut inventory.targets); - assert_eq!( - inventory.targets[topk] - .fused_computation - .as_ref() - .unwrap() - .absorbed_targets, - vec![grouped, per_series] - ); - assert_eq!(inventory.combinations(), enumerated_choices(&inventory)); - } - - /// `combinations` counts each choice once: picking a fused - /// whole-expression candidate with any but the first candidate of the - /// target it absorbs is the same candidate. An absorbed target with three - /// candidates (an approximate inner count: its accumulator, Count-Min and - /// UnivMon) gives 1 exact · 3 + 2 whole-expression = 5, not 3 · 3. - #[test] - fn combinations_skip_choices_a_whole_expression_candidate_absorbs() { - let inventory = inventory("topk by (job) (10, count_over_time(m[1m]))"); - let topk = topk_target(&inventory); - let target = &inventory.targets[topk]; - let Some(fused) = &target.fused_computation else { - panic!("a fused computation: {:?}", target.ways_to_compute) - }; - let inner = fused.absorbed_targets[0]; - let n = target.ways_to_compute.len(); - let whole = fused.ways_to_compute.len(); - let m = inventory.targets[inner].ways_to_compute.len(); - let enumerated = enumerated_choices(&inventory); - assert!(whole > 0 && m > 1, "{n} {whole} {m}"); - assert_eq!(inventory.combinations(), n * m + whole); - assert_eq!(inventory.combinations(), 5); - assert_eq!(inventory.combinations(), enumerated); - } - - /// A quantile estimate of a metric's samples carries their sign only for - /// φ in [0, 1]: outside it PromQL answers -Inf or +Inf. Pass 1 offers - /// no sketch for such a φ. - #[test] - fn a_quantile_estimate_shares_its_raw_data_samples_sign_only_inside_the_unit_interval() { - let estimate_of = |query: &str| { - let inventory = inventory(query); - (0..inventory.targets.len()) - .flat_map(|t| composed_with(&inventory, t)) - .find_map(|(_, root)| { - OperatorNode::reachable(&root) - .into_iter() - .find(|n| matches!(n.asap(), Some(ASAPOp::SummaryEstimate { .. }))) - }) - }; - let inside = estimate_of("quantile_over_time(0.5, m[1m])").expect("a sketch estimate"); - assert_eq!(estimates_raw_data_samples_of(&inside).as_deref(), Some("m")); - let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = inside.asap() else { - unreachable!() - }; - for q in [-1.0, 2.0] { - let outside = OperatorNode::with_schema( - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: Rc::clone(summary_input), - query: SketchStatistic::Quantile { q }, - }), - inside.schema.clone(), - ); - assert_eq!(estimates_raw_data_samples_of(&outside), None, "q = {q}"); - } - for query in [ - "quantile_over_time(-1, m[1m])", - "quantile_over_time(2, m[1m])", - ] { - assert!(estimate_of(query).is_none(), "{query}: a sketch is offered"); - } - } - - /// A quantile read from merged tumbling windows of a metric's samples is - /// still one of the samples (or between two), so it carries their sign - /// as the same quantile over the raw window does; φ outside [0, 1] - /// still carries none. - #[test] - fn a_quantile_of_merged_tumbling_windows_shares_its_raw_data_samples_sign() { - let inventory = inventory("quantile_over_time(0.99, m[100s])"); - let estimate = (0..inventory.targets.len()) - .flat_map(|t| composed_with(&inventory, t)) - .find_map(|(_, root)| { - OperatorNode::reachable(&root) - .into_iter() - .find(|n| matches!(n.asap(), Some(ASAPOp::SummaryEstimate { .. }))) - }) - .expect("a sketch estimate"); - let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = estimate.asap() else { - unreachable!() - }; - let Some(ASAPOp::SummaryAgg { - child: range, - family, - input, - reduction, - grouping, - filter, - }) = summary_input.asap() - else { - panic!("{summary_input:?}") - }; - let chain = Rc::new( - OperatorNode::new(Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - child: Rc::clone(range.children()[0]), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: filter.clone(), - tumbling_window_width_ms: 10_000, - tumbling_window_count: 10, - })) - .unwrap(), - ); - let merged = Rc::new( - OperatorNode::new(Operator::ASAP(ASAPOp::SummaryMerge { - children: vec![chain], - newest_tumbling_windows: None, - group_by: None, - })) - .unwrap(), - ); - let read = |q: f64| { - OperatorNode::with_schema( - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: Rc::clone(&merged), - query: SketchStatistic::Quantile { q }, - }), - estimate.schema.clone(), - ) - }; - assert_eq!( - estimates_raw_data_samples_of(&read(0.99)).as_deref(), - Some("m") - ); - assert_eq!( - sign_statistic(&fold_stats(&Reduction::by(vec![]), &Rc::new(read(0.99))).unwrap()), - Some(raw_data_samples_sign_statistic("m").as_str()) - ); - for q in [-1.0, 2.0] { - assert_eq!(estimates_raw_data_samples_of(&read(q)), None, "q = {q}"); - } - } - - /// The metric whose raw data samples, or plain sums of them, a composed node's - /// values estimate: a whole-expression heap's estimates and an exact - /// sum over the samples are; a rate or any other function is not. A - /// fold over them names its unknown sign by that metric. - #[test] - fn values_estimating_raw_data_samples_name_their_sign_by_the_metric() { - let inventory = inventory("topk by (job) (10, sum_over_time(m[1m]))"); - let topk = topk_target(&inventory); - for (candidate, root) in composed_with(&inventory, topk) { - let estimate = OperatorNode::reachable(&root) - .into_iter() - .find(|n| { - matches!( - n.asap(), - Some( - ASAPOp::SummaryEstimate { .. } - | ASAPOp::FinalizeExactAccumulator { .. } - ) - ) - }) - .unwrap(); - assert_eq!( - estimates_raw_data_samples_of(&estimate).as_deref(), - Some("m"), - "{candidate:?}" - ); - let stats = fold_stats(&Reduction::by(vec![]), &estimate).unwrap(); - assert_eq!( - asap_accuracy::test_support::sign_statistic(&stats), - Some(asap_accuracy::test_support::raw_data_samples_sign_statistic("m").as_str()) - ); - } - let rate = inventory_root("sum by (job) (rate(m[1m]))"); - let finalized = OperatorNode::reachable(&rate) - .into_iter() - .filter(|n| matches!(n.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. }))) - .collect::>(); - assert!(!finalized.is_empty()); - for node in finalized { - assert_eq!(estimates_raw_data_samples_of(&node), None); - } - let stats = fold_stats(&Reduction::by(vec![]), &rate).unwrap(); - assert_eq!(sign_statistic(&stats), None); - } - - /// The first composed candidate of `query`. - fn inventory_root(query: &str) -> Rc { - let inventory = inventory(query); - let composed = - compose_logical_candidate(&inventory, &vec![0; inventory.targets.len()]).unwrap(); - let QueryRoot::Operator(root) = &composed[0] else { - panic!("operator root") - }; - root.clone() - } - /// A Count-Min estimate has a bound only over weights proven /// non-negative: an open requirement (`RawDataSamplesOfMetric`) is not a proof. #[test] @@ -3735,7 +2855,7 @@ mod tests { fn classic_hll_confidence_needs_one_instance_over_a_sources_own_rows() { use asap_types::ir::schema::HydraKind; let hll = |grouping| FieldDataType::Sketch(SketchAlgorithm::Hll, grouping); - let input = |query| crate::test_support::lower_promql(query, AccuracyTarget::exact()); + let input = |query| crate::test_fixtures::lower_promql(query, AccuracyTarget::exact()); let guarantee = |family: &FieldDataType, query| { source_distinct_guarantee(family, &SketchStatistic::Cardinality, &input(query)) }; @@ -3755,226 +2875,4 @@ mod tests { }); assert!(guarantee(&hydra, "a").is_none()); } - - /// `avg_over_time` divides with a finite-division guard; a grouped `avg` does not. - #[test] - fn temporal_average_divides_with_a_finite_division_guard() { - let division_guard = |query: &str| { - let root = inventory_root(query); - let divisions: Vec = OperatorNode::reachable(&root) - .iter() - .filter_map(|node| match node.non_asap() { - Some(NonASAPOp::BinaryOp { operator, .. }) - if operator.kind == BinaryOpKind::Arithmetic(ArithmeticOpKind::Div) => - { - Some(operator.checked_finite_division) - } - _ => None, - }) - .collect(); - let [guarded] = divisions[..] else { - panic!("{query}: one sum / count division: {root:#?}") - }; - guarded - }; - assert!(division_guard("avg_over_time(a[5m])")); - assert!(!division_guard("avg by (job) (m)")); - } - - /// The candidates of `query` lowered at `accuracy`, over rows without the - /// series-identity column (open PromQL schemas): over identity-carrying - /// (closed) rows a grouped top-k gets no whole-expression heap. - fn inventory_at(query: &str, accuracy: AccuracyTarget) -> PerComputationCandidates { - let root = crate::test_support::lower_promql(query, accuracy); - generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap() - } - - fn open_inventory(query: &str) -> PerComputationCandidates { - inventory_at( - query, - AccuracyTarget::max_error(0.01).failure_probability(0.001), - ) - } - - /// The estimate and state of the top-k's fused whole-expression heap of - /// `algorithm`. - fn fused_heap( - inventory: &PerComputationCandidates, - algorithm: SketchAlgorithm, - ) -> (SketchStatistic, Rc) { - let topk = topk_target(inventory); - let own = inventory.targets[topk].ways_to_compute.len(); - let (_, root) = composed_with(inventory, topk) - .into_iter() - .skip(own) - .find(|(candidate, _)| *candidate == HowToComputeAggregateMeasure::Summary(algorithm)) - .unwrap_or_else(|| panic!("a fused {algorithm:?} candidate")); - let [state] = &heap_states(&root)[..] else { - panic!("one heap: {root:#?}") - }; - let estimate = OperatorNode::reachable(&root) - .into_iter() - .find_map(|node| match node.asap() { - Some(ASAPOp::SummaryEstimate { - summary_input, - query, - }) if Rc::ptr_eq(summary_input, state) => Some(query.clone()), - _ => None, - }) - .expect("the heap's estimate"); - (estimate, state.clone()) - } - - fn heap_update(state: &OperatorNode) -> &SummaryUpdate { - match state.asap() { - Some(ASAPOp::SummaryAgg { input, .. }) => input, - _ => unreachable!(), - } - } - - /// A top-k over a `count` fuses into one global unit-weight heap over the raw rows, ranking the label. - #[test] - fn count_ranked_topk_fuses_to_one_global_heap_sketch() { - let inventory = open_inventory("topk(10, count by (service) (m))"); - let topk = topk_target(&inventory); - let fused = inventory.targets[topk] - .fused_computation - .as_ref() - .expect("a fused heap"); - assert!(fused - .ways_to_compute - .contains(&HowToComputeAggregateMeasure::Summary( - SketchAlgorithm::CmsWithHeap - ))); - let (estimate, state) = fused_heap(&inventory, SketchAlgorithm::CmsWithHeap); - assert_eq!(estimate, SketchStatistic::TopK { k: 10 }); - let update = heap_update(&state); - assert_eq!( - update.item, - Some(SummaryInputExpr::Column(ColumnRef::Named("service".into()))) - ); - assert_eq!(update.weight, SummaryInputExpr::Constant(1.0)); - assert_eq!( - update.weight_domain, - WeightDomain::NonNegative { - proof: NonNegativeWeightProof::UnitCount, - } - ); - let Some(ASAPOp::SummaryAgg { child, .. }) = state.asap() else { - unreachable!() - }; - assert!(!child.contains_asap()); - } - - /// The ranked item over several group keys is their tuple, in the lowered (name-sorted) key order. - #[test] - fn multidimensional_ranked_item_is_a_structured_tuple() { - let inventory = open_inventory("topk(10, count by (service, region) (m))"); - let (_, state) = fused_heap(&inventory, SketchAlgorithm::CmsWithHeap); - let column = |name: &str| SummaryInputExpr::Column(ColumnRef::Named(name.into())); - assert_eq!( - heap_update(&state).item, - Some(SummaryInputExpr::Tuple(vec![ - column("region"), - column("service") - ])) - ); - } - - /// The top-k's own partition keys partition the heap and are left out of the ranked tuple. - #[test] - fn subpopulation_columns_are_not_part_of_the_ranked_item_tuple() { - let inventory = - open_inventory("topk by (region) (10, count by (service, method, region) (m))"); - let (_, state) = fused_heap(&inventory, SketchAlgorithm::CmsWithHeap); - let column = |name: &str| SummaryInputExpr::Column(ColumnRef::Named(name.into())); - assert_eq!( - heap_update(&state).item, - Some(SummaryInputExpr::Tuple(vec![ - column("method"), - column("service") - ])) - ); - let Some(ASAPOp::SummaryAgg { - child, reduction, .. - }) = state.asap() - else { - unreachable!() - }; - let keys: Vec<_> = reduction - .group_keys() - .expect("partitioned by region") - .keys() - .iter() - .map(|&k| child.schema.fields[k].name.as_str()) - .collect(); - assert_eq!(keys, ["region"]); - } - - /// A per-series top-k ranks series identity (column, else label set) weighted by sample value. - #[test] - fn temporal_per_entity_topk_uses_series_identity_and_sample_value() { - let query = "topk by (service) (5, sum_over_time(m[1m]))"; - let with_identity = inventory(query); - let without_identity = open_inventory(query); - for (inventory, item) in [ - ( - with_identity, - SummaryInputExpr::Column(ColumnRef::Named( - asap_types::ir::schema::PROMQL_SERIES_IDENTITY.into(), - )), - ), - ( - without_identity, - SummaryInputExpr::EntityIdentity(EntityIdentity::PromqlLabelSet { - excluding: vec![ColumnRef::Named("service".into())], - }), - ), - ] { - let (_, state) = fused_heap(&inventory, SketchAlgorithm::CountSketchWithHeap); - let update = heap_update(&state); - assert_eq!(update.item, Some(item)); - assert_eq!( - update.weight, - SummaryInputExpr::Column(ColumnRef::SampleValue) - ); - } - } - - // Identity realization must preserve named labels and every top-k candidate's output contract. - #[test] - fn topk_keeps_known_labels_beside_series_identity() { - let inventory = inventory("topk by (service) (5, sum_over_time(m{instance=\"a\"}[1m]))"); - let topk = topk_target(&inventory); - let expected = &inventory.aggregate(&inventory.targets[topk]).schema; - assert!(expected.fields.iter().any(|field| field.name == "instance")); - assert!(expected - .fields - .iter() - .any(|field| field.name == asap_types::ir::schema::PROMQL_SERIES_IDENTITY)); - for (_, root) in composed_with(&inventory, topk) { - assert_eq!(root.schema.fields, expected.fields); - assert_eq!(root.schema.time_index, expected.time_index); - assert_eq!(root.schema.closed, expected.closed); - // A physical heap may prove an additional partition/item key. - // Every required uniqueness fact must still follow from its keys. - for required in &expected.unique_keys { - assert!( - root.schema.unique_keys.iter().any(|supplied| { - supplied.iter().all(|column| required.contains(column)) - }), - "required key {required:?} is not implied by {:?}", - root.schema.unique_keys - ); - } - } - let (_, state) = fused_heap(&inventory, SketchAlgorithm::CountSketchWithHeap); - assert!(matches!( - heap_update(&state).item, - Some(SummaryInputExpr::Tuple(_)) - )); - } } diff --git a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs index f53d2b551..c8311f580 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates_tests.rs @@ -1,23 +1,10 @@ //! Frontend-to-Pass-1 acceptance: candidate discovery precedes empirical selection. -use crate::per_computation_candidates::{ - computation_candidates_for_intent, generate_per_computation_candidates, - PerComputationCandidateError, -}; -use asap_stage0_memo::memo_of_queries; -use asap_types::ir::operator::operator_properties::{Reduction, Source}; +use crate::per_computation_candidates::computation_candidates_for_intent; use asap_types::ir::operator::AggIntent; use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::scalar::{ColumnRef, CompareOpKind, ScalarValue}; -use asap_types::ir::schema::{ - DataType, ExactKind, Field, FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, - SummaryUpdate, -}; -use asap_types::ir::{ - ASAPOp, ExprSemantics, NonASAPOp, Operator, OperatorNode, Predicate, QueryRoot, ScalarExpr, -}; +use asap_types::ir::schema::SketchAlgorithm; -use asap_types::stages::{HavingEntry, HavingPlacement, HowToComputeAggregateMeasure}; -use std::rc::Rc; +use asap_types::stages::HowToComputeAggregateMeasure; fn approximate() -> AccuracyTarget { AccuracyTarget::max_error(0.05).failure_probability(0.01) @@ -36,44 +23,6 @@ fn algorithms(choices: &[HowToComputeAggregateMeasure]) -> Vec }) .collect() } -fn scan(nullable_value: bool) -> Rc { - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Scan { - source: Source::Table { - table_ref: "flows".into(), - }, - predicates: vec![], - schema: Schema::lifted( - vec![ - Field::plain("src_ip", DataType::Utf8, false), - Field::plain("value", DataType::Float64, nullable_value), - ], - None, - ), - })) - .unwrap() -} -fn aggregate_over( - child: Rc, - measures: Vec, - filters: Vec>, - having: Option, -) -> Rc { - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child, - reduction: Reduction::by(vec![]), - measures, - output_names: vec![], - filters, - having, - })) - .unwrap() -} -fn aggregate(intent: AggIntent) -> Rc { - aggregate_over(scan(false), vec![intent], vec![], None) -} -fn always() -> Predicate { - Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))) -} /// Example 2 preserves specialized distinct summaries and the universal alternative. #[test] @@ -117,158 +66,6 @@ fn frequency_statistics_keep_universal_choices() { } } -/// Scalar roots expose their producer targets; repeated references retain one target identity. -#[test] -fn scalar_root_producers_are_discovered_once() { - let producer = aggregate(AggIntent::Cardinality { - cols: vec![0], - accuracy: approximate(), - }); - let roots = vec![ - QueryRoot::Scalar(ScalarExpr::ScalarSubquery(producer.clone())), - QueryRoot::Operator(producer.clone()), - ]; - let candidates = - generate_per_computation_candidates(&memo_of_queries(&roots).unwrap()).unwrap(); - assert_eq!(candidates.roots.len(), 2); - for root in &candidates.roots { - root.validate_structure().unwrap(); - } - assert_eq!(candidates.targets.len(), 1); - assert_eq!(**candidates.aggregate(&candidates.targets[0]), *producer); - - assert!(producer.guarantee.is_none()); - producer.validate_structure().unwrap(); -} - -/// #509 Example 1 Q1 is exact: each target's one exact option is its exact -/// accumulator state (grouped sum, rate); no plain exact form and no summary. -/// One PromQL query with the default exact target, lowered through the -/// workload frontend. -fn exact_promql(query: &str) -> Vec { - use asap_types::workload::{ - BatchEntry, PlanningWorkload, Query, QueryLanguage, QueryRequirements, QueryWorkload, - }; - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - ..Default::default() - }, - predictability: Default::default(), - invocations: 1, - execute_at: None, - range_evaluation: None, - }]), - repeating_queries: None, - }, - data_workload: Some(asap_types::workload::DataWorkload::sampled_every( - asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), - asap_types::workload::DurationMs(1000), - )), - }; - asap_frontend_promql::lower_promql_query_workload(&workload).unwrap() -} - -#[test] -fn example_1_exact_q1_keeps_exact_accumulators_only() { - let roots = exact_promql("sum by (job) (rate(http_requests_total[1m]))"); - let candidates = - generate_per_computation_candidates(&memo_of_queries(&roots).unwrap()).unwrap(); - let found: Vec<_> = candidates - .targets - .iter() - .map(|target| target.ways_to_compute.clone()) - .collect(); - assert_eq!( - found, - vec![ - vec![HowToComputeAggregateMeasure::ExactAccumulator(vec![ - ExactKind::Sum - ])], - vec![HowToComputeAggregateMeasure::ExactAccumulator(vec![ - ExactKind::Rate - ])], - ] - ); -} - -/// ASAP operators and invalid probability requirements fail at the stage -/// boundary. -#[test] -fn asap_input_and_invalid_accuracy_are_rejected() { - let summary = OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryAgg { - child: scan(false), - family: FieldDataType::Sketch(SketchAlgorithm::Kll, Default::default()), - input: SummaryUpdate::column(ColumnRef::SampleValue), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - })) - .unwrap(); - assert!(matches!( - generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(summary)]).unwrap() - ), - Err(PerComputationCandidateError::ContainsAsap) - )); - let epsilon_delta = - |epsilon, delta| AccuracyTarget::max_error(epsilon).failure_probability(delta); - for target in [ - AccuracyTarget::max_error(f64::NAN), - epsilon_delta(0.0, 0.01), - epsilon_delta(-0.1, 0.01), - epsilon_delta(1.0, 0.01), - epsilon_delta(f64::INFINITY, 0.01), - epsilon_delta(0.1, 0.0), - epsilon_delta(0.1, 1.0), - ] { - assert!( - matches!( - computation_candidates_for_intent(&AggIntent::Count { - accuracy: target.clone() - }), - Err(PerComputationCandidateError::InvalidAccuracy { - root: None, - measure: None - }) - ), - "{target:?}" - ); - } - let invalid = aggregate_over( - scan(false), - vec![ - AggIntent::Count { - accuracy: approximate(), - }, - AggIntent::Count { - accuracy: epsilon_delta(1.5, 0.01), - }, - ], - vec![], - None, - ); - let error = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(invalid)]).unwrap(), - ) - .unwrap_err(); - assert!( - matches!( - &error, - PerComputationCandidateError::InvalidAccuracy { - root: Some(root), - measure: Some(1) - } if root == "0" - ), - "{error:?}" - ); - assert!(error.to_string().contains("root 0, measure 1 "), "{error}"); -} - /// TopK keeps both declared heap substrates without choosing an implementation. #[test] fn topk_keeps_both_specialized_heap_choices() { @@ -297,907 +94,3 @@ fn summary_candidates_are_unsized() { }); assert_eq!(loose, tight); } - -/// Each measure of a multi-measure aggregate is its own target; a measure whose -/// only realization is its exact accumulator is still a target. -#[test] -fn multi_measure_aggregate_has_one_target_per_measure() { - let node = aggregate_over( - scan(false), - vec![ - AggIntent::Sum { col: Some(1) }, - AggIntent::Count { - accuracy: approximate(), - }, - ], - vec![], - None, - ); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(node)]).unwrap(), - ) - .unwrap(); - let candidates = &inventory.targets; - assert_eq!( - candidates - .iter() - .map(|target| target.measure_index) - .collect::>(), - vec![0, 1] - ); - assert_eq!( - candidates[0].ways_to_compute, - vec![HowToComputeAggregateMeasure::ExactAccumulator(vec![ - ExactKind::Sum - ])] - ); - assert_sql_count_choices(&choices_of(&inventory, 1)); -} - -/// An aggregate with no realization besides exact execution is not a target. -#[test] -fn aggregate_without_realization_is_not_a_target() { - let node = aggregate(AggIntent::StdDev { - col: Some(1), - population: true, - }); - assert!(generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(node)]).unwrap() - ) - .unwrap() - .targets - .is_empty()); -} - -/// A filtered SQL count keeps its executable accumulator and records its -/// filter, which composition applies to the state. -#[test] -fn filtered_measure_keeps_candidates_and_records_its_filter() { - let count = || AggIntent::Count { - accuracy: approximate(), - }; - let filtered = aggregate_over( - scan(false), - vec![count(), count()], - vec![Some(always()), None], - None, - ); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(filtered)]).unwrap(), - ) - .unwrap(); - let targets = &inventory.targets; - assert_eq!(targets.len(), 2); - for (index, target) in targets.iter().enumerate() { - assert_sql_count_choices(&choices_of(&inventory, index)); - assert!(target.having_filters.is_empty()); - } - assert_eq!(targets[0].measure_filter, Some(always())); - assert_eq!(targets[1].measure_filter, None); -} - -/// Target `target`'s own ways to compute. -fn choices_of( - inventory: &crate::per_computation_candidates::PerComputationCandidates, - target: usize, -) -> Vec { - inventory.targets[target].ways_to_compute.clone() -} - -fn assert_sql_count_choices(choices: &[HowToComputeAggregateMeasure]) { - assert_eq!( - choices, - vec![HowToComputeAggregateMeasure::ExactAccumulator(vec![ - ExactKind::Count - ])] - ); -} - -fn quantile() -> AggIntent { - AggIntent::Quantile { - col: Some(1), - q: 0.5, - accuracy: approximate(), - } -} - -fn grouped_quantile(having: Option) -> Rc { - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child: scan(false), - reduction: Reduction::by(vec![0]), - measures: vec![quantile()], - output_names: vec![], - filters: vec![], - having, - })) - .unwrap() -} - -/// `count(*) by (src_ip)`, the form the SQL frontend gives `GROUP BY src_ip`. -fn grouped_count(having: Option) -> Rc { - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child: scan(false), - reduction: Reduction::by(vec![0]), - measures: vec![AggIntent::Count { - accuracy: approximate(), - }], - output_names: vec![], - filters: vec![], - having, - })) - .unwrap() -} -/// `HAVING` as the SQL frontend lowers it: a `Filter` above the aggregate. -fn having_filter(child: Rc, pred: Predicate) -> Rc { - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Filter { pred, child })).unwrap() -} -/// `column = 'a'` over the aggregate output (0: `src_ip`, 1: the count). -fn equals(column: usize, value: ScalarValue) -> Predicate { - Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(column)), - op: CompareOpKind::Eq, - right: Box::new(ScalarExpr::Literal(value)), - semantics: ExprSemantics::Sql, - }) -} - -/// A `HAVING` over group keys only is pushed below the aggregate and keeps -/// every executable candidate. -#[test] -fn key_only_having_is_pushed_below_and_keeps_candidates() { - let node = grouped_count(None); - let root = having_filter(node.clone(), equals(0, ScalarValue::Utf8("a".into()))); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(root.clone())]).unwrap(), - ) - .unwrap(); - let targets = &inventory.targets; - assert_eq!(targets.len(), 1); - assert_eq!(**inventory.aggregate(&targets[0]), *node); - assert_eq!( - targets[0].having_filters, - vec![HavingEntry { - filter: Some(root.clone()), - predicate: equals(0, ScalarValue::Utf8("a".into())), - placement: HavingPlacement::BelowAggregate, - }] - ); - assert_sql_count_choices(&choices_of(&inventory, 0)); -} - -/// A `HAVING` over an aggregate value, as a `Filter` above or as the -/// aggregate's own `having`, stays above the output and keeps executable candidates. -#[test] -fn value_having_stays_above_output_and_keeps_candidates() { - let value = || equals(1, ScalarValue::Int64(10)); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[ - QueryRoot::Operator(having_filter(grouped_count(None), value())), - QueryRoot::Operator(grouped_count(Some(value()))), - ]) - .unwrap(), - ) - .unwrap(); - let targets = &inventory.targets; - assert_eq!(targets.len(), 2); - for (index, target) in targets.iter().enumerate() { - let placements: Vec<_> = target - .having_filters - .iter() - .map(|entry| entry.placement) - .collect(); - assert_eq!(placements, vec![HavingPlacement::AboveOutput]); - assert_sql_count_choices(&choices_of(&inventory, index)); - } -} - -/// A key-only `HAVING` above a shared aggregate stays above the output when -/// another consumer reads the aggregate without it. -#[test] -fn having_on_one_consumer_of_a_shared_aggregate_stays_above() { - let node = grouped_count(None); - let key = equals(0, ScalarValue::Utf8("a".into())); - let filtered = having_filter(node.clone(), key.clone()); - let targets = generate_per_computation_candidates( - &memo_of_queries(&[ - QueryRoot::Operator(filtered.clone()), - QueryRoot::Operator(node.clone()), - ]) - .unwrap(), - ) - .unwrap() - .targets; - assert_eq!(targets.len(), 1); - assert_eq!( - targets[0].having_filters, - vec![HavingEntry { - filter: Some(filtered), - predicate: key.clone(), - placement: HavingPlacement::AboveOutput, - }] - ); - - // Two consumers applying the same key-only predicate push it below. - let first = having_filter(node.clone(), key.clone()); - let second = having_filter(node, key); - let targets = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(first), QueryRoot::Operator(second)]).unwrap(), - ) - .unwrap() - .targets; - assert!(targets[0] - .having_filters - .iter() - .all(|entry| entry.placement == HavingPlacement::BelowAggregate)); - // The two equal `Filter`s are one memo group, so one HAVING entry. - assert_eq!(targets[0].having_filters.len(), 1); -} - -/// A scalar reference to a grouped aggregate (`Filter(scan, EXISTS G)` as a -/// second root) is a consumer that reads it without the `HAVING`, so the -/// first root's key-only `HAVING` stays above the output. -#[test] -fn a_scalar_reference_consumer_keeps_a_key_only_having_above() { - let node = grouped_count(None); - let key = equals(0, ScalarValue::Utf8("a".into())); - let filtered = having_filter(node.clone(), key.clone()); - let exists = having_filter( - scan(false), - Predicate(ScalarExpr::Exists { - subquery: node.clone(), - negated: false, - }), - ); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[ - QueryRoot::Operator(filtered.clone()), - QueryRoot::Operator(exists), - ]) - .unwrap(), - ) - .unwrap(); - let targets = &inventory.targets; - let target = targets - .iter() - .find(|target| **inventory.aggregate(target) == *node) - .expect("the grouped count's target"); - assert_eq!( - target.having_filters, - vec![HavingEntry { - filter: Some(filtered), - predicate: key, - placement: HavingPlacement::AboveOutput, - }] - ); -} - -/// Of two stacked `Filter`s above an aggregate, only the adjacent one is -/// its `HAVING`; the outer one filters the inner `Filter`'s output. -#[test] -fn only_the_adjacent_of_stacked_filters_is_having() { - let node = grouped_count(None); - let inner_key = equals(0, ScalarValue::Utf8("a".into())); - let inner = having_filter(node.clone(), inner_key.clone()); - let outer = having_filter(inner.clone(), equals(0, ScalarValue::Utf8("b".into()))); - let targets = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(outer)]).unwrap(), - ) - .unwrap() - .targets; - assert_eq!(targets.len(), 1); - assert_eq!( - targets[0].having_filters, - vec![HavingEntry { - filter: Some(inner), - predicate: inner_key, - placement: HavingPlacement::BelowAggregate, - }] - ); -} - -/// A global aggregate's `HAVING` always stays above the output, even a -/// constant one: the one output row exists with no input rows. -#[test] -fn global_aggregate_having_stays_above() { - let count = || { - vec![AggIntent::Count { - accuracy: approximate(), - }] - }; - let never = Predicate(ScalarExpr::Literal(ScalarValue::Boolean(false))); - let targets = generate_per_computation_candidates( - &memo_of_queries(&[ - QueryRoot::Operator(having_filter( - aggregate_over(scan(false), count(), vec![], None), - never.clone(), - )), - QueryRoot::Operator(aggregate_over(scan(false), count(), vec![], Some(never))), - ]) - .unwrap(), - ) - .unwrap() - .targets; - assert_eq!(targets.len(), 2); - for target in &targets { - let placements: Vec<_> = target - .having_filters - .iter() - .map(|entry| entry.placement) - .collect(); - assert_eq!(placements, vec![HavingPlacement::AboveOutput]); - } -} - -/// The `HAVING` entries of the target whose measure satisfies `is`. -fn having_of( - inventory: &crate::per_computation_candidates::PerComputationCandidates, - is: impl Fn(&AggIntent) -> bool, -) -> Vec { - let target = inventory - .targets - .iter() - .find(|target| match inventory.aggregate(target).non_asap() { - Some(NonASAPOp::Aggregate { measures, .. }) => is(&measures[target.measure_index]), - _ => false, - }) - .expect("target"); - target.having_filters.clone() -} - -/// `Column(column) > ScalarSubquery(subquery)`. -fn above_scalar(column: usize, subquery: Rc) -> Predicate { - Predicate(ScalarExpr::Compare { - left: Box::new(ScalarExpr::Column(column)), - op: CompareOpKind::Gt, - right: Box::new(ScalarExpr::ScalarSubquery(subquery)), - semantics: ExprSemantics::Sql, - }) -} - -/// A `Filter` that reads an aggregate only from its predicate is not that -/// aggregate's `HAVING`; one that also filters the aggregate gives one entry. -#[test] -fn scalar_reference_filter_is_not_having() { - let count = || { - vec![AggIntent::Count { - accuracy: approximate(), - }] - }; - let agg = aggregate_over(scan(false), count(), vec![], None); - let root = having_filter(scan(false), above_scalar(1, agg.clone())); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap(); - let targets = &inventory.targets; - assert_eq!(targets.len(), 1); - assert_eq!(**inventory.aggregate(&targets[0]), *agg); - assert!( - targets[0].having_filters.is_empty(), - "{:?}", - targets[0].having_filters - ); - - let agg = aggregate_over(scan(false), count(), vec![], None); - let root = having_filter(agg.clone(), above_scalar(0, agg.clone())); - let targets = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(root.clone())]).unwrap(), - ) - .unwrap() - .targets; - assert_eq!(targets.len(), 1); - assert_eq!( - targets[0].having_filters, - vec![HavingEntry { - filter: Some(root), - predicate: above_scalar(0, agg), - placement: HavingPlacement::AboveOutput, - }] - ); -} - -/// PromQL `scalar(...)` operands are scalar references, not `HAVING`. -#[test] -fn promql_scalar_operand_is_not_having() { - let roots = exact_promql("http_requests_total > scalar(sum(http_requests_total))"); - let targets = generate_per_computation_candidates(&memo_of_queries(&roots).unwrap()) - .unwrap() - .targets; - assert!(!targets.is_empty()); - for target in &targets { - assert!( - target.having_filters.is_empty(), - "{:?}", - target.having_filters - ); - } - - let roots = exact_promql("sum by (job) (x) > scalar(count(x))"); - let inventory = generate_per_computation_candidates(&memo_of_queries(&roots).unwrap()).unwrap(); - assert!(having_of(&inventory, |intent| matches!( - intent, - AggIntent::Count { .. } - )) - .is_empty()); - let sum = having_of(&inventory, |intent| matches!(intent, AggIntent::Sum { .. })); - assert_eq!(sum.len(), 1, "{sum:?}"); - assert_eq!(sum[0].placement, HavingPlacement::AboveOutput); -} - -/// A non-root, non-`Filter` consumer keeps a key-only `HAVING` above. -#[test] -fn having_shared_with_a_project_consumer_stays_above() { - let node = grouped_count(None); - let key = equals(0, ScalarValue::Utf8("a".into())); - let filtered = having_filter(node.clone(), key); - let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: vec![asap_types::ir::ProjectItem { - alias: None, - expr: ScalarExpr::Column(0), - }], - qualifier: None, - child: node, - })) - .unwrap(); - let targets = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(filtered), QueryRoot::Operator(project)]).unwrap(), - ) - .unwrap() - .targets; - assert_eq!(targets.len(), 1); - let placements: Vec<_> = targets[0] - .having_filters - .iter() - .map(|entry| entry.placement) - .collect(); - assert_eq!(placements, vec![HavingPlacement::AboveOutput]); -} - -/// `without(...)` and per-entity aggregates have no fixed key prefix, so a -/// `HAVING` over column 0 stays above. -#[test] -fn without_and_per_entity_having_stays_above() { - for reduction in [ - Reduction::Reduce( - asap_types::ir::operator::operator_properties::GroupKeys::without(vec![1]), - ), - Reduction::PerEntity, - ] { - let node = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child: scan(false), - reduction: reduction.clone(), - measures: vec![AggIntent::Count { - accuracy: approximate(), - }], - output_names: vec![], - filters: vec![], - having: None, - })) - .unwrap(); - let root = having_filter(node, equals(0, ScalarValue::Utf8("a".into()))); - let targets = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap() - .targets; - assert_eq!(targets.len(), 1, "{reduction:?}"); - let placements: Vec<_> = targets[0] - .having_filters - .iter() - .map(|entry| entry.placement) - .collect(); - assert_eq!( - placements, - vec![HavingPlacement::AboveOutput], - "{reduction:?}" - ); - } -} - -/// A workload candidate replaces only chosen targets, derives the summary's -/// coverage from its subtree, and keeps unchosen plans as they are, now -/// with an exact guarantee. -#[test] -fn composed_candidate_replaces_chosen_target_with_summary_evaluation() { - use crate::per_computation_candidates::compose_logical_candidate; - let producer = aggregate(AggIntent::Cardinality { - cols: vec![0], - accuracy: approximate(), - }); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(producer.clone())]).unwrap(), - ) - .unwrap(); - let exact = compose_logical_candidate(&inventory, &[0]).unwrap(); - let QueryRoot::Operator(kept) = &exact[0] else { - panic!("operator root expected") - }; - assert_eq!(kept.operator.kind_name(), producer.operator.kind_name()); - assert_eq!(kept.schema, producer.schema); - assert!(kept.guarantee.as_ref().is_some_and(|g| g.is_exact())); - - let hll = compose_logical_candidate(&inventory, &[1]).unwrap(); - let QueryRoot::Operator(estimate) = &hll[0] else { - panic!("operator root expected") - }; - assert!(matches!( - estimate.asap(), - Some(ASAPOp::SummaryEstimate { .. }) - )); - hll[0].validate_structure().unwrap(); - assert!(compose_logical_candidate(&inventory, &[99]).is_err()); -} - -/// The one root of the workload candidate composed from `roots` and `choice`. -fn composed(roots: Vec>, choice: &[usize]) -> Vec> { - use crate::per_computation_candidates::compose_logical_candidate; - let inventory = generate_per_computation_candidates( - &memo_of_queries( - &roots - .into_iter() - .map(QueryRoot::Operator) - .collect::>(), - ) - .unwrap(), - ) - .unwrap(); - compose_logical_candidate(&inventory, choice) - .unwrap() - .into_iter() - .map(|root| { - root.validate_structure().unwrap(); - match root { - QueryRoot::Operator(node) => node, - QueryRoot::Scalar(_) => panic!("operator root expected"), - } - }) - .collect() -} -/// Output columns as (name, type, nullable). -fn columns(node: &OperatorNode) -> Vec<(String, FieldDataType, bool)> { - node.schema - .fields - .iter() - .map(|field| (field.name.clone(), field.dtype.clone(), field.nullable)) - .collect() -} -// Quantile readouts restore the aggregate's output names with an identity projection. -fn without_output_aliases(node: &OperatorNode) -> &OperatorNode { - if let Some(NonASAPOp::Project { cols, child, .. }) = node.non_asap() { - assert!(cols - .iter() - .enumerate() - .all(|(index, item)| item.expr == ScalarExpr::Column(index))); - without_output_aliases(child) - } else { - node - } -} -fn summary_agg(node: &OperatorNode) -> &ASAPOp { - let node = without_output_aliases(node); - match node.asap() { - Some(ASAPOp::SummaryEstimate { summary_input, .. }) => summary_agg(summary_input), - Some(ASAPOp::FinalizeExactAccumulator { child }) => summary_agg(child), - Some(agg @ ASAPOp::SummaryAgg { .. }) => agg, - _ => panic!("summary evaluation expected, got {node:#?}"), - } -} - -/// A measure filter becomes the state's filter; a key-only `HAVING` applied -/// by every consumer moves below the state; a value `HAVING` stays a `Filter` -/// above the estimate. -#[test] -fn composed_filters_and_having() { - let kll = 1; - let filtered = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child: scan(false), - reduction: Reduction::by(vec![0]), - measures: vec![quantile()], - output_names: vec![], - filters: vec![Some(always())], - having: None, - })) - .unwrap(); - let [root] = <[_; 1]>::try_from(composed(vec![filtered.clone()], &[kll])).unwrap(); - assert!( - matches!(summary_agg(&root), ASAPOp::SummaryAgg { filter: Some(f), child, .. } - if *f == always() && child.non_asap().is_some_and(|op| matches!(op, NonASAPOp::Scan { .. }))) - ); - assert_eq!(columns(&root)[0], columns(&filtered)[0]); - - let key = equals(0, ScalarValue::Utf8("a".into())); - let [root] = <[_; 1]>::try_from(composed( - vec![having_filter(grouped_quantile(None), key.clone())], - &[kll], - )) - .unwrap(); - let ASAPOp::SummaryAgg { child, filter, .. } = summary_agg(&root) else { - unreachable!() - }; - assert_eq!(*filter, None); - assert!(matches!(child.non_asap(), Some(NonASAPOp::Filter { pred, .. }) if *pred == key)); - - let value = || equals(1, ScalarValue::Int64(10)); - for root in composed( - vec![ - having_filter(grouped_quantile(None), value()), - grouped_quantile(Some(value())), - ], - &[kll, kll], - ) { - let Some(NonASAPOp::Filter { pred, child }) = root.non_asap() else { - panic!("value HAVING above the estimate expected, got {root:#?}") - }; - assert_eq!(*pred, value()); - assert!(matches!( - without_output_aliases(child).asap(), - Some(ASAPOp::SummaryEstimate { .. }) - )); - } -} - -/// SQL `avg` joins its finalized `sum` and `count` accumulator states on the -/// group keys and divides them. -#[test] -fn composed_avg_divides_sum_and_count_states() { - let avg = aggregate_over( - scan(false), - vec![AggIntent::Avg { col: Some(1) }], - vec![], - None, - ); - let [root] = <[_; 1]>::try_from(composed(vec![avg.clone()], &[0])).unwrap(); - // The quotient is renamed to the aggregate's column. - assert_eq!(columns(&root), columns(&avg)); - let join = OperatorNode::reachable(&root) - .into_iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Join { .. }))) - .unwrap_or_else(|| panic!("sum joined with count expected, got {root:#?}")); - let Some(NonASAPOp::Join { left, right, .. }) = join.non_asap() else { - unreachable!() - }; - for (side, kind) in [(left, ExactKind::Sum), (right, ExactKind::Count)] { - assert!(matches!( - summary_agg(side), - ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(found, _), .. } if *found == kind - )); - } -} - -/// Each measure of a multi-measure aggregate is substituted on its own and the -/// measures are joined back on the group keys, with the aggregate's columns. -#[test] -fn composed_multi_measure_aggregate_joins_its_measures() { - for reduction in [Reduction::by(vec![0]), Reduction::by(vec![])] { - let node = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child: scan(false), - reduction, - measures: vec![ - AggIntent::Sum { col: Some(1) }, - quantile(), - AggIntent::StdDev { - col: Some(1), - population: true, - }, - ], - output_names: vec![], - filters: vec![], - having: None, - })) - .unwrap(); - let [root] = <[_; 1]>::try_from(composed(vec![node.clone()], &[0, 1])).unwrap(); - assert!(matches!(root.non_asap(), Some(NonASAPOp::Project { .. }))); - let reached = OperatorNode::reachable(&root); - let joins = reached - .iter() - .filter(|node| matches!(node.non_asap(), Some(NonASAPOp::Join { .. }))) - .count(); - assert_eq!(joins, 2); - assert!(reached - .iter() - .any(|node| matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. })))); - let names = |node: &OperatorNode| -> Vec<_> { - columns(node) - .into_iter() - .map(|(name, dtype, _)| (name, dtype)) - .collect() - }; - assert_eq!(names(&root), names(&node)); - } -} - -/// A measure left exact beside a sketched one keeps its output name: the -/// split exact aggregate names its one measure as the target named it, so the -/// executor finds one name per measure. -#[test] -fn composed_mixed_exact_and_sketch_measures_keep_output_names() { - let quantile = |q| AggIntent::Quantile { - col: Some(1), - q, - accuracy: approximate(), - }; - let node = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate { - child: scan(false), - reduction: Reduction::by(vec![]), - measures: vec![quantile(0.5), quantile(0.9)], - output_names: vec!["p50".into(), "p90".into()], - filters: vec![], - having: None, - })) - .unwrap(); - assert_eq!( - candidates_for(quantile(0.5))[0], - HowToComputeAggregateMeasure::Exact - ); - // p50 exact, p90 from the first sketch family. - let [root] = <[_; 1]>::try_from(composed(vec![node.clone()], &[0, 1])).unwrap(); - let exact_parts: Vec<_> = OperatorNode::reachable(&root) - .into_iter() - .filter_map(|node| match node.non_asap() { - Some(NonASAPOp::Aggregate { - measures, - output_names, - .. - }) => Some((measures.len(), output_names.clone())), - _ => None, - }) - .collect(); - assert_eq!(exact_parts, vec![(1, vec!["p50".to_string()])]); - assert_eq!(columns(&root), columns(&node)); -} - -/// A summary estimate carries its sized family's bound; an exact accumulator -/// is exact; anything computed from an approximate value, a value `HAVING` -/// included, has no guarantee. -#[test] -fn composed_guarantees() { - let value = equals(1, ScalarValue::Int64(10)); - let roots = composed( - vec![ - aggregate(AggIntent::Cardinality { - cols: vec![0], - accuracy: approximate(), - }), - aggregate(AggIntent::Sum { col: Some(1) }), - having_filter(grouped_quantile(None), value), - ], - &[1, 0, 1], - ); - let guarantee = |node: &OperatorNode| node.guarantee.clone(); - let hll = guarantee(&roots[0]).expect("HLL bound"); - assert!(!hll.is_exact()); - assert!(guarantee(&roots[1]).is_some_and(|g| g.is_exact())); - let Some(NonASAPOp::Filter { child, .. }) = roots[2].non_asap() else { - panic!("value HAVING above the estimate expected") - }; - assert!(guarantee(child).is_some()); - assert!(guarantee(&roots[2]).is_none()); -} - -/// The aggregate's own value `having` stays a `Filter` above the output, and -/// a subquery in it reads the subquery's substitution. -#[test] -fn own_having_subquery_is_substituted() { - use crate::per_computation_candidates::compose_logical_candidate; - let inner = aggregate(quantile()); - let outer = grouped_quantile(Some(above_scalar(1, inner.clone()))); - let inventory = generate_per_computation_candidates( - &memo_of_queries(&[QueryRoot::Operator(outer.clone())]).unwrap(), - ) - .unwrap(); - assert_eq!(inventory.targets.len(), 2); - let kll = 1; - let composed = compose_logical_candidate(&inventory, &[kll, kll]).unwrap(); - let QueryRoot::Operator(root) = &composed[0] else { - panic!("operator root expected") - }; - let Some(NonASAPOp::Filter { pred, child }) = root.non_asap() else { - panic!("own HAVING above the estimate expected, got {root:#?}") - }; - assert!(matches!( - without_output_aliases(child).asap(), - Some(ASAPOp::SummaryEstimate { .. }) - )); - let [subquery] = <[_; 1]>::try_from(pred.0.operator_refs()).unwrap(); - assert!( - matches!( - without_output_aliases(subquery).asap(), - Some(ASAPOp::SummaryEstimate { .. }) - ), - "{subquery:#?}" - ); - assert!(OperatorNode::reachable(root) - .iter() - .all(|node| !matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })))); -} - -/// A per-series `avg` over PromQL rows that carry the series identity -/// (`$promql_series_identity` after the sample value) composes from its sum -/// and count states: the summed value is found by role, not as the last -/// column. -#[test] -fn per_series_avg_with_series_identity_composes_from_sum_and_count() { - use asap_types::ir::schema::schema_support::with_promql_series_identity; - let [QueryRoot::Operator(root)] = &exact_promql("avg_over_time(m[5m])")[..] else { - panic!("one operator root"); - }; - let root = with_promql_series_identity(root).unwrap(); - let [composed] = <[_; 1]>::try_from(composed(vec![root.clone()], &[0])).unwrap(); - assert_eq!(columns(&composed), columns(&root)); -} - -/// Exact per-series `rate` keeps every label and retypes the value column to the Rate state. -#[test] -fn per_series_rate_keeps_labels_and_retypes_its_state() { - use asap_types::ir::schema::ExactParams; - let [QueryRoot::Operator(root)] = &exact_promql("rate(m{job=\"api\"}[5m])")[..] else { - panic!("one operator root"); - }; - let [out] = <[_; 1]>::try_from(composed(vec![root.clone()], &[0])).unwrap(); - let rate = FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate); - let ASAPOp::SummaryAgg { - family, reduction, .. - } = summary_agg(&out) - else { - unreachable!() - }; - assert_eq!(*family, rate); - assert_eq!(*reduction, Reduction::PerEntity); - let state = OperatorNode::reachable(&out) - .into_iter() - .find(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. }))) - .unwrap(); - // The state replaces the sample value in place, named `state`; the - // finalized output has the rate's own columns. - let label = Field::plain("job", DataType::Utf8, true); - assert_eq!( - columns(&state), - [ - ( - "ts".into(), - FieldDataType::Plain(DataType::Timestamp), - false - ), - ("state".into(), rate, false), - (label.name, label.dtype, label.nullable), - ] - ); - assert_eq!(state.schema.time_index, Some(0)); - assert_eq!(out.schema, root.schema); -} - -/// Exact grouped `avg` composes from sum and count with the aggregate's whole schema. -#[test] -fn grouped_avg_composition_preserves_the_whole_schema() { - let [QueryRoot::Operator(root)] = &exact_promql("avg by (job) (m)")[..] else { - panic!("one operator root"); - }; - let [out] = <[_; 1]>::try_from(composed(vec![root.clone()], &[0])).unwrap(); - assert!(OperatorNode::reachable(&out) - .iter() - .any(|node| matches!(node.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })))); - assert_eq!(out.schema, root.schema); -} - -// Structurally equal aggregates are one memo group with one target, and its -// HAVING placement is decided over every consumer of the group: an unfiltered -// consumer keeps the key-only HAVING above the output for the filtered ones. -#[test] -fn memo_cached_rules_preserve_occurrence_specific_having() { - let shared = grouped_count(None); - let other = grouped_count(None); - let key = equals(0, ScalarValue::Utf8("a".into())); - let roots = vec![ - QueryRoot::Operator(having_filter(shared.clone(), key.clone())), - QueryRoot::Operator(shared), - QueryRoot::Operator(having_filter(other, key)), - ]; - let memo = memo_of_queries(&roots).unwrap(); - let cached = generate_per_computation_candidates(&memo).unwrap(); - assert_eq!(cached.targets.len(), 1); - // Roots 0 and 2 apply the same `Filter`, one memo group: one entry. - let [entry] = &cached.targets[0].having_filters[..] else { - panic!("one HAVING entry: {:?}", cached.targets[0].having_filters); - }; - assert_eq!(entry.placement, HavingPlacement::AboveOutput); -} diff --git a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs index 922692bb6..4eb7afb7c 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs @@ -8,8 +8,10 @@ use asap_types::ir::operator::operator_properties::Source; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::schema::{ColumnId, DataType, Field, Schema}; use asap_types::ir::{NonASAPOp, OperatorNode}; - -use crate::test_support::lower_promql; +use asap_types::workload::{ + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, +}; /// A `TimeSeries("m")` scan over `[ts(0), value(1), labels...]`, time index 0, /// no unique key. @@ -51,79 +53,33 @@ pub fn scan_from(source: Source, schema: Schema) -> Rc { .unwrap() } -/// The Count-Min summary of a Stage 1 candidate of `query`. -pub fn cms_summary(query: &str) -> OperatorNode { - use crate::per_computation_candidates::compose_logical_candidate; - use crate::per_computation_candidates::generate_per_computation_candidates; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; - use asap_types::ir::{ASAPOp, QueryRoot}; - let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); - let root = lower_promql(query, target); - // Inspect each local computation choice under the built-in deployment. - let per_computation = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap() - .without_unsupported_summaries(None); - let summary = per_computation - .targets - .iter() - .enumerate() - .flat_map(|(target, choices)| { - let inventory = &per_computation; - (0..choices.choice_count()).filter_map(move |candidate| { - let mut assignment = vec![0; inventory.targets.len()]; - assignment[target] = candidate; - compose_logical_candidate(inventory, &assignment).ok() - }) - }) - .flat_map(|roots| match &roots[0] { - QueryRoot::Operator(node) => OperatorNode::reachable(node), - QueryRoot::Scalar(_) => vec![], - }) - .find(|node| { - matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) if *kind == SketchAlgorithm::Cms - ) - }) - .map(|node| (*node).clone()) - .unwrap_or_else(|| panic!("{query}: a Count-Min summary")); - summary -} - -/// `summary` with its update weight replaced by `weight`. -pub fn weighted( - mut summary: OperatorNode, - weight: asap_types::ir::schema::SummaryInputExpr, -) -> Rc { - use asap_types::ir::{ASAPOp, Operator}; - if let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &mut summary.operator { - input.weight = weight; - } - Rc::new(summary) -} - -/// A point-count estimate over `summary` carrying `guarantee`. -pub fn estimate_over( - summary: Rc, - guarantee: asap_types::ir::properties::AccuracyGuarantee, -) -> Rc { - use asap_types::ir::scalar::ColumnRef; - use asap_types::ir::schema::SketchStatistic; - use asap_types::ir::{ASAPOp, Operator}; - Rc::new( - OperatorNode::new(Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: summary, - query: SketchStatistic::PointCount { - key: ColumnRef::SampleValue, - value: None, - }, - })) +/// `query` lowered by the PromQL frontend, with accuracy target `accuracy`. +pub fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc { + let workload = PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(vec![BatchEntry { + query: Query(query.into()), + requirements: QueryRequirements { + accuracy, + ..Default::default() + }, + predictability: Predictability::Unknown, + invocations: 1, + execute_at: None, + range_evaluation: None, + }]), + repeating_queries: None, + }, + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1_000), + )), + }; + asap_frontend_promql::lower_promql_query_workload(&workload) .unwrap() - .with_guarantee(Some(guarantee)), - ) + .remove(0) + .as_operator() + .cloned() + .expect("operator root") } diff --git a/crates/stage1-pass1-per-computation-candidates/src/test_support.rs b/crates/stage1-pass1-per-computation-candidates/src/test_support.rs index be5f6f699..f5952cf2c 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/test_support.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/test_support.rs @@ -1,48 +1,6 @@ -//! Test helpers: the Pass 1 internals and the PromQL lowering other crates' -//! tests call (feature `test-support`), and the fixtures this crate's own -//! tests share (`cfg(test)`). +//! The Pass 1 internals other crates' tests call (feature `test-support`): +//! Pass 1's working form and single-candidate composition. -use std::rc::Rc; - -// Pass 1 internals other crates' tests call: Pass 1's working form and -// single-candidate composition. pub use crate::per_computation_candidates::{ compose_logical_candidate, generate_per_computation_candidates, }; - -use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::OperatorNode; -use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, - QueryRequirements, QueryWorkload, -}; - -pub fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc { - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy, - ..Default::default() - }, - predictability: Predictability::Unknown, - invocations: 1, - execute_at: None, - range_evaluation: None, - }]), - repeating_queries: None, - }, - data_workload: Some(DataWorkload::sampled_every( - asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), - DurationMs(1_000), - )), - }; - asap_frontend_promql::lower_promql_query_workload(&workload) - .unwrap() - .remove(0) - .as_operator() - .cloned() - .expect("operator root") -} From 40e46a82f034070311014fdfdea4216853648476 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:03:10 +0000 Subject: [PATCH 3/5] test: delete the frontend and Pass 1 tests that cross stage crates (#1207 PR 0b) Frontend tests that compose Pass 1 candidates and Pass 1 tests that lower PromQL through the frontend are deleted, with the helpers, dev-dependencies and test targets only they used. MetricsQL's test that compares with PromQL's lowering goes too, so no frontend tests depend on another frontend. The crate-graph test checks dev-dependencies between stage crates with no exceptions. --- Cargo.lock | 8 - crates/frontend-metricsql/Cargo.toml | 4 - .../tests/external_corpus.rs | 146 ----- .../tests/lowering_semantics.rs | 13 - crates/frontend-metricsql/tests/main.rs | 6 - crates/frontend-promql/Cargo.toml | 7 - .../frontend-promql/tests/count_planning.rs | 180 +---- crates/frontend-promql/tests/main.rs | 1 - .../observability/metrics_observability.rs | 163 ----- .../tests/observability/promql_corpus.rs | 46 -- crates/frontend-promql/tests/support.rs | 76 --- .../tests/univmon_candidates.rs | 55 -- crates/frontend-sql/Cargo.toml | 3 - crates/frontend-sql/tests/frequency_l2.rs | 36 - crates/integration-tests/tests/crate_graph.rs | 37 +- .../Cargo.toml | 1 - .../src/per_computation_candidates.rs | 615 +----------------- .../src/test_fixtures.rs | 36 - .../metrics-observability-corpora.md | 5 + 19 files changed, 14 insertions(+), 1424 deletions(-) delete mode 100644 crates/frontend-metricsql/tests/external_corpus.rs delete mode 100644 crates/frontend-promql/tests/observability/metrics_observability.rs delete mode 100644 crates/frontend-promql/tests/univmon_candidates.rs diff --git a/Cargo.lock b/Cargo.lock index 686868b87..8d083039f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,9 +360,6 @@ version = "0.1.0" dependencies = [ "asap-frontend-common", "asap-frontend-metricsql", - "asap-frontend-promql", - "asap-stage0-memo", - "asap-stage1-pass1-per-computation-candidates", "asap-types", "metricsql_parser", "thiserror 2.0.18", @@ -375,8 +372,6 @@ dependencies = [ "asap-accuracy", "asap-frontend-common", "asap-planner", - "asap-stage0-memo", - "asap-stage1-pass1-per-computation-candidates", "asap-types", "promql-parser", ] @@ -389,8 +384,6 @@ dependencies = [ "asap-frontend-sql", "asap-planner", "asap-sql-function-catalog", - "asap-stage0-memo", - "asap-stage1-pass1-per-computation-candidates", "asap-types", "datafusion", "serde", @@ -461,7 +454,6 @@ name = "asap-stage1-pass1-per-computation-candidates" version = "0.1.0" dependencies = [ "asap-accuracy", - "asap-frontend-promql", "asap-types", "thiserror 2.0.18", ] diff --git a/crates/frontend-metricsql/Cargo.toml b/crates/frontend-metricsql/Cargo.toml index caa418db2..912119261 100644 --- a/crates/frontend-metricsql/Cargo.toml +++ b/crates/frontend-metricsql/Cargo.toml @@ -22,7 +22,3 @@ path = "tests/main.rs" [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-frontend-metricsql = { path = ".", features = ["test-support"] } -asap-frontend-promql = { path = "../frontend-promql" } -# `support.rs` (shared with frontend-promql's tests) binds through Pass 1. -asap-stage0-memo = { path = "../stage0-memo" } -asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } diff --git a/crates/frontend-metricsql/tests/external_corpus.rs b/crates/frontend-metricsql/tests/external_corpus.rs deleted file mode 100644 index 6aa8d1427..000000000 --- a/crates/frontend-metricsql/tests/external_corpus.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! An out-of-tree MetricsQL corpus: every query lowers and binds without -//! panicking. The queries live outside this repository (e.g. a private -//! corpus); the test reads every `*.txt` in `$ASAP_EXTERNAL_CORPUS_DIR` and -//! passes trivially when it is unset, so CI still compiles it on every PR. -//! -//! Each file holds one query per line (`#` lines are comments) and a -//! `# floor: N` line: the minimum number of its queries that must lower. -//! Raise a floor to the current `lowered` count when coverage improves. -//! -//! `ASAP_EXTERNAL_CORPUS_DIR= cargo test -p asap-frontend-metricsql -//! --test metricsql external_corpus:: -- --nocapture` - -use std::collections::HashMap; -use std::path::Path; -use std::time::Duration; - -use asap_frontend_metricsql::{lower_metricsql_query, MetricsqlError}; -use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::QueryRoot; - -use crate::support::logical_plan; - -const DIR_VAR: &str = "ASAP_EXTERNAL_CORPUS_DIR"; - -fn queries(corpus: &str) -> impl Iterator { - corpus - .lines() - .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) -} - -/// The `# floor: N` line's `N`. -fn floor(corpus: &str) -> Option { - corpus - .lines() - .find_map(|l| l.trim().strip_prefix("# floor:")) - .and_then(|n| n.trim().parse().ok()) -} - -#[derive(Default, Debug)] -struct Tally { - lowered: usize, - unparseable: usize, - rejected: usize, - bind_errored: usize, -} - -/// `msg` with sanitized identifiers replaced by `_` and digit runs by `N`, so -/// the same failure in different queries groups under one message. -fn normalize(msg: &str) -> String { - let mut out = String::new(); - let mut word = String::new(); - for c in msg.chars().chain([' ']) { - if c.is_alphanumeric() || c == '_' { - word.push(c); - continue; - } - if word.starts_with("sanitized_") { - out.push('_'); - } else if !word.is_empty() && word.chars().all(|c| c.is_ascii_digit()) { - out.push('N'); - } else { - out.push_str(&word); - } - word.clear(); - out.push(c); - } - out.pop(); - out -} - -/// Tally `corpus`, counting each failure's normalized message in `errors`. -fn tally(corpus: &str, errors: &mut HashMap) -> Tally { - let accuracy = AccuracyTarget::max_error(0.01); - let mut t = Tally::default(); - for q in queries(corpus) { - match lower_metricsql_query(q, accuracy.clone(), Duration::from_secs(15)) { - Ok(QueryRoot::Operator(dag)) => { - t.lowered += 1; - if logical_plan(&dag).is_err() { - t.bind_errored += 1; - } - } - Ok(QueryRoot::Scalar(_)) => t.lowered += 1, - Err(e) => { - if matches!(e, MetricsqlError::Parse(_)) { - t.unparseable += 1; - } else { - t.rejected += 1; - } - *errors.entry(normalize(&e.to_string())).or_default() += 1; - } - } - } - t -} - -/// Lowering and binding are total over every corpus file: a panic fails the -/// test, no lowerable query fails to bind, and coverage stays at its floor. -/// Also prints each unparseable/rejected cause with its count, most common -/// first, to show which unsupported features block the most queries. -#[test] -fn corpus_lowers_and_binds() { - let Some(dir) = std::env::var_os(DIR_VAR) else { - eprintln!("{DIR_VAR} unset; skipping the external corpus"); - return; - }; - let mut files: Vec<_> = std::fs::read_dir(Path::new(&dir)) - .unwrap_or_else(|e| panic!("{DIR_VAR}: {e}")) - .map(|entry| entry.unwrap().path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "txt")) - .collect(); - files.sort(); - assert!(!files.is_empty(), "{DIR_VAR} has no *.txt files"); - - let mut failures = Vec::new(); - let mut errors = HashMap::new(); - for path in files { - let name = path.file_stem().unwrap().to_string_lossy(); - let corpus = std::fs::read_to_string(&path).unwrap(); - let min_lowered = floor(&corpus).unwrap_or_else(|| panic!("{name}: no `# floor: N` line")); - let t = tally(&corpus, &mut errors); - eprintln!("{name:24} {t:?}"); - if t.bind_errored > 0 || t.lowered < min_lowered { - failures.push(format!("{name}: {t:?} (floor {min_lowered})")); - } - } - let mut errors: Vec<_> = errors.into_iter().collect(); - errors.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - eprintln!("\nFailures by cause:"); - for (msg, n) in errors { - eprintln!("{n:4} {msg}"); - } - assert!(failures.is_empty(), "{failures:#?}"); -} - -#[test] -fn floor_line() { - assert_eq!(floor("# floor: 25\nrate(x[5m])"), Some(25)); - assert_eq!(floor("rate(x[5m])"), None); - assert_eq!( - queries("# floor: 3\n\n up \n# c").collect::>(), - ["up"] - ); - assert_eq!(normalize("sanitized_foo at 123"), "_ at N"); -} diff --git a/crates/frontend-metricsql/tests/lowering_semantics.rs b/crates/frontend-metricsql/tests/lowering_semantics.rs index 3a1eb7842..a46540441 100644 --- a/crates/frontend-metricsql/tests/lowering_semantics.rs +++ b/crates/frontend-metricsql/tests/lowering_semantics.rs @@ -148,16 +148,3 @@ fn workload_lowering_uses_the_declared_sample_interval() { }); lower_metricsql_query_workload(&at_rest).unwrap(); } - -/// Vector negation lowers to the same IR as PromQL's: a sample-value -/// `Negative` that drops the metric name, not a multiplication by -1. -#[test] -fn vector_negation_lowers_as_in_promql() { - let query = "-rate(up[5m])"; - let metricsql = - lower_metricsql_query_workload(&workload(QueryLanguage::MetricsQL, query)).unwrap(); - let promql = - asap_frontend_promql::lower_promql_query_workload(&workload(QueryLanguage::PromQL, query)) - .unwrap(); - assert_eq!(metricsql, promql); -} diff --git a/crates/frontend-metricsql/tests/main.rs b/crates/frontend-metricsql/tests/main.rs index f3aa9f7c9..0c8315d09 100644 --- a/crates/frontend-metricsql/tests/main.rs +++ b/crates/frontend-metricsql/tests/main.rs @@ -2,12 +2,6 @@ //! its own copy of every dependency, which outgrew CI's disk. //! One file: `cargo test -p asap-frontend-metricsql --test metricsql ::`. -mod external_corpus; mod lowering; mod lowering_semantics; mod victoriametrics_go_golden; - -// The PromQL tests' binding helpers (`logical_plan`), for `external_corpus`. -#[path = "../../frontend-promql/tests/support.rs"] -#[allow(dead_code)] -mod support; diff --git a/crates/frontend-promql/Cargo.toml b/crates/frontend-promql/Cargo.toml index 4a5871204..63890e6d3 100644 --- a/crates/frontend-promql/Cargo.toml +++ b/crates/frontend-promql/Cargo.toml @@ -19,9 +19,6 @@ promql-parser = { git = "https://github.com/ProjectASAP/promql-parser", rev = "1 [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-accuracy = { path = "../accuracy", features = ["test-support"] } -# Stage 0's memo and Pass 1's target list (`per_computation_candidates`) for the tests. -asap-stage0-memo = { path = "../stage0-memo" } -asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } # The planning stages through the facade (a dev-dependency cycle, for # integration tests only). asap-planner = { path = "../planner" } @@ -40,10 +37,6 @@ path = "tests/observability/promql_corpus.rs" name = "o11y_bench_promql" path = "tests/observability/o11y_bench_promql.rs" -[[test]] -name = "metrics_observability" -path = "tests/observability/metrics_observability.rs" - [[test]] name = "promql" path = "tests/main.rs" diff --git a/crates/frontend-promql/tests/count_planning.rs b/crates/frontend-promql/tests/count_planning.rs index 09a3b2797..603b47ffc 100644 --- a/crates/frontend-promql/tests/count_planning.rs +++ b/crates/frontend-promql/tests/count_planning.rs @@ -1,42 +1,10 @@ //! Query text through summary selection: counts use observations, never value weights. use crate::planner_support::selected_dag; use crate::support; -use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::schema::{ - ExactKind, FieldDataType, GroupingStrategy, NonNegativeWeightProof, SketchAlgorithm, - SummaryInputExpr, WeightDomain, -}; +use asap_types::ir::schema::{ExactKind, FieldDataType}; use asap_types::ir::{ASAPOp, Operator, OperatorNode}; -use std::rc::Rc; -use support::{logical_candidates, lower_promql, physical_asap_dag}; - -/// Whether `node` reads a Hydra state (one sketch shared by every group). -fn reads_hydra(node: &Rc) -> bool { - OperatorNode::reachable(node).iter().any(|node| { - matches!( - &node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(_, GroupingStrategy::SharedMultiSubpopulation { .. }), - .. - }) - ) - }) -} - -#[test] -#[ignore = "Pass 1 offers no Hydra state for a grouped count yet (#703)"] -fn grouped_count_offers_hydra_but_selection_keeps_per_group_state() { - let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); - // Hydra hashes a non-null item per row: the series identity (PromQL - // labels are nullable). - let root = asap_types::ir::schema::schema_support::with_promql_series_identity( - &lower_promql("count by(job)(up)", target.clone()).unwrap(), - ) - .unwrap(); - assert!(logical_candidates(Rc::clone(&root)).iter().any(reads_hydra)); - assert!(!reads_hydra(&selected_dag(root, target))); -} +use support::lower_promql; // Exact series and temporal counts must select a count accumulator, not distinct or sum. #[test] @@ -59,76 +27,6 @@ fn exact_counts_select_count_accumulators() { } } -// CMS/UnivMon count updates stay +1 even for zero or negative samples. -// CountSketch cannot answer totals and must not be offered for count. -#[test] -fn frequency_count_candidates_use_unit_weights() { - for query in ["count_over_time(up[5m])", "count(up)"] { - let accuracy = AccuracyTarget::max_error(0.02); - let root = lower_promql(query, accuracy.clone()).unwrap(); - let candidates = logical_candidates(root); - let mut algorithms = Vec::new(); - for node in &candidates { - let Some(summary_input) = - OperatorNode::reachable(node) - .into_iter() - .find_map(|node| match &node.operator { - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - Some(Rc::clone(summary_input)) - } - _ => None, - }) - else { - continue; - }; - let Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - input, - .. - }) = &summary_input.operator - else { - continue; - }; - assert!( - !matches!(kind, SketchAlgorithm::Hll | SketchAlgorithm::CountSketch), - "{query}: {node:?}" - ); - if !matches!(kind, SketchAlgorithm::Cms | SketchAlgorithm::UnivMon) { - continue; - } - let dag = physical_asap_dag(node); - assert!( - dag.nodes.iter().any(|node| matches!( - &node.payload, - PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { input: actual, .. }) if actual == input - )), - "post-ASAP DAG must preserve the count update contract" - ); - algorithms.push(*kind); - assert!(input.item.is_some(), "frequency keys must be explicit"); - assert_eq!( - input.weight, - SummaryInputExpr::Constant(1.0), - "{query}: {node:?}" - ); - assert_eq!( - input.weight_domain, - WeightDomain::NonNegative { - proof: NonNegativeWeightProof::UnitCount - } - ); - } - assert!( - algorithms.contains(&SketchAlgorithm::Cms), - "{query}: {candidates:?}" - ); - assert!( - algorithms.contains(&SketchAlgorithm::UnivMon), - "{query}: {candidates:?}" - ); - } -} - // Fixtures are already selected at one instant or within one five-minute window. // This narrow test oracle interprets the emitted aggregate, not Prometheus ingestion, // staleness, or scrape scheduling. Unsupported plan shapes fail explicitly. @@ -218,77 +116,3 @@ fn count_over_time_counts_scrapes_not_sample_values() { vec![10.0, 10.0] ); } - -// Execute the emitted CMS update-weight expression on the reported values. -// This checks the planner's numerical update contract, not a sketch-library runtime. -#[test] -fn cms_count_updates_total_ten_for_zero_positive_and_negative_samples() { - use asap_types::ir::scalar::ColumnRef; - let accuracy = AccuracyTarget::max_error(0.02); - let root = lower_promql("count_over_time(up[5m])", accuracy.clone()).unwrap(); - let candidates = logical_candidates(root); - let dag = candidates - .iter() - .find_map(|node| { - let dag = physical_asap_dag(node); - dag.nodes - .iter() - .any(|node| { - matches!(&node.payload, - PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) - if kind == &SketchAlgorithm::Cms) - }) - .then_some(dag) - }) - .expect("CMS count candidate"); - let update = dag - .nodes - .iter() - .find_map(|node| match &node.payload { - PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { input, .. }) => Some(input), - _ => None, - }) - .unwrap(); - for value in [1.0, 0.0, 3.0, -3.0] { - let total: f64 = [value; 10] - .into_iter() - .map(|sample| { - let weight = match &update.weight { - SummaryInputExpr::Constant(value) => *value, - SummaryInputExpr::Column(ColumnRef::SampleValue) => sample, - SummaryInputExpr::Column(ColumnRef::Named(name)) if name == "value" => sample, - other => panic!("unsupported update weight: {other:?}"), - }; - assert!( - weight >= 0.0, - "CMS must not receive a negative update for {sample}" - ); - weight - }) - .sum(); - assert_eq!(total, 10.0, "ten samples of {value}"); - } -} - -// The assertion helper must fail on an injected composition error; its coverage -// must not depend on a real planner bug remaining unfixed. -#[test] -#[should_panic(expected = "does not compose")] -fn logical_candidates_fails_on_a_candidate_that_does_not_compose() { - support::require_composed_operator_candidates(vec![support::LogicalCandidate { - id: "invalid-composition".into(), - roots: Err("invalid choice".into()), - }]); -} - -// Ranked-row schemas let an aggregate consume TopK's numeric output (#672). -#[test] -fn aggregate_over_topk_candidates_compose() { - let accuracy = AccuracyTarget::max_error(0.01); - let root = lower_promql( - "sum by (job) (topk by (job, instance) (1, rate(m[5m])))", - accuracy.clone(), - ) - .unwrap(); - assert!(!logical_candidates(root).is_empty()); -} diff --git a/crates/frontend-promql/tests/main.rs b/crates/frontend-promql/tests/main.rs index 428e8a089..b962cb00b 100644 --- a/crates/frontend-promql/tests/main.rs +++ b/crates/frontend-promql/tests/main.rs @@ -12,4 +12,3 @@ mod promql_conformance; mod promql_equivalence; mod promql_lowering; mod scalar_design; -mod univmon_candidates; diff --git a/crates/frontend-promql/tests/observability/metrics_observability.rs b/crates/frontend-promql/tests/observability/metrics_observability.rs deleted file mode 100644 index 39815ca2e..000000000 --- a/crates/frontend-promql/tests/observability/metrics_observability.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! Corpus totality checks over the metrics-observability benchmark exports. -//! -//! The source dump mixes Prometheus query logs, Grafana dashboard JSON, and -//! Prometheus rule YAML. The preprocessing script produces the line-oriented -//! fixtures consumed here. - -use asap_frontend_promql::PromqlError; -#[path = "../support.rs"] -mod support; -use asap_types::ir::properties::AccuracyTarget; -use support::{logical_plan, lower_promql}; - -/// Today's coverage of one corpus, as a ratchet: a change that plans fewer -/// queries with an ASAP operator, or fails to plan more of them, fails the -/// test. Raise `min_asap_candidates` (or lower `max_planning_errors`) when -/// coverage improves. -struct CoverageRatchet { - /// Queries whose logical plan binds an ASAP operator below the root. - min_asap_candidates: usize, - /// Lowered queries whose logical planning returns an error. - max_planning_errors: usize, -} - -const fn ratchet(min_asap_candidates: usize, max_planning_errors: usize) -> CoverageRatchet { - CoverageRatchet { - min_asap_candidates, - max_planning_errors, - } -} - -const CORPORA: &[(&str, &str, CoverageRatchet)] = &[ - ( - "CMU query logs", - include_str!("data/metrics_observability/cmu_chad_logs.txt"), - ratchet(162, 0), - ), - ( - "CMU dashboards", - include_str!("data/metrics_observability/cmu_chad_dashboards.txt"), - ratchet(174, 0), - ), - ( - "CMU rules", - include_str!("data/metrics_observability/cmu_chad_rules.txt"), - ratchet(202, 0), - ), - ( - "Claude query logs", - include_str!("data/metrics_observability/from_claude_logs.txt"), - ratchet(71, 0), - ), - ( - "Grafana dashboards", - include_str!("data/metrics_observability/grafana_dashboards.txt"), - ratchet(164, 0), - ), - ( - "Kubernetes mixin dashboards", - include_str!("data/metrics_observability/kubernetes_mixin_dashboards.txt"), - ratchet(147, 0), - ), - ( - "Kubernetes mixin alerts", - include_str!("data/metrics_observability/kubernetes_mixin_alerts.txt"), - ratchet(27, 2), - ), - ( - "Kubernetes mixin rules", - include_str!("data/metrics_observability/kubernetes_mixin_rules.txt"), - ratchet(66, 0), - ), - ( - "Promset", - include_str!("data/metrics_observability/promset.txt"), - ratchet(1505, 4), - ), -]; - -fn queries(corpus: &str) -> impl Iterator { - corpus - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) -} - -#[test] -fn benchmark_corpora_are_total_and_keep_their_coverage() { - for (name, corpus, ratchet) in CORPORA { - let mut total = 0; - let mut lowered = 0; - let mut parse_failures = 0; - let mut rejected = 0; - let mut post_asap_candidates = 0; - let mut post_asap_unchanged = 0; - let mut post_asap_errors = 0; - - for query in queries(corpus) { - total += 1; - // Approximate accuracy exercises the sketch-replacement boundary - // used by the existing post-ASAP corpus binding test. - match lower_promql(query, AccuracyTarget::max_error(0.01)) { - Ok(expr) => { - lowered += 1; - match logical_plan(&expr) { - // An ASAP operator bound somewhere below the root. - Ok(node) if node.contains_asap() => post_asap_candidates += 1, - Ok(_) => { - post_asap_unchanged += 1; - if std::env::var_os("METRICS_OBSERVABILITY_REPORT").is_some() { - eprintln!("POST_ASAP_MISS\t{name}\t{query}"); - } - } - Err(error) => { - post_asap_errors += 1; - if std::env::var_os("METRICS_OBSERVABILITY_REPORT").is_some() { - eprintln!("POST_ASAP_ERROR\t{name}\t{query}\t{error}"); - } - } - } - } - Err(PromqlError::Parse(_)) => parse_failures += 1, - Err(_) => rejected += 1, - } - } - - eprintln!("{name}: total={total}, parse_errors={parse_failures}, lowering_errors={rejected}, pre_asap={lowered}, post_asap_candidates={post_asap_candidates}, post_asap_unchanged={post_asap_unchanged}, post_asap_errors={post_asap_errors}"); - assert!(total > 0, "{name} fixture is empty"); - // Dashboard variables and panel expressions can contain Grafana - // template syntax rather than standalone PromQL. They are still - // counted above so extraction drift is visible, but are not expected - // to parse as PromQL. - assert_eq!(total, lowered + rejected + parse_failures); - assert!( - post_asap_candidates >= ratchet.min_asap_candidates, - "{name}: {post_asap_candidates} queries bind an ASAP operator, fewer than {}", - ratchet.min_asap_candidates - ); - assert!( - post_asap_errors <= ratchet.max_planning_errors, - "{name}: {post_asap_errors} queries fail logical planning, more than {}", - ratchet.max_planning_errors - ); - } -} - -/// The two Kubernetes mixin alerts whose candidates do not compose: each -/// aggregates over a `topk` output, which the frontend declares as -/// `[keys.., topk_k]` with a `Utf8` placeholder and no value column, so the -/// outer `sum`'s accumulator has no value to read (and the plain plan would -/// sum a string). The ranked-row TopK contract now exposes its numeric value -/// and retained identity columns (#672). -#[test] -fn kubernetes_mixin_aggregates_over_topk_compose() { - for query in [ - r#"sum without (id, metrics_path, name, image, endpoint, job, node) ( topk by (cluster, namespace, pod, container, instance) (1, increase( container_cpu_cfs_throttled_periods_total{container!="", job="cadvisor", } [5m]) ) ) / on (cluster, namespace, pod, container, instance) group_left sum without (id, metrics_path, name, image, endpoint, job, node) ( topk by (cluster, namespace, pod, container, instance) (1, increase( container_cpu_cfs_periods_total{job="cadvisor", } [5m]) ) ) > ( 25 / 100 )"#, - r#"histogram_quantile(0.99, sum by (cluster, instance, le) ( topk by (cluster, instance, le, operation_type) (1, rate(kubelet_pod_worker_duration_seconds_bucket{job="kubelet"}[5m]) ) ) ) * on(cluster, instance) group_left(node) topk by (cluster, instance, node) (1, kubelet_node_name{job="kubelet"} ) > 60"#, - ] { - let root = lower_promql(query, AccuracyTarget::max_error(0.01)).unwrap(); - if let Err(error) = logical_plan(&root) { - panic!("{query}: {error}"); - } - } -} diff --git a/crates/frontend-promql/tests/observability/promql_corpus.rs b/crates/frontend-promql/tests/observability/promql_corpus.rs index cd0aab26c..461a8a4f4 100644 --- a/crates/frontend-promql/tests/observability/promql_corpus.rs +++ b/crates/frontend-promql/tests/observability/promql_corpus.rs @@ -17,7 +17,6 @@ use asap_frontend_promql::PromqlError as LoweringError; #[path = "../support.rs"] mod support; use asap_types::ir::properties::AccuracyTarget; -use support::{logical_plan, lower_promql}; const DOCS: &str = include_str!("data/promql_corpus_docs.txt"); const TESTDATA: &str = include_str!("data/promql_corpus_testdata.txt"); @@ -67,51 +66,6 @@ fn tally(corpus: &str) -> Tally { t } -/// Every query that lowers, additionally run through logical ASAP-aware -/// optimization ([`logical_plan`]: Pass 1 and composition), at an -/// approximate accuracy target so summary candidates actually appear (an -/// `Exact` target would only ever exercise the exact-accumulator arm). -#[derive(Default, Debug)] -struct BindTally { - /// An ASAP operator was bound somewhere below the root — the pass did - /// something. - transformed: usize, - /// The kept pre-ASAP dag — the pass left the query untouched. - unchanged: usize, - /// [`logical_plan`] returned `Err`: Pass 1 rejected the query, or its - /// exact candidate did not compose. - errored: usize, -} - -fn bind_tally(corpus: &str, accuracy: AccuracyTarget) -> BindTally { - let mut t = BindTally::default(); - for q in queries(corpus) { - let Ok(dag) = lower_promql(q, accuracy.clone()) else { - continue; - }; - match logical_plan(&dag) { - Ok(bound) if !bound.contains_asap() => t.unchanged += 1, - Ok(_) => t.transformed += 1, - Err(_) => t.errored += 1, - } - } - t -} - -#[test] -fn binding_is_total_over_the_entire_corpus() { - let accuracy = AccuracyTarget::max_error(0.01); - let docs = bind_tally(DOCS, accuracy.clone()); - let td = bind_tally(TESTDATA, accuracy); - eprintln!("docs corpus post-ASAP binding: {docs:?}"); - eprintln!("testdata corpus post-ASAP binding: {td:?}"); - - // Same totality guarantee as lowering: reaching here means `logical_plan` - // never panicked over any lowerable query in the corpus. - assert_eq!(docs.errored, 0, "post-ASAP binding errored: {docs:?}"); - assert_eq!(td.errored, 0, "post-ASAP binding errored: {td:?}"); -} - #[test] fn lowering_is_total_over_the_entire_corpus() { let docs = tally(DOCS); diff --git a/crates/frontend-promql/tests/support.rs b/crates/frontend-promql/tests/support.rs index 2acd52396..18132cafd 100644 --- a/crates/frontend-promql/tests/support.rs +++ b/crates/frontend-promql/tests/support.rs @@ -135,79 +135,3 @@ pub fn declared_data(root: &Rc) -> DataWorkload { sources, } } - -/// Local computation candidates for frontend binding tests. Other computations -/// keep their default choices; a timed MILP archive is not a candidate catalog. -#[allow(dead_code)] -pub fn logical_candidates(root: Rc) -> Vec> { - local_computation_candidates(root).expect("generated computation candidates compose") -} - -fn local_computation_candidates(root: Rc) -> Result>, String> { - use asap_stage1_pass1_per_computation_candidates::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - use asap_types::ir::QueryRoot; - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .map_err(|error| error.to_string())?; - let mut choices = vec![vec![0; inventory.targets.len()]]; - for (index, target) in inventory.targets.iter().enumerate() { - for choice in 1..target.choice_count() { - let mut selected = vec![0; inventory.targets.len()]; - selected[index] = choice; - choices.push(selected); - } - } - choices - .into_iter() - .map(|choice| { - let mut roots = compose_logical_candidate(&inventory, &choice) - .map_err(|error| error.to_string())?; - match roots.remove(0) { - QueryRoot::Operator(root) => Ok(root), - QueryRoot::Scalar(_) => Err("expected an operator root".into()), - } - }) - .collect() -} - -/// A composed logical candidate, or why it does not compose. -#[allow(dead_code)] -pub struct LogicalCandidate { - pub id: String, - pub roots: Result, String>, -} - -/// Fail on a rejected candidate rather than silently dropping it from assertions. -#[allow(dead_code)] -pub fn require_composed_operator_candidates( - candidates: Vec, -) -> Vec> { - candidates - .into_iter() - .filter_map(|candidate| { - match candidate - .roots - .unwrap_or_else(|e| panic!("{} does not compose: {e}", candidate.id)) - .remove(0) - { - asap_types::ir::QueryRoot::Operator(node) => Some(node), - asap_types::ir::QueryRoot::Scalar(_) => None, - } - }) - .collect() -} - -/// One generated ASAP candidate, or the exact computation if no local rule -/// applies. Corpus coverage tests exercise binding, not cost-based selection. -#[allow(dead_code)] -pub fn logical_plan(root: &Rc) -> Result, String> { - let candidates = local_computation_candidates(Rc::clone(root))?; - Ok(candidates - .iter() - .find(|node| node.contains_asap()) - .unwrap_or(&candidates[0]) - .clone()) -} diff --git a/crates/frontend-promql/tests/univmon_candidates.rs b/crates/frontend-promql/tests/univmon_candidates.rs deleted file mode 100644 index 53bcf6239..000000000 --- a/crates/frontend-promql/tests/univmon_candidates.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::rc::Rc; - -use crate::planner_support::run_all_stages; -use crate::support; - -use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; -use asap_types::ir::{ASAPOp, Operator, OperatorNode}; -use support::{logical_candidates, lower_promql}; - -/// The summary evaluation over a UnivMon state that `root` reads, if any. -fn univmon_estimate(root: &Rc) -> Option> { - OperatorNode::reachable(root).into_iter().find(|node| { - let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator else { - return false; - }; - matches!(&summary_input.operator, Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) - if kind == &SketchAlgorithm::UnivMon) - }) -} - -#[test] -fn uncalibrated_frequency_evaluations_do_not_bypass_accuracy_targets() { - // An unmeasured heuristic remains inspectable but is never certified or - // automatically selected for a caller-visible bounded-error result. - // L2 is certified; entropy is not. - for query in ["entropy_over_time(m[5m])"] { - for target in [ - AccuracyTarget::exact(), - AccuracyTarget::max_error(0.02), - AccuracyTarget::max_error(0.02).failure_probability(0.01), - ] { - let root = lower_promql(query, target.clone()).unwrap(); - let generated = logical_candidates(Rc::clone(&root)); - let run = run_all_stages(root, target.clone()); - let unknown: Vec<_> = generated - .iter() - .filter(|node| univmon_estimate(node).is_some() && node.guarantee.is_none()) - .collect(); - if target.is_exact() { - assert!(unknown.is_empty(), "{query}"); - } else { - assert!(!unknown.is_empty(), "{query}"); - let [asap_types::ir::QueryRoot::Operator(selected)] = run - .selected_plan - .as_deref() - .expect("a certified plan is selected") - else { - panic!("one operator result"); - }; - assert!(selected.guarantee.is_some(), "{query}"); - } - } - } -} diff --git a/crates/frontend-sql/Cargo.toml b/crates/frontend-sql/Cargo.toml index 0e8818b7d..98d09a5e0 100644 --- a/crates/frontend-sql/Cargo.toml +++ b/crates/frontend-sql/Cargo.toml @@ -36,9 +36,6 @@ test-support = [] [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-frontend-sql = { path = ".", features = ["test-support"] } -# Stage 0's memo and Pass 1's target list (`per_computation_candidates`) for the tests. -asap-stage0-memo = { path = "../stage0-memo" } -asap-stage1-pass1-per-computation-candidates = { path = "../stage1-pass1-per-computation-candidates", features = ["test-support"] } # The planning stages through the facade (a dev-dependency cycle, for # integration tests only). asap-planner = { path = "../planner" } diff --git a/crates/frontend-sql/tests/frequency_l2.rs b/crates/frontend-sql/tests/frequency_l2.rs index 6152a9ba0..61be8b0cc 100644 --- a/crates/frontend-sql/tests/frequency_l2.rs +++ b/crates/frontend-sql/tests/frequency_l2.rs @@ -7,7 +7,6 @@ use asap_types::ir::operator::AggIntent; use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::schema::{DataType, Field, Schema}; use asap_types::ir::{NonASAPOp, OperatorNode}; -use asap_types::stages::HowToComputeAggregateMeasure; use std::rc::Rc; fn catalog(nullable: bool) -> SqlCatalog { @@ -120,41 +119,6 @@ async fn frequency_empty_input_guard_uses_an_exact_count() { )); } -/// Stage 1 offers the exact option and summary candidates for the -/// recognized L2 intent. -#[tokio::test] -async fn stage1_offers_exact_and_summary_l2_candidates() { - use asap_stage1_pass1_per_computation_candidates::test_support::generate_per_computation_candidates; - use asap_types::ir::QueryRoot; - let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); - let root = lower_sql(CAST_COUNT, &catalog(false), target) - .await - .unwrap(); - let inventory = generate_per_computation_candidates( - &asap_stage0_memo::memo_of_queries(&[QueryRoot::Operator(root)]).unwrap(), - ) - .unwrap(); - let l2 = inventory - .targets - .iter() - .find(|t| has_l2(inventory.aggregate(t))) - .expect("L2 target"); - let candidates = &l2.ways_to_compute; - assert!( - candidates.iter().any(|c| matches!( - c, - HowToComputeAggregateMeasure::Exact | HowToComputeAggregateMeasure::ExactAccumulator(_) - )), - "{candidates:?}" - ); - assert!( - candidates - .iter() - .any(|c| matches!(c, HowToComputeAggregateMeasure::Summary(_))), - "{candidates:?}" - ); -} - /// #509 Example 2 over `flows(ts, src_ip)` with its one-minute window: Q2 /// lowers to entropy and the Float64 Q3 to L2; the design's literal Int64 /// Q3 stays relational. diff --git a/crates/integration-tests/tests/crate_graph.rs b/crates/integration-tests/tests/crate_graph.rs index 3f4d90c3a..9e4a15a35 100644 --- a/crates/integration-tests/tests/crate_graph.rs +++ b/crates/integration-tests/tests/crate_graph.rs @@ -19,8 +19,9 @@ //! - **devtools** and **integration-tests** may depend on anything, and no //! other crate may depend on them. //! -//! The edges the rules forbid but the workspace has today are listed, each -//! with its reason, in [`EXCEPTIONS`] and [`DEV_EXCEPTIONS`]. A new workspace crate fails the test +//! The `[dependencies]` edges the rules forbid but the workspace has today are +//! listed, each with its reason, in [`EXCEPTIONS`]; no `[dev-dependencies]` +//! edge between stage crates is excepted. A new workspace crate fails the test //! until it gets a role. use serde_json::Value; @@ -103,36 +104,6 @@ const EXCEPTIONS: &[Exception] = &[ ("asap-planner", "asap-executor"), ]; -/// The `[dev-dependencies]` edges between stage crates the workspace keeps -/// today: the query-language frontends' tests and Pass 1's tests. -const DEV_EXCEPTIONS: &[Exception] = &[ - // Pass 1's tests lower PromQL test queries (`test_fixtures::lower_promql`). - ( - "asap-stage1-pass1-per-computation-candidates", - "asap-frontend-promql", - ), - // The frontends' tests compose Pass 1 candidates of the lowered queries - // (`tests/support.rs`'s `logical_candidates`, `frequency_l2.rs`). - ("asap-frontend-promql", "asap-stage0-memo"), - ( - "asap-frontend-promql", - "asap-stage1-pass1-per-computation-candidates", - ), - ("asap-frontend-sql", "asap-stage0-memo"), - ( - "asap-frontend-sql", - "asap-stage1-pass1-per-computation-candidates", - ), - ("asap-frontend-metricsql", "asap-stage0-memo"), - ( - "asap-frontend-metricsql", - "asap-stage1-pass1-per-computation-candidates", - ), - // The MetricsQL tests share the PromQL tests' `support.rs` and compare - // with the PromQL frontend's lowering. - ("asap-frontend-metricsql", "asap-frontend-promql"), -]; - /// One message per edge of `metadata` (the JSON of /// `cargo metadata --format-version 1 --no-deps`) that breaks a rule, naming /// the edge and the rule: a `[dependencies]` edge that [`rule_violated`] @@ -175,7 +146,7 @@ fn crate_graph_violations(metadata: &Value) -> Vec { "[dev-dependencies]", (from_role == Role::Stage && to_role == Role::Stage) .then_some("a stage crate's tests never call another stage crate"), - DEV_EXCEPTIONS, + &[][..], ), Some(_) => continue, }; diff --git a/crates/stage1-pass1-per-computation-candidates/Cargo.toml b/crates/stage1-pass1-per-computation-candidates/Cargo.toml index 4af9be734..0a4df4c1a 100644 --- a/crates/stage1-pass1-per-computation-candidates/Cargo.toml +++ b/crates/stage1-pass1-per-computation-candidates/Cargo.toml @@ -20,4 +20,3 @@ test-support = [] [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-accuracy = { path = "../accuracy", features = ["test-support"] } -asap-frontend-promql = { path = "../frontend-promql" } diff --git a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs index dbec09f54..3ca44bfd4 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/per_computation_candidates.rs @@ -2145,46 +2145,14 @@ fn summary_update( #[cfg(test)] mod tests { - /// Aggregate replacement qualifiers survive both renaming and later input rebinding. - #[test] - fn aggregate_replacement_preserves_sql_field_identity() { - let original = - crate::test_fixtures::lower_promql("sum by (job) (m)", AccuracyTarget::exact()); - let mut target = original.as_ref().clone(); - target.schema.fields[0].table = Some("metrics".into()); - let projected = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: target - .schema - .fields - .iter() - .enumerate() - .map(|(column, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(column), - }) - .collect(), - qualifier: None, - child: original, - })) - .unwrap(); - assert_eq!(projected.schema.fields[0].table, None); - let replaced = with_names_of(&target, projected).unwrap(); - assert_eq!(replaced.schema, target.schema); - let rebound = replaced.with_new_children(Rc::clone).unwrap(); - assert_eq!(rebound.schema, target.schema); - } use super::*; - use asap_accuracy::composition::PRODUCT_OPERAND_DOMAINS; use asap_accuracy::test_support::{ - composed_guarantee, estimate_bound_over_raw_data_samples, estimate_has_bound, - local_guarantee, raw_data_samples_sign_statistic, scalar_guarantee, - source_distinct_guarantee, QUOTIENT_OPERAND_DOMAINS, - }; - use asap_types::ir::properties::guarantee::{ - AccuracyGuarantee, AccuracyMetric, BoundExpr, ProbabilityExpr, + estimate_bound_over_raw_data_samples, estimate_has_bound, local_guarantee, + raw_data_samples_sign_statistic, }; + use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::schema::SketchStatistic; /// Approximate count keeps one exact option, its accumulator, beside the @@ -2234,553 +2202,6 @@ mod tests { } } - /// Guarantee composition through scalar and vector arithmetic (#669). - mod guarantee_composition { - use super::*; - use crate::test_fixtures::lower_promql; - use asap_types::ir::properties::AccuracyTarget; - - fn with_bound(metric: AccuracyMetric, bound: f64, delta: f64) -> AccuracyGuarantee { - AccuracyGuarantee::new( - metric, - BoundExpr::Constant { value: bound }, - ProbabilityExpr::Constant { value: delta }, - vec![], - ) - } - fn abs(bound: f64, delta: f64) -> AccuracyGuarantee { - with_bound(AccuracyMetric::ABSOLUTE, bound, delta) - } - fn rel(bound: f64) -> AccuracyGuarantee { - with_bound(AccuracyMetric::Relative, bound, 0.0) - } - /// `sum(m)` carrying `guarantee`. - fn node(guarantee: AccuracyGuarantee) -> Rc { - node_over("sum(m)", guarantee) - } - /// `query` carrying `guarantee`. - fn node_over(query: &str, guarantee: AccuracyGuarantee) -> Rc { - let node = lower_promql(query, AccuracyTarget::exact()); - Rc::new((*node).clone().with_guarantee(Some(guarantee))) - } - fn scalar(guarantee: AccuracyGuarantee) -> ScalarExpr { - ScalarExpr::PromqlScalarFromVector(node(guarantee)) - } - fn lit(value: f64) -> ScalarExpr { - ScalarExpr::literal_f64(value) - } - fn arith(op: ArithmeticOpKind, left: ScalarExpr, right: ScalarExpr) -> ScalarExpr { - ScalarExpr::Arithmetic { - op, - left: Box::new(left), - right: Box::new(right), - semantics: ExprSemantics::Promql, - } - } - fn call(name: &str, args: Vec) -> ScalarExpr { - ScalarExpr::FunctionCall { - name: name.into(), - args, - } - } - fn of(expr: &ScalarExpr) -> Option { - scalar_guarantee(expr, &|_| None) - } - fn bound(g: &AccuracyGuarantee) -> f64 { - g.spec.error.evaluate().expect("a resolved bound") - } - fn delta(g: &AccuracyGuarantee) -> f64 { - g.spec - .failure_probability - .evaluate() - .expect("a resolved probability") - } - fn close(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-12 - } - - /// Absolute errors add through `x ± y`, with the union bound on the - /// failure probability; `−x` and `x ± c` keep the error, `c · x` and - /// `x / c` scale it by `|c|` and `1/|c|`, division by a literal zero - /// has none. - #[test] - fn absolute_errors_compose_through_arithmetic() { - use ArithmeticOpKind::*; - let (x, y) = (scalar(abs(0.01, 0.001)), scalar(abs(0.02, 0.002))); - for op in [Add, Sub] { - let g = of(&arith(op.clone(), x.clone(), y.clone())).unwrap(); - assert_eq!(g.spec.accuracy_metric, AccuracyMetric::ABSOLUTE, "{op:?}"); - assert!(close(bound(&g), 0.03) && close(delta(&g), 0.003), "{op:?}"); - for shifted in [ - arith(op.clone(), x.clone(), lit(5.0)), - arith(op.clone(), lit(5.0), x.clone()), - arith(op.clone(), x.clone(), ScalarExpr::EvalTimestamp), - ] { - let g = of(&shifted).unwrap(); - assert!(close(bound(&g), 0.01) && close(delta(&g), 0.001), "{op:?}"); - } - } - let negated = ScalarExpr::Negative { - expr: Box::new(x.clone()), - semantics: ExprSemantics::Promql, - }; - assert!(close(bound(&of(&negated).unwrap()), 0.01)); - for (expr, expected) in [ - (arith(Mul, lit(-3.0), x.clone()), 0.03), - (arith(Mul, x.clone(), lit(3.0)), 0.03), - (arith(Div, x.clone(), lit(-4.0)), 0.0025), - ] { - let g = of(&expr).unwrap(); - assert!( - close(bound(&g), expected) && close(delta(&g), 0.001), - "{expr:?}" - ); - } - for none in [ - arith(Div, x.clone(), lit(0.0)), - arith(Mul, x.clone(), ScalarExpr::EvalTimestamp), - arith(Pow, x.clone(), lit(2.0)), - ] { - assert_eq!(of(&none), None, "{none:?}"); - } - } - - /// A product of two estimates has an absolute bound - /// `|x|·e_y + |y|·e_x + e_x·e_y`, and a quotient with an approximate - /// denominator one that needs the denominator bounded away from - /// zero: both need operand domains, so Stage 1 leaves them symbolic - /// for plan selection to bind. Relative errors need no domain: a - /// product has the closed form `(1 + a)(1 + b) − 1`, a quotient - /// `(a + b) / (1 − b)` (`a = 0` for an exact numerator). The failure - /// probability is the union bound. A rank error composes to none. - #[test] - fn products_and_quotients_of_estimates_have_closed_or_symbolic_bounds() { - use ArithmeticOpKind::*; - let unknown = |statistic: &str| BoundExpr::Unknown { - statistic: statistic.into(), - }; - let (x, y) = (scalar(abs(0.01, 0.001)), scalar(abs(0.02, 0.002))); - let product = of(&arith(Mul, x.clone(), y.clone())).unwrap(); - assert_eq!(product.spec.accuracy_metric, AccuracyMetric::ABSOLUTE); - assert_eq!(product.spec.error, unknown(PRODUCT_OPERAND_DOMAINS)); - assert!(close(delta(&product), 0.003)); - for quotient in [ - arith(Div, x.clone(), y.clone()), - arith(Div, lit(1.0), x.clone()), - arith(Div, x.clone(), scalar(rel(0.01))), - ] { - let g = of("ient).unwrap(); - assert_eq!( - g.spec.accuracy_metric, - AccuracyMetric::ABSOLUTE, - "{quotient:?}" - ); - assert_eq!( - g.spec.error, - unknown(QUOTIENT_OPERAND_DOMAINS), - "{quotient:?}" - ); - } - let ratio = of(&arith( - Div, - scalar(with_bound(AccuracyMetric::Relative, 0.01, 0.001)), - scalar(with_bound(AccuracyMetric::Relative, 0.02, 0.002)), - )) - .unwrap(); - assert_eq!(ratio.spec.accuracy_metric, AccuracyMetric::Relative); - assert!(close(bound(&ratio), (0.01 + 0.02) / (1.0 - 0.02))); - assert!(close(delta(&ratio), 0.003)); - let reciprocal = of(&arith(Div, lit(2.0), scalar(rel(0.02)))).unwrap(); - assert_eq!(reciprocal.spec.accuracy_metric, AccuracyMetric::Relative); - assert!(close(bound(&reciprocal), 0.02 / (1.0 - 0.02))); - // A denominator bound of 1 or more leaves the ratio unbounded. - assert_eq!(of(&arith(Div, scalar(rel(0.01)), scalar(rel(1.0)))), None); - // A relative bound still symbolic in Stage 1 has no ratio rule. - let symbolic = AccuracyGuarantee::new( - AccuracyMetric::Relative, - unknown("values_sign"), - ProbabilityExpr::Zero, - vec![], - ); - assert_eq!(of(&arith(Div, scalar(symbolic), scalar(rel(0.01)))), None); - let relative = of(&arith(Mul, scalar(rel(0.01)), scalar(rel(0.02)))).unwrap(); - assert_eq!(relative.spec.accuracy_metric, AccuracyMetric::Relative); - assert!(close(bound(&relative), 0.01 + 0.02 + 0.0002)); - let rank = scalar(with_bound(AccuracyMetric::Rank, 0.01, 0.001)); - assert_eq!(of(&arith(Mul, rank.clone(), x.clone())), None); - assert_eq!(of(&arith(Div, x, rank)), None); - } - - /// A relative error survives negation and scaling by an exact value, - /// but not a shift (`x + c`) or a sum or difference of estimates - /// (cancellation); a rank error composes to no guarantee (a rank - /// interval counts as none in the MVP). - #[test] - fn relative_and_rank_errors_compose_only_where_sound() { - use ArithmeticOpKind::*; - let x = scalar(rel(0.01)); - for kept in [ - ScalarExpr::Negative { - expr: Box::new(x.clone()), - semantics: ExprSemantics::Promql, - }, - arith(Mul, x.clone(), lit(-2.0)), - arith(Div, x.clone(), ScalarExpr::EvalTimestamp), - ] { - let g = of(&kept).unwrap(); - assert_eq!(g.spec.accuracy_metric, AccuracyMetric::Relative, "{kept:?}"); - assert!(close(bound(&g), 0.01), "{kept:?}"); - } - for none in [ - arith(Add, x.clone(), lit(1.0)), - arith(Sub, x.clone(), scalar(rel(0.01))), - ] { - assert_eq!(of(&none), None, "{none:?}"); - } - let rank = scalar(with_bound(AccuracyMetric::Rank, 0.01, 0.001)); - for none in [ - arith(Sub, rank.clone(), rank.clone()), - arith(Mul, rank.clone(), lit(2.0)), - call("promql_clamp_max", vec![rank, lit(1.0)]), - ] { - assert_eq!(of(&none), None, "{none:?}"); - } - } - - /// `clamp`, `clamp_min`, `clamp_max` and `abs` are 1-Lipschitz and - /// monotone in each argument: the result keeps the largest absolute - /// (or relative) error of its arguments, with the union bound on the - /// failure probability. - #[test] - fn clamp_and_abs_keep_the_largest_argument_error() { - let x = scalar(abs(0.01, 0.001)); - for expr in [ - call("promql_clamp", vec![x.clone(), lit(0.0), lit(10.0)]), - call("promql_clamp_min", vec![x.clone(), lit(0.0)]), - call("promql_clamp_max", vec![x.clone(), lit(10.0)]), - call("promql_abs", vec![x.clone()]), - call("abs", vec![x.clone()]), - ] { - let g = of(&expr).unwrap(); - assert_eq!(g.spec.accuracy_metric, AccuracyMetric::ABSOLUTE, "{expr:?}"); - assert!( - close(bound(&g), 0.01) && close(delta(&g), 0.001), - "{expr:?}" - ); - } - let bounded = of(&call( - "promql_clamp", - vec![x.clone(), scalar(abs(0.03, 0.002)), lit(10.0)], - )) - .unwrap(); - assert!(close(bound(&bounded), 0.03) && close(delta(&bounded), 0.003)); - let relative = of(&call( - "promql_clamp", - vec![scalar(rel(0.01)), scalar(rel(0.02)), lit(10.0)], - )) - .unwrap(); - assert_eq!(relative.spec.accuracy_metric, AccuracyMetric::Relative); - assert!(close(bound(&relative), 0.02)); - let mixed = call("promql_clamp_min", vec![x, scalar(rel(0.02))]); - assert_eq!(of(&mixed), None); - assert_eq!(of(&call("promql_floor", vec![scalar(rel(0.01))])), None); - } - - /// A comparison over an estimate has no guarantee on its output (a - /// band guarantee is later work); over exact operands it is exact. - #[test] - fn comparisons_over_estimates_have_no_guarantee() { - let compare = |left: ScalarExpr| ScalarExpr::Compare { - left: Box::new(left), - op: CompareOpKind::Gt, - right: Box::new(lit(5.0)), - semantics: ExprSemantics::Promql, - }; - assert_eq!(of(&compare(scalar(abs(0.01, 0.001)))), None); - assert!(of(&compare(scalar(AccuracyGuarantee::exact("exact")))) - .is_some_and(|g| g.is_exact())); - } - - /// A SQL scalar subquery inherits its relation's guarantee, as - /// PromQL `scalar(v)` does. - #[test] - fn a_scalar_subquery_inherits_its_guarantee() { - let g = abs(0.01, 0.001); - let expr = arith( - ArithmeticOpKind::Add, - ScalarExpr::ScalarSubquery(node(g.clone())), - lit(1.0), - ); - assert!(close(bound(&of(&expr).unwrap()), 0.01)); - } - - /// Two estimates count their failure probabilities once each (union - /// bound), even when they read one shared summary: the built-in - /// guarantees hold per estimate, not simultaneously. The same - /// planned estimate read twice fails or holds as one event, so its - /// failure probability counts once. - #[test] - fn failure_probability_counts_once_per_distinct_estimate() { - let shared = node(abs(0.01, 0.001)); - // Equal to `shared`, but planned separately. - let other = node(abs(0.01, 0.001)); - let same = ScalarExpr::PromqlScalarFromVector(shared.clone()); - let twice = of(&arith(ArithmeticOpKind::Add, same.clone(), same.clone())).unwrap(); - assert!(close(bound(&twice), 0.02) && close(delta(&twice), 0.001)); - let distinct = of(&arith( - ArithmeticOpKind::Add, - same, - ScalarExpr::PromqlScalarFromVector(other.clone()), - )) - .unwrap(); - assert!(close(bound(&distinct), 0.02) && close(delta(&distinct), 0.002)); - let vector = |lhs: &Rc, rhs: &Rc| { - let node = OperatorNode::new(Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), - vector_match: None, - }, - return_bool: false, - lhs: lhs.clone(), - rhs: rhs.clone(), - })) - .unwrap(); - composed_guarantee(&node).unwrap() - }; - assert!(close(delta(&vector(&shared, &shared)), 0.001)); - assert!(close(delta(&vector(&shared, &other)), 0.002)); - } - - /// `left JOIN right ON pred`, composed. - fn join( - kind: JoinKind, - left: &Rc, - right: &Rc, - pred: ScalarExpr, - ) -> OperatorNode { - let mut node = OperatorNode::new(Operator::NonASAP(NonASAPOp::Join { - kind, - pred: Predicate(pred), - left: left.clone(), - right: right.clone(), - })) - .unwrap(); - node.guarantee = composed_guarantee(&node); - node - } - fn column(id: usize) -> ScalarExpr { - ScalarExpr::Column(id) - } - fn equals(left: ScalarExpr, right: ScalarExpr) -> ScalarExpr { - ScalarExpr::Compare { - left: Box::new(left), - op: CompareOpKind::Eq, - right: Box::new(right), - semantics: ExprSemantics::Sql, - } - } - /// `CASE WHEN condition THEN NULL ELSE otherwise END` over `child`, - /// composed. - fn case_over( - child: &OperatorNode, - condition: ScalarExpr, - otherwise: ScalarExpr, - ) -> Option { - let node = OperatorNode::new(Operator::NonASAP(NonASAPOp::Project { - cols: vec![ProjectItem { - alias: Some("v".into()), - expr: ScalarExpr::Case { - operand: None, - branches: vec![(condition, ScalarExpr::Literal(ScalarValue::Null))], - else_expr: Some(Box::new(otherwise)), - }, - }], - qualifier: None, - child: Rc::new(child.clone()), - })) - .unwrap(); - composed_guarantee(&node) - } - /// An approximate node and an exact one, and the approximate one's - /// column count (its columns come first in a join). - fn approximate_and_exact() -> (Rc, Rc, usize) { - let approximate = node(rel(0.01)); - let exact = node_over("count(m)", AccuracyGuarantee::exact("test")); - let width = approximate.schema.fields.len(); - (approximate, exact, width) - } - - /// A join whose other input is exact and whose predicate reads only - /// exact columns keeps the approximate input's guarantee: a cross - /// join, or a key equality on the exact input's columns. - #[test] - fn join_beside_an_exact_input_on_exact_columns_keeps_the_guarantee() { - let (approximate, exact, width) = approximate_and_exact(); - for (kind, pred) in [ - ( - JoinKind::Cross, - ScalarExpr::Literal(ScalarValue::Boolean(true)), - ), - (JoinKind::Inner, equals(column(width), lit(0.0))), - (JoinKind::Left, equals(column(width), lit(0.0))), - ] { - assert_eq!( - join(kind.clone(), &approximate, &exact, pred).guarantee, - Some(rel(0.01)), - "{kind:?}" - ); - } - // Either side. - assert_eq!( - join( - JoinKind::Cross, - &exact, - &approximate, - ScalarExpr::Literal(ScalarValue::Boolean(true)) - ) - .guarantee, - Some(rel(0.01)) - ); - } - - /// A join keyed on an approximate input's column, a semi-join, or - /// two approximate inputs have no guarantee (column-level tracking - /// is #689). - #[test] - fn join_on_an_approximate_key_has_no_guarantee() { - let (approximate, exact, width) = approximate_and_exact(); - assert_eq!( - join( - JoinKind::Inner, - &approximate, - &exact, - equals(column(0), column(width)) - ) - .guarantee, - None - ); - assert_eq!( - join( - JoinKind::Semi, - &exact, - &approximate, - ScalarExpr::Literal(ScalarValue::Boolean(true)) - ) - .guarantee, - None - ); - assert_eq!( - join( - JoinKind::Cross, - &approximate, - &approximate, - ScalarExpr::Literal(ScalarValue::Boolean(true)) - ) - .guarantee, - None - ); - } - - /// A `CASE` whose condition reads only exact columns keeps each - /// branch's guarantee (a NULL branch is exact); one testing an - /// approximate column has none. - #[test] - fn case_on_exact_columns_keeps_the_branch_guarantee() { - let (approximate, exact, width) = approximate_and_exact(); - let joined = join( - JoinKind::Cross, - &approximate, - &exact, - ScalarExpr::Literal(ScalarValue::Boolean(true)), - ); - let value = approximate.schema.fields.len() - 1; - assert_eq!( - case_over(&joined, equals(column(width), lit(0.0)), column(value)), - Some(rel(0.01)) - ); - assert_eq!( - case_over(&joined, equals(column(value), lit(0.0)), column(value)), - None - ); - } - - /// `node` projecting `cols` over `child`, composed. - fn project(child: &OperatorNode, cols: Vec) -> OperatorNode { - let mut node = OperatorNode::new(Operator::NonASAP(NonASAPOp::Project { - cols: cols - .into_iter() - .enumerate() - .map(|(i, expr)| ProjectItem { - alias: Some(format!("c{i}")), - expr, - }) - .collect(), - qualifier: None, - child: Rc::new(child.clone()), - })) - .unwrap(); - node.guarantee = composed_guarantee(&node); - node - } - - /// Over a join, a projection reads each column's own guarantee, - /// not the child's sample value (which may be an exact input's - /// column): `sum(m) * 1000` beside the exact side's `value` has - /// the scaled error 1000, not the approximate side's 1. A - /// projection over that computed projection has no per-column - /// guarantees, so it has none (#689). - #[test] - fn projection_over_a_join_reads_each_columns_guarantee() { - let approximate = node(abs(1.0, 0.01)); - let exact = node_over("m", AccuracyGuarantee::exact("test")); - let joined = join( - JoinKind::Cross, - &approximate, - &exact, - ScalarExpr::Literal(ScalarValue::Boolean(true)), - ); - assert_eq!(joined.guarantee, Some(abs(1.0, 0.01))); - let sum = approximate.schema.fields.len() - 1; - let value = approximate.schema.fields.len() + exact.schema.fields.len() - 1; - let scaled = project( - &joined, - vec![ - arith(ArithmeticOpKind::Mul, column(sum), lit(1000.0)), - column(value), - ], - ); - assert_eq!(scaled.guarantee.as_ref().map(bound), Some(1000.0)); - let stacked = project( - &scaled, - vec![arith(ArithmeticOpKind::Add, column(1), lit(1.0)), column(0)], - ); - assert_eq!(stacked.guarantee, None); - } - } - - /// Only raw data samples and plain sums of them: arithmetic, `rate` and other - /// functions end the requirement. - #[test] - fn raw_data_samples_of_metric_ends_at_any_other_operator() { - for (query, metric) in [ - ("sum_over_time(m[1m])", Some("m")), - ("sum by (job) (sum_over_time(m[1m]))", Some("m")), - ("sum_over_time(m[1m]) - 100", None), - ("-sum_over_time(m[1m])", None), - ("rate(m[1m])", None), - ("max_over_time(m[1m])", None), - ] { - let root = crate::test_fixtures::lower_promql(query, AccuracyTarget::exact()); - assert_eq!( - raw_data_samples_of_metric(&root).as_deref(), - metric, - "{query}" - ); - } - } - /// A Count-Min estimate has a bound only over weights proven /// non-negative: an open requirement (`RawDataSamplesOfMetric`) is not a proof. #[test] @@ -2845,34 +2266,4 @@ mod tests { .evaluate(); assert_eq!(unconfirmed, None); } - - /// Classic HLL's failure probability is left symbolic in a source's - /// distinct bound only for one instance per group over that source's - /// own rows (#693's scope condition): not over a join of two sources, - /// not over computed rows, and not for one Hydra structure shared by - /// every group, whose distinct items the source's bound does not bound. - #[test] - fn classic_hll_confidence_needs_one_instance_over_a_sources_own_rows() { - use asap_types::ir::schema::HydraKind; - let hll = |grouping| FieldDataType::Sketch(SketchAlgorithm::Hll, grouping); - let input = |query| crate::test_fixtures::lower_promql(query, AccuracyTarget::exact()); - let guarantee = |family: &FieldDataType, query| { - source_distinct_guarantee(family, &SketchStatistic::Cardinality, &input(query)) - }; - let own = hll(GroupingStrategy::PerSubpopulationInstance); - let symbolic = guarantee(&own, "a").expect("a source's own rows"); - assert_eq!( - symbolic.spec.failure_probability, - ProbabilityExpr::Unknown { - statistic: asap_accuracy::estimators::hll::classic_failure_statistic(15, 0.05, "a") - } - ); - for (query, over) in [("a * on() b", "a join"), ("a * 2", "computed rows")] { - assert!(guarantee(&own, query).is_none(), "{over}: {query}"); - } - let hydra = hll(GroupingStrategy::SharedMultiSubpopulation { - kind: HydraKind::HydraCms, - }); - assert!(guarantee(&hydra, "a").is_none()); - } } diff --git a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs index 4eb7afb7c..a69c984be 100644 --- a/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs +++ b/crates/stage1-pass1-per-computation-candidates/src/test_fixtures.rs @@ -5,13 +5,8 @@ use std::rc::Rc; use asap_types::ir::operator::operator_properties::Source; -use asap_types::ir::properties::AccuracyTarget; use asap_types::ir::schema::{ColumnId, DataType, Field, Schema}; use asap_types::ir::{NonASAPOp, OperatorNode}; -use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, - QueryRequirements, QueryWorkload, -}; /// A `TimeSeries("m")` scan over `[ts(0), value(1), labels...]`, time index 0, /// no unique key. @@ -52,34 +47,3 @@ pub fn scan_from(source: Source, schema: Schema) -> Rc { })) .unwrap() } - -/// `query` lowered by the PromQL frontend, with accuracy target `accuracy`. -pub fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc { - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy, - ..Default::default() - }, - predictability: Predictability::Unknown, - invocations: 1, - execute_at: None, - range_evaluation: None, - }]), - repeating_queries: None, - }, - data_workload: Some(DataWorkload::sampled_every( - asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), - DurationMs(1_000), - )), - }; - asap_frontend_promql::lower_promql_query_workload(&workload) - .unwrap() - .remove(0) - .as_operator() - .cloned() - .expect("operator root") -} diff --git a/docs/develop_docs/metrics-observability-corpora.md b/docs/develop_docs/metrics-observability-corpora.md index f9684a0ec..181f75045 100644 --- a/docs/develop_docs/metrics-observability-corpora.md +++ b/docs/develop_docs/metrics-observability-corpora.md @@ -1,5 +1,10 @@ # Metrics-observability corpus coverage +> **Status.** The executable corpus test, `metrics_observability.rs`, was deleted +> when each planning stage became its own crate: a frontend crate's tests may not +> call Stage 0 or Stage 1. The fixtures stay, for the Pass 1 coverage tests that +> replace it. The sections below describe the deleted test. + This note describes the PromQL corpora derived from the metrics-observability benchmark dump and the commands used to reproduce their measurements. The audience is developers evaluating changes to the PromQL front end or ASAP From 3576a3c578efc03add1efe942ebdd3052d0de74c Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:11:30 +0000 Subject: [PATCH 4/5] test: delete the frontend tests that plan through the facade (#1207 PR 0b) The PromQL test that planned through asap-planner is deleted with its planner_support helpers; the PromQL and SQL frontends drop their asap-planner dev-dependency, and PromQL its unused asap-accuracy one. The crate-graph test now also forbids a stage crate's dev-dependency on the facade. --- Cargo.lock | 3 -- crates/frontend-promql/Cargo.toml | 4 -- .../frontend-promql/tests/count_planning.rs | 24 ---------- crates/frontend-promql/tests/main.rs | 1 - .../frontend-promql/tests/planner_support.rs | 44 ------------------- crates/frontend-promql/tests/support.rs | 38 ---------------- crates/frontend-sql/Cargo.toml | 3 -- crates/integration-tests/tests/crate_graph.rs | 39 +++++++++++++--- 8 files changed, 32 insertions(+), 124 deletions(-) delete mode 100644 crates/frontend-promql/tests/planner_support.rs diff --git a/Cargo.lock b/Cargo.lock index 8d083039f..2877f3db0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,9 +369,7 @@ dependencies = [ name = "asap-frontend-promql" version = "0.1.0" dependencies = [ - "asap-accuracy", "asap-frontend-common", - "asap-planner", "asap-types", "promql-parser", ] @@ -382,7 +380,6 @@ version = "0.1.0" dependencies = [ "asap-frontend-common", "asap-frontend-sql", - "asap-planner", "asap-sql-function-catalog", "asap-types", "datafusion", diff --git a/crates/frontend-promql/Cargo.toml b/crates/frontend-promql/Cargo.toml index 63890e6d3..c65fb30ff 100644 --- a/crates/frontend-promql/Cargo.toml +++ b/crates/frontend-promql/Cargo.toml @@ -18,10 +18,6 @@ promql-parser = { git = "https://github.com/ProjectASAP/promql-parser", rev = "1 [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } -asap-accuracy = { path = "../accuracy", features = ["test-support"] } -# The planning stages through the facade (a dev-dependency cycle, for -# integration tests only). -asap-planner = { path = "../planner" } # Corpus tests live in domain folders (observability) but must still be declared # as test targets — Cargo only auto-discovers `.rs` files directly in `tests/`. diff --git a/crates/frontend-promql/tests/count_planning.rs b/crates/frontend-promql/tests/count_planning.rs index 603b47ffc..17b1ff931 100644 --- a/crates/frontend-promql/tests/count_planning.rs +++ b/crates/frontend-promql/tests/count_planning.rs @@ -1,32 +1,8 @@ //! Query text through summary selection: counts use observations, never value weights. -use crate::planner_support::selected_dag; use crate::support; use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::schema::{ExactKind, FieldDataType}; -use asap_types::ir::{ASAPOp, Operator, OperatorNode}; use support::lower_promql; -// Exact series and temporal counts must select a count accumulator, not distinct or sum. -#[test] -fn exact_counts_select_count_accumulators() { - for query in ["count(up)", "count by(job)(up)", "count_over_time(up[5m])"] { - let root = lower_promql(query, AccuracyTarget::exact()).unwrap(); - let selected = selected_dag(root, AccuracyTarget::exact()); - assert!( - OperatorNode::reachable(&selected).iter().any(|node| { - matches!( - &node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Count, _), - .. - }) - ) - }), - "{query}: {selected:?}" - ); - } -} - // Fixtures are already selected at one instant or within one five-minute window. // This narrow test oracle interprets the emitted aggregate, not Prometheus ingestion, // staleness, or scrape scheduling. Unsupported plan shapes fail explicitly. diff --git a/crates/frontend-promql/tests/main.rs b/crates/frontend-promql/tests/main.rs index b962cb00b..6055452b4 100644 --- a/crates/frontend-promql/tests/main.rs +++ b/crates/frontend-promql/tests/main.rs @@ -2,7 +2,6 @@ //! its own copy of every dependency, which outgrew CI's disk. //! One file: `cargo test -p asap-frontend-promql --test promql ::`. -mod planner_support; mod support; mod count_planning; diff --git a/crates/frontend-promql/tests/planner_support.rs b/crates/frontend-promql/tests/planner_support.rs deleted file mode 100644 index 39f00e0ca..000000000 --- a/crates/frontend-promql/tests/planner_support.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Planner-facade helpers for this crate's tests. Kept out of `support.rs`, -//! which the private corpus also compiles without depending on asap-planner. -use std::rc::Rc; - -use asap_types::ir::properties::AccuracyTarget; -use asap_types::ir::OperatorNode; - -use crate::support::declared_data; - -/// Every planning stage's output for the single query `root` at `accuracy`, -/// with the built-in models. -pub fn run_all_stages( - root: Rc, - accuracy: AccuracyTarget, -) -> asap_planner::PlanningRun { - let data = declared_data(&root); - asap_planner::run_planning( - vec![asap_types::ir::QueryRoot::Operator(root)], - &[asap_types::workload::QueryWorkloadEntry { - query: Default::default(), - requirements: asap_types::workload::QueryRequirements { - accuracy, - max_latency_ms: None, - }, - predictability: Default::default(), - recurrence: Default::default(), - range_evaluation: None, - }], - &data, - asap_planner::PlanningModels::builtin(), - asap_planner::PlanSearchSettings::default(), - ) - .expect("Pass 1 accepts the query") -} - -/// The logical DAG the planner pipeline selects for the single query `root` -/// at `accuracy`. -pub fn selected_dag(root: Rc, accuracy: AccuracyTarget) -> Rc { - let run = run_all_stages(root, accuracy); - match run.selected_plan.as_deref().expect("a plan is selected") { - [asap_types::ir::QueryRoot::Operator(node)] => Rc::clone(node), - other => panic!("one operator root: {other:?}"), - } -} diff --git a/crates/frontend-promql/tests/support.rs b/crates/frontend-promql/tests/support.rs index 18132cafd..185c99e38 100644 --- a/crates/frontend-promql/tests/support.rs +++ b/crates/frontend-promql/tests/support.rs @@ -76,16 +76,6 @@ pub fn promql_scalar(node: &ScalarExpr) -> Option { } } -/// Time `root` under the default physical properties assignment (every summary -/// at query time) and export the physical DAG — export needs every node -/// timed first. -#[allow(dead_code)] -pub fn physical_asap_dag( - root: &Rc, -) -> asap_types::ir::physical_export::PhysicalASAPDAG { - asap_types::test_support::physical_asap_dag_at_query_time(root).expect("post-ASAP DAG export") -} - #[allow(dead_code)] pub fn scalar_root(query: &str) -> ScalarExpr { match lower_promql_query_workload(&workload(query, AccuracyTarget::exact())) @@ -107,31 +97,3 @@ pub fn sample_expression(node: &OperatorNode) -> &ScalarExpr { other => panic!("expected sample expression, got {other:?}"), } } - -/// The data these tests plan over, declared: 1,000 rows arriving per -/// second, and each metric `root` reads holding 1,000 series. -#[allow(dead_code)] -pub fn declared_data(root: &Rc) -> DataWorkload { - let sources = OperatorNode::reachable(root) - .into_iter() - .filter_map(|node| match node.non_asap() { - Some(NonASAPOp::Scan { - source: asap_types::ir::operator::Source::TimeSeries { metric }, - .. - }) => Some(( - metric.clone(), - asap_types::workload::SourceFacts { - series_count: Some(1_000), - ..Default::default() - }, - )), - _ => None, - }) - .collect(); - DataWorkload { - arrival: asap_types::workload::DataArrival::Ingesting { - rows_per_second: 1_000.0, - }, - sources, - } -} diff --git a/crates/frontend-sql/Cargo.toml b/crates/frontend-sql/Cargo.toml index 98d09a5e0..fe593c384 100644 --- a/crates/frontend-sql/Cargo.toml +++ b/crates/frontend-sql/Cargo.toml @@ -36,9 +36,6 @@ test-support = [] [dev-dependencies] asap-types = { path = "../types", features = ["test-support"] } asap-frontend-sql = { path = ".", features = ["test-support"] } -# The planning stages through the facade (a dev-dependency cycle, for -# integration tests only). -asap-planner = { path = "../planner" } tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } # bgp_jan2024_workload corpus is sourced verbatim as YAML (ASAPQuery PR #561) # rather than transcribed into the flat .sql shape the other corpora use. diff --git a/crates/integration-tests/tests/crate_graph.rs b/crates/integration-tests/tests/crate_graph.rs index 9e4a15a35..1dcb0ec12 100644 --- a/crates/integration-tests/tests/crate_graph.rs +++ b/crates/integration-tests/tests/crate_graph.rs @@ -2,8 +2,9 @@ //! //! The test reads `cargo metadata --format-version 1 --no-deps` and checks every //! `[dependencies]` edge between two workspace crates, and every -//! `[dev-dependencies]` edge between two stage crates (a test of one stage -//! never calls another stage). Build-dependencies are not checked. Each crate +//! `[dev-dependencies]` edge from a stage crate to another stage crate or to +//! the facade (a test of one stage never calls another stage, directly or +//! through `asap-planner`). Build-dependencies are not checked. Each crate //! has a role ([`role_of`]), and [`rule_violated`] says which role may depend //! on which: //! @@ -21,7 +22,7 @@ //! //! The `[dependencies]` edges the rules forbid but the workspace has today are //! listed, each with its reason, in [`EXCEPTIONS`]; no `[dev-dependencies]` -//! edge between stage crates is excepted. A new workspace crate fails the test +//! edge is excepted. A new workspace crate fails the test //! until it gets a role. use serde_json::Value; @@ -107,7 +108,8 @@ const EXCEPTIONS: &[Exception] = &[ /// One message per edge of `metadata` (the JSON of /// `cargo metadata --format-version 1 --no-deps`) that breaks a rule, naming /// the edge and the rule: a `[dependencies]` edge that [`rule_violated`] -/// forbids, or a `[dev-dependencies]` edge between two stage crates. +/// forbids, or a `[dev-dependencies]` edge from a stage crate to another +/// stage crate or the facade. fn crate_graph_violations(metadata: &Value) -> Vec { let packages = metadata["packages"] .as_array() @@ -144,8 +146,11 @@ fn crate_graph_violations(metadata: &Value) -> Vec { ), Some("dev") => ( "[dev-dependencies]", - (from_role == Role::Stage && to_role == Role::Stage) - .then_some("a stage crate's tests never call another stage crate"), + (from_role == Role::Stage && matches!(to_role, Role::Stage | Role::Facade)) + .then_some( + "a stage crate's tests never call another stage crate, \ + directly or through the facade", + ), &[][..], ), Some(_) => continue, @@ -249,7 +254,27 @@ fn checker_rejects_a_dev_dependency_between_stage_crates() { crate_graph_violations(&metadata), vec![ "forbidden [dev-dependencies] edge asap-stage1-pass1-per-computation-candidates -> \ - asap-stage0-memo: a stage crate's tests never call another stage crate (#1054)" + asap-stage0-memo: a stage crate's tests never call another stage crate, \ + directly or through the facade (#1054)" + .to_string() + ] + ); +} + +#[test] +fn checker_rejects_a_frontend_dev_dependency_on_the_facade() { + let metadata = serde_json::json!({"packages": [ + package("asap-types", &[], &[]), + package("asap-planner", &["asap-types"], &[]), + // Forbidden: the PromQL frontend's tests plan through the facade. + package("asap-frontend-promql", &["asap-types"], &["asap-planner"]), + ]}); + assert_eq!( + crate_graph_violations(&metadata), + vec![ + "forbidden [dev-dependencies] edge asap-frontend-promql -> asap-planner: \ + a stage crate's tests never call another stage crate, directly or through \ + the facade (#1054)" .to_string() ] ); From 4445d45dfb9e003ecc6e1df02a3b655b05b4a26d Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:01:01 +0000 Subject: [PATCH 5/5] build(planner): its tests read asap-types' test-support directly (#1207 PR 0b) The feature came through asap-logical-asap-optimization's test-support, which the move deleted, so cargo test -p asap-planner did not build. --- crates/planner/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/planner/Cargo.toml b/crates/planner/Cargo.toml index bec778efc..dee3e9590 100644 --- a/crates/planner/Cargo.toml +++ b/crates/planner/Cargo.toml @@ -35,6 +35,9 @@ serde_json = "1" thiserror = "2" [dev-dependencies] +# The tests read `SelectionBox::new`, `RawDataRetention::lookback_ms`, … and +# build `PhysicalASAPDAG`s directly (`asap_types::test_support`). +asap-types = { path = "../types", features = ["test-support"] } asap-stage3-plan-selection = { path = "../stage3-plan-selection" } asap-stage2-physical-optimization = { path = "../stage2-physical-optimization" } # The deployment accuracy model tests implement `AccuracyModel`.