From 6971255f481b6ef15a7a82a3ff6a3c73a8c3f745 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:00:44 +0000 Subject: [PATCH 01/18] refactor(planner): plan one shared unified workload DAG Cut today's planner over to the unified OperatorNode IR. The planner calls the unified frontends (`asap_frontend_*::unified`) and returns OperatorNode / QueryRoot; ParsedWorkload keeps scalar roots; Pass 1 (ASAPStrategies), the identical-sub-DAG sharing, selection, DAG assembly and lifecycle costing all run on OperatorNode; and PlanOutput exposes the whole workload DAG. Planned summary states derive their coverage from the node (#646). The legacy physical planner read the planner's QueryExpr output, so it is removed here together with its expression compiler and raw sources; every caller uses the native compiler from #541 (`unified_physical_planner`, `unified_sources`), which reads the PhysicalASAPDAG export. Operator names follow the evaluation vocabulary (readout -> evaluation). The MetricsQL frontend input carries the planning clock and lowers through `lower_metricsql_query_workload`, so bare selectors get the workload's ingestion-interval lookback (#540). The legacy frontend modules and the QueryExpr/SummaryNode IRs stay compiled but are no longer re-exported from post_asap. The next PR moves the unified modules to the crate roots; a later one removes the legacy IRs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- Cargo.lock | 1 + .../src/accuracy/composition.rs | 6 +- .../src/accuracy/estimators/cms.rs | 2 +- .../src/accuracy/estimators/hll.rs | 8 +- .../src/accuracy/estimators/kll.rs | 2 +- .../src/accuracy/estimators/mod.rs | 6 +- .../src/accuracy/estimators/univmon.rs | 2 +- .../src/accuracy/evidence.rs | 25 +- crates/asap-aware-mapping/src/accuracy/mod.rs | 12 +- .../src/accuracy/reconciliation.rs | 99 +- .../asap-aware-mapping/src/analytical_cost.rs | 12 +- crates/asap-aware-mapping/src/cost_model.rs | 274 +- .../src/empirical_comparison.rs | 10 +- .../asap-aware-mapping/src/empirical_cost.rs | 13 +- .../src/exact_composition.rs | 355 +- crates/asap-aware-mapping/src/explanation.rs | 216 +- crates/asap-aware-mapping/src/grouping.rs | 210 +- crates/asap-aware-mapping/src/lib.rs | 26 +- .../src/maintained_population.rs | 284 +- crates/asap-aware-mapping/src/pane_sharing.rs | 8 +- crates/asap-aware-mapping/src/pass/major.rs | 21 +- crates/asap-aware-mapping/src/pass/mod.rs | 72 +- .../src/physical_operator_statistics.rs | 8 +- .../src/physical_plan_cost_model.rs | 72 +- .../src/query_physical_lowering.rs | 839 ++-- crates/asap-aware-mapping/src/recurrence.rs | 179 +- crates/asap-aware-mapping/src/replacement.rs | 3705 +++++++++-------- crates/asap-aware-mapping/src/rewrite.rs | 462 +- crates/asap-aware-mapping/src/rollup.rs | 216 +- .../src/summary_maintenance_cost/estimator.rs | 497 +-- .../src/summary_maintenance_cost/evidence.rs | 85 +- .../src/summary_maintenance_cost/mod.rs | 11 +- .../src/summary_maintenance_cost/model.rs | 1303 +++--- .../src/summary_maintenance_cost/window.rs | 58 +- .../src/summary_maintenance_dag_export.rs | 66 +- .../src/summary_maintenance_lifecycle.rs | 737 ++-- crates/asap-aware-mapping/src/test_support.rs | 170 +- crates/asap-aware-mapping/src/topk_reuse.rs | 117 +- .../tests/physical_handoff_cost.rs | 2 +- crates/asap-aware-mapping/tests/storage_io.rs | 2 +- .../asap-physical-operators/src/capability.rs | 67 +- crates/asap-physical-operators/src/dag/mod.rs | 4 +- .../src/{readout.rs => evaluation.rs} | 24 +- .../src/expressions/arithmetic.rs | 5 +- .../src/expressions/binary.rs | 38 +- .../src/expressions/mod.rs | 19 +- .../src/expressions/planner.rs | 545 --- .../src/expressions/unified_planner.rs | 4 + crates/asap-physical-operators/src/lib.rs | 4 +- .../src/operators/aggregate/mod.rs | 12 +- .../src/operators/aggregate/temporal.rs | 18 +- .../src/operators/aligned_binary.rs | 12 +- .../src/operators/common.rs | 15 +- .../src/operators/joins/mod.rs | 47 +- .../src/operators/mod.rs | 57 +- .../src/operators/scope_timestamp.rs | 2 +- .../src/operators/series_labels.rs | 7 +- .../src/operators/summary/mod.rs | 105 +- .../src/operators/unchecked.rs | 10 +- .../src/operators/vector_binary.rs | 3 +- .../src/operators/vector_window.rs | 5 +- .../src/physical_planner/candidates.rs | 483 --- .../src/physical_planner/compiled.rs | 352 -- .../src/physical_planner/mod.rs | 1156 ----- .../src/physical_planner/precompute.rs | 645 --- .../src/physical_planner/promql_fallback.rs | 550 --- .../src/physical_planner/promql_rows.rs | 316 -- .../src/physical_planner/promql_values.rs | 278 -- .../src/physical_planner/row_values.rs | 69 - .../src/runtime/batch_execution.rs | 24 +- .../src/sources/memory.rs | 44 - .../src/sources/mod.rs | 176 - .../src/summary_kernels/exact.rs | 103 +- .../src/summary_kernels/factory.rs | 10 +- .../src/summary_kernels/traits.rs | 6 +- .../src/summary_kernels/univmon.rs | 10 +- .../unified_physical_planner/candidates.rs | 169 +- .../src/unified_physical_planner/compiled.rs | 4 +- .../src/unified_physical_planner/mod.rs | 65 +- .../unified_physical_planner/precompute.rs | 21 +- .../unified_physical_planner/promql_rows.rs | 2 + .../unified_physical_planner/promql_values.rs | 4 +- crates/asap-physical-operators/src/values.rs | 30 +- .../tests/blocking_resources.rs | 19 +- .../tests/current_series_heap.rs | 47 +- .../tests/deployment.rs | 2 +- .../tests/deployment_computation.rs | 265 +- .../tests/physical_dag.rs | 369 +- .../tests/physical_plan_recovery.rs | 17 +- .../tests/physical_semantics.rs | 90 +- .../tests/plan_properties.rs | 43 +- .../tests/planspace_series_identity_heap.rs | 38 +- .../tests/precompute_candidates.rs | 164 +- .../tests/precompute_population.rs | 130 +- .../tests/promql_binary.rs | 152 +- .../tests/promql_fallback.rs | 1594 ------- .../tests/promql_values.rs | 25 +- .../asap-physical-operators/tests/raw_scan.rs | 126 +- .../tests/summary_projection.rs | 64 +- .../tests/weighted_topk_binding.rs | 231 +- .../devtools/examples/canonical_examples.rs | 6 +- crates/devtools/src/bin/analyze_corpora.rs | 6 +- crates/devtools/src/bin/dag_export.rs | 288 +- crates/devtools/src/bin/show_post_asap_ir.rs | 55 +- crates/devtools/src/bin/show_pre_asap_ir.rs | 3 +- crates/devtools/src/bin/sketch_coverage.rs | 9 +- crates/devtools/src/bin/variant_coverage.rs | 283 +- crates/devtools/src/lib.rs | 10 +- crates/devtools/tests/cross_language.rs | 83 +- .../frontend-promql/tests/count_planning.rs | 73 +- .../tests/maintained_population_horizon.rs | 23 +- .../awesome_prometheus_alerts.rs | 130 +- .../observability/metrics_observability.rs | 27 +- .../tests/observability/promql_corpus.rs | 56 +- .../tests/promql_binding_regressions.rs | 8 +- .../tests/promql_equivalence.rs | 7 +- .../tests/unified_promql_lowering.rs | 23 + .../frontend-promql/tests/unified_support.rs | 16 + .../tests/univmon_candidates.rs | 55 +- .../tests/bgp_analytics/bgp_analytics.rs | 29 +- .../bgp_jan2024_workload.rs | 24 +- .../synthetic_packet_trace.rs | 130 +- .../tests/maintained_population.rs | 95 +- crates/frontend-sql/tests/netflow/netflow.rs | 119 +- crates/frontend-sql/tests/pearson_corr.rs | 72 +- crates/frontend-sql/tests/temporal_types.rs | 23 +- crates/integration-tests/Cargo.toml | 1 + crates/integration-tests/src/lib.rs | 46 +- crates/integration-tests/tests/aggregate.rs | 41 +- crates/integration-tests/tests/binary_op.rs | 240 +- crates/integration-tests/tests/cse.rs | 54 +- .../tests/exact_composition.rs | 315 +- .../tests/frontend_timestamps.rs | 38 +- .../tests/kll_pane_execution.rs | 22 +- crates/integration-tests/tests/nested.rs | 361 +- .../tests/operator_design_examples.rs | 401 ++ .../tests/operator_sharing.rs | 195 + .../tests/physical_common/mod.rs | 17 +- .../tests/precompute_raw_samples.rs | 105 +- .../tests/promql_numeric_regressions.rs | 72 +- .../tests/promql_to_post_asap.rs | 787 ++-- crates/integration-tests/tests/scan.rs | 82 +- crates/integration-tests/tests/schema.rs | 38 +- .../tests/sql_to_physical.rs | 48 +- .../tests/sql_to_post_asap.rs | 606 +-- .../summary_maintenance_lifecycle_e2e.rs | 274 +- crates/integration-tests/tests/time_range.rs | 35 +- .../src/optimizer/const_evaluator.rs | 2 +- crates/planner/src/lib.rs | 53 +- crates/planner/tests/e2e_plan.rs | 62 +- crates/planner/tests/summary_sharing.rs | 66 +- crates/sql-function-catalog/src/lib.rs | 4 +- crates/types/src/dag_export.rs | 2240 +++++----- crates/types/src/parsed_workload.rs | 62 +- .../src/post_asap/execution_data_state.rs | 2 + crates/types/src/post_asap/expr.rs | 2 +- crates/types/src/post_asap/guarantee.rs | 18 +- .../src/post_asap/maintained_population.rs | 6 +- crates/types/src/post_asap/mod.rs | 21 +- .../post_asap/query_time/error_estimation.rs | 10 +- crates/types/src/post_asap/sketch.rs | 17 +- .../src/post_asap/summary_maintenance.rs | 2 +- crates/types/src/post_asap/summary_window.rs | 4 +- crates/types/tests/planner_vocabulary.rs | 5 +- 164 files changed, 11085 insertions(+), 16011 deletions(-) rename crates/asap-physical-operators/src/{readout.rs => evaluation.rs} (82%) delete mode 100644 crates/asap-physical-operators/src/expressions/planner.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/candidates.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/compiled.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/mod.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/precompute.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/promql_fallback.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/promql_rows.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/promql_values.rs delete mode 100644 crates/asap-physical-operators/src/physical_planner/row_values.rs delete mode 100644 crates/asap-physical-operators/src/sources/memory.rs delete mode 100644 crates/asap-physical-operators/src/sources/mod.rs delete mode 100644 crates/asap-physical-operators/tests/promql_fallback.rs create mode 100644 crates/integration-tests/tests/operator_design_examples.rs create mode 100644 crates/integration-tests/tests/operator_sharing.rs diff --git a/Cargo.lock b/Cargo.lock index 79da8962c..de76c1704 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,7 @@ dependencies = [ "asap-frontend-promql", "asap-frontend-sql", "asap-physical-operators", + "asap-planner", "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", "futures", diff --git a/crates/asap-aware-mapping/src/accuracy/composition.rs b/crates/asap-aware-mapping/src/accuracy/composition.rs index f42e8f158..daad8071c 100644 --- a/crates/asap-aware-mapping/src/accuracy/composition.rs +++ b/crates/asap-aware-mapping/src/accuracy/composition.rs @@ -448,9 +448,7 @@ fn composed_provenance( } pub(super) fn exact_operation_rule(operation: &ExactOperation) -> Option { - let ExactOperation::Aggregate { measures, .. } = operation else { - return None; - }; + let ExactOperation::Aggregate { measures, .. } = operation; match measures.as_slice() { [intent] => crate::function_rules::function_rules(intent).map(|rules| rules.accuracy), // The remaining functions are exact over exact samples, but have @@ -1035,8 +1033,8 @@ mod tests { reduction: asap_types::pre_asap::Reduction::PerEntity, measures: vec![intent], output_names: vec![], - having: None, filters: vec![], + having: None, }; assert_eq!( DefaultAccuracyModel.exact_operation_rule(&operation(AggIntent::Rate)), diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs b/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs index 46559358d..9482c539a 100644 --- a/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/cms.rs @@ -65,7 +65,7 @@ mod tests { } #[test] - fn heap_readout_retains_frequency_metric() { + fn heap_evaluation_retains_frequency_metric() { use asap_types::post_asap::{GroupingStrategy, SketchKind}; let cms_heap = SketchParams::CmsWithHeap { width: 272, diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs b/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs index 4431a4118..f4556446c 100644 --- a/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/hll.rs @@ -1,7 +1,7 @@ //! Estimator-specific confidence for classic HLL's linear-counting branch. //! //! This is conditional on independent uniform bucket hashes and an enforced -//! upper bound on distinct items in the complete readout population (including +//! upper bound on distinct items in the complete evaluation population (including //! all merged panes). It is not an RSE-to-normal conversion or an ERP fit. use super::*; @@ -56,13 +56,13 @@ impl ClassicHllConfidence { value: self.relative_error, }, failure_probability: ProbabilityExpr::Constant { value: delta }, - provenance: vec![GuaranteeSource::SketchReadout { + provenance: vec![GuaranteeSource::SketchEvaluation { algorithm: "Hll".into(), contract: "classic_hll_linear_counting_collision_bound_v1".into(), params: serde_json::json!({"precision": precision, "max_distinct": self.max_distinct, "relative_error": self.relative_error, "hash_assumption": "independent_uniform_buckets", - "population_scope": "complete_readout_including_merged_panes"}), + "population_scope": "complete_evaluation_including_merged_panes"}), query: "Cardinality".into(), }], }) @@ -187,7 +187,7 @@ mod tests { } } } - /// The model's readout formula matches the actual classic estimator after merge. + /// The model's evaluation formula matches the actual classic estimator after merge. #[test] fn native_classic_estimator_and_merged_registers_use_the_same_contract() { use asap_sketchlib::sketches::hll::{Classic, HyperLogLogP16}; diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs b/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs index 4cfe6f073..4f880894d 100644 --- a/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/kll.rs @@ -69,7 +69,7 @@ mod tests { assert_eq!(g.approximate_layer_count(), 1); assert!(g.provenance.iter().any(|source| matches!( source, - GuaranteeSource::SketchReadout { contract, .. } + GuaranteeSource::SketchEvaluation { contract, .. } if contract == "apache_datasketches_kll_empirical_99_a9b42755072b" ))); } diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/mod.rs b/crates/asap-aware-mapping/src/accuracy/estimators/mod.rs index 4eabf40be..0cd5fcb4f 100644 --- a/crates/asap-aware-mapping/src/accuracy/estimators/mod.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/mod.rs @@ -46,7 +46,7 @@ fn bounded_guarantee( metric, bound: BoundExpr::Constant { value: bound }, failure_probability: delta, - provenance: vec![GuaranteeSource::SketchReadout { + provenance: vec![GuaranteeSource::SketchEvaluation { algorithm: format!("{algorithm:?}"), contract: contract.into(), params: serde_json::to_value(params).unwrap_or(serde_json::Value::Null), @@ -169,9 +169,9 @@ impl<'a> EstimatorAccuracy<'a> { fn hll(&self) -> Option { let EstimatorContract::ClassicHll { - max_distinct_per_readout, + max_distinct_per_evaluation, } = self.contract?; - hll::ClassicHllConfidence::new(max_distinct_per_readout, self.epsilon) + hll::ClassicHllConfidence::new(max_distinct_per_evaluation, self.epsilon) } pub(crate) fn size_params(&self, algorithm: &SketchAlgorithm) -> Option { diff --git a/crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs b/crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs index 297cf53d3..6a89a105e 100644 --- a/crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs +++ b/crates/asap-aware-mapping/src/accuracy/estimators/univmon.rs @@ -1,4 +1,4 @@ -//! UnivMon currently certifies only its exact unit-update total readout. +//! UnivMon currently certifies only its exact unit-update total evaluation. use super::*; pub(super) fn guarantee(query: &SketchStatistic) -> Option { diff --git a/crates/asap-aware-mapping/src/accuracy/evidence.rs b/crates/asap-aware-mapping/src/accuracy/evidence.rs index 6391c746e..f779d5782 100644 --- a/crates/asap-aware-mapping/src/accuracy/evidence.rs +++ b/crates/asap-aware-mapping/src/accuracy/evidence.rs @@ -2,12 +2,12 @@ use super::*; /// A trusted source assertion scoped by `AccuracyEvidenceProvider` to one -/// complete readout. Choosing this variant asserts the estimator and hash +/// complete evaluation. Choosing this variant asserts the estimator and hash /// assumptions; it must not be inferred from sampled population statistics. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EstimatorContract { /// Classic HLL with independent uniform bucket hashing, including merged panes. - ClassicHll { max_distinct_per_readout: u32 }, + ClassicHll { max_distinct_per_evaluation: u32 }, } /// An enforced domain for every sample of a direct quantile operand, in every @@ -18,7 +18,7 @@ pub enum EstimatorContract { pub struct QuantileInputDomain { pub lower: f64, pub upper: f64, - /// Upper bound on samples per evaluation, matching the pinned readout's + /// Upper bound on samples per evaluation, matching the pinned evaluation's /// exact Float64 rank limit. The population must also be nonempty. pub max_samples: u64, pub contract: String, @@ -87,31 +87,22 @@ pub struct PropagationStats { /// Supplies typed planning-time evidence required by propagation rules. pub trait AccuracyEvidenceProvider { /// Trusted estimator contract for this complete aggregate expression, - /// including source, filters, grouping and all panes in each readout. + /// including source, filters, grouping and all panes in each evaluation. /// An observed cardinality is not an enforced population bound. - fn estimator_contract( - &self, - _expression: &asap_types::pre_asap::QueryExpr, - ) -> Option { + fn estimator_contract(&self, _expression: &OperatorNode) -> Option { None } /// Enforced upper bound on distinct (partition, item) identities across a - /// complete TopK readout. Used to union-bound score errors for adaptively + /// complete TopK evaluation. Used to union-bound score errors for adaptively /// selected candidates. Observed cardinality is not sufficient evidence. - fn topk_max_distinct_items( - &self, - _expression: &asap_types::pre_asap::QueryExpr, - ) -> Option { + fn topk_max_distinct_items(&self, _expression: &OperatorNode) -> Option { None } /// Proof scoped to this complete quantile expression, including its source, /// filters, grouping and window. `None` means unknown, including emptiness. - fn quantile_input_domain( - &self, - _operand: &asap_types::pre_asap::query_expr::QueryExpr, - ) -> Option { + fn quantile_input_domain(&self, _operand: &OperatorNode) -> Option { None } diff --git a/crates/asap-aware-mapping/src/accuracy/mod.rs b/crates/asap-aware-mapping/src/accuracy/mod.rs index 9026de468..a2f3354c0 100644 --- a/crates/asap-aware-mapping/src/accuracy/mod.rs +++ b/crates/asap-aware-mapping/src/accuracy/mod.rs @@ -20,18 +20,20 @@ pub use evidence::{ QuantileInputDomain, WorkloadAccuracyEvidence, }; +use asap_types::ir::OperatorNode; use asap_types::post_asap::{ - AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, ExactOperation, FieldDataType, - GuaranteeSource, ProbabilityExpr, ResultGuarantee, SketchAlgorithm, SketchParams, - SketchStatistic, + AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, FieldDataType, GuaranteeSource, + ProbabilityExpr, ResultGuarantee, SketchAlgorithm, SketchParams, SketchStatistic, }; use asap_types::types::AccuracyTarget; +use crate::exact_composition::ExactOperation; + /// The deployment-extensible accuracy algebra. `asap-aware-mapping` ships /// [`DefaultAccuracyModel`]; a deployment with a proof for a composition the /// default rejects (a registered cross-metric conversion, say) implements /// this trait and passes it to -/// [`crate::replacement::SketchAlgorithmStrategy::new_with_planning_inputs`]. +/// [`crate::replacement::ASAPStrategies::new_with_planning_inputs`]. pub trait AccuracyModel { /// The definition-registered rule for applying `operation` to an /// approximate input. `None` means the function is exact only over exact @@ -78,7 +80,7 @@ pub struct DefaultAccuracyModel; const SATISFACTION_TOLERANCE: f64 = 1e-9; impl DefaultAccuracyModel { - /// Derive the guarantee for the committed estimator parameters and readout. + /// Derive the guarantee for the committed estimator parameters and evaluation. pub fn sketch_guarantee( algorithm: &SketchAlgorithm, params: &SketchParams, diff --git a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs index 6014d3ba3..36a219d4e 100644 --- a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs +++ b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs @@ -28,10 +28,10 @@ //! //! ## What counts as a "near-duplicate", and why //! -//! Two [`QueryExpr::Aggregate`] nodes are accuracy-near-duplicates here iff, +//! Two `NonASAPOp::Aggregate` nodes are accuracy-near-duplicates here iff, //! **in this order**: //! -//! 1. Both are the same bindable shape [`crate::replacement::SketchAlgorithmStrategy`] +//! 1. Both are the same bindable shape [`crate::replacement::ASAPStrategies`] //! itself targets — a single measure, no `HAVING` (`bindable_intent`'s own //! scope) — **and** that one measure is one of the four accuracy-bearing //! [`AggIntent`] variants ([`crate::replacement::accuracy_target`]'s own @@ -105,7 +105,7 @@ //! //! Like every [`ReplacementStrategy`], this only ever *proposes* — the //! looser-accuracy consumer's own independently-sized candidate (from -//! [`crate::replacement::SketchAlgorithmStrategy`]) stays in its +//! [`crate::replacement::ASAPStrategies`]) stays in its //! [`crate::replacement::TargetSubDAGCandidates`] right alongside this strategy's //! "read the tighter sibling instead" [`Replacement::Rewrite`] candidate; //! [`crate::cost_model::CostModel`]-driven ranking picks between them; @@ -149,11 +149,13 @@ //! same structural child, so adding the edge preserves the reference DAG's //! parent-before-child topological ordering. +use asap_types::ir::non_asap::any_measure_filtered; use std::cmp::Ordering; use std::rc::Rc; +use asap_types::ir::operator_properties::Reduction; +use asap_types::ir::{NonASAPOp, OperatorNode}; use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::query_expr::{any_measure_filtered, QueryExpr, Reduction}; use asap_types::types::AccuracyTarget; use crate::replacement::{ @@ -169,27 +171,27 @@ type BindableAccuracyAggregate<'a> = ( &'a AggIntent, &'a AccuracyTarget, &'a [String], - &'a Rc, + &'a Rc, ); /// The `(reduction, intent, accuracy, output_names, child)` shape this /// module operates on: the same single-measure, no-`HAVING` bindable shape -/// [`crate::replacement::SketchAlgorithmStrategy`] targets (see that +/// [`crate::replacement::ASAPStrategies`] targets (see that /// module's private `bindable_intent`), further narrowed to a measure whose /// intent actually carries an [`AccuracyTarget`] /// ([`crate::replacement::accuracy_target`]'s own scope: `Count` / /// `Quantile` / `Cardinality` / `TopK`). `None` for anything else, including /// a multi-measure or `HAVING` aggregate, a non-`Aggregate` node, or an /// accuracy-free intent (`Sum`, `Avg`, …). -fn bindable_accuracy_aggregate(node: &QueryExpr) -> Option> { - let QueryExpr::Aggregate { +fn bindable_accuracy_aggregate(node: &OperatorNode) -> Option> { + let Some(NonASAPOp::Aggregate { reduction, measures, output_names, filters, having, child, - } = node + }) = node.non_asap() else { return None; }; @@ -278,7 +280,7 @@ fn strictly_tighter(a: &AccuracyTarget, b: &AccuracyTarget) -> bool { /// this strategy from the same post-CSE `Aggregate` sibling set it already /// builds for `RollupStrategy`. pub struct AccuracyReconciliationStrategy { - siblings: Vec>, + siblings: Vec>, } impl AccuracyReconciliationStrategy { @@ -286,7 +288,7 @@ impl AccuracyReconciliationStrategy { /// each as a candidate tighter-accuracy source (or looser-accuracy /// target) — typically the full set of `Aggregate` nodes a workload-wide /// discovery pass already found. - pub fn new(siblings: &[Rc]) -> Self { + pub fn new(siblings: &[Rc]) -> Self { Self { siblings: siblings.to_vec(), } @@ -311,14 +313,14 @@ impl AccuracyReconciliationStrategy { /// reports no unique key — see `cse.rs`'s "Legality" section) would get /// proposed for reconciliation even though nothing guarantees a second /// read of it lines up row-for-row with the first. - fn tighter_sources<'a>(&'a self, target: &TargetSubDAG<'_>) -> Vec<&'a Rc> { + fn tighter_sources<'a>(&'a self, target: &TargetSubDAG<'_>) -> Vec<&'a Rc> { let Some((target_reduction, target_intent, target_accuracy, target_names, target_child)) = bindable_accuracy_aggregate(target.root) else { return Vec::new(); }; - let mut sources: Vec<&Rc> = self + let mut sources: Vec<&Rc> = self .siblings .iter() .filter(|candidate| { @@ -335,9 +337,7 @@ impl AccuracyReconciliationStrategy { && (Rc::ptr_eq(child, target_child) || child == target_child) && same_intent_except_accuracy(intent, target_intent) && strictly_tighter(accuracy, target_accuracy) - && candidate - .output_schema() - .is_ok_and(|schema| schema.has_unique_key()) + && candidate.schema.has_unique_key() }) .collect(); sources.sort_by(|a, b| { @@ -369,7 +369,7 @@ impl ReplacementStrategy for AccuracyReconciliationStrategy { .expect("tighter_sources only returns bindable_accuracy_aggregate matches"); ReplacementSubDAG { strategy: self.name(), - replacement: Replacement::Rewrite(Rc::clone(source)), + replacement: Replacement::SubDAG(Rc::clone(source)), provenance: ReplacementProvenance::AccuracyReconciliation, rationale: format!( "reuses a near-duplicate sibling aggregate — identical intent and grouping \ @@ -391,9 +391,9 @@ impl ReplacementStrategy for AccuracyReconciliationStrategy { mod tests { use super::*; use crate::cost_model::{CostModel, DefaultCostModel}; + use asap_types::ir::cse::share_common_sub_dags; + use asap_types::ir::operator_properties::{GroupKeys, Source}; use asap_types::post_asap::SketchAlgorithm; - use asap_types::pre_asap::cse::share_common_sub_dags; - use asap_types::pre_asap::query_expr::{GroupKeys, Source}; use asap_types::pre_asap::schema::{ColumnId, DataType, Field, Schema}; /// `[ts(0), value(1), job(2)]`. @@ -401,8 +401,8 @@ mod tests { /// willing to hoist it — see `Schema::has_unique_key`/`cse.rs`'s own /// "Legality" section: a producer with no provable unique key is always /// inserted fresh, never hoisted, regardless of structural equality. - fn metric_scan() -> Rc { - Rc::new(QueryExpr::Scan { + fn metric_scan() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: Schema::with_time_index( @@ -414,21 +414,23 @@ mod tests { 0, vec![vec![0]], ), - }) + })) + .unwrap() } - fn agg(by: Vec, intent: AggIntent, child: &Rc) -> Rc { - Rc::new(QueryExpr::Aggregate { + fn agg(by: Vec, intent: AggIntent, child: &Rc) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(by), measures: vec![intent], output_names: vec![], filters: vec![], having: None, child: Rc::clone(child), - }) + })) + .unwrap() } - fn quantile(q: f64, accuracy: AccuracyTarget, child: &Rc) -> Rc { + fn quantile(q: f64, accuracy: AccuracyTarget, child: &Rc) -> Rc { agg( vec![2], AggIntent::Quantile { @@ -441,10 +443,14 @@ mod tests { } /// A globally-grouped (`by(vec![])`) quantile — `aggregate_output_schema` - /// reports no unique key for an empty `by` (see `query_expr.rs`'s own + /// reports no unique key for an empty `by` (see `aggregate_schema.rs`'s own /// `unique_keys = if by.is_empty() || has_count_values { vec![] } else /// { .. }`). - fn global_quantile(q: f64, accuracy: AccuracyTarget, child: &Rc) -> Rc { + fn ungrouped_quantile( + q: f64, + accuracy: AccuracyTarget, + child: &Rc, + ) -> Rc { agg( vec![], AggIntent::Quantile { @@ -463,9 +469,9 @@ mod tests { q: f64, accuracy: AccuracyTarget, excluded: Vec, - child: &Rc, - ) -> Rc { - Rc::new(QueryExpr::Aggregate { + child: &Rc, + ) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::Reduce(GroupKeys::without(excluded)), measures: vec![AggIntent::Quantile { col: None, @@ -476,7 +482,8 @@ mod tests { filters: vec![], having: None, child: Rc::clone(child), - }) + })) + .unwrap() } // ── dominates / strictly_tighter ───────────────────────────────────── @@ -548,7 +555,7 @@ mod tests { assert!(strategy.matches(&TargetSubDAG::new(&loose))); let replacements = strategy.replacements(&TargetSubDAG::new(&loose)); assert_eq!(replacements.len(), 1); - let Replacement::Rewrite(rc) = &replacements[0].replacement else { + let Replacement::SubDAG(rc) = &replacements[0].replacement else { panic!("expected a Rewrite candidate"); }; assert!(Rc::ptr_eq(rc, &tight)); @@ -580,7 +587,7 @@ mod tests { assert!( loose_group.candidates.iter().any(|candidate| { candidate.strategy == "AccuracyReconciliationStrategy" - && matches!(candidate.replacement, Replacement::Rewrite(_)) + && matches!(candidate.replacement, Replacement::SubDAG(_)) }), "expected an AccuracyReconciliationStrategy candidate for the looser consumer, got: \ {:?}", @@ -661,7 +668,7 @@ mod tests { // ── exact structural equality / share_common_sub_dags is unchanged ──── #[test] - fn share_common_sub_dags_still_never_merges_differing_accuracy() { + fn share_common_subdags_still_never_merges_differing_accuracy() { // The additive guarantee this issue explicitly must not violate: // pre-ASAP CSE's own exact-equality merge stays exact. Two // aggregates differing only in `accuracy` must come back as two @@ -669,8 +676,8 @@ mod tests { // is the *only* place cross-accuracy sharing gets proposed, never // `share_common_sub_dags` itself. let scan = metric_scan(); - let a = (*quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan)).clone(); - let b = (*quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan)).clone(); + let a = quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan); + let b = quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan); let roots = share_common_sub_dags(vec![("a", a), ("b", b)]); assert!( @@ -684,10 +691,10 @@ mod tests { // The identical scan child, though, is still shared exactly as // before — this module changes nothing about that. - let QueryExpr::Aggregate { child: child_a, .. } = roots[0].1.as_ref() else { + let Some(NonASAPOp::Aggregate { child: child_a, .. }) = roots[0].1.non_asap() else { panic!("expected an Aggregate root"); }; - let QueryExpr::Aggregate { child: child_b, .. } = roots[1].1.as_ref() else { + let Some(NonASAPOp::Aggregate { child: child_b, .. }) = roots[1].1.non_asap() else { panic!("expected an Aggregate root"); }; assert!(Rc::ptr_eq(child_a, child_b)); @@ -700,8 +707,8 @@ mod tests { // exact equality — unrelated to this module, but pins the contrast // with the test above. let scan = metric_scan(); - let a = (*quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan)).clone(); - let b = (*quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan)).clone(); + let a = quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan); + let b = quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan); let roots = share_common_sub_dags(vec![("a", a), ("b", b)]); assert!(Rc::ptr_eq(&roots[0].1, &roots[1].1)); @@ -717,11 +724,11 @@ mod tests { // `share_common_sub_dags`/`RollupStrategy` apply, which this // strategy must not bypass (module docs, point 5). let scan = metric_scan(); - let tight = global_quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan); - let loose = global_quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan); + let tight = ungrouped_quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan); + let loose = ungrouped_quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan); assert!( - !tight.output_schema().unwrap().has_unique_key(), + !tight.schema.clone().has_unique_key(), "fixture sanity: a globally-grouped aggregate has no provable unique key" ); @@ -741,7 +748,7 @@ mod tests { let loose = without_quantile(0.99, AccuracyTarget::Epsilon(0.05), vec![2], &scan); assert!( - !tight.output_schema().unwrap().has_unique_key(), + !tight.schema.clone().has_unique_key(), "fixture sanity: a without(...) aggregate has no provable unique key" ); @@ -831,7 +838,7 @@ mod tests { "global_selection must commit to some candidate for a single-consumer looser target" ); // With no recompute term at all (it never rebuilds `target`), this - // candidate strictly undercuts every SketchAlgorithmStrategy + // candidate strictly undercuts every ASAPStrategies // candidate (which each pay a recompute term on top of their own // maintenance term) under DefaultCostModel's numbers — the sane // direction: reading an already-necessary sibling should be able to diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index ad921d23a..8256d6f10 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -2970,7 +2970,7 @@ mod tests { } fn comparison_scope() -> ComparisonScope { - use asap_types::pre_asap::query_expr::Source; + use asap_types::ir::operator_properties::Source; use asap_types::workload::{ DurationMs, QueryRecurrence, QueryTimeScope, RepeatedDemand, RepetitionInterval, TimeSelection, TimestampMs, @@ -3009,9 +3009,7 @@ mod tests { #[test] fn comparison_rejects_different_snapshot_predicate_time_or_horizon() { - use std::rc::Rc; - - use asap_types::pre_asap::query_expr::{Predicate, QueryExpr}; + use asap_types::ir::{Predicate, ScalarExpr}; use asap_types::workload::{DurationMs, TimestampMs}; let raw = comparison_scope(); @@ -3027,7 +3025,7 @@ mod tests { candidate = raw.clone(); candidate.sources[0] .predicates - .push(Predicate(Rc::new(QueryExpr::promql_scalar(1.0)))); + .push(Predicate(ScalarExpr::literal_f64(1.0))); assert_eq!( validate_comparison_scopes(&raw, &candidate), Err(AnalyticalCostError::ComparisonScopeMismatch("sources")) @@ -3247,7 +3245,7 @@ mod tests { operator: PhysicalOperator::Scan, children: vec![], scan_selection: Some(ScanSelection { - source: asap_types::pre_asap::query_expr::Source::Table { + source: asap_types::ir::operator_properties::Source::Table { table_ref: "other_metrics".into(), }, source_snapshot_id: "catalog-version-42".into(), @@ -3312,7 +3310,7 @@ mod tests { let mut scope = comparison_scope(); let coverage = scope.sources[0].clone(); scope.sources.push(ScanSelection { - source: asap_types::pre_asap::query_expr::Source::Table { + source: asap_types::ir::operator_properties::Source::Table { table_ref: "auxiliary".into(), }, source_snapshot_id: "catalog-version-42".into(), diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index 0315e88f1..9fc170666 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -26,7 +26,7 @@ //! than overloading these ones across incompatible `Kind`/`Params` types. //! //! Every entry point that doesn't take an explicit `&dyn CostModel` -//! ([`SketchAlgorithmStrategy::default_cost_model`](crate::replacement::SketchAlgorithmStrategy::default_cost_model), +//! ([`ASAPStrategies::default_cost_model`](crate::replacement::ASAPStrategies::default_cost_model), //! [`search_workload`](crate::replacement::search_workload)) runs against //! [`DefaultCostModel`], so a deployment that never plugs in its own cost //! model keeps today's static-preference-order behavior exactly, byte for @@ -36,7 +36,7 @@ //! //! [`CseCandidate`]/[`ShareDecision`]/[`CostModel::cse_share_decision`] below //! decide whether a CSE-detected shared sub-DAG -//! ([`asap_types::pre_asap::cse::share_common_sub_dags`], issue #223 stages +//! ([`asap_types::ir::cse::share_common_sub_dags`], issue #223 stages //! 1-2, PR #235) is actually worth sharing, via a real Volcano/Cascades-style //! cost comparison rather than a fixed rule. See //! `docs/design_docs/cse-cost-model-decision.md` for the full design discussion (why @@ -48,14 +48,14 @@ use std::rc::Rc; +use crate::exact_composition::ExactOperation; +use asap_types::ir::{ASAPOp, Operator, OperatorNode}; use asap_types::post_asap::{ - ExactOperation, FieldDataType, GroupingStrategy, HydraParams, ResultGuarantee, SketchAlgorithm, - SketchParams, SketchStatistic, SummaryExpr, SummaryMaintenanceLifecycleGuarantee, SummaryNode, - SummaryWindowFramework, + FieldDataType, GroupingStrategy, HydraParams, ResultGuarantee, SketchAlgorithm, SketchParams, + SketchStatistic, SummaryMaintenanceLifecycleGuarantee, SummaryWindowFramework, }; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::expr_ir::ColumnRef; -use asap_types::pre_asap::query_expr::QueryExpr; use asap_types::types::AccuracyTarget; use crate::exact_composition::{ExactComposition, OperationPlacement}; @@ -94,7 +94,7 @@ pub struct CostProvenance { /// operator on the update path must never be handed one. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct ValueOperationCapabilities { - /// The runtime can apply an exact operator to summary readouts at + /// The runtime can apply an exact operator to summary evaluations at /// query evaluation time. pub query_time: bool, /// The runtime can apply an exact row transform on the update path, @@ -127,17 +127,17 @@ impl ValueOperationCapabilities { /// composes with. #[derive(Debug, Clone, Copy)] pub struct ExactCompositionCostRequest<'a> { - /// The pre-ASAP target the composed candidate replaces. - pub target: &'a QueryExpr, + /// The target the composed candidate replaces. + pub target: &'a OperatorNode, /// The composition itself — placement, operator, child target. pub composition: &'a ExactComposition, /// For [`OperationPlacement::Read`]: the child target's *selected* - /// summary readout candidate the exact operator consumes. For + /// summary evaluation candidate the exact operator consumes. For /// [`OperationPlacement::Maintenance`]: the maintained summary *above* the /// transform that consumes its output (the `SummaryAgg` this transform /// feeds). Either way, the summary whose maintenance/read cost the /// formula charges. - pub summary: &'a SummaryNode, + pub summary: &'a OperatorNode, /// How many times this site actually runs once ancestors' own choices /// are accounted for (see `CandidateLogicalASAPDAGs::global_selection`). pub effective_consumer_count: usize, @@ -147,11 +147,11 @@ pub struct ExactCompositionCostRequest<'a> { /// optional: **an unknown stays `None` — never a zero** — so a formula /// with a missing input yields no rate at all rather than a spuriously /// cheap one, and global selection then keeps the conservative -/// `KeepPreAsap` behavior. A deployment model that wants defaults supplies +/// keep-as-is behavior. A deployment model that wants defaults supplies /// them explicitly by overriding [`CostModel::exact_composition_cost_inputs`]. #[derive(Debug, Clone, PartialEq)] pub struct ExactCompositionCostInputs { - /// Exact operator cost per row it processes — per readout row for a + /// Exact operator cost per row it processes — per evaluation row for a /// read-time operation, per input row for an maintenance-time operation. pub exact_cost_per_row: Option, /// Rows the exact operator consumes per evaluation (read-time operation) or @@ -161,14 +161,14 @@ pub struct ExactCompositionCostInputs { pub expected_output_rows: Option, /// Cost of one update to the composed-with summary's maintained state. pub summary_maintenance_cost_per_update: Option, - /// Cost of one readout of that summary at evaluation time. + /// Cost of one evaluation of that summary at evaluation time. pub summary_read_cost: Option, /// Update (ingest) events per second reaching this site. pub update_rate: Option, /// Evaluations per second across every consumer of this site. pub evaluation_rate: Option, /// Cost of one full raw recompute of the target from pre-ASAP data — - /// the `KeepPreAsap` baseline's per-evaluation cost. + /// the kept-query baseline's per-evaluation cost. pub raw_recompute_cost: Option, /// Recurring formulas require `CostUnitsPerSecond`; totals yield no rate. pub unit: CostUnit, @@ -271,16 +271,16 @@ fn finite_rate(units_per_second: f64) -> Option { /// [`CandidateLogicalASAPDAGs::cost_sorted`](crate::replacement::CandidateLogicalASAPDAGs::cost_sorted) /// (via [`crate::replacement`]'s own `cse_preference`) the first time it /// needs a representative bound node for a sub-DAG that -/// [`asap_types::pre_asap::cse::share_common_sub_dags`] already collapsed +/// [`asap_types::ir::cse::share_common_sub_dags`] already collapsed /// onto one `Rc` for two or more workload roots. See /// `docs/design_docs/cse-cost-model-decision.md`. pub struct CseCandidate<'a> { - /// The shared pre-ASAP sub-DAG itself. - pub sub_dag: &'a QueryExpr, - /// The `SummaryNode` this sub-DAG bound to — gives the cost model the + /// The shared sub-DAG itself. + pub sub_dag: &'a Rc, + /// The node this sub-DAG bound to — gives the cost model the /// concrete `FieldDataType`/`(kind, params)` actually at stake, not - /// just the pre-ASAP shape. - pub bound_summary: &'a SummaryNode, + /// just the logical shape. + pub bound_summary: &'a OperatorNode, /// How many workload roots reference this exact shared sub-DAG, counted /// once up front over the whole workload (always >= 2 — a candidate is /// only ever constructed for an actually-shared sub-DAG). @@ -302,7 +302,7 @@ pub struct Cost(pub f64); /// node. Node identity is preserved so whole-DAG models can bind per-state /// evidence without relying on traversal order. pub struct CostedSummaryDeployment<'a> { - pub summary: &'a SummaryNode, + pub summary: &'a OperatorNode, pub guarantee: &'a SummaryMaintenanceLifecycleGuarantee, pub selected_cost: Cost, } @@ -359,7 +359,7 @@ impl std::ops::Mul for Cost { /// [`CseCandidate`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShareDecision { - /// Reuse one bound `SummaryNode` across every consumer. + /// Reuse one bound node across every consumer. Share, /// Bind each occurrence independently — the shared-maintenance cost /// isn't worth it for this candidate. @@ -367,23 +367,23 @@ pub enum ShareDecision { } /// Default [`CostModel::cse_recompute_cost`]: a structural-size proxy — the -/// number of *unique* nodes in `sub_dag`'s DAG -/// ([`asap_types::pre_asap::cse::dag_node_count`], the same module this +/// number of *unique* nodes in `sub-DAG`'s DAG +/// ([`asap_types::ir::cse::dag_node_count`], the same module this /// candidate's sharing was detected in). Deliberately **not** a raw -/// `serde_json` serialization length: after CSE, `sub_dag` generally has -/// internal sharing (a `CseCandidate` only exists because something got +/// `serde_json` serialization length: after CSE, `sub-DAG` is generally a +/// DAG, not a tree (a `CseCandidate` only exists because something got /// shared), and a naive full serialization re-serializes — over-counts — -/// any descendant `sub_dag` already shares internally, once per parent +/// any descendant `sub-DAG` already shares internally, once per parent /// that references it, instead of once for the whole DAG. `dag_node_count` /// dedupes by `Rc` pointer identity, so it charges each unique node's /// contribution exactly once regardless of how many places within -/// `sub_dag` reference it. Cheap to compute (one pass, no serialization), +/// `sub-DAG` reference it. Cheap to compute (one pass, no serialization), /// and still scales with real structural complexity — a genuinely tiny /// leaf costs little to recompute, a deep multi-join sub-DAG costs a lot. /// A deployment with real per-row/per-update cost knowledge should /// override [`CostModel::cse_recompute_cost`] instead of relying on this. -pub fn default_cse_recompute_cost(sub_dag: &QueryExpr) -> Cost { - Cost(asap_types::pre_asap::cse::dag_node_count(sub_dag) as f64) +pub fn default_cse_recompute_cost(sub_dag: &Rc) -> Cost { + Cost(asap_types::ir::cse::dag_node_count(sub_dag) as f64) } /// Default [`CostModel::cse_shared_maintenance_cost`]: a small @@ -506,7 +506,7 @@ pub trait CostModel { /// Estimated number of distinct subpopulations produced by `target`'s /// grouping keys. `None` means the deployment has no cardinality estimate; /// grouping alternatives remain legal but keep their discovery order. - fn estimated_subpopulation_count(&self, _target: &QueryExpr) -> Option { + fn estimated_subpopulation_count(&self, _target: &OperatorNode) -> Option { None } @@ -518,7 +518,7 @@ pub trait CostModel { candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>, ) -> Option { - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { return None; }; let (kind, grouping) = sketch_state(node)?; @@ -545,17 +545,17 @@ pub trait CostModel { Realization::PassThrough } - /// Build the `SummaryEstimate` readout for an `Extension` intent this + /// Build the `SummaryEstimate` evaluation for an `Extension` intent this /// same `CostModel` realized as `Realization::Sketch` via /// [`realize_extension`](Self::realize_extension). Only ever called /// when `realize_extension` returned `Sketch` for the same - /// `(ext_kind, payload)` — `replacement::readout` has no other way to build a + /// `(ext_kind, payload)` — `replacement::evaluation` has no other way to build a /// `SketchStatistic` for a shape core doesn't know. A deployment that /// overrides `realize_extension` to return `Sketch` for some /// `ext_kind` MUST also override this for that same `ext_kind`, or /// this default panics loudly (rather than silently misinterpreting /// `payload`) the first time that intent is actually read out. - fn readout_extension( + fn evaluation_extension( &self, ext_kind: &str, _payload: &serde_json::Value, @@ -563,11 +563,11 @@ pub trait CostModel { ) -> SketchStatistic { unimplemented!( "CostModel::realize_extension returned Sketch for ext_kind={ext_kind:?} but \ - readout_extension wasn't overridden to match" + evaluation_extension wasn't overridden to match" ) } - /// Estimate the one-time cost of recomputing `candidate.sub_dag` + /// Estimate the one-time cost of recomputing `candidate.sub-DAG` /// independently at a single use site. Default: /// [`default_cse_recompute_cost`] (a structural-size proxy). See /// `docs/design_docs/cse-cost-model-decision.md`. @@ -581,7 +581,7 @@ pub trait CostModel { /// weight table), applied to whichever field of /// `candidate.bound_summary`'s output schema actually carries summary /// state (falls back to the cheapest, `Plain`, weight if none does — - /// e.g. `bound_summary` is a passthrough `KeepPreAsap` node with nothing + /// e.g. `bound_summary` is a kept non-ASAP sub-DAG with nothing /// summary-shaped to maintain). See `docs/design_docs/cse-cost-model-decision.md`. fn cse_shared_maintenance_cost(&self, candidate: &CseCandidate) -> Cost { let family = candidate @@ -598,7 +598,7 @@ pub trait CostModel { default_cse_shared_maintenance_cost(&family) } - /// Decide whether to reuse one shared `SummaryNode` across every + /// Decide whether to reuse one shared node across every /// consumer of `candidate`, or bind each occurrence independently — a /// Volcano/Cascades-style cost comparison (issue #237, #223 stage 4; see /// `docs/design_docs/cse-cost-model-decision.md`): share iff the estimated cost of @@ -666,7 +666,7 @@ pub trait CostModel { Cost(1.0) } - /// Cost of recomputing `candidate.sub_dag` once, from the pre-ASAP/raw + /// Cost of recomputing `candidate.sub-DAG` once, from the pre-ASAP/raw /// path. Units: cost units per recomputation — the `raw_recompute_cost` /// term of `recompute_cost_rate`. Default: delegates to /// [`cse_recompute_cost`](Self::cse_recompute_cost) (the same @@ -738,21 +738,21 @@ pub trait CostModel { /// on [`CandidateLogicalASAPDAGs::cost_sorted`](crate::replacement::CandidateLogicalASAPDAGs::cost_sorted)), /// not just order candidates against each other — that ordering job /// already belongs to [`rank_candidates`](Self::rank_candidates) (for a - /// [`SketchAlgorithmStrategy`](crate::replacement::SketchAlgorithmStrategy) + /// [`ASAPStrategies`](crate::replacement::ASAPStrategies) /// group) and [`cse_share_decision`](Self::cse_share_decision) (for a /// [`SharedSubDAGStrategy`](crate::replacement::SharedSubDAGStrategy) /// group). /// /// One method covers both candidate shapes this crate ships: - /// `candidate.replacement`'s [`Replacement::Summary`] arm (a - /// `SketchAlgorithmStrategy` candidate — the bound `SummaryNode` is right - /// there, nothing to reconstruct) and its [`Replacement::Rewrite`] arm + /// `candidate.replacement`'s [`Replacement::SubDAG`] from a summary + /// realization (a `ASAPStrategies` candidate — the bound node is + /// right there, nothing to reconstruct) and the same arm from a rewrite /// (a `SharedSubDAGStrategy` share-vs-recompute candidate — no bound - /// `SummaryNode` of its own, since sharing is a decision about a target + /// summary of its own, since sharing is a decision about a target /// already bound some other way; a representative binding is recovered /// from `target` itself). `target` is threaded through explicitly /// (rather than only ever the target embedded in `candidate` — there - /// isn't one for a `Rewrite`) so both arms have the `consumer_count` + /// isn't one for a rewrite) so both arms have the `consumer_count` /// context a cost estimate needs to be meaningful. /// /// Default: **not a real cost model** — always returns `f64::NAN`. @@ -776,7 +776,7 @@ pub trait CostModel { /// optimistic zeroes. fn summary_maintenance_lifecycle_cost_inputs( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { SummaryMaintenanceLifecycleCostInputs::default() } @@ -786,7 +786,7 @@ pub trait CostModel { /// rate so the horizon integral equals one peak-capacity charge. fn summary_maintenance_lifecycle_cost_inputs_for_horizon( &self, - summary: &SummaryNode, + summary: &OperatorNode, _horizon: Option, ) -> SummaryMaintenanceLifecycleCostInputs { self.summary_maintenance_lifecycle_cost_inputs(summary) @@ -796,7 +796,7 @@ pub trait CostModel { /// conservative default advertises no long-lived maintenance capability. fn summary_maintenance_capabilities( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { SummaryMaintenanceCapabilities::default() } @@ -807,8 +807,8 @@ pub trait CostModel { /// must not then reuse the partial per-state sum. fn complete_summary_candidate_cost( &self, - _root: &SummaryNode, - _target: Option<&QueryExpr>, + _root: &OperatorNode, + _target: Option<&OperatorNode>, deployments: &[CostedSummaryDeployment<'_>], _horizon: Option, _expected_reads: Option, @@ -827,8 +827,8 @@ pub trait CostModel { /// that do not perform either decision. fn complete_summary_candidate_estimate( &self, - root: &SummaryNode, - target: Option<&QueryExpr>, + root: &OperatorNode, + target: Option<&OperatorNode>, deployments: &[CostedSummaryDeployment<'_>], horizon: Option, expected_reads: Option, @@ -861,7 +861,7 @@ pub trait CostModel { /// Cost of evaluating `target` directly from its logical/raw inputs once. /// When known, lifecycle-aware materialization compares this fallback with /// the aggregate cost of the selected summary deployments. - fn raw_query_recompute_cost(&self, _target: &QueryExpr) -> Option { + fn raw_query_recompute_cost(&self, _target: &OperatorNode) -> Option { None } @@ -870,7 +870,7 @@ pub trait CostModel { /// cardinality changes between evaluations. fn raw_query_recompute_total_cost( &self, - target: &QueryExpr, + target: &OperatorNode, expected_reads: f64, ) -> Option { self.raw_query_recompute_cost(target) @@ -879,7 +879,7 @@ pub trait CostModel { /// Physical feasibility evidence for a complete summary candidate. /// `None` defers admission to physical/deployment compilation; `Some(false)` /// excludes the candidate without changing its computation or parameters. - fn summary_support_evidence(&self, _summary: &SummaryNode) -> Option { + fn summary_support_evidence(&self, _summary: &OperatorNode) -> Option { None } @@ -932,7 +932,7 @@ pub trait CostModel { /// /// Default: every input unknown ([`ExactCompositionCostInputs::unknown`]) /// — unknown is never zero, and with no rate derivable - /// `CandidateLogicalASAPDAGs::global_selection` keeps the conservative `KeepPreAsap` + /// `CandidateLogicalASAPDAGs::global_selection` keeps the conservative keep-as-is /// behavior for the site. A deployment that wants defaults must supply /// them here explicitly. fn exact_composition_cost_inputs( @@ -948,14 +948,16 @@ pub trait CostModel { } fn sketch_state( - node: &SummaryNode, + node: &OperatorNode, ) -> Option<(&asap_types::post_asap::SketchKind, &GroupingStrategy)> { - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => sketch_state(summary_input), - SummaryExpr::SummaryAgg { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + sketch_state(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, grouping), .. - } => Some((kind, grouping)), + }) => Some((kind, grouping)), _ => None, } } @@ -1027,15 +1029,17 @@ impl CostModel for DefaultCostModel { /// `cse_share_decision`'s default body already composes — rather than a /// second formula: /// - /// - [`Replacement::Summary`]: `cse_recompute_cost` (the one-time + /// - A [`ReplacementProvenance::SummaryRealization`] candidate (a + /// `ASAPStrategies` binding): `cse_recompute_cost` (the one-time /// structural cost of building `target` at all) plus /// `cse_shared_maintenance_cost` of the candidate's own bound family /// (a pricier family — a sketch over an exact accumulator, say — /// costs more here, consistent with the per-family weighting /// [`default_cse_shared_maintenance_cost`] already orders candidates /// by). - /// - [`Replacement::Rewrite`]: recovers one representative bound - /// `SummaryNode` for `target` via `realize_child` (the same + /// - Any other [`Replacement::SubDAG`] (a logical rewrite or a CSE + /// share/recompute candidate): recovers one + /// representative bound node for `target` via `realize_child` (the same /// rank-and-take-first helper `replacement::realize_child` reuses for the /// identical need), then charges /// `cse_shared_maintenance_cost` for the candidate that shares @@ -1060,7 +1064,9 @@ impl CostModel for DefaultCostModel { fn estimate_cost(&self, candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>) -> f64 { let consumer_count = target.consumer_count.max(1); match &candidate.replacement { - Replacement::Summary(node) => { + Replacement::SubDAG(node) + if candidate.provenance == ReplacementProvenance::SummaryRealization => + { let cse = CseCandidate { sub_dag: target.root, bound_summary: node, @@ -1068,7 +1074,7 @@ impl CostModel for DefaultCostModel { }; (self.cse_recompute_cost(&cse) + self.cse_shared_maintenance_cost(&cse)).0 } - Replacement::Rewrite(rc) + Replacement::SubDAG(rc) if candidate.provenance == ReplacementProvenance::AccuracyReconciliation => { let Ok(sibling_bound) = realize_child(rc, self) else { @@ -1087,7 +1093,7 @@ impl CostModel for DefaultCostModel { }; self.cse_shared_maintenance_cost(&cse).0 } - Replacement::Rewrite(rc) => { + Replacement::SubDAG(rc) => { let Ok(bound) = realize_child(target.root, self) else { return f64::NAN; }; @@ -1320,11 +1326,11 @@ mod tests { assert_eq!( DefaultCostModel.value_operation_support_evidence( &ExactOperation::Aggregate { - reduction: asap_types::pre_asap::query_expr::Reduction::by(vec![]), + reduction: asap_types::ir::operator_properties::Reduction::by(vec![]), measures: vec![AggIntent::Max { col: None }], output_names: vec![], - having: None, filters: vec![], + having: None, }, OperationPlacement::Read, ), @@ -1346,14 +1352,15 @@ mod tests { // ── CSE sharing (issue #237, #223 stage 4) ────────────────────────── + use asap_types::ir::operator_properties::Source; + use asap_types::ir::{NonASAPOp, Predicate, ScalarExpr}; use asap_types::post_asap::{ - ExactKind, ExactParams, Field, GroupingStrategy, Schema, SketchKind, SummaryExpr, + ExactKind, ExactParams, Field, GroupingStrategy, Schema, SketchKind, }; - use asap_types::pre_asap::query_expr::Source; use asap_types::pre_asap::schema::DataType; - fn scan() -> QueryExpr { - QueryExpr::Scan { + fn scan() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: Schema::with_time_index( @@ -1364,37 +1371,39 @@ mod tests { 0, vec![], ), - } - } - - fn summary_node(family: FieldDataType) -> SummaryNode { - SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: std::rc::Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::new(scan())), - schema: Schema::lifted(vec![], None), - guarantee: None, + })) + .unwrap() + } + + /// A `SummaryAgg` directly over the kept `scan()` sub-DAG. + fn summary_node(family: FieldDataType) -> Rc { + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: scan(), + family: family.clone(), + input: asap_types::post_asap::SummaryUpdate::column( + asap_types::pre_asap::expr_ir::ColumnRef::Named("value".into()), + ), + reduction: asap_types::ir::operator_properties::Reduction::by(vec![]), + grouping: GroupingStrategy::default(), + filter: None, }), - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column( - asap_types::pre_asap::expr_ir::ColumnRef::Named("value".into()), - ), - reduction: asap_types::pre_asap::query_expr::Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }, - schema: Schema::lifted(vec![Field::new("state", family, false)], None), - guarantee: None, - } + Schema::lifted(vec![Field::new("state", family, false)], None), + ) + .with_guarantee(None), + ) } #[test] fn default_recompute_cost_is_positive_and_grows_with_structural_size() { let leaf = scan(); - let nested = QueryExpr::Dedup { - cols: vec![0], - child: std::rc::Rc::new(leaf.clone()), - }; + let nested = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: Rc::clone(&leaf), + })) + .unwrap(); assert!(default_cse_recompute_cost(&leaf) > Cost::ZERO); assert!(default_cse_recompute_cost(&nested) > default_cse_recompute_cost(&leaf)); } @@ -1407,27 +1416,27 @@ mod tests { /// identity-blind recursive walk) would count it. #[test] fn default_recompute_cost_does_not_double_count_an_internally_shared_descendant() { + use asap_types::ir::operator_properties::JoinKind; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::{JoinKind, Predicate}; - let true_pred = || { - Predicate(std::rc::Rc::new(QueryExpr::Literal(ScalarValue::Boolean( - true, - )))) - }; - let shared_leaf = std::rc::Rc::new(scan()); - let no_sharing = QueryExpr::Join { - kind: JoinKind::Inner, - pred: true_pred(), - left: std::rc::Rc::new(scan()), - right: std::rc::Rc::new(scan()), - }; - let with_sharing = QueryExpr::Join { - kind: JoinKind::Inner, - pred: true_pred(), - left: std::rc::Rc::clone(&shared_leaf), - right: std::rc::Rc::clone(&shared_leaf), - }; + let true_pred = || Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))); + let shared_leaf = scan(); + let no_sharing = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { + kind: JoinKind::Inner, + pred: true_pred(), + left: scan(), + right: scan(), + })) + .unwrap(); + let with_sharing = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { + kind: JoinKind::Inner, + pred: true_pred(), + left: Rc::clone(&shared_leaf), + right: Rc::clone(&shared_leaf), + })) + .unwrap(); assert_eq!( default_cse_recompute_cost(&no_sharing), Cost(3.0), @@ -1555,20 +1564,20 @@ mod tests { } } - let root = Rc::new(scan()); + let root = scan(); let target = TargetSubDAG::new(&root); let candidate = ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Summary(Rc::new(summary_node(FieldDataType::Plain( + replacement: Replacement::SubDAG(summary_node(FieldDataType::Plain( asap_types::pre_asap::DataType::Float64, - )))), + ))), provenance: crate::replacement::ReplacementProvenance::SummaryRealization, rationale: "whatever".into(), }; assert!(RankOnly.estimate_cost(&candidate, &target).is_nan()); } - /// `DefaultCostModel::estimate_cost` for a [`Replacement::Summary`] + /// `DefaultCostModel::estimate_cost` for a summary-rooted [`Replacement::SubDAG`] /// candidate reuses [`default_cse_shared_maintenance_cost`]'s own /// per-family ordering: a candidate bound to a cheap-to-maintain family /// (an exact accumulator) must cost less than one bound to an @@ -1578,25 +1587,26 @@ mod tests { /// above. #[test] fn estimate_cost_for_summary_orders_candidates_by_family_cheapest_to_priciest() { - let root = Rc::new(scan()); + let root = scan(); let target = TargetSubDAG::new(&root); let cheap = ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Summary(Rc::new(summary_node( - FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + replacement: Replacement::SubDAG(summary_node(FieldDataType::ExactAggregate( + ExactKind::Sum, + ExactParams::Sum, ))), provenance: crate::replacement::ReplacementProvenance::SummaryRealization, rationale: "exact accumulator".into(), }; let pricey = ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Summary(Rc::new(summary_node(FieldDataType::StatModel( + replacement: Replacement::SubDAG(summary_node(FieldDataType::StatModel( asap_types::post_asap::StatModelKind::Parametric, asap_types::post_asap::StatModelParams::Parametric { family: "gaussian_mixture".into(), }, - )))), + ))), provenance: crate::replacement::ReplacementProvenance::SummaryRealization, rationale: "fitted statistical model".into(), }; @@ -1614,7 +1624,7 @@ mod tests { ); } - /// `DefaultCostModel::estimate_cost` for a [`Replacement::Rewrite`] pair + /// `DefaultCostModel::estimate_cost` for a relational [`Replacement::SubDAG`] pair /// (the `SharedSubDAGStrategy` share-vs-recompute shape) agrees with /// what `cse_share_decision` would already pick for the same target: with /// many consumers of a cheap-to-recompute leaf, the "share" candidate @@ -1625,18 +1635,18 @@ mod tests { /// directly. #[test] fn estimate_cost_for_rewrite_prefers_sharing_when_recompute_dominates_maintenance() { - let target_root = Rc::new(scan()); + let target_root = scan(); let target = TargetSubDAG::with_consumer_count(&target_root, 20); let share = ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Rewrite(Rc::clone(&target_root)), + replacement: Replacement::SubDAG(Rc::clone(&target_root)), provenance: crate::replacement::ReplacementProvenance::CseShare, rationale: "build once and share".into(), }; let recompute = ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Rewrite(Rc::new((*target_root).clone())), + replacement: Replacement::SubDAG(Rc::new((*target_root).clone())), provenance: crate::replacement::ReplacementProvenance::CseRecompute, rationale: "build independently".into(), }; diff --git a/crates/asap-aware-mapping/src/empirical_comparison.rs b/crates/asap-aware-mapping/src/empirical_comparison.rs index ecd2a6cd4..17e3e9a82 100644 --- a/crates/asap-aware-mapping/src/empirical_comparison.rs +++ b/crates/asap-aware-mapping/src/empirical_comparison.rs @@ -41,7 +41,7 @@ pub struct OfflineExactMeasurement { } /// The companion format binds otherwise query-agnostic sketch primitives to -/// their measured readout and exact reference. Bindings describe state after +/// their measured evaluation and exact reference. Bindings describe state after /// ingestion, without merges or intervening updates during the read sequence. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -247,7 +247,9 @@ pub fn recommend_offline( || error.query.get("value_type").and_then(|v| v.as_str()) != Some(request.query.value_type.as_str()) { - return Err("offline error observation has incompatible readout semantics".into()); + return Err( + "offline error observation has incompatible evaluation semantics".into(), + ); } if error.metric != request.accuracy.metric || error.trials < request.accuracy.minimum_trials @@ -802,14 +804,14 @@ mod tests { } assert!(recommend_offline(&evidence, &request).is_err(), "{case}"); } - for case in ["metric", "trials", "binding", "readout"] { + for case in ["metric", "trials", "binding", "evaluation"] { let mut evidence = evidence.clone(); let mut request = request.clone(); match case { "metric" => request.accuracy.metric = "rank_error".into(), "trials" => request.accuracy.minimum_trials = 100, "binding" => evidence.query_bindings.clear(), - "readout" => { + "evaluation" => { for row in &mut evidence.sketch_evidence.records { row.error.as_mut().unwrap().query["kind"] = serde_json::json!("total_count"); diff --git a/crates/asap-aware-mapping/src/empirical_cost.rs b/crates/asap-aware-mapping/src/empirical_cost.rs index b6d4f9a08..18d1d7ee4 100644 --- a/crates/asap-aware-mapping/src/empirical_cost.rs +++ b/crates/asap-aware-mapping/src/empirical_cost.rs @@ -2,9 +2,8 @@ //! configuration and environment; they are neither runtime feedback nor proofs //! of an accuracy guarantee. CPU quantities are nanoseconds, never CPU operations. -use asap_types::post_asap::{ - FieldDataType, GroupingStrategy, SketchAlgorithm, SketchParams, SummaryExpr, SummaryNode, -}; +use asap_types::ir::{ASAPOp, Operator, OperatorNode}; +use asap_types::post_asap::{FieldDataType, GroupingStrategy, SketchAlgorithm, SketchParams}; use asap_types::pre_asap::AggIntent; use serde::{Deserialize, Serialize}; @@ -214,13 +213,13 @@ impl EmpiricalEvidenceProvider { /// mixed with an existing deployment's unitless or CPU-operation costs. pub fn lifecycle_cost_inputs( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, GroupingStrategy::PerSubpopulationInstance), grouping: GroupingStrategy::PerSubpopulationInstance, .. - } = &summary.expr + }) = &summary.operator else { return SummaryMaintenanceLifecycleCostInputs::default(); }; @@ -280,7 +279,7 @@ impl CostModel for EmpiricalCostModel { fn summary_maintenance_lifecycle_cost_inputs( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { self.provider.lifecycle_cost_inputs(summary) } diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index 54bce26f6..f48bc08b5 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -4,21 +4,23 @@ //! `construct_summary_agg` already nests accumulator realizations, such as //! KLL over exact `Sum` state or a quantile over `Rate` state. This strategy //! covers the more general cases where an exact function must consume a -//! summary readout, or where a maintained summary consumes the values of an +//! summary evaluation, or where a maintained summary consumes the values of an //! exact function that has no accumulator realization. //! -//! Both cases use the general [`SummaryExpr::ValueOperation`] node. Its -//! semantic [`ValueOperation`] is independent from [`ExecutionTiming`], so -//! adding a function does not require adding a new physical node type. +//! Both cases use an ordinary `NonASAPOp::Aggregate` node over the child +//! plan. The node carries no timing: it runs when its consumer runs, so the +//! same operator serves both placements and adding a function does not +//! require adding a new physical node type. [`OperationPlacement`] is the +//! search-time placement choice. //! //! ## Reference, don't select //! //! A composed candidate needs a child plan to compose *with* — the inner -//! quantile's own summary readout, say. This strategy deliberately does +//! quantile's own summary evaluation, say. This strategy deliberately does //! **not** pick that child itself (the way `construct_summary_agg`'s //! `realize_child` takes the head of the child's own ranking): a //! [`Replacement::ExactComposition`] carries only the child *target* -//! (`ExactComposition::child_target`, the same `Rc` whose +//! (`ExactComposition::child_target`, the same `Rc` whose //! `TargetSubDAGCandidates` in `CandidateLogicalASAPDAGs` already holds every candidate for it). It is //! [`CandidateLogicalASAPDAGs::global_selection`](crate::replacement::CandidateLogicalASAPDAGs::global_selection) //! that commits the compatible parent/child pair — so the child's own @@ -34,7 +36,7 @@ //! //! - the target is a single-measure, `HAVING`-free exact aggregate; //! - read-time operation: the child is a bindable aggregate that has at least one -//! readout-producing summary implementation (a sketch/sample/wavelet/ +//! evaluation-producing summary implementation (a sketch/sample/wavelet/ //! model — the shapes a maintained accumulator can't sit above), and the //! target's grouping keys resolve in the child's output schema; //! transform: the target is a per-entity exact function with no @@ -58,18 +60,21 @@ //! - Decide whether a composition is *worth it*: that is //! `global_selection`'s job, using the issue's cost-units-per-second //! formulas (see `crate::cost_model::read_operation_plan_cost_rate` and -//! siblings). Missing statistics keep the conservative `KeepPreAsap`. +//! siblings). Missing statistics keep the conservative kept sub-DAG. +use asap_types::ir::non_asap::any_measure_filtered; use std::rc::Rc; -use asap_types::post_asap::execution_data_state::validate_execution_data_states_at; +use asap_types::ir::aggregate_schema::aggregate_output_schema; +use asap_types::ir::operator_properties::Reduction; +use asap_types::ir::timing::{planned_data_state, validate_default}; +use asap_types::ir::{NonASAPOp, Operator, OperatorNode, Predicate}; +use asap_types::post_asap::execution_data_state::lift_plain; use asap_types::post_asap::{ - exact_operation_output_schema, produced_data_state, AccuracyError, ExactOperation, - ExecutionDataState, ExecutionDataStateError, ResultGuarantee, Schema, SummaryExpr, SummaryNode, - ValueOperation, + AccuracyError, ExactOperationSchemaError, ExecutionDataState, ExecutionDataStateError, + ResultGuarantee, Schema, }; use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::query_expr::{any_measure_filtered, QueryExpr, Reduction}; use asap_types::types::AccuracyTarget; use crate::cost_model::CostModel; @@ -84,9 +89,61 @@ use asap_types::post_asap::ExecutionTiming; /// Which side of the maintenance/read boundary an [`ExactComposition`]'s /// exact function executes on. +/// The exact function an [`ExactComposition`] applies: the parameters of +/// the `NonASAPOp::Aggregate` node the composition builds over its child. +#[derive(Debug, Clone, PartialEq)] +pub enum ExactOperation { + Aggregate { + reduction: Reduction, + measures: Vec, + output_names: Vec, + filters: Vec>, + having: Option, + }, +} + +impl ExactOperation { + /// Output schema of this operation over a child whose edge carries + /// `input` — the same canonical derivation the pre-ASAP `Aggregate` + /// node uses. `Err` when the child carries non-plain state the operator + /// cannot read. + pub fn output_schema(&self, input: &Schema) -> Result { + if !input.is_all_plain() { + return Err(ExactOperationSchemaError::NonPlainInput); + } + let plain = lift_plain(input); + let ExactOperation::Aggregate { + reduction, + measures, + output_names, + .. + } = self; + let out = aggregate_output_schema(&plain, reduction, measures, output_names)?; + Ok(lift_plain(&out)) + } + + fn into_op(self, child: Rc) -> NonASAPOp { + let ExactOperation::Aggregate { + reduction, + measures, + output_names, + filters, + having, + } = self; + NonASAPOp::Aggregate { + reduction, + measures, + output_names, + filters, + having, + child, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum OperationPlacement { - /// After the child's summary readout. + /// After the child's summary evaluation. Read, /// On the maintenance path, feeding /// maintained state above. @@ -120,7 +177,7 @@ pub struct ExactComposition { pub op: ExactOperation, /// The pre-ASAP child the operator consumes; its `TargetSubDAGCandidates` holds the /// candidates `global_selection` may commit this composition with. - pub child_target: Rc, + pub child_target: Rc, /// The composed node's output schema — the target's own pre-ASAP /// output schema, lifted with every column `Plain` (an exact operator /// only ever produces plain values). @@ -128,24 +185,27 @@ pub struct ExactComposition { } impl ExactComposition { + /// The data state `child` produces when this operation (its consumer) + /// runs at the placement's timing. + fn child_data_state(&self, child: &Rc) -> ExecutionDataState { + planned_data_state(child, self.placement.data_state().timing) + } + /// Can `child` legally be this composition's input? Phase legality - /// (the child's produced data_state — a `KeepPreAsap` leaf takes the + /// (the child's produced data_state — a kept pre-ASAP sub-DAG takes the /// phase this edge assigns) plus the plain-operand rule, checked /// through the same schema derivation [`Self::compose`] uses. - pub fn accepts_child(&self, child: &SummaryNode) -> bool { - let phase_ok = match produced_data_state(&child.expr) { - None => true, - Some(avail) => avail == self.placement.data_state(), - }; - phase_ok && exact_operation_output_schema(&self.op, &child.schema).is_ok() + pub fn accepts_child(&self, child: &Rc) -> bool { + self.child_data_state(child) == self.placement.data_state() + && self.op.output_schema(&child.schema).is_ok() } /// Build the composed, data_state-validated node over `child`. Every edge of /// the result (including everything beneath `child`) is checked by - /// `asap_types::post_asap::validate_execution_data_states`; an illegal + /// `asap_types::ir::timing::validate_default`; an illegal /// placement is a typed [`RealizationError::ExecutionDataState`], never deferred to a /// runtime. - pub fn compose(&self, child: Rc) -> Result, RealizationError> { + pub fn compose(&self, child: Rc) -> Result, RealizationError> { self.compose_with_accuracy(child, &DefaultAccuracyModel) } @@ -154,24 +214,23 @@ impl ExactComposition { /// unsupported folds fail closed with a typed accuracy error. pub fn compose_with_accuracy( &self, - child: Rc, + child: Rc, accuracy_model: &dyn AccuracyModel, - ) -> Result, RealizationError> { - if let Some(produced) = produced_data_state(&child.expr) { - if produced != self.placement.data_state() { - let edge = match self.placement { - OperationPlacement::Maintenance => "ValueOperation.child (maintenance time)", - OperationPlacement::Read => "ValueOperation.child (read time)", - }; - return Err(RealizationError::ExecutionDataState( - ExecutionDataStateError::IllegalChildDataState { - edge, - child: produced, - }, - )); - } + ) -> Result, RealizationError> { + let produced = self.child_data_state(&child); + if produced != self.placement.data_state() { + let edge = match self.placement { + OperationPlacement::Maintenance => "exact operation child (maintenance time)", + OperationPlacement::Read => "exact operation child (read time)", + }; + return Err(RealizationError::ExecutionDataState( + ExecutionDataStateError::IllegalChildDataState { + edge, + child: produced, + }, + )); } - let schema = exact_operation_output_schema(&self.op, &child.schema)?; + let schema = self.op.output_schema(&child.schema)?; let guarantee = match &child.guarantee { None => None, Some(input) if input.is_exact() => Some(ResultGuarantee::exact(format!( @@ -198,23 +257,11 @@ impl ExactComposition { None => None, }, }; - let timing = match self.placement { - OperationPlacement::Read => asap_types::post_asap::ExecutionTiming::QueryTime, - OperationPlacement::Maintenance => { - asap_types::post_asap::ExecutionTiming::IngestionTime - } - }; - let expr = SummaryExpr::ValueOperation { - child, - operation: ValueOperation::Exact(self.op.clone()), - timing, - }; - let node = Rc::new(SummaryNode { - expr, - schema, - guarantee, - }); - validate_execution_data_states_at(&node, self.placement.data_state())?; + let node = Rc::new( + OperatorNode::with_schema(Operator::NonASAP(self.op.clone().into_op(child)), schema) + .with_guarantee(guarantee), + ); + validate_default(&node, self.placement.data_state().timing)?; Ok(node) } @@ -227,7 +274,7 @@ impl ExactComposition { } } -/// Which exact reducers may run as a query-time fold over readout rows. +/// Which exact reducers may run as a query-time fold over evaluation rows. /// `Count` only at `Exact` accuracy (an approximate count is a sketch /// target, not an exact fold). fn is_query_time_reducer(intent: &AggIntent) -> bool { @@ -245,9 +292,9 @@ fn is_query_time_reducer(intent: &AggIntent) -> bool { ) } -/// Does `implementation` need a `SummaryEstimate` readout to yield a value +/// Does `implementation` need a `SummaryEstimate` evaluation to yield a value /// — i.e. is it a shape a maintained accumulator can't legally sit above? -fn needs_readout(implementation: &Realization) -> bool { +fn needs_evaluation(implementation: &Realization) -> bool { matches!( implementation, Realization::Sketch(_) @@ -259,17 +306,17 @@ fn needs_readout(implementation: &Realization) -> bool { /// The `(op, child)` of a read-time operation-shaped target, or `None`. fn query_time_shape( - root: &QueryExpr, + root: &OperatorNode, cost_model: &dyn CostModel, -) -> Option<(ExactOperation, Rc, AggIntent)> { - let QueryExpr::Aggregate { +) -> Option<(ExactOperation, Rc, AggIntent)> { + let Some(NonASAPOp::Aggregate { reduction, measures, output_names, filters, having: None, child, - } = root + }) = root.non_asap() else { return None; }; @@ -291,13 +338,12 @@ fn query_time_shape( let child_intent = bindable_intent(child)?; if !realizations_for_intent(child_intent, cost_model) .iter() - .any(needs_readout) + .any(needs_evaluation) { return None; } // Grouping keys must resolve in the child's output schema — the same // derivation the composed node's own schema will use. - root.output_schema().ok()?; Some(( ExactOperation::Aggregate { reduction: reduction.clone(), @@ -314,17 +360,17 @@ fn query_time_shape( /// The `(op, child)` of a function-shaped target — a per-entity exact /// transform with no accumulator form — or `None`. fn ingestion_time_shape( - root: &QueryExpr, + root: &OperatorNode, cost_model: &dyn CostModel, -) -> Option<(ExactOperation, Rc, AggIntent)> { - let QueryExpr::Aggregate { +) -> Option<(ExactOperation, Rc, AggIntent)> { + let Some(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures, output_names, filters, having: None, child, - } = root + }) = root.non_asap() else { return None; }; @@ -346,7 +392,6 @@ fn ingestion_time_shape( { return None; } - root.output_schema().ok()?; Some(( ExactOperation::Aggregate { reduction: Reduction::PerEntity, @@ -386,10 +431,7 @@ impl<'a> ExactCompositionStrategy<'a> { } fn candidates(&self, target: &TargetSubDAG<'_>) -> Vec { - let Ok(schema) = target.root.output_schema() else { - return Vec::new(); - }; - let schema = asap_types::post_asap::execution_data_state::lift_plain(&schema); + let schema = lift_plain(&target.root.schema); let mut out = Vec::new(); if let Some((op, child, intent)) = query_time_shape(target.root, self.cost_model) { @@ -410,10 +452,10 @@ impl<'a> ExactCompositionStrategy<'a> { }), provenance: ReplacementProvenance::ValueOperationAtQueryTime, rationale: format!( - "{} is an exact fold whose input is the readout of {} — a maintained \ - accumulator cannot consume query-time values, so instead of collapsing \ - the whole DAG into KeepPreAsap this applies the fold as an \ - ExactRead over whichever summary readout global_selection \ + "{} is an exact fold whose input is the evaluation of {} — a maintained \ + accumulator cannot consume query-time values, so instead of keeping \ + the whole tree pre-ASAP this applies the fold as an \ + ExactRead over whichever summary evaluation global_selection \ commits for the child target (asap_aware_mapping::exact_composition)", describe_intent(&intent), child_desc @@ -441,7 +483,7 @@ impl<'a> ExactCompositionStrategy<'a> { "{} is an exact per-entity function with no accumulator form; as an \ explicit ExactMaintenance on the update path its output can feed a \ maintained summary above it instead of being handed over as an opaque \ - raw KeepPreAsap blob (asap_aware_mapping::exact_composition)", + raw kept sub_dag (asap_aware_mapping::exact_composition)", describe_intent(&intent) ), }); @@ -465,59 +507,20 @@ impl ReplacementStrategy for ExactCompositionStrategy<'_> { mod tests { use super::*; use crate::cost_model::{DefaultCostModel, ValueOperationCapabilities}; - use crate::replacement::keep_pre_asap; + use crate::replacement::retain_exact; + use crate::test_support::{agg, agg_per_entity as per_entity, metric_scan, timed}; + use asap_types::ir::ASAPOp; use asap_types::post_asap::{ExecutionDataStateError, FieldDataType, SketchAlgorithm}; use asap_types::pre_asap::agg_intent::default_quantile; - use asap_types::pre_asap::query_expr::Source; - use asap_types::pre_asap::schema::{DataType, Field, Schema}; - - fn metric_scan(labels: &[&str]) -> QueryExpr { - 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)), - ); - QueryExpr::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index(columns, 0, vec![]), - } - } - - fn agg(by: Vec, intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { - reduction: Reduction::by(by), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(child), - } - } - - fn per_entity(intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(child), - } - } /// `max by (zone) (quantile by (zone, host) (m))`. - fn max_over_quantile() -> Rc { + fn max_over_quantile() -> Rc { let inner = agg( vec![2, 3], default_quantile(0.99), metric_scan(&["zone", "host"]), ); - Rc::new(agg(vec![0], AggIntent::Max { col: None }, inner)) + agg(vec![0], AggIntent::Max { col: None }, inner) } #[test] @@ -539,7 +542,7 @@ mod tests { candidates[0].provenance, ReplacementProvenance::ValueOperationAtQueryTime ); - let QueryExpr::Aggregate { child, .. } = root.as_ref() else { + let Some(NonASAPOp::Aggregate { child, .. }) = root.non_asap() else { unreachable!() }; assert!( @@ -553,7 +556,7 @@ mod tests { #[test] fn proposes_query_time_operation_for_avg_over_quantile_alongside_the_rewrite() { let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); - let root = Rc::new(agg(vec![0], AggIntent::Avg { col: None }, inner)); + let root = agg(vec![0], AggIntent::Avg { col: None }, inner); let target = TargetSubDAG::new(&root); assert_eq!( ExactCompositionStrategy::default_cost_model() @@ -567,7 +570,7 @@ mod tests { #[test] fn proposes_ingestion_time_operation_for_a_per_entity_pass_through_over_raw_input() { - let root = Rc::new(per_entity(AggIntent::Deriv, metric_scan(&["zone"]))); + let root = per_entity(AggIntent::Deriv, metric_scan(&["zone"])); let target = TargetSubDAG::new(&root); let candidates = ExactCompositionStrategy::default_cost_model().replacements(&target); assert_eq!(candidates.len(), 1); @@ -579,21 +582,21 @@ mod tests { #[test] fn does_not_propose_for_shapes_already_covered_by_accumulators() { - // sum by (zone) over an exact Sum child: the child has no readout, + // sum by (zone) over an exact Sum child: the child has no evaluation, // so SummaryAgg(Sum) over SummaryAgg(Sum) is already legal. let inner = agg( vec![2, 3], AggIntent::Sum { col: None }, metric_scan(&["zone", "host"]), ); - let root = Rc::new(agg(vec![0], AggIntent::Sum { col: None }, inner)); + let root = agg(vec![0], AggIntent::Sum { col: None }, inner); assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&root))); // rate is an exact accumulator — directly nestable, no separate value operation. - let rate = Rc::new(per_entity(AggIntent::Rate, metric_scan(&[]))); + let rate = per_entity(AggIntent::Rate, metric_scan(&[])); assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&rate))); // A sketch-capable outer intent is not an exact fold. let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["zone"])); - let root = Rc::new(agg(vec![0], default_quantile(0.99), inner)); + let root = agg(vec![0], default_quantile(0.99), inner); assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&root))); } @@ -618,7 +621,7 @@ mod tests { let strategy = ExactCompositionStrategy::new(&NoMixedExecution); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); - let deriv = Rc::new(per_entity(AggIntent::Deriv, metric_scan(&[]))); + let deriv = per_entity(AggIntent::Deriv, metric_scan(&[])); assert!(!strategy.matches(&TargetSubDAG::new(&deriv))); } @@ -630,12 +633,13 @@ mod tests { let Replacement::ExactComposition(comp) = &candidates[0].replacement else { unreachable!() }; - // A bare SummaryAgg (state, no readout) is not a legal read-time operation + // A bare SummaryAgg (state, no evaluation) is not a legal read-time operation // input — the operator would be consuming sketch state. let state_child = crate::replacement::realize_child(&comp.child_target, &DefaultCostModel).unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &state_child.expr else { - panic!("expected the child to realize to a readout"); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &state_child.operator + else { + panic!("expected the child to realize to a evaluation"); }; assert!(!comp.accepts_child(summary_input)); assert!(matches!( @@ -644,7 +648,7 @@ mod tests { ExecutionDataStateError::IllegalChildDataState { .. } )) )); - // The readout itself is accepted and composes to a plain schema. + // The evaluation itself is accepted and composes to a plain schema. assert!(comp.accepts_child(&state_child)); let composed = comp.compose(state_child).unwrap(); assert!( @@ -652,12 +656,13 @@ mod tests { "rank error has no registered conversion through max" ); assert!(matches!( - composed.expr, - SummaryExpr::ValueOperation { - timing: ExecutionTiming::QueryTime, - .. - } + composed.operator, + Operator::NonASAP(NonASAPOp::Aggregate { .. }) )); + // Timing is no longer stored by composition: under the default + // lifecycle assignment the composed read-time operation runs at + // query time. + assert_eq!(timed(&composed).timing, Some(ExecutionTiming::QueryTime)); assert!(composed .schema .fields @@ -666,32 +671,78 @@ mod tests { } #[test] - fn compose_rejects_a_readout_child_for_a_ingestion_time_operation() { + fn compose_rejects_a_evaluation_child_for_a_ingestion_time_operation() { let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); - let root = Rc::new(per_entity(AggIntent::Deriv, inner)); + let root = per_entity(AggIntent::Deriv, inner); let candidates = ExactCompositionStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); let Replacement::ExactComposition(comp) = &candidates[0].replacement else { unreachable!() }; - let readout = + let evaluation = crate::replacement::realize_child(&comp.child_target, &DefaultCostModel).unwrap(); - assert!(!comp.accepts_child(&readout)); + assert!(!comp.accepts_child(&evaluation)); assert!(matches!( - comp.compose(readout), + comp.compose(evaluation), Err(RealizationError::ExecutionDataState( ExecutionDataStateError::IllegalChildDataState { .. } )) )); // Raw update input is fine. - let raw = keep_pre_asap(&comp.child_target).unwrap(); + let raw = retain_exact(&comp.child_target).unwrap(); assert!(comp.accepts_child(&raw)); + // Timing is no longer stored by composition: the composition's + // placement is maintenance time, the composed exact operation is a + // plain Aggregate over the raw rows, and it is legal (and planned to + // run) at ingestion time. + assert_eq!(comp.placement, OperationPlacement::Maintenance); + let composed = comp.compose(raw).unwrap(); assert!(matches!( - comp.compose(raw).unwrap().expr, - SummaryExpr::ValueOperation { - timing: ExecutionTiming::IngestionTime, - .. - } + composed.operator, + Operator::NonASAP(NonASAPOp::Aggregate { .. }) + )); + validate_default(&composed, ExecutionTiming::IngestionTime).unwrap(); + assert_eq!( + planned_data_state(&composed, ExecutionTiming::IngestionTime).timing, + ExecutionTiming::IngestionTime + ); + } + + fn max_op(by: Vec) -> ExactOperation { + ExactOperation::Aggregate { + reduction: Reduction::by(by), + measures: vec![AggIntent::Max { col: None }], + output_names: vec![], + filters: vec![], + having: None, + } + } + + #[test] + fn exact_operator_schema_matches_pre_asap_aggregate_derivation() { + let child = lift_plain(&metric_scan(&["zone"]).schema); + let out = max_op(vec![2]).output_schema(&child).unwrap(); + let names: Vec<_> = out.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, vec!["zone", "max"]); + assert!(out.is_all_plain()); + } + + #[test] + fn exact_operator_rejects_non_plain_input() { + let state = Schema::lifted( + vec![asap_types::pre_asap::Field::new( + "state", + FieldDataType::ExactAggregate( + asap_types::post_asap::ExactKind::Sum, + asap_types::post_asap::ExactParams::Sum, + ), + false, + )], + None, + ); + assert!(matches!( + max_op(vec![]).output_schema(&state), + Err(ExactOperationSchemaError::NonPlainInput) )); } } diff --git a/crates/asap-aware-mapping/src/explanation.rs b/crates/asap-aware-mapping/src/explanation.rs index e7afff2ba..bc67be96f 100644 --- a/crates/asap-aware-mapping/src/explanation.rs +++ b/crates/asap-aware-mapping/src/explanation.rs @@ -31,7 +31,7 @@ //! collapses into a single question this module asks of *that* data instead: //! **for a given `TargetSubDAG`, does its candidate list contain anything //! other than the trivial, no-op realization?** A `TargetSubDAG` whose only -//! candidate is "the one thing `SketchAlgorithmStrategy` would have committed +//! candidate is "the one thing `ASAPStrategies` would have committed //! to anyway, with no alternative" has no optimization to report — that //! candidate isn't an *opportunity*, it's just the target's existing shape //! reflected back. A `TargetSubDAG` with more than one candidate (several @@ -43,9 +43,9 @@ //! [`TargetSubDAGCandidates`]s into that shape: //! //! - [`ExplanationKind::SketchApproximation`] — the `TargetSubDAG`'s -//! candidate list contains at least one [`Replacement::Summary`] that +//! candidate list contains at least one summary-realization [`Replacement::SubDAG`] that //! actually realizes a sketch family (`FieldDataType::Sketch`), i.e. -//! [`SketchAlgorithmStrategy`] found something to offer beyond whatever +//! [`ASAPStrategies`] found something to offer beyond whatever //! exact/pass-through candidate [`crate::replacement`]'s own //! `realizations_for_intent` would have committed to on its own. //! - [`ExplanationKind::CommonSubexpressionReuse`] — the `TargetSubDAG` @@ -103,7 +103,7 @@ //! [`ReplacementStrategy`] already *is* that extension point, one layer //! down, and [`explain_replacements_with`]'s own `strategies` //! parameter is where a caller plugs in a custom one (or a custom -//! `CostModel`, via [`crate::replacement::SketchAlgorithmStrategy::new`]) — the identical spot +//! `CostModel`, via [`crate::replacement::ASAPStrategies::new`]) — the identical spot //! [`crate::replacement::search_workload_with`] itself exposes. //! //! ## Two guarantees the old traversal made, re-verified against the new one @@ -134,7 +134,7 @@ //! ## One thing [`CandidateLogicalASAPDAGs`] doesn't carry that this module still needs: //! human-readable `location` text //! -//! [`TargetSubDAGCandidates`]/[`CandidateLogicalASAPDAGs`] deliberately track only `Rc` +//! [`TargetSubDAGCandidates`]/[`CandidateLogicalASAPDAGs`] deliberately track only `Rc` //! pointer identity — the currency the search itself needs — not //! caller-facing prose. [`ReplacementExplanation::location`] is prose (a //! breadcrumb like `root "dash_a" > lhs`), so this module keeps one small, @@ -161,8 +161,8 @@ //! //! | Catalog entry | Status | Where a future `ExplanationKind` would come from | //! |---|---|---| -//! | Semantic-equivalent rewriting (e.g. `avg` → `sum`/`count`) | [`AvgToSumOverCountStrategy`](crate::rewrite::AvgToSumOverCountStrategy) exists and is wired into `default_strategies()` (issue #253) — but still no `ExplanationKind` of its own below, since this table is about *direct* findings for a catalog entry, and this strategy's whole point is indirect: its `Replacement::Rewrite` candidate exposes `sum`/`count` as independently bindable discovered targets, which can then earn `CommonSubexpressionReuse` findings when the workload actually reuses them | A dedicated variant would need `findings_from_candidate_logical_asap_dags` to recognize a `LogicalRewrite`-provenance candidate as a finding in its own right, not just rely on what it exposes downstream | -//! | Roll-ups (fine-to-coarse group-by reuse) | [`RollupStrategy`](crate::rollup::RollupStrategy), derived from workload siblings after CSE/target discovery (issue #254) | Any `Replacement::Rewrite` candidate that rolls a coarse aggregate up from a compatible finer aggregate | +//! | Semantic-equivalent rewriting (e.g. `avg` → `sum`/`count`) | [`AvgToSumOverCountStrategy`](crate::rewrite::AvgToSumOverCountStrategy) exists and is wired into `default_strategies()` (issue #253) — but still no `ExplanationKind` of its own below, since this table is about *direct* findings for a catalog entry, and this strategy's whole point is indirect: its `Replacement::SubDAG` rewrite candidate exposes `sum`/`count` as independently bindable discovered targets, which can then earn `CommonSubexpressionReuse` findings when the workload actually reuses them | A dedicated variant would need `findings_from_candidate_logical_asap_dags` to recognize a `LogicalRewrite`-provenance candidate as a finding in its own right, not just rely on what it exposes downstream | +//! | Roll-ups (fine-to-coarse group-by reuse) | [`RollupStrategy`](crate::rollup::RollupStrategy), derived from workload siblings after CSE/target discovery (issue #254) | Any `Replacement::SubDAG` rewrite candidate that rolls a coarse aggregate up from a compatible finer aggregate | //! | Wavelets/OMP | Params type exists (`WaveletKind`/`WaveletParams`), reachable only via a deployment `CostModel::realize_extension` (no core `AggIntent` dispatch picks it) | A `ReplacementStrategy` that inspects a deployment's own `CostModel`, once some intent shape actually maps to `Realization::Wavelet` | //! | Sampling | Same story as Wavelets: `SamplingKind`/`SamplingParams` exist, unreachable from core dispatch | Same hook as Wavelets, for `Realization::Sample` | //! | Deep generative compression | No representation at all — no `Realization`/`FieldDataType` variant | Needs a new summary family added to `asap_types::post_asap` first | @@ -175,9 +175,8 @@ //! [`ReplacementStrategy`]: crate::replacement::ReplacementStrategy //! [`ReplacementSubDAG`]: crate::replacement::ReplacementSubDAG //! [`Replacement`]: crate::replacement::Replacement -//! [`Replacement::Summary`]: crate::replacement::Replacement::Summary -//! [`Replacement::Rewrite`]: crate::replacement::Replacement::Rewrite -//! [`SketchAlgorithmStrategy`]: crate::replacement::SketchAlgorithmStrategy +//! [`Replacement::SubDAG`]: crate::replacement::Replacement::SubDAG +//! [`ASAPStrategies`]: crate::replacement::ASAPStrategies //! [`SharedSubDAGStrategy`]: crate::replacement::SharedSubDAGStrategy //! [`CandidateLogicalASAPDAGs`]: crate::replacement::CandidateLogicalASAPDAGs //! [`TargetSubDAGCandidates`]: crate::replacement::TargetSubDAGCandidates @@ -186,9 +185,9 @@ use std::collections::HashMap; use std::fmt::Display; use std::rc::Rc; -use asap_types::post_asap::{FieldDataType, SummaryExpr, SummaryNode}; -use asap_types::pre_asap::cse::{structural_hash, HashCache}; -use asap_types::pre_asap::query_expr::QueryExpr; +use asap_types::ir::cse::{structural_hash, HashCache}; +use asap_types::ir::{ASAPOp, Operator, OperatorNode}; +use asap_types::post_asap::FieldDataType; use crate::replacement::{ self, CandidateLogicalASAPDAGs, Replacement, ReplacementStrategy, TargetSubDAGCandidates, @@ -206,8 +205,8 @@ use crate::replacement::{ #[non_exhaustive] pub enum ExplanationKind { /// A `TargetSubDAG`'s candidate list contains at least one - /// [`Replacement::Summary`] that realizes a sketch family — - /// [`crate::replacement::SketchAlgorithmStrategy`] found a genuine sketch + /// [`Replacement::SubDAG`] that realizes a sketch family — + /// [`crate::replacement::ASAPStrategies`] found a genuine sketch /// alternative for this `Aggregate`, beyond whatever exact/pass-through /// candidate `crate::replacement`'s own `realizations_for_intent` would /// have committed to on its own. @@ -222,8 +221,8 @@ pub enum ExplanationKind { /// [`Replacement::ExactComposition`] — /// [`crate::exact_composition::ExactCompositionStrategy`] found an exact /// operator that can be composed with a summary plan across an explicit - /// update/readout boundary instead of collapsing the whole DAG into - /// `KeepPreAsap` (issue #171). + /// update/evaluation boundary instead of keeping the whole tree as it is + /// (issue #171). ExactComposition, } @@ -233,11 +232,11 @@ pub enum ExplanationKind { /// not machine parsing — literally the matching candidate's own /// [`crate::replacement::ReplacementSubDAG::rationale`]). /// -/// `node_hash` is [`structural_hash`](asap_types::pre_asap::cse::structural_hash) +/// `node_hash` is [`structural_hash`](asap_types::ir::cse::structural_hash) /// of the `TargetSubDAG`'s own `target` sub-DAG — the same function, on the -/// same `Rc` shape, that [`asap_types::dag_export::DAGNode::hash`] +/// same `Rc` shape, that [`asap_types::dag_export::DAGNode::hash`] /// is computed with. A downstream consumer that independently exported the -/// same `QueryExpr` (e.g. via `asap_types::dag_export::export`) can match +/// same node (e.g. via `asap_types::dag_export::export`) can match /// this explanation to a `DAGNode` by first comparing hashes and then /// confirming structural equality with [`ReplacementExplanation::target`]. #[derive(Debug, Clone, PartialEq)] @@ -249,7 +248,7 @@ pub struct ReplacementExplanation { /// The exact target expression the explanation describes. Reporting /// integrations use this together with `node_hash`: the hash narrows the /// search, and structural equality makes the final match collision-safe. - pub target: Rc, + pub target: Rc, } /// Explain every replacement [`crate::replacement::search_workload`] finds @@ -265,7 +264,7 @@ pub struct ReplacementExplanation { /// candidate-plan space, then reads findings off it — see the module docs' /// "The reframing" section for what that translation actually checks. pub fn explain_replacements( - roots: Vec<(Id, QueryExpr)>, + roots: Vec<(Id, Rc)>, ) -> Vec { explain_replacements_with(roots, &replacement::default_strategies()) } @@ -274,17 +273,17 @@ pub fn explain_replacements( /// instead of [`crate::replacement::default_strategies`] — the extension /// point for a deployment-specific [`ReplacementStrategy`], or a custom /// `CostModel` plugged into -/// [`crate::replacement::SketchAlgorithmStrategy::new`] (e.g. via +/// [`crate::replacement::ASAPStrategies::new`] (e.g. via /// [`crate::replacement::default_strategies_with`]). /// /// [`ReplacementStrategy`]: crate::replacement::ReplacementStrategy pub fn explain_replacements_with<'s, Id: Display>( - roots: Vec<(Id, QueryExpr)>, + roots: Vec<(Id, Rc)>, strategies: &[Box], ) -> Vec { - let ided: Vec<(String, Rc)> = roots + let ided: Vec<(String, Rc)> = roots .into_iter() - .map(|(id, expr)| (id.to_string(), Rc::new(expr))) + .map(|(id, expr)| (id.to_string(), expr)) .collect(); let space = replacement::search_workload_with(ided, strategies); findings_from_candidate_logical_asap_dags(&space) @@ -375,7 +374,7 @@ fn sketch_finding_reason(group: &TargetSubDAGCandidates) -> Option { .candidates .iter() .filter( - |c| matches!(&c.replacement, Replacement::Summary(node) if is_sketch_realization(node)), + |c| matches!(&c.replacement, Replacement::SubDAG(node) if is_sketch_realization(node)), ) .map(|c| c.rationale.as_str()) .collect(); @@ -387,7 +386,7 @@ fn sketch_finding_reason(group: &TargetSubDAGCandidates) -> Option { } /// Does `group` have two or more consumers *and* a "build once and share" -/// candidate (the [`Replacement::Rewrite`] whose `Rc` is the group's own +/// candidate (the [`Replacement::SubDAG`] whose `Rc` is the group's own /// `target`) in its candidate list? If so, the finding's `reason` is that /// candidate's own `rationale`. fn shared_subexpr_finding_reason(group: &TargetSubDAGCandidates) -> Option { @@ -398,7 +397,7 @@ fn shared_subexpr_finding_reason(group: &TargetSubDAGCandidates) -> Option Option bool { +fn is_sketch_realization(node: &OperatorNode) -> bool { if node .guarantee .as_ref() @@ -417,9 +416,13 @@ fn is_sketch_realization(node: &SummaryNode) -> bool { { return false; } - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => is_sketch_realization(summary_input), - SummaryExpr::SummaryAgg { family, .. } => matches!(family, FieldDataType::Sketch(..)), + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + is_sketch_realization(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => { + matches!(family, FieldDataType::Sketch(..)) + } _ => false, } } @@ -432,8 +435,10 @@ fn is_sketch_realization(node: &SummaryNode) -> bool { /// every breadcrumb path that reaches a given `Rc`, not just the first: a /// shared node referenced from two workload roots (or two branches of one /// root) needs both breadcrumbs in its finding's `location`, not just one. -fn collect_locations(roots: &[(String, Rc)]) -> HashMap<*const QueryExpr, Vec> { - let mut locations: HashMap<*const QueryExpr, Vec> = HashMap::new(); +fn collect_locations( + roots: &[(String, Rc)], +) -> HashMap<*const OperatorNode, Vec> { + let mut locations: HashMap<*const OperatorNode, Vec> = HashMap::new(); for (id, root) in roots { visit(root, format!("root {id:?}"), &mut locations); } @@ -444,9 +449,9 @@ fn collect_locations(roots: &[(String, Rc)]) -> HashMap<*const QueryE /// through its children. A shared ancestor is intentionally traversed once /// per incoming path so every descendant receives every valid breadcrumb. fn visit( - node: &Rc, + node: &Rc, label: String, - locations: &mut HashMap<*const QueryExpr, Vec>, + locations: &mut HashMap<*const OperatorNode, Vec>, ) { let ptr = Rc::as_ptr(node); locations.entry(ptr).or_default().push(label.clone()); @@ -455,20 +460,24 @@ fn visit( /// `node`'s own **relational-skeleton** operator children — the same scope /// `crate::replacement`'s own target-discovery `walk_children` (and -/// `asap_types::pre_asap::cse::share_common_sub_dags`'s `rebuild_children`) -/// use. Exhaustive over every `QueryExpr` variant: a new variant fails to -/// compile here until this match is extended too. +/// `asap_types::ir::cse::share_common_sub_dags`) use. Exhaustive over every +/// `NonASAPOp` variant: a new variant fails to compile here until this match +/// is extended too. An ASAP node never occurs in a workload root. fn visit_children( - node: &QueryExpr, + node: &OperatorNode, label: &str, - locations: &mut HashMap<*const QueryExpr, Vec>, + locations: &mut HashMap<*const OperatorNode, Vec>, ) { - use QueryExpr::*; - match node { - Scan { .. } | PromqlScalarBridge(_) | EvalTimestamp | CurrentTimestamp => {} - PromqlVectorFromScalar(c) | PromqlScalarFromVector(c) => { + use asap_types::ir::{NonASAPOp::*, ScalarExpr}; + let Operator::NonASAP(op) = &node.operator else { + return; + }; + match op { + Scan { .. } | Values { .. } => {} + PromqlVectorFromScalar(ScalarExpr::PromqlScalarFromVector(c)) => { visit(c, format!("{label} > child"), locations) } + PromqlVectorFromScalar(_) => {} PromqlRelabel { child, .. } | PromqlInfoEnrich { child, .. } | PromqlSeriesSample { child, .. } @@ -484,7 +493,7 @@ fn visit_children( | Limit { child, .. } => visit(child, format!("{label} > child"), locations), Concat { children, .. } => { for (i, c) in children.iter().enumerate() { - visit_children(c, &format!("{label} > concat[{i}]"), locations); + visit(c, format!("{label} > concat[{i}]"), locations); } } Join { left, right, .. } | SetOp { left, right, .. } => { @@ -495,31 +504,20 @@ fn visit_children( visit(lhs, format!("{label} > lhs"), locations); visit(rhs, format!("{label} > rhs"), locations); } - Column(_) - | Literal(_) - | Compare { .. } - | BoolAnd(_) - | BoolOr(_) - | Not(_) - | IsNull(_) - | IsNotNull(_) - | Cast { .. } - | InList { .. } - | FunctionCall { .. } - | Arithmetic { .. } - | Case { .. } => {} } } #[cfg(test)] mod tests { use super::*; + use asap_types::ir::operator_properties::{BinaryOpKind, Reduction, Source}; + use asap_types::ir::{BinaryOperator, NonASAPOp, OperatorNode, Predicate, ScalarExpr}; use asap_types::pre_asap::agg_intent::{default_quantile, AggIntent}; - use asap_types::pre_asap::query_expr::{Reduction, Source}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; + use asap_types::types::AccuracyTarget; - fn metric_scan(labels: &[&str]) -> QueryExpr { + fn metric_scan(labels: &[&str]) -> Rc { let mut columns = vec![ Field::plain("ts", DataType::Timestamp, false), Field::plain("value", DataType::Float64, false), @@ -529,22 +527,43 @@ mod tests { .iter() .map(|n| Field::plain(*n, DataType::Utf8, true)), ); - QueryExpr::Scan { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: Schema::with_time_index(columns, 0, vec![]), - } + })) + .unwrap() } - fn agg(by: Vec, intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { + fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(by), measures: vec![intent], output_names: vec![], filters: vec![], having: None, - child: Rc::new(child), - } + child, + })) + .unwrap() + } + + fn binary( + kind: BinaryOpKind, + lhs: Rc, + rhs: Rc, + ) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + kind, + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + return_bool: false, + lhs, + rhs, + })) + .unwrap() } // ── SketchApproximation ────────────────────────────────────────────── @@ -567,7 +586,7 @@ mod tests { } /// `node_hash` must be the literal `structural_hash` a downstream - /// consumer would compute over the *same* `QueryExpr` sub-DAG via + /// consumer would compute over the *same* `OperatorNode` sub-DAG via /// `asap_types::dag_export::export` — the whole point of carrying it is /// that two independent exports of the same DAG agree, with no /// string-matching against `location` required. @@ -586,7 +605,7 @@ mod tests { Some(sketch.node_hash), expected_hash, "ReplacementExplanation::node_hash must match dag_export's DAGNode::hash \ - for the same QueryExpr sub-DAG" + for the same OperatorNode sub_dag" ); } @@ -641,21 +660,18 @@ mod tests { /// A sketch-applicable `Aggregate` reachable via two paths that CSE /// collapses onto one `Rc` — the same `median(x) == median(x)` shape - /// `pre_asap::cse`'s own `single_query_shares_its_own_repeated_sub_dag` + /// `pre_asap::cse`'s own `single_query_shares_its_own_repeated_sub-DAG` /// test uses — must be reported once, not once per path: it is exactly /// one [`crate::replacement::TargetSubDAGCandidates`], keyed by `Rc` pointer identity, /// not one per path that reaches it. #[test] fn a_shared_sketchable_aggregate_is_reported_only_once() { let quantile = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let root = QueryExpr::BinaryOp { - op: asap_types::pre_asap::query_expr::BinaryOpKind::Compare( - asap_types::pre_asap::expr_ir::CompareOpKind::Eq, - ), - lhs: Rc::new(quantile.clone()), - rhs: Rc::new(quantile), - vector_match: None, - }; + let root = binary( + BinaryOpKind::Compare(asap_types::pre_asap::expr_ir::CompareOpKind::Eq), + Rc::clone(&quantile), + quantile, + ); let findings = explain_replacements(vec![("ratio", root)]); let sketch: Vec<_> = findings .iter() @@ -695,8 +711,8 @@ mod tests { #[test] fn descendant_of_a_shared_root_keeps_every_root_breadcrumb() { let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let outer = agg(vec![2], AggIntent::Sum { col: None }, inner); - let findings = explain_replacements(vec![("dash_a", outer.clone()), ("dash_b", outer)]); + let outer = agg(vec![0], AggIntent::Sum { col: None }, inner); + let findings = explain_replacements(vec![("dash_a", Rc::clone(&outer)), ("dash_b", outer)]); let inner_sketch = findings .iter() .find(|f| { @@ -742,14 +758,11 @@ mod tests { // The same shared branch appearing twice within one query (an `a/a` // shape) — single-query CSE. let branch = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let q = QueryExpr::BinaryOp { - op: asap_types::pre_asap::query_expr::BinaryOpKind::Arithmetic( - asap_types::pre_asap::expr_ir::ArithmeticOpKind::Div, - ), - lhs: Rc::new(branch.clone()), - rhs: Rc::new(branch), - vector_match: None, - }; + let q = binary( + BinaryOpKind::Arithmetic(asap_types::pre_asap::expr_ir::ArithmeticOpKind::Div), + Rc::clone(&branch), + branch, + ); let findings = explain_replacements(vec![("ratio", q)]); let reuse: Vec<_> = findings .iter() @@ -766,7 +779,7 @@ mod tests { /// A shared node nested three levels under two *different*, unshared /// `Filter` parents (mirrors `crate::replacement::tests:: - /// nested_shared_sub_dag_below_an_unshared_parent_is_still_discovered`) + /// nested_shared_sub-DAG_below_an_unshared_parent_is_still_discovered`) /// must still be exactly one finding — the maximal-`TargetSubDAG` /// guarantee the module docs describe, now provided by /// `crate::replacement`'s own target discovery rather than this module's @@ -774,17 +787,20 @@ mod tests { #[test] fn a_deeply_shared_sub_dag_under_different_parents_is_reported_once() { use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let root_a = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64(1)))), - child: Rc::new(shared.clone()), - }; - let root_b = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64(2)))), - child: Rc::new(shared), - }; + let root_a = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), + child: Rc::clone(&shared), + })) + .unwrap(); + let root_b = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), + child: shared, + })) + .unwrap(); let findings = explain_replacements(vec![("a", root_a), ("b", root_b)]); let reuse: Vec<_> = findings .iter() @@ -825,7 +841,7 @@ mod tests { let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let custom_model = AlwaysDDSketch; let strategies: Vec> = vec![Box::new( - crate::replacement::SketchAlgorithmStrategy::new(&custom_model), + crate::replacement::ASAPStrategies::new(&custom_model), )]; let findings = explain_replacements_with(vec![("q", q)], &strategies); assert_eq!(findings.len(), 1); diff --git a/crates/asap-aware-mapping/src/grouping.rs b/crates/asap-aware-mapping/src/grouping.rs index fc4abfe62..bbbaad37f 100644 --- a/crates/asap-aware-mapping/src/grouping.rs +++ b/crates/asap-aware-mapping/src/grouping.rs @@ -7,7 +7,7 @@ //! //! ## Placement: planning metadata and edge-state type //! -//! `SummaryExpr::SummaryAgg` carries the grouping choice next to the +//! `ASAPOp::SummaryAgg` carries the grouping choice next to the //! `Reduction` whose `by` keys determine legality. The same choice is also //! committed to `FieldDataType::Sketch` on the aggregate's output edge. //! That duplication is intentional: the node field makes the choice easy to @@ -41,7 +41,7 @@ //! An earlier draft of this module (written against the very first draft of //! #251) reused a `CostModel`-wrapping adapter that "steered" a //! whole-recursive-bind decision procedure toward a specific `SketchKind`, -//! the same pattern [`crate::replacement::SketchAlgorithmStrategy`]'s own module +//! the same pattern [`crate::replacement::ASAPStrategies`]'s own module //! docs explain was deliberately deleted from this crate as an anti-pattern: //! forcing a choice via a whole-DAG `CostModel` adapter had a real bug where //! the forced choice could leak into a target's own nested aggregates. This @@ -53,7 +53,7 @@ //! passes that exact, //! already-decided `Realization` to //! [`crate::replacement::construct_summary`] — the same first-class, -//! one-candidate-at-a-time primitive [`crate::replacement::SketchAlgorithmStrategy`] +//! one-candidate-at-a-time primitive [`crate::replacement::ASAPStrategies`] //! itself calls once per candidate. No adapter, no steering, no risk of a //! forced choice leaking into nested aggregates. //! @@ -71,13 +71,14 @@ use std::rc::Rc; +use asap_types::ir::operator_properties::Reduction; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode}; use asap_types::post_asap::{ default_hydra_params, hydra_kind_for, AccuracyError, BoundExpr, CompositionOperator, FieldDataType, GroupingStrategy, GuaranteeSource, HydraKind, ProbabilityExpr, ResultGuarantee, - SketchAlgorithm, SketchParams, SummaryExpr, SummaryNode, + SketchAlgorithm, SketchParams, }; use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; use crate::accuracy::{ AccuracyBudgetAllocator, AccuracyEvidenceProvider, AccuracyModel, PropagationStats, @@ -110,15 +111,15 @@ pub fn has_subpopulations(reduction: &Reduction) -> bool { /// A single static instance so [`HydraGroupingStrategy::default_cost_model`] /// can hand out a `&'static dyn CostModel` without heap-allocating one — same -/// pattern [`crate::replacement::SketchAlgorithmStrategy`] uses. +/// pattern [`crate::replacement::ASAPStrategies`] uses. static DEFAULT_COST_MODEL: DefaultCostModel = DefaultCostModel; /// Wraps the `GroupingStrategy` axis (issue #256) as a -/// [`ReplacementStrategy`]: for a target [`SketchAlgorithmStrategy`](crate::replacement::SketchAlgorithmStrategy) +/// [`ReplacementStrategy`]: for a target [`ASAPStrategies`](crate::replacement::ASAPStrategies) /// already has an opinion on, offers an additional /// `GroupingStrategy::SharedMultiSubpopulation` candidate wherever the /// legality conditions in the module docs above hold — alongside, not -/// instead of, the per-subpopulation candidates `SketchAlgorithmStrategy` +/// instead of, the per-subpopulation candidates `ASAPStrategies` /// itself enumerates. The workload search composes both strategies over the /// same target, so it sees every summary-family alternative *and* the Hydra /// alternative; the built-in workload search registers both strategies, and @@ -132,7 +133,7 @@ pub struct HydraGroupingStrategy<'a> { impl HydraGroupingStrategy<'static> { /// A strategy that ranks/binds via the built-in [`DefaultCostModel`] — /// what a deployment gets with no custom cost model plugged in, the same - /// default [`crate::replacement::SketchAlgorithmStrategy::default_cost_model`] + /// default [`crate::replacement::ASAPStrategies::default_cost_model`] /// offers. pub fn default_cost_model() -> Self { Self { @@ -144,7 +145,7 @@ impl HydraGroupingStrategy<'static> { impl<'a> HydraGroupingStrategy<'a> { /// A strategy that ranks/binds via `cost_model` instead of the built-in /// static preference order — the same customization point - /// [`crate::replacement::SketchAlgorithmStrategy::new`] already offers. + /// [`crate::replacement::ASAPStrategies::new`] already offers. pub fn new(cost_model: &'a dyn CostModel) -> Self { Self { planning_inputs: CandidatePlanningInputs::with_default_accuracy(cost_model), @@ -173,7 +174,7 @@ impl<'a> HydraGroupingStrategy<'a> { /// variant modeled. fn hydra_proposals(&self, target: &TargetSubDAG<'_>) -> Proposals { let mut proposals = Proposals::default(); - let QueryExpr::Aggregate { reduction, .. } = target.root.as_ref() else { + let Some(NonASAPOp::Aggregate { reduction, .. }) = target.root.non_asap() else { return proposals; }; if !has_subpopulations(reduction) { @@ -208,11 +209,11 @@ impl<'a> HydraGroupingStrategy<'a> { /// `PerSubpopulationInstance` to /// `SharedMultiSubpopulation { kind: hydra_kind, .. }` — reusing the /// entire bind decision procedure (schema derivation, column resolution, - /// readout construction) unchanged, patching only the one field this + /// evaluation construction) unchanged, patching only the one field this /// axis owns. fn build_candidate( &self, - root: &Rc, + root: &Rc, intent: &AggIntent, sketch_kind: SketchAlgorithm, hydra_kind: HydraKind, @@ -233,12 +234,12 @@ impl<'a> HydraGroupingStrategy<'a> { params, }; - let (family, query) = match &node.expr { - SummaryExpr::SummaryEstimate { + let (family, query) = match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } => match &summary_input.expr { - SummaryExpr::SummaryAgg { family, .. } => (family, Some(query)), + }) => match &summary_input.operator { + Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => (family, Some(query)), _ => return None, }, _ => return None, @@ -295,7 +296,7 @@ impl<'a> HydraGroupingStrategy<'a> { } Some(ReplacementSubDAG { strategy: "HydraGroupingStrategy", - replacement: Replacement::Summary(patched), + replacement: Replacement::SubDAG(patched), provenance: crate::replacement::ReplacementProvenance::SummaryRealization, rationale: format!( "{} realizes as a shared {hydra_kind:?} structure over {sketch_kind:?} \ @@ -313,7 +314,7 @@ impl<'a> HydraGroupingStrategy<'a> { impl ReplacementStrategy for HydraGroupingStrategy<'_> { fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - let QueryExpr::Aggregate { reduction, .. } = target.root.as_ref() else { + let Some(NonASAPOp::Aggregate { reduction, .. }) = target.root.non_asap() else { return false; }; if !has_subpopulations(reduction) { @@ -357,15 +358,15 @@ impl ReplacementStrategy for HydraGroupingStrategy<'_> { /// destructures the right variant for `kind`; this function's only job is /// to find whatever `SketchParams` the bind decision already committed to /// and hand the whole thing over unchanged. -fn per_subpopulation_sketch_params(node: &SummaryNode) -> Option { - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => { +fn per_subpopulation_sketch_params(node: &OperatorNode) -> Option { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { per_subpopulation_sketch_params(summary_input) } - SummaryExpr::SummaryAgg { + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } => Some(kind.params().clone()), + }) => Some(kind.params().clone()), _ => None, } } @@ -373,33 +374,35 @@ fn per_subpopulation_sketch_params(node: &SummaryNode) -> Option { /// Rebuild `node`, replacing its `SummaryAgg`'s `grouping` field with /// `grouping` — patching the one field this axis owns onto an /// already-correctly-bound node rather than re-deriving the rest of it. -/// Recurses through a `SummaryEstimate` readout wrapper (the shape every +/// Recurses through a `SummaryEstimate` evaluation wrapper (the shape every /// sketch candidate this module builds actually has) to reach the /// `SummaryAgg` underneath. fn with_grouping( - node: Rc, + node: Rc, grouping: GroupingStrategy, stats: &PropagationStats, -) -> Rc { - match &node.expr { - SummaryExpr::SummaryEstimate { +) -> Rc { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } => Rc::new(SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input: with_grouping(Rc::clone(summary_input), grouping, stats), - query: query.clone(), - }, - schema: node.schema.clone(), - guarantee: node.guarantee.as_ref().map(|g| hydra_guarantee(g, stats)), - }), - SummaryExpr::SummaryAgg { + }) => std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: with_grouping(Rc::clone(summary_input), grouping, stats), + query: query.clone(), + }), + node.schema.clone(), + ) + .with_guarantee(node.guarantee.as_ref().map(|g| hydra_guarantee(g, stats))), + ), + Operator::ASAP(ASAPOp::SummaryAgg { child, family, input, reduction, .. - } => { + }) => { let grouped_family = match family { FieldDataType::Sketch(kind, _) => { FieldDataType::Sketch(kind.clone(), grouping.clone()) @@ -412,18 +415,20 @@ fn with_grouping( field.dtype = FieldDataType::Sketch(kind.clone(), grouping.clone()); } } - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: Rc::clone(child), - family: grouped_family, - input: input.clone(), - reduction: reduction.clone(), - grouping, - filter: None, - }, - schema: grouped_schema, - guarantee: None, - }) + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: Rc::clone(child), + family: grouped_family, + input: input.clone(), + reduction: reduction.clone(), + grouping, + filter: None, + }), + grouped_schema, + ) + .with_guarantee(None), + ) } // Never reached by this module's own callers (they only ever pass a // node `construct_summary_with` just bound for a `Sketch` @@ -492,51 +497,11 @@ fn hydra_guarantee(inner: &ResultGuarantee, stats: &PropagationStats) -> ResultG mod tests { use super::*; use crate::accuracy::{DefaultAccuracyModel, EqualSplitAllocator}; + use crate::test_support::{agg, agg_per_entity, metric_scan}; use asap_types::post_asap::ErrorMetric; use asap_types::pre_asap::agg_intent::{default_cardinality, default_quantile}; - use asap_types::pre_asap::query_expr::Source; - use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; - fn metric_scan(labels: &[&str]) -> QueryExpr { - 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)), - ); - QueryExpr::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index(columns, 0, vec![]), - } - } - - fn agg(by: Vec, intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { - reduction: Reduction::by(by), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(child), - } - } - - fn agg_per_entity(intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(child), - } - } - // ── has_subpopulations ──────────────────────────────────────────────── #[test] @@ -556,7 +521,7 @@ mod tests { #[test] fn without_grouping_has_a_subpopulation_concept_even_when_empty() { - use asap_types::pre_asap::query_expr::GroupKeys; + use asap_types::ir::operator_properties::GroupKeys; // `without([])` groups by every remaining label — a real // subpopulation concept, unlike `by([])`'s genuine full reduction. assert!(has_subpopulations(&Reduction::Reduce(GroupKeys::without( @@ -603,7 +568,7 @@ mod tests { delta: 0.01, }, }; - let q = Rc::new(agg(vec![2], intent, metric_scan(&["job"]))); + let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); assert!(HydraGroupingStrategy::default_cost_model().matches(&target)); } @@ -611,7 +576,7 @@ mod tests { #[test] fn does_not_match_an_empty_by_aggregate() { // Global reduction — no subpopulation concept, no Hydra alternative. - let q = Rc::new(agg(vec![], default_quantile(0.99), metric_scan(&["job"]))); + let q = agg(vec![], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let strategy = HydraGroupingStrategy::default_cost_model(); assert!(!strategy.matches(&target)); @@ -620,10 +585,7 @@ mod tests { #[test] fn does_not_match_a_per_entity_aggregate() { - let q = Rc::new(agg_per_entity( - default_quantile(0.99), - metric_scan(&["job"]), - )); + let q = agg_per_entity(default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let strategy = HydraGroupingStrategy::default_cost_model(); assert!(!strategy.matches(&target)); @@ -632,14 +594,14 @@ mod tests { #[test] fn does_not_match_a_non_aggregate_node() { - let scan = Rc::new(metric_scan(&["job"])); + let scan = metric_scan(&["job"]); let target = TargetSubDAG::new(&scan); assert!(!HydraGroupingStrategy::default_cost_model().matches(&target)); } #[test] fn quantile_has_no_hydra_candidate_without_a_modeled_error_bound() { - let q = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let replacements = HydraGroupingStrategy::default_cost_model().replacements(&target); assert!(replacements.is_empty(), "{replacements:?}"); @@ -653,13 +615,13 @@ mod tests { delta: 0.01, }, }; - let q = Rc::new(agg(vec![2], intent, metric_scan(&["job"]))); + let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let replacements = HydraGroupingStrategy::default_cost_model().replacements(&target); assert_eq!(replacements.len(), 2, "{replacements:?}"); assert!(replacements.iter().all(|candidate| matches!( &candidate.replacement, - Replacement::Summary(node) + Replacement::SubDAG(node) if node.guarantee.as_ref().is_some_and(|guarantee| guarantee.bound.evaluate().is_none() && guarantee.failure_probability.evaluate().is_none()) @@ -691,7 +653,7 @@ mod tests { delta: 0.01, }, }; - let q = Rc::new(agg(vec![2], intent, metric_scan(&["job"]))); + let q = agg(vec![2], intent, metric_scan(&["job"])); let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, @@ -702,7 +664,7 @@ mod tests { assert_eq!(replacements.len(), 2, "{replacements:?}"); assert!(replacements.iter().all(|candidate| matches!( &candidate.replacement, - Replacement::Summary(node) + Replacement::SubDAG(node) if node.guarantee.as_ref().is_some_and(|g| g.bound.evaluate().is_some() && g.failure_probability.evaluate().is_some()) @@ -731,7 +693,7 @@ mod tests { delta: 0.01, }, }; - let q = Rc::new(agg(vec![2], intent, metric_scan(&["job"]))); + let q = agg(vec![2], intent, metric_scan(&["job"])); let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, @@ -771,7 +733,7 @@ mod tests { } } } - let q = Rc::new(agg( + let q = agg( vec![2], AggIntent::Count { accuracy: AccuracyTarget::EpsilonDelta { @@ -780,7 +742,7 @@ mod tests { }, }, metric_scan(&["job"]), - )); + ); let strategy = HydraGroupingStrategy::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, @@ -801,7 +763,7 @@ mod tests { // summary_candidates(Cardinality) = [Hll, Theta, Kmv] — none have a // modeled Hydra variant, so no candidate at all (not an error, just // an empty result, same conservatism as every other strategy here). - let q = Rc::new(agg(vec![2], default_cardinality(), metric_scan(&["job"]))); + let q = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let strategy = HydraGroupingStrategy::default_cost_model(); assert!(!strategy.matches(&target)); @@ -817,7 +779,7 @@ mod tests { q: 0.99, accuracy: AccuracyTarget::Exact, }; - let q = Rc::new(agg(vec![2], intent, metric_scan(&["job"]))); + let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let strategy = HydraGroupingStrategy::default_cost_model(); assert!(!strategy.matches(&target)); @@ -828,11 +790,7 @@ mod tests { fn exact_mergeable_intent_has_no_hydra_candidate() { // Sum's exact accumulator has no candidate summary families at all // (summary_candidates only covers approximate-capable intents). - let q = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let strategy = HydraGroupingStrategy::default_cost_model(); assert!(!strategy.matches(&target)); @@ -843,14 +801,16 @@ mod tests { fn does_not_match_a_multi_intent_or_having_aggregate() { let strategy = HydraGroupingStrategy::default_cost_model(); - let multi = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(metric_scan(&["job"])), - }); + let multi = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![2]), + measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], + output_names: vec![], + filters: vec![], + having: None, + child: metric_scan(&["job"]), + })) + .unwrap(); let target = TargetSubDAG::new(&multi); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); @@ -859,7 +819,7 @@ mod tests { /// A custom `CostModel` doesn't change *which* candidate is offered — /// only which sketch candidate `realizations_for_intent` itself would /// have ranked first, and how that candidate's own params are sized — - /// same guarantee `SketchAlgorithmStrategy` makes for its own candidates. + /// same guarantee `ASAPStrategies` makes for its own candidates. struct PreferDDSketch; impl CostModel for PreferDDSketch { fn rank_candidates( @@ -878,7 +838,7 @@ mod tests { #[test] fn custom_cost_model_cannot_enable_unproven_hydra_kll() { - let q = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let custom = PreferDDSketch; let replacements = HydraGroupingStrategy::new(&custom).replacements(&target); diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 95e50a552..16e6e6118 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -1,18 +1,18 @@ //! `asap-plan` — the cost-aware optimizer layer over the pre-ASAP intent algebra. //! //! This crate sits between the language-agnostic IR ([`asap_ir`]) and -//! any runtime: it consumes pre-ASAP [`QueryExpr`](asap_types::pre_asap::QueryExpr) +//! any runtime: it consumes pre-ASAP [`OperatorNode`](asap_types::ir::OperatorNode) //! DAGs and makes the cost-aware decisions the pre-ASAP IR deliberately //! leaves open — which sketch (if any) realises each approximate intent. //! //! **Common sub-expression elimination (CSE) is not this crate's job.** -//! Detection is a primary pass over the pre-ASAP `QueryExpr` IR itself -//! (`asap_types::pre_asap`, design tracked in issue #223), run before a -//! DAG ever reaches [`replacement::SketchAlgorithmStrategy`] — see issue #222 +//! Detection is a primary pass over the pre-ASAP operator IR itself +//! (`asap_types::ir::cse`, design tracked in issue #223), run before a +//! tree ever reaches [`replacement::ASAPStrategies`] — see issue #222 //! for why (batch query optimization needs to see shared work across a //! `QueryWorkload` before summary binding, not after). This crate may //! eventually run a second, narrower CSE pass of its own over an -//! already-bound `SummaryExpr`/`SummaryNode` DAG, recognizing sharing that's invisible +//! already-bound post-ASAP `OperatorNode` DAG, recognizing sharing that's invisible //! at the pre-ASAP level by construction — e.g. `Quantile(x, 0.99)` and //! `Quantile(x, 0.95)` are structurally distinct `AggIntent`s but can //! still share one built sketch, read out twice. That post-ASAP pass is @@ -92,7 +92,7 @@ //! #33) is an additional `ReplacementStrategy`: the orthogonal //! `GroupingStrategy` axis (one summary instance per `by` subpopulation //! versus one shared Hydra-family structure serving all of them), offered -//! alongside the candidates [`replacement::SketchAlgorithmStrategy`] +//! alongside the candidates [`replacement::ASAPStrategies`] //! enumerates for the same target. //! - [`rewrite`] — the "semantic-equivalent rewriting (e.g. `avg` → //! `sum`/`count`) to increase how often the [sharing/sketch] optimizations @@ -117,7 +117,7 @@ //! |---|---|---| //! | Schema resolution | Derive input schemas and resolve column names to positions | `asap_types::pre_asap::SchemaResolver::resolve_schema`, `resolve_root` | //! | Realization | Enumerate ranked physical forms for one aggregate intent | `replacement::realizations_for_intent` | -//! | Replacement | Construct each candidate summary sub-DAG | [`replacement::SketchAlgorithmStrategy`] | +//! | Replacement | Construct each candidate summary sub-DAG | [`replacement::ASAPStrategies`] | //! | Search | Enumerate and compare alternatives across a workload | [`replacement::search_workload`] | //! | Runtime placement | Choose deployment locations and concrete executors | Downstream physical plan providers | //! @@ -207,12 +207,12 @@ pub use recurrence::{ UpdateRate, }; pub use replacement::{ - default_strategies, default_strategies_with, search_workload, search_workload_with, - search_workload_with_targets, summary_candidates, CandidateLogicalASAPDAGs, - CompositionDecision, GlobalSelection, Matcher, Proposals, RankedTargetSubDAGCandidates, - Realization, RealizationError, RecurrenceProfileMap, RejectedCandidate, Replacement, - ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, SharedSubDAGStrategy, - SketchAlgorithmStrategy, TargetSubDAG, TargetSubDAGCandidates, TargetSubDAGSelection, + default_strategies, default_strategies_with, is_logical_rewrite, search_workload, + search_workload_with, search_workload_with_targets, summary_candidates, ASAPStrategies, + CandidateLogicalASAPDAGs, CompositionDecision, GlobalSelection, Matcher, Proposals, + RankedTargetSubDAGCandidates, Realization, RealizationError, RecurrenceProfileMap, + RejectedCandidate, Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, + SharedSubDAGStrategy, TargetSubDAG, TargetSubDAGCandidates, TargetSubDAGSelection, MAX_SEARCH_ITERATIONS, }; pub use rewrite::{AvgToSumOverCountStrategy, SemanticEquivalentRewriteStrategy}; diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index b749a6967..cb1236fd7 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -2,22 +2,14 @@ use crate::replacement::{ Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; -use asap_types::post_asap::{ - maintained_population::*, ExecutionTiming, ResultGuarantee, SummaryExpr, SummaryNode, - ValueOperation, -}; -use asap_types::pre_asap::{ - any_measure_filtered, AggIntent, CompareOpKind, DataType, QueryExpr, Reduction, ScalarValue, - Schema, Source, -}; +use asap_types::ir::non_asap::any_measure_filtered; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; +use asap_types::post_asap::{maintained_population::*, ResultGuarantee}; +use asap_types::pre_asap::{AggIntent, CompareOpKind, DataType, Reduction, ScalarValue, Source}; use std::rc::Rc; -fn plain(schema: Schema) -> Schema { - Schema::lifted(schema.fields, schema.time_index) -} - -fn strip_projection(mut root: &QueryExpr) -> &QueryExpr { - while let QueryExpr::Project { child, .. } = root { +fn strip_projection(mut root: &OperatorNode) -> &OperatorNode { + while let Some(NonASAPOp::Project { child, .. }) = root.non_asap() { root = child; } root @@ -26,17 +18,20 @@ fn strip_projection(mut root: &QueryExpr) -> &QueryExpr { /// Skips a projection that keeps every column in place, such as the one /// `SELECT *` lowers to: it changes neither the rows nor the column positions /// a Sort key refers to. -fn strip_identity_projection(expr: &Rc) -> &Rc { - if let QueryExpr::Project { +fn strip_identity_projection(expr: &Rc) -> &Rc { + if let Some(NonASAPOp::Project { cols, qualifier: None, child, - } = expr.as_ref() + }) = expr.non_asap() { - let width = child.output_schema().map(|schema| schema.fields.len()); + let width = child + .operator + .output_schema() + .map(|schema| schema.fields.len()); if width.ok() == Some(cols.len()) && cols.iter().enumerate().all(|(i, item)| { - item.alias.is_none() && matches!(item.expr, QueryExpr::Column(c) if c == i) + item.alias.is_none() && matches!(item.expr, ScalarExpr::Column(c) if c == i) }) { return child; @@ -46,11 +41,11 @@ fn strip_identity_projection(expr: &Rc) -> &Rc { } fn recognize( - root: &QueryExpr, -) -> Option<(MaintainedPopulation, PopulationStatistic, Rc)> { + root: &OperatorNode, +) -> Option<(MaintainedPopulation, PopulationStatistic, Rc)> { let root = strip_projection(root); - let (source, grouping, readout, value_column) = match root { - QueryExpr::Aggregate { + let (source, grouping, evaluation, value_column) = match root.non_asap()? { + NonASAPOp::Aggregate { child, reduction: Reduction::Reduce(grouping), measures, @@ -64,7 +59,7 @@ fn recognize( if any_measure_filtered(filters) { return None; } - let (col, readout) = match intent { + let (col, evaluation) = match intent { AggIntent::Quantile { q, col, .. } if q.is_finite() => { (*col, PopulationStatistic::Quantile { q: *q }) } @@ -74,29 +69,30 @@ fn recognize( AggIntent::Avg { col } => (*col, PopulationStatistic::Average), _ => return None, }; - let schema = child.output_schema().ok()?; + let schema = &child.schema; if col.is_some_and(|c| schema.fields.get(c).is_none()) { return None; } - (child, grouping, readout, col) + (child, grouping, evaluation, col) } - QueryExpr::Limit { - n, + NonASAPOp::Limit { + n: Some(n), offset: 0, child, + .. } => { - let QueryExpr::Sort { + let Some(NonASAPOp::Sort { child, keys, partition_by, - } = child.as_ref() + }) = child.non_asap() else { return None; }; let [key] = keys.as_slice() else { return None; }; - let QueryExpr::Column(col) = &key.expr else { + let ScalarExpr::Column(col) = &key.expr else { return None; }; if key.ascending { @@ -111,11 +107,11 @@ fn recognize( } _ => return None, }; - if let QueryExpr::Scan { + if let Some(NonASAPOp::Scan { source: Source::Table { .. }, schema, .. - } = source.as_ref() + }) = source.non_asap() { let value_column = value_column.or_else(|| { schema @@ -132,29 +128,29 @@ fn recognize( max_k: 0, quantiles: false, }; - if !schema.closed || !population.matches_input(source) { + if !schema.closed || !population.matches_node(source) { return None; } - return Some((population, readout, Rc::clone(source))); + return Some((population, evaluation, Rc::clone(source))); } // A bare PromQL selector carries the declared ingestion interval as a // temporal input scope. Membership must expire at that horizon; retain // the wrapper as the maintained input so validation can check agreement. - let (series_source, lookback_ms) = match source.as_ref() { - QueryExpr::TimeRange { range, child } => { + let (series_source, lookback_ms) = match source.non_asap() { + Some(NonASAPOp::TimeRange { range, child, .. }) => { let ms = u64::try_from(range.as_millis()).ok()?; if ms == 0 || std::time::Duration::from_millis(ms) != *range { return None; } (child.as_ref(), ms) } - other => (other, 300_000), + _ => (source.as_ref(), 300_000), }; - let QueryExpr::Scan { + let Some(NonASAPOp::Scan { source: Source::TimeSeries { metric }, predicates, schema, - } = series_source + }) = series_source.non_asap() else { return None; }; @@ -174,10 +170,13 @@ fn recognize( }; let mut matchers = Vec::new(); for predicate in predicates { - let QueryExpr::Compare { left, op, right } = predicate.0.as_ref() else { + let ScalarExpr::Compare { + left, op, right, .. + } = &predicate.0 + else { return None; }; - let (QueryExpr::Column(col), QueryExpr::Literal(ScalarValue::Utf8(value))) = + let (ScalarExpr::Column(col), ScalarExpr::Literal(ScalarValue::Utf8(value))) = (left.as_ref(), right.as_ref()) else { return None; @@ -216,46 +215,46 @@ fn recognize( max_k: 0, quantiles: false, }, - readout, + evaluation, Rc::clone(source), )) } -/// Workload-aware rule: compatible readouts share one retractable population. +/// Workload-aware rule: compatible evaluations share one retractable population. /// Deployments opt in by registering this strategy when they can maintain complete -/// population updates and price the maintenance/readout boundary. -/// The population is exact; max_k bounds the shared readout cache, not its members. +/// population updates and price the maintenance/evaluation boundary. +/// The population is exact; max_k bounds the shared evaluation cache, not its members. pub struct MaintainedPopulationStrategy { - roots: Vec>, + roots: Vec>, } impl MaintainedPopulationStrategy { - pub fn new(roots: &[Rc]) -> Self { + pub fn new(roots: &[Rc]) -> Self { Self { roots: roots.to_vec(), } } - pub fn candidate(&self, root: &Rc) -> Option> { - if let QueryExpr::Project { + pub fn candidate(&self, root: &Rc) -> Option> { + if let Some(NonASAPOp::Project { cols, qualifier, child, - } = root.as_ref() + }) = root.non_asap() { let child = self.candidate(child)?; - return Some(Rc::new(SummaryNode { - guarantee: child.guarantee.clone(), - schema: plain(root.output_schema().ok()?), - expr: SummaryExpr::ValueOperation { - child, - operation: ValueOperation::Project { + let guarantee = child.guarantee.clone(); + return Some(Rc::new( + OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::Project { cols: cols.clone(), qualifier: qualifier.clone(), - }, - timing: ExecutionTiming::QueryTime, - }, - })); + child, + }), + root.schema.clone(), + ) + .with_guarantee(guarantee), + )); } - let (mut population, readout, source) = recognize(root)?; + let (mut population, evaluation, source) = recognize(root)?; let identity = population.clone(); for other in self.roots.iter().chain(std::iter::once(root)) { if let Some((p, r, _)) = recognize(other) { @@ -272,36 +271,39 @@ impl MaintainedPopulationStrategy { } } } - let input_schema = plain(source.output_schema().ok()?); - let scan = Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(source), - schema: input_schema.clone(), - guarantee: Some(ResultGuarantee::exact("source samples")), - }); - // Query time is only the initial layout: whether the population is - // retained at ingestion or rebuilt per query is its lifecycle choice - // (`SummaryMaintenanceLifecyclePlan::execution_timed_dag`). The readout - // and projection above it are query-time by construction. - let maintained = Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child: scan, - operation: ValueOperation::MaintainPopulation { population }, - timing: ExecutionTiming::QueryTime, - }, - schema: input_schema, - guarantee: Some(ResultGuarantee::exact( + let input_schema = source.schema.clone(); + // The source node itself is the maintained input (a non-ASAP node + // keeps its derived schema), kept with its exact guarantee. + let scan = Rc::new( + source + .as_ref() + .clone() + .with_guarantee(Some(ResultGuarantee::exact("source samples"))), + ); + let maintained = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::MaintainPopulation { + child: scan, + population, + }), + input_schema, + ) + .with_guarantee(Some(ResultGuarantee::exact( "exact members under the declared population semantics", - )), - }); - Some(Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child: maintained, - operation: ValueOperation::ReadPopulation { readout }, - timing: ExecutionTiming::QueryTime, - }, - schema: plain(root.output_schema().ok()?), - guarantee: Some(ResultGuarantee::exact("exact current-population readout")), - })) + ))), + ); + Some(std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::EvaluatePopulation { + child: maintained, + evaluation, + }), + root.schema.clone(), + ) + .with_guarantee(Some(ResultGuarantee::exact( + "exact current-population evaluation", + ))), + )) } } impl ReplacementStrategy for MaintainedPopulationStrategy { @@ -312,10 +314,10 @@ impl ReplacementStrategy for MaintainedPopulationStrategy { self.candidate(target.root) .map(|node| ReplacementSubDAG { strategy: "MaintainedPopulationStrategy", - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), provenance: ReplacementProvenance::SummaryRealization, rationale: - "share an exact maintained population across compatible aggregate readouts" + "share an exact maintained population across compatible aggregate evaluations" .into(), }) .into_iter() @@ -327,10 +329,26 @@ impl ReplacementStrategy for MaintainedPopulationStrategy { mod tests { use super::*; use crate::test_support::lower_promql; - use asap_types::post_asap::{compile_post_asap_dag, share_common_summary_sub_dags}; + use asap_types::ir::cse::share_common_sub_dags; + use asap_types::ir::physical_export::compile_physical_asap_dag as export_timed; + use asap_types::ir::timing::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; - fn lower(q: &str) -> Rc { - Rc::new(lower_promql(q, asap_types::types::AccuracyTarget::Exact)) + /// Time `root` under the default lifecycle assignment (which runs the + /// data-state / population-contract validation) and export it. + fn compile_physical_asap_dag(root: &Rc) -> Result<(), String> { + root.validate_structure().map_err(|e| e.to_string())?; + let timed = apply_lifecycle_timings( + root, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .map_err(|e| format!("{e:?}"))?; + export_timed(&timed).map_err(|e| format!("{e:?}"))?; + Ok(()) + } + + fn lower(q: &str) -> Rc { + lower_promql(q, asap_types::types::AccuracyTarget::Exact) } // Instant scalar aggregations share the same retractable series population. @@ -351,11 +369,11 @@ mod tests { let candidate = rule .candidate(&root) .expect("current-series rule candidate"); - compile_post_asap_dag(&candidate).expect("typed post-ASAP DAG"); + compile_physical_asap_dag(&candidate).expect("typed post-ASAP DAG"); } } - // Different readout parameters retain one shared maintenance producer in the DAG. + // Different evaluation parameters retain one shared maintenance producer in the DAG. #[test] fn quantiles_and_topk_share_a_planner_population() { let roots: Vec<_> = [ @@ -379,7 +397,7 @@ mod tests { .target_subdag_candidates() .flat_map(|g| &g.candidates) .any(|c| c.strategy == "MaintainedPopulationStrategy")); - let plans = share_common_summary_sub_dags( + let plans = share_common_sub_dags( roots .iter() .enumerate() @@ -388,19 +406,11 @@ mod tests { ); let mut producers = Vec::new(); for (_, plan) in &plans { - compile_post_asap_dag(plan).unwrap(); - let SummaryExpr::ValueOperation { - child, - operation: ValueOperation::ReadPopulation { .. }, - .. - } = &plan.expr - else { - panic!("missing typed readout") + compile_physical_asap_dag(plan).unwrap(); + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &plan.operator else { + panic!("missing typed evaluation") }; - let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, - .. - } = &child.expr + let Operator::ASAP(ASAPOp::MaintainPopulation { population, .. }) = &child.operator else { panic!("missing maintained population") }; @@ -426,14 +436,10 @@ mod tests { let (p, _, _) = recognize(&roots[0]).unwrap(); assert!(matches!(p.input, PopulationInput::CurrentSeries(ref s) if s.grouping.is_empty())); let candidate = strategy.candidate(&roots[0]).unwrap(); - let SummaryExpr::ValueOperation { child, .. } = &candidate.expr else { + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &candidate.operator else { unreachable!() }; - let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, - .. - } = &child.expr - else { + let Operator::ASAP(ASAPOp::MaintainPopulation { population, .. }) = &child.operator else { unreachable!() }; assert_eq!(population.max_k, 5); @@ -460,58 +466,50 @@ mod tests { assert_eq!(p.matchers[0].operation, CurrentSeriesMatch::Regex); } // Population timing is a lifecycle choice: a retained or rebuilt - // population both validate, while its readout must stay at query time. + // population both validate, while its evaluation must stay at query time. #[test] fn population_timing_is_not_structural() { let root = lower("topk(5,a)"); let candidate = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)) .candidate(&root) .unwrap(); - let with_timings = |population: ExecutionTiming, readout: ExecutionTiming| { + use asap_types::post_asap::ExecutionTiming; + let with_timings = |population: ExecutionTiming, evaluation: ExecutionTiming| { let mut node = (*candidate).clone(); - let SummaryExpr::ValueOperation { child, timing, .. } = &mut node.expr else { - unreachable!() - }; - *timing = readout; - let SummaryExpr::ValueOperation { timing, .. } = &mut Rc::make_mut(child).expr else { + node.timing = Some(evaluation); + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &mut node.operator + else { unreachable!() }; - *timing = population; - compile_post_asap_dag(&Rc::new(node)) + Rc::make_mut(child).timing = Some(population); + compile_physical_asap_dag(&Rc::new(node)) }; use ExecutionTiming::{IngestionTime, QueryTime}; assert!(with_timings(IngestionTime, QueryTime).is_ok()); assert!(with_timings(QueryTime, QueryTime).is_ok()); assert!(with_timings(IngestionTime, IngestionTime).is_err()); } - // A readout cannot reinterpret arbitrary rows as maintained state or exceed its producer's contract. + // A evaluation cannot reinterpret arbitrary rows as maintained state or exceed its producer's contract. #[test] fn malformed_population_dags_fail_closed() { let root = lower("topk(5,a)"); let strategy = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)); let candidate = strategy.candidate(&root).unwrap(); + compile_physical_asap_dag(&candidate).expect("the unmodified candidate is legal"); let mut bad = (*candidate).clone(); - let SummaryExpr::ValueOperation { operation, .. } = &mut bad.expr else { + let Operator::ASAP(ASAPOp::EvaluatePopulation { evaluation, .. }) = &mut bad.operator + else { unreachable!() }; - *operation = ValueOperation::ReadPopulation { - readout: PopulationStatistic::TopK { k: 6 }, - }; - assert!(compile_post_asap_dag(&Rc::new(bad.clone())).is_err()); - let SummaryExpr::ValueOperation { - child, operation, .. - } = &mut bad.expr + *evaluation = PopulationStatistic::TopK { k: 6 }; + assert!(compile_physical_asap_dag(&Rc::new(bad.clone())).is_err()); + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, evaluation }) = &mut bad.operator else { unreachable!() }; - *operation = ValueOperation::ReadPopulation { - readout: PopulationStatistic::TopK { k: 5 }, - }; + *evaluation = PopulationStatistic::TopK { k: 5 }; let producer = Rc::make_mut(child); - let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, - .. - } = &mut producer.expr + let Operator::ASAP(ASAPOp::MaintainPopulation { population, .. }) = &mut producer.operator else { unreachable!() }; @@ -519,6 +517,6 @@ mod tests { unreachable!() }; spec.metric = "b".into(); - assert!(compile_post_asap_dag(&Rc::new(bad)).is_err()); + assert!(compile_physical_asap_dag(&Rc::new(bad)).is_err()); } } diff --git a/crates/asap-aware-mapping/src/pane_sharing.rs b/crates/asap-aware-mapping/src/pane_sharing.rs index b5f5bff3a..0d944b2da 100644 --- a/crates/asap-aware-mapping/src/pane_sharing.rs +++ b/crates/asap-aware-mapping/src/pane_sharing.rs @@ -1,6 +1,6 @@ //! Costed reuse of compatible physical pane producers. The executor supplies //! an equality key covering source, state, phase and evidence. This pass never -//! changes logical readout windows or assumes compatibility from metric names. +//! changes logical evaluation windows or assumes compatibility from metric names. /// A concrete mergeable-pane implementation and its horizon costs. #[derive(Debug, Clone)] @@ -9,9 +9,9 @@ pub struct PaneReuseCandidate { pub lookback_ms: u64, /// Build, update, residency and retirement for this producer. Candidates /// with the same key must use the same unit costs and pane width, making - /// the longest-lived producer sufficient for every readout in the group. + /// the longest-lived producer sufficient for every evaluation in the group. pub producer_cost: f64, - /// Readout cost for all consumers of this distinct producer. + /// Evaluation cost for all consumers of this distinct producer. pub read_cost: f64, } @@ -84,7 +84,7 @@ mod tests { read_cost: 2.0, } } - // Share source work once while retaining both readout charges and longest history. + // Share source work once while retaining both evaluation charges and longest history. #[test] fn shares_compatible_windows() { assert_eq!( diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index 3671ece58..7be53e0f0 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -6,10 +6,10 @@ //! it *one* pass rather than *the* algorithm. `ReplacementStrategy` is //! therefore a concept of this pass, not of the optimization interface. +use asap_types::ir::cse::share_common_sub_dags; use std::rc::Rc; -use asap_types::post_asap::{share_common_summary_sub_dags, SummaryNode}; -use asap_types::pre_asap::query_expr::QueryExpr; +use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; use super::{OptimizationInput, OptimizationPass, OptimizeError, PlanOutput, QueryLifecyclePlan}; @@ -39,10 +39,10 @@ impl OptimizationPass for MajorPass { // search result carries the workload binding the lifecycle stage and // the output both need. CSE may make two identical queries share one // `Rc`, but it never drops or reorders a root, so this stays aligned. - let roots: Vec<(usize, Rc, Option)> = workload + let roots: Vec<(usize, Rc, Option)> = workload .entries() - .enumerate() - .map(|(index, (entry, expr))| { + .zip(workload.operator_indices().iter().copied()) + .map(|((entry, expr), index)| { ( index, Rc::clone(expr), @@ -57,7 +57,7 @@ impl OptimizationPass for MajorPass { // One index per root, in `CandidateLogicalASAPDAGs::roots` order — which is the order // the roots went in, which is `entries()` order. - let entry_indices: Vec = (0..workload.len()).collect(); + let entry_indices = workload.operator_indices().to_vec(); let demand = WorkloadDemand { workload: workload.query_workload(), data_workload: workload.data_workload(), @@ -94,8 +94,7 @@ impl OptimizationPass for MajorPass { .ok_or_else(|| self.missing_group(*entry_index))?; assembled.push(dag); } - let interned = - share_common_summary_sub_dags(assembled.iter().cloned().enumerate().collect()); + let interned = share_common_sub_dags(assembled.iter().cloned().enumerate().collect()); let states: Vec<_> = interned .iter() .map(|(_, dag)| summary_states(dag)) @@ -105,7 +104,7 @@ impl OptimizationPass for MajorPass { // their entries, in every plan that reaches it, so each plan picks // the same lifecycle for it. When that union cannot be costed the // roots keep their own, unshared DAG and entries. - let mut shared_entries: Vec<(Rc, Option>)> = Vec::new(); + let mut shared_entries: Vec<(Rc, Option>)> = Vec::new(); for (position, (entry_index, root)) in space.roots.iter().enumerate() { for state in &states[position] { if shared_entries.iter().any(|(s, _)| Rc::ptr_eq(s, state)) { @@ -183,7 +182,9 @@ impl OptimizationPass for MajorPass { plan, }); } - Ok(PlanOutput::new(plans)) + let mut output = PlanOutput::new(plans); + output.scalar_roots = workload.scalar_roots().to_vec(); + Ok(output) } } diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index a0b993d97..f9dd0ac2d 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -17,8 +17,8 @@ mod major; use std::collections::BTreeMap; use std::rc::Rc; +use asap_types::ir::OperatorNode; use asap_types::parsed_workload::ParsedWorkload; -use asap_types::post_asap::SummaryNode; use asap_types::workload::WorkloadError; use crate::accuracy::{ @@ -173,7 +173,7 @@ pub struct QueryLifecyclePlan { pub plan: SummaryMaintenanceLifecyclePlan, } -/// One plan per workload entry, in `QueryWorkload::entries()` order; +/// One multi-root workload DAG with query/lifecycle bindings in entry order; /// [`check_contract`] enforces that. /// /// Plans are not deduplicated across entries: a summary state that several @@ -184,25 +184,83 @@ pub struct QueryLifecyclePlan { #[non_exhaustive] pub struct PlanOutput { pub plans: Vec, + /// Exact scalar expressions, keyed by workload entry; embedded plan reads remain visible. + pub scalar_roots: Vec<(usize, asap_types::ir::ScalarExpr)>, } impl PlanOutput { pub fn new(plans: Vec) -> Self { - Self { plans } + Self { + plans, + scalar_roots: Vec::new(), + } } /// Entry indices in output order. pub fn entry_indices(&self) -> Vec { - self.plans.iter().map(|p| p.entry_index).collect() + let mut indices: Vec<_> = self + .plans + .iter() + .map(|p| p.entry_index) + .chain(self.scalar_roots.iter().map(|(i, _)| *i)) + .collect(); + if !self.scalar_roots.is_empty() { + indices.sort_unstable(); + } + indices + } + + /// All query roots in workload order, including standalone scalars. + pub fn roots(&self) -> Vec { + let mut roots: Vec<_> = self + .plans + .iter() + .map(|p| { + ( + p.entry_index, + asap_types::ir::QueryRoot::Operator(Rc::clone(&p.plan.root)), + ) + }) + .chain( + self.scalar_roots + .iter() + .map(|(i, expr)| (*i, asap_types::ir::QueryRoot::Scalar(expr.clone()))), + ) + .collect(); + roots.sort_by_key(|(i, _)| *i); + roots.into_iter().map(|(_, root)| root).collect() } - /// The selected DAG root per query. - pub fn dags(&self) -> Vec> { + /// The selected operator roots. Use `roots()` to include scalar queries. + pub fn operator_roots(&self) -> Vec> { self.plans.iter().map(|p| Rc::clone(&p.plan.root)).collect() } + /// Unique operators in the entire workload DAG, including scalar-plan dependencies. + /// Several query roots can reach the same operator; it is returned once. + pub fn operators(&self) -> Vec> { + let mut seen = std::collections::HashSet::new(); + let mut nodes = Vec::new(); + for root in self.roots() { + let inputs = match root { + asap_types::ir::QueryRoot::Operator(node) => vec![node], + asap_types::ir::QueryRoot::Scalar(expr) => { + expr.operator_refs().into_iter().cloned().collect() + } + }; + for input in inputs { + for node in OperatorNode::reachable(&input) { + if seen.insert(Rc::as_ptr(&node)) { + nodes.push(node); + } + } + } + } + nodes + } + pub fn len(&self) -> usize { - self.plans.len() + self.plans.len() + self.scalar_roots.len() } pub fn is_empty(&self) -> bool { diff --git a/crates/asap-aware-mapping/src/physical_operator_statistics.rs b/crates/asap-aware-mapping/src/physical_operator_statistics.rs index 7be1d292c..1d2b9ebb0 100644 --- a/crates/asap-aware-mapping/src/physical_operator_statistics.rs +++ b/crates/asap-aware-mapping/src/physical_operator_statistics.rs @@ -6,7 +6,9 @@ use std::collections::HashMap; -use asap_types::pre_asap::query_expr::{InfoMatcher, Predicate, Source}; +use asap_types::ir::operator_properties::{InfoMatcher, Source}; +use asap_types::ir::Predicate; + use asap_types::workload::{ DataArrival, DataWorkload, DurationMs, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, TimeSelection, TimestampMs, @@ -278,8 +280,8 @@ pub struct PartitionStatistics { /// is the authoritative operator vocabulary: every one of its variants has a /// matching statistics variant here. /// -/// This enum intentionally does not mirror either logical IR. `QueryExpr` and -/// `SummaryExpr` are inputs to physical lowering, and one logical node may +/// This enum intentionally does not mirror the logical IR. `OperatorNode`s +/// are inputs to physical lowering, and one logical node may /// expand into several physical nodes or choose among several algorithms. /// Physical configuration such as a Top-K limit or hash-join build side lives /// on `PhysicalOperator`; this enum contains only workload/catalog evidence diff --git a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs index 509e6a13a..d846f907b 100644 --- a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs +++ b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs @@ -2,8 +2,9 @@ use std::{cell::RefCell, rc::Rc}; -use asap_types::post_asap::{SketchAlgorithm, SummaryExpr, SummaryNode}; -use asap_types::pre_asap::{AggIntent, QueryExpr}; +use asap_types::ir::OperatorNode; +use asap_types::post_asap::SketchAlgorithm; +use asap_types::pre_asap::AggIntent; use asap_types::resources::CacheProfile; use crate::analytical_cost::{ @@ -40,7 +41,7 @@ pub struct PhysicalEvidenceSnapshot { /// operator. Post-ASAP summary operators need a physical plan provider because their /// implementation, placement, and retained-state layout are deployment /// choices; that provider must return the complete summary DAG, including any -/// embedded `KeepPreAsap` work. +/// non-ASAP work kept inside it. pub trait PlannerPhysicalPlanProvider { /// Atomically captures the comparison scope and evidence generation. fn capture_evidence_snapshot( @@ -57,7 +58,7 @@ pub trait PlannerPhysicalPlanProvider { fn summary_physical_dag( &self, snapshot: &PhysicalEvidenceSnapshot, - summary: &Rc, + summary: &Rc, target: &TargetSubDAG<'_>, ) -> Result; } @@ -91,7 +92,7 @@ pub struct PhysicalPlanCostModel<'a> { } struct CachedTargetEvidence { - root: Rc, + root: Rc, consumer_count: usize, snapshot: PhysicalEvidenceSnapshot, raw: PhysicalDAG, @@ -188,15 +189,14 @@ impl<'a> PhysicalPlanCostModel<'a> { Replacement::ExactComposition(_) => { return Err(AnalyticalCostError::UnsupportedCandidate) } - Replacement::Rewrite(query) => lower_query_physical_dag(query, scope, &evidence)?, - Replacement::Summary(summary) => match &summary.expr { - SummaryExpr::KeepPreAsap(query) => { - lower_query_physical_dag(query, scope, &evidence)? - } - _ => self - .provider - .summary_physical_dag(&snapshot, summary, target)?, - }, + // A sub-DAG without summary state is the planner's own query + // lowering; anything with summary state is deployment-provided. + Replacement::SubDAG(sub_dag) if !sub_dag.contains_asap() => { + lower_query_physical_dag(sub_dag, scope, &evidence)? + } + Replacement::SubDAG(summary) => self + .provider + .summary_physical_dag(&snapshot, summary, target)?, }; let resources = estimate_physical_dag_comparison( PhysicalDAGEstimateRequest { @@ -359,7 +359,8 @@ mod tests { use std::cell::Cell; use std::collections::HashMap; - use asap_types::pre_asap::{DataType, Field, QueryExpr, Reduction, Schema, Source}; + use asap_types::ir::{NonASAPOp, OperatorNode}; + use asap_types::pre_asap::{DataType, Field, Reduction, Schema, Source}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ DataArrival, DurationMs, QueryRecurrence, QueryTimeScope, TimeSelection, TimestampMs, @@ -405,8 +406,16 @@ mod tests { } } - fn query() -> Rc { - Rc::new(QueryExpr::Aggregate { + fn query() -> Rc { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), + })) + .unwrap(); + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![]), measures: vec![AggIntent::Count { accuracy: AccuracyTarget::Epsilon(0.01), @@ -414,14 +423,9 @@ mod tests { output_names: vec![], filters: vec![], having: None, - child: Rc::new(QueryExpr::Scan { - source: Source::Table { - table_ref: "events".into(), - }, - predicates: vec![], - schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), - }), - }) + child: scan, + })) + .unwrap() } fn scope() -> ComparisonScope { @@ -583,7 +587,7 @@ mod tests { fn summary_physical_dag( &self, snapshot: &PhysicalEvidenceSnapshot, - _summary: &Rc, + _summary: &Rc, _target: &TargetSubDAG<'_>, ) -> Result { assert_eq!(snapshot.version, "test-snapshot-1"); @@ -687,7 +691,7 @@ mod tests { version: "unused-base-v1".into(), }; let candidates = - crate::replacement::SketchAlgorithmStrategy::default_cost_model().replacements(&target); + crate::replacement::ASAPStrategies::default_cost_model().replacements(&target); provider.storage_io = Some(profile.clone()); let model = PhysicalPlanCostModel::new(&provider, base.clone()).unwrap(); let estimate = model.estimate_candidate(&candidates[0], &target).unwrap(); @@ -718,7 +722,7 @@ mod tests { let root = query(); let target = TargetSubDAG::new(&root); let candidates = - crate::replacement::SketchAlgorithmStrategy::default_cost_model().replacements(&target); + crate::replacement::ASAPStrategies::default_cost_model().replacements(&target); let provider = TestProvider::new(true, 800); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); let estimate = model.estimate_candidate(&candidates[0], &target).unwrap(); @@ -913,7 +917,7 @@ mod tests { fn summary_physical_dag( &self, snapshot: &PhysicalEvidenceSnapshot, - summary: &Rc, + summary: &Rc, target: &TargetSubDAG<'_>, ) -> Result { self.0.summary_physical_dag(snapshot, summary, target) @@ -1001,7 +1005,7 @@ mod tests { fn summary_physical_dag( &self, snapshot: &PhysicalEvidenceSnapshot, - summary: &Rc, + summary: &Rc, target: &TargetSubDAG<'_>, ) -> Result { let mut dag = self.0.summary_physical_dag(snapshot, summary, target)?; @@ -1015,7 +1019,7 @@ mod tests { } let root = query(); - let candidates = crate::replacement::SketchAlgorithmStrategy::default_cost_model() + let candidates = crate::replacement::ASAPStrategies::default_cost_model() .replacements(&TargetSubDAG::new(&root)); let provider = WrongScope(TestProvider::new(true, 800)); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); @@ -1053,7 +1057,7 @@ mod tests { fn summary_physical_dag( &self, _snapshot: &PhysicalEvidenceSnapshot, - _summary: &Rc, + _summary: &Rc, _target: &TargetSubDAG<'_>, ) -> Result { panic!("blank snapshot versions must fail before summary binding") @@ -1061,7 +1065,7 @@ mod tests { } let root = query(); - let candidates = crate::replacement::SketchAlgorithmStrategy::default_cost_model() + let candidates = crate::replacement::ASAPStrategies::default_cost_model() .replacements(&TargetSubDAG::new(&root)); let model = PhysicalPlanCostModel::new(&BlankVersionProvider, calibration()).unwrap(); assert_eq!( @@ -1088,7 +1092,7 @@ mod tests { #[test] fn sibling_candidates_share_one_scope_and_raw_baseline() { let root = query(); - let candidates = crate::replacement::SketchAlgorithmStrategy::default_cost_model() + let candidates = crate::replacement::ASAPStrategies::default_cost_model() .replacements(&TargetSubDAG::new(&root)); assert!(candidates.len() >= 2); let provider = TestProvider::new(true, 800); diff --git a/crates/asap-aware-mapping/src/query_physical_lowering.rs b/crates/asap-aware-mapping/src/query_physical_lowering.rs index 65b7fab93..055d19c79 100644 --- a/crates/asap-aware-mapping/src/query_physical_lowering.rs +++ b/crates/asap-aware-mapping/src/query_physical_lowering.rs @@ -1,7 +1,9 @@ -//! Recursive lowering from the canonical query IR to evidenced physical DAGs. +//! Recursive lowering from the operator IR to evidenced physical DAGs. use std::rc::Rc; +use asap_types::ir::{NonASAPOp, OperatorNode, ScalarExpr}; + use crate::analytical_cost::{ validate_operator_semantics, AnalyticalCostError, EvidenceBackedPhysicalDAG, ExecutionMultiplicity, HashJoinBuildSide, PhysicalDAGNode, PhysicalNodeEvidence, @@ -13,7 +15,7 @@ use crate::physical_operator_statistics::{ }; pub struct PhysicalNodeRequest<'a> { - pub logical_node: &'a asap_types::pre_asap::QueryExpr, + pub logical_node: &'a OperatorNode, pub operator: PhysicalOperator, pub occurrence: usize, pub synthetic: bool, @@ -40,19 +42,19 @@ where } } -/// Lower a resolved query operator DAG to the physical operators understood by +/// Lower a non-ASAP operator DAG to the physical operators understood by /// this cost model. The authoritative provider supplies statistics by the /// stable physical IDs owned by that provider; missing evidence makes the /// complete query unavailable. Scalar expressions remain part of their -/// containing operator's local cost. +/// containing operator's local cost. An ASAP node is unsupported here. pub fn lower_query_physical_dag( - root: &Rc, + root: &Rc, scope: &ComparisonScope, evidence: &dyn PhysicalNodeEvidenceProvider, ) -> Result { use std::collections::HashMap; - use asap_types::pre_asap::{GroupKeys, QueryExpr, RelationalSetOpKind}; + use asap_types::pre_asap::{GroupKeys, RelationalSetOpKind}; scope.validate()?; @@ -65,7 +67,7 @@ pub fn lower_query_physical_dag( } impl Lowerer<'_> { - fn lower(&mut self, query: &QueryExpr) -> Result { + fn lower(&mut self, query: &OperatorNode) -> Result { let occurrence = self.next_id; self.next_id += 1; self.lower_new(query, occurrence) @@ -73,7 +75,7 @@ pub fn lower_query_physical_dag( fn resolve( &self, - query: &QueryExpr, + query: &OperatorNode, operator: PhysicalOperator, occurrence: usize, synthetic: bool, @@ -128,12 +130,23 @@ pub fn lower_query_physical_dag( fn lower_unary( &mut self, - query: &QueryExpr, + query: &OperatorNode, occurrence: usize, operator: PhysicalOperator, - child: &QueryExpr, + child: &OperatorNode, ) -> Result { let child_id = self.lower(child)?; + self.push_unary(query, occurrence, operator, child_id) + } + + /// `operator` over an already-lowered child. + fn push_unary( + &mut self, + query: &OperatorNode, + occurrence: usize, + operator: PhysicalOperator, + child_id: String, + ) -> Result { let children = vec![child_id.clone()]; let evidence = self.resolve(query, operator, occurrence, false, &children, None)?; let statistics = &evidence.statistics; @@ -151,17 +164,17 @@ pub fn lower_query_physical_dag( fn lower_promql_unary( &mut self, - query: &QueryExpr, + query: &OperatorNode, occurrence: usize, operator: PhysicalOperator, - child: &QueryExpr, + child: &OperatorNode, ) -> Result { self.lower_unary(query, occurrence, operator, child) } fn lower_promql_scalar_leaf( &mut self, - query: &QueryExpr, + query: &OperatorNode, occurrence: usize, ) -> Result { let operator = PhysicalOperator::PromqlScalarLeaf; @@ -171,6 +184,28 @@ pub fn lower_query_physical_dag( self.push(evidence, operator, vec![], None) } + /// Lower the owned scalar operand of `vector(s)`. A literal or + /// `time()` is a physical scalar leaf; `scalar(v)` reads its vector + /// through `PromqlVectorToScalar`. + fn lower_scalar_operand( + &mut self, + query: &OperatorNode, + occurrence: usize, + expr: &ScalarExpr, + ) -> Result { + match expr { + ScalarExpr::Literal(asap_types::pre_asap::ScalarValue::Float64(_)) + | ScalarExpr::EvalTimestamp => self.lower_promql_scalar_leaf(query, occurrence), + ScalarExpr::PromqlScalarFromVector(vector) => self.lower_promql_unary( + query, + occurrence, + PhysicalOperator::PromqlVectorToScalar, + vector, + ), + _ => Err(AnalyticalCostError::UnsupportedQueryOperator), + } + } + fn node_statistics(&self, id: &str) -> Result<&OperatorStatistics, AnalyticalCostError> { self.evidence .get(id) @@ -182,11 +217,14 @@ pub fn lower_query_physical_dag( fn lower_new( &mut self, - query: &QueryExpr, + query: &OperatorNode, occurrence: usize, ) -> Result { - match query { - QueryExpr::Scan { + let Some(op) = query.non_asap() else { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + }; + match op { + NonASAPOp::Scan { source, predicates, .. } => { let coverage = bind_scan_coverage( @@ -252,13 +290,13 @@ pub fn lower_query_physical_dag( require_operator_statistics(filter_operator, &filter_evidence.statistics)?; self.push(filter_evidence, filter_operator, children, None) } - QueryExpr::Filter { pred, child } => { + NonASAPOp::Filter { pred, child } => { let operator = PhysicalOperator::Filter { predicate_operations_per_row: scalar_operation_count(&pred.0)?.max(1), }; self.lower_unary(query, occurrence, operator, child) } - QueryExpr::Project { cols, child, .. } => { + NonASAPOp::Project { cols, child, .. } => { let expression_operations_per_row = cols .iter() .try_fold(0_u64, |total, item| { @@ -280,7 +318,7 @@ pub fn lower_query_physical_dag( child, ) } - QueryExpr::Aggregate { + NonASAPOp::Aggregate { reduction, measures, filters, @@ -289,7 +327,7 @@ pub fn lower_query_physical_dag( .. } => { if having.is_some() - || asap_types::pre_asap::any_measure_filtered(filters) + || asap_types::ir::non_asap::any_measure_filtered(filters) || measures.is_empty() { return Err(AnalyticalCostError::UnsupportedQueryOperator); @@ -338,13 +376,9 @@ pub fn lower_query_physical_dag( child, ) } - QueryExpr::Dedup { cols, child } => { + NonASAPOp::Dedup { cols, child } => { let key_count = if cols.is_empty() { - child - .output_schema() - .map_err(|_| AnalyticalCostError::UnsupportedQueryOperator)? - .fields - .len() + child.schema.fields.len() } else { cols.len() }; @@ -361,7 +395,7 @@ pub fn lower_query_physical_dag( child, ) } - QueryExpr::Sort { + NonASAPOp::Sort { keys, partition_by, child, @@ -381,13 +415,26 @@ pub fn lower_query_physical_dag( child, ) } - QueryExpr::Limit { n, offset, child } => { - if let QueryExpr::Sort { + NonASAPOp::Limit { + n, + offset, + partition_by: limit_partition_by, + child, + } => { + // Offset-only and per-group limits have no physical + // operator here. + let Some(n) = n else { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + }; + if limit_partition_by != &GroupKeys::none() { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + if let Some(NonASAPOp::Sort { keys, partition_by, child: sorted_child, .. - } = child.as_ref() + }) = child.non_asap() { if !keys.is_empty() && partition_by == &GroupKeys::none() { let child_id = self.lower(sorted_child)?; @@ -442,7 +489,7 @@ pub fn lower_query_physical_dag( require_operator_statistics(operator, statistics)?; self.push(evidence, operator, children, None) } - QueryExpr::SQLWindowFunc { + NonASAPOp::SQLWindowFunc { func, partition_by, order_by, @@ -472,7 +519,7 @@ pub fn lower_query_physical_dag( child, ) } - QueryExpr::TimeRange { range, child } => { + NonASAPOp::TimeRange { range, child, .. } => { let range_millis = duration_millis(*range, "range")?; self.lower_promql_unary( query, @@ -481,7 +528,7 @@ pub fn lower_query_physical_dag( child, ) } - QueryExpr::PromqlSubquery { + NonASAPOp::PromqlSubquery { range, resolution, child, @@ -513,7 +560,7 @@ pub fn lower_query_physical_dag( } Ok(id) } - QueryExpr::PromqlRelabel { value, child, .. } => self.lower_promql_unary( + NonASAPOp::PromqlRelabel { value, child, .. } => self.lower_promql_unary( query, occurrence, PhysicalOperator::PromqlRelabel { @@ -521,7 +568,7 @@ pub fn lower_query_physical_dag( }, child, ), - QueryExpr::PromqlSeriesSample { + NonASAPOp::PromqlSeriesSample { by, kind, child, .. } => { if by.is_without() { @@ -557,7 +604,7 @@ pub fn lower_query_physical_dag( child, ) } - QueryExpr::PromqlInfoEnrich { selector, child } => { + NonASAPOp::PromqlInfoEnrich { selector, child } => { let left_id = self.lower(child)?; let coverage = bind_info_coverage( &format!("occurrence-{occurrence}-info"), @@ -609,34 +656,13 @@ pub fn lower_query_physical_dag( require_operator_statistics(operator, &evidence.statistics)?; self.push(evidence, operator, children, None) } - QueryExpr::BinaryOp { - op, - lhs, - rhs, - vector_match, + NonASAPOp::BinaryOp { + operator, lhs, rhs, .. } => { - let left_scalar = is_promql_scalar(lhs); - let right_scalar = is_promql_scalar(rhs); - if left_scalar && right_scalar { - return Err(AnalyticalCostError::UnsupportedQueryOperator); - } + let op = &operator.kind; + let vector_match = &operator.vector_match; let operation = promql_binary_operation(op); - if (left_scalar || right_scalar) - && !matches!(operation, PromqlBinaryOperation::ArithmeticOrComparison) - { - return Err(AnalyticalCostError::UnsupportedQueryOperator); - } - let operand_mode = match (left_scalar, right_scalar) { - (false, false) => PromqlBinaryOperandMode::VectorVector, - (false, true) => PromqlBinaryOperandMode::VectorScalar, - (true, false) => PromqlBinaryOperandMode::ScalarVector, - (true, true) => unreachable!("scalar/scalar returned above"), - }; - if operand_mode != PromqlBinaryOperandMode::VectorVector - && vector_match.is_some() - { - return Err(AnalyticalCostError::UnsupportedQueryOperator); - } + let operand_mode = PromqlBinaryOperandMode::VectorVector; let cardinality = promql_vector_cardinality(vector_match.as_ref()); let left_id = self.lower(lhs)?; let right_id = self.lower(rhs)?; @@ -670,41 +696,31 @@ pub fn lower_query_physical_dag( require_operator_statistics(operator, &evidence.statistics)?; self.push(evidence, operator, children, None) } - QueryExpr::PromqlVectorFromScalar(child) => self.lower_promql_unary( - query, - occurrence, - PhysicalOperator::PromqlScalarToVector, - child, - ), - QueryExpr::PromqlScalarFromVector(child) => self.lower_promql_unary( - query, - occurrence, - PhysicalOperator::PromqlVectorToScalar, - child, - ), - QueryExpr::PromqlScalarBridge(inner) - if matches!( - inner.as_ref(), - QueryExpr::Literal(asap_types::pre_asap::ScalarValue::Float64(_)) - ) => - { - self.lower_promql_scalar_leaf(query, occurrence) + NonASAPOp::PromqlVectorFromScalar(scalar) => { + let scalar_occurrence = self.next_id; + self.next_id += 1; + let child_id = self.lower_scalar_operand(query, scalar_occurrence, scalar)?; + self.push_unary( + query, + occurrence, + PhysicalOperator::PromqlScalarToVector, + child_id, + ) } - QueryExpr::EvalTimestamp => self.lower_promql_scalar_leaf(query, occurrence), - QueryExpr::TimeShift { shift, child } => { + NonASAPOp::TimeShift { shift, child } => { if !shift.is_identity() { return Err(AnalyticalCostError::UnsupportedQueryOperator); } self.lower_unary(query, occurrence, PhysicalOperator::PassThrough, child) } - QueryExpr::Concat { children, .. } => { + NonASAPOp::Concat { children, .. } => { let child_ids = children .iter() .map(|child| self.lower(child)) .collect::, _>>()?; self.lower_concat(query, occurrence, child_ids) } - QueryExpr::SetOp { + NonASAPOp::SetOp { kind: RelationalSetOpKind::Union, all: true, left, @@ -714,7 +730,7 @@ pub fn lower_query_physical_dag( let right_id = self.lower(right)?; self.lower_concat(query, occurrence, vec![left_id, right_id]) } - QueryExpr::Join { + NonASAPOp::Join { kind, pred, left, @@ -764,7 +780,7 @@ pub fn lower_query_physical_dag( fn lower_concat( &mut self, - query: &QueryExpr, + query: &OperatorNode, occurrence: usize, child_ids: Vec, ) -> Result { @@ -957,7 +973,7 @@ fn require_operator_statistics( fn bind_scan_coverage( node_id: &str, source: &asap_types::pre_asap::Source, - predicates: &[asap_types::pre_asap::Predicate], + predicates: &[asap_types::ir::Predicate], scope: &ComparisonScope, ) -> Result { let mut matches = scope.sources.iter().filter(|coverage| { @@ -1051,17 +1067,14 @@ fn promql_vector_cardinality( } fn hash_join_key_count( - expr: &asap_types::pre_asap::QueryExpr, - left: &asap_types::pre_asap::QueryExpr, - right: &asap_types::pre_asap::QueryExpr, + expr: &ScalarExpr, + left: &OperatorNode, + right: &OperatorNode, ) -> Option { - use asap_types::pre_asap::{CompareOpKind, QueryExpr}; + use asap_types::pre_asap::CompareOpKind; - let (Ok(left_schema), Ok(right_schema)) = (left.output_schema(), right.output_schema()) else { - return None; - }; - let left_width = left_schema.fields.len(); - let total_width = left_width.saturating_add(right_schema.fields.len()); + let left_width = left.schema.fields.len(); + let total_width = left_width.saturating_add(right.schema.fields.len()); fn column_side(column: usize, left_width: usize, total_width: usize) -> Option { if column < left_width { @@ -1073,14 +1086,15 @@ fn hash_join_key_count( } } - fn predicate(expr: &QueryExpr, left_width: usize, total_width: usize) -> Option { + fn predicate(expr: &ScalarExpr, left_width: usize, total_width: usize) -> Option { match expr { - QueryExpr::Compare { + ScalarExpr::Compare { left, op: CompareOpKind::Eq, right, + .. } => match (left.as_ref(), right.as_ref()) { - (QueryExpr::Column(left), QueryExpr::Column(right)) => match ( + (ScalarExpr::Column(left), ScalarExpr::Column(right)) => match ( column_side(*left, left_width, total_width), column_side(*right, left_width, total_width), ) { @@ -1089,7 +1103,7 @@ fn hash_join_key_count( }, _ => None, }, - QueryExpr::BoolAnd(parts) if !parts.is_empty() => { + ScalarExpr::BoolAnd(parts) if !parts.is_empty() => { parts.iter().try_fold(0_u64, |count, part| { count.checked_add(predicate(part, left_width, total_width)?) }) @@ -1101,12 +1115,8 @@ fn hash_join_key_count( predicate(expr, left_width, total_width) } -fn scalar_operation_count( - expr: &asap_types::pre_asap::QueryExpr, -) -> Result { - use asap_types::pre_asap::QueryExpr; - - let add = |parts: &[&QueryExpr]| { +fn scalar_operation_count(expr: &ScalarExpr) -> Result { + let add = |parts: &[&ScalarExpr]| { parts.iter().try_fold(0_u64, |total, part| { total .checked_add(scalar_operation_count(part)?) @@ -1119,14 +1129,14 @@ fn scalar_operation_count( .ok_or(AnalyticalCostError::Overflow) }; match expr { - QueryExpr::Column(_) - | QueryExpr::Literal(_) - | QueryExpr::EvalTimestamp - | QueryExpr::CurrentTimestamp => Ok(0), - QueryExpr::Compare { left, right, .. } | QueryExpr::Arithmetic { left, right, .. } => { + ScalarExpr::Column(_) + | ScalarExpr::Literal(_) + | ScalarExpr::EvalTimestamp + | ScalarExpr::CurrentTimestamp => Ok(0), + ScalarExpr::Compare { left, right, .. } | ScalarExpr::Arithmetic { left, right, .. } => { with_local(&[left, right]) } - QueryExpr::BoolAnd(parts) | QueryExpr::BoolOr(parts) => { + ScalarExpr::BoolAnd(parts) | ScalarExpr::BoolOr(parts) => { let children = parts.iter().collect::>(); add(&children)? .checked_add( @@ -1135,12 +1145,11 @@ fn scalar_operation_count( ) .ok_or(AnalyticalCostError::Overflow) } - QueryExpr::Not(child) - | QueryExpr::IsNull(child) - | QueryExpr::IsNotNull(child) - | QueryExpr::PromqlScalarBridge(child) => with_local(&[child]), - QueryExpr::Cast { expr, .. } => with_local(&[expr]), - QueryExpr::InList { expr, list, .. } => { + ScalarExpr::Not(child) | ScalarExpr::IsNull(child) | ScalarExpr::IsNotNull(child) => { + with_local(&[child]) + } + ScalarExpr::Cast { expr, .. } => with_local(&[expr]), + ScalarExpr::InList { expr, list, .. } => { let mut children = Vec::with_capacity(list.len() + 1); children.push(expr.as_ref()); children.extend(list.iter()); @@ -1148,11 +1157,11 @@ fn scalar_operation_count( .checked_add(u64::try_from(list.len()).map_err(|_| AnalyticalCostError::Overflow)?) .ok_or(AnalyticalCostError::Overflow) } - QueryExpr::FunctionCall { args, .. } => { + ScalarExpr::FunctionCall { args, .. } => { let children = args.iter().collect::>(); with_local(&children) } - QueryExpr::Case { + ScalarExpr::Case { operand, branches, else_expr, @@ -1246,16 +1255,6 @@ fn fixed_state_per_series_intent(intent: &asap_types::pre_asap::AggIntent) -> bo ) } -fn is_promql_scalar(query: &asap_types::pre_asap::QueryExpr) -> bool { - use asap_types::pre_asap::QueryExpr; - matches!( - query, - QueryExpr::PromqlScalarBridge(_) - | QueryExpr::PromqlScalarFromVector(_) - | QueryExpr::EvalTimestamp - ) -} - #[cfg(test)] mod tests { use super::*; @@ -1266,6 +1265,7 @@ mod tests { validate_comparison_scopes, BinaryEdgeStatistics, PartitionStatistics, PromqlEdgeStatistics, PromqlUnaryEdgeStatistics, PromqlValueKind, UnaryEdgeStatistics, }; + use asap_types::ir::{BinaryOperator, ExprSemantics, Predicate, SortKey, TimeRangeKind}; use asap_types::workload::{ DataArrival, DurationMs, QueryRecurrence, QueryTimeScope, TimeSelection, TimestampMs, }; @@ -1401,10 +1401,7 @@ mod tests { } } - fn coverage( - source: asap_types::pre_asap::Source, - predicates: Vec, - ) -> ScanSelection { + fn coverage(source: asap_types::pre_asap::Source, predicates: Vec) -> ScanSelection { ScanSelection { source, source_snapshot_id: "snapshot-1".into(), @@ -1451,27 +1448,30 @@ mod tests { // Correlation can be costed as an exact hash aggregate using provider-supplied state size. #[test] fn correlation_lowers_to_physical_hash_aggregate() { - use asap_types::pre_asap::{ - AggIntent, DataType, Field, QueryExpr, Reduction, Schema, Source, - }; + use asap_types::pre_asap::{AggIntent, DataType, Field, Reduction, Schema, Source}; let source = Source::Table { table_ref: "pairs".into(), }; - let root = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::PearsonCorr { left: 0, right: 1 }], - output_names: vec!["r".into()], - filters: vec![], - having: None, - child: Rc::new(QueryExpr::Scan { - source: source.clone(), - predicates: vec![], - schema: Schema::new(vec![ - Field::plain("x", DataType::Float64, true), - Field::plain("y", DataType::Float64, true), - ]), - }), - }); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![]), + measures: vec![AggIntent::PearsonCorr { left: 0, right: 1 }], + output_names: vec!["r".into()], + filters: vec![], + having: None, + child: OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::Scan { + source: source.clone(), + predicates: vec![], + schema: Schema::new(vec![ + Field::plain("x", DataType::Float64, true), + Field::plain("y", DataType::Float64, true), + ]), + }, + )) + .unwrap(), + })) + .unwrap(); let scope = scope(vec![coverage(source, vec![])]); let provided = HashMap::from([ ( @@ -1501,51 +1501,57 @@ mod tests { #[test] fn query_lowering_recurses_and_fuses_global_sort_limit() { - use asap_types::pre_asap::{AggIntent, GroupKeys, QueryExpr, Reduction, SortKey, Source}; + use asap_types::pre_asap::{AggIntent, GroupKeys, Reduction, Source}; use asap_types::pre_asap::{DataType, Field, Schema}; use std::rc::Rc; - let scan = Rc::new(QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: "events".into(), }, - predicates: vec![asap_types::pre_asap::Predicate(Rc::new( - QueryExpr::Literal(asap_types::pre_asap::ScalarValue::Boolean(true)), + predicates: vec![Predicate(ScalarExpr::Literal( + asap_types::pre_asap::ScalarValue::Boolean(true), ))], schema: Schema::new(vec![ Field::plain("service", DataType::Utf8, false), Field::plain("value", DataType::Float64, false), ]), - }); - let aggregate = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(vec![0]), - measures: vec![AggIntent::Sum { col: Some(1) }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(&scan), - }); - let sort = Rc::new(QueryExpr::Sort { + })) + .unwrap(); + let aggregate = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![0]), + measures: vec![AggIntent::Sum { col: Some(1) }], + output_names: vec![], + filters: vec![], + having: None, + child: Rc::clone(&scan), + })) + .unwrap(); + let sort = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Sort { keys: vec![SortKey { - expr: QueryExpr::Column(0), + expr: ScalarExpr::Column(0), ascending: false, nulls_first: false, }], partition_by: GroupKeys::none(), child: aggregate, - }); - let root = Rc::new(QueryExpr::Limit { - n: 10, + })) + .unwrap(); + let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(10), offset: 5, + partition_by: GroupKeys::none(), child: sort, - }); + })) + .unwrap(); let scan_coverage = coverage( Source::Table { table_ref: "events".into(), }, - vec![asap_types::pre_asap::Predicate(Rc::new( - QueryExpr::Literal(asap_types::pre_asap::ScalarValue::Boolean(true)), + vec![Predicate(ScalarExpr::Literal( + asap_types::pre_asap::ScalarValue::Boolean(true), ))], ); let scope = scope(vec![scan_coverage]); @@ -1638,26 +1644,29 @@ mod tests { #[test] fn query_lowering_shares_only_provider_identified_physical_nodes() { use asap_types::pre_asap::{CompareOpKind, DataType, Field, Schema}; - use asap_types::pre_asap::{JoinKind, Predicate, QueryExpr, Source}; + use asap_types::pre_asap::{JoinKind, Source}; use std::rc::Rc; - let shared = Rc::new(QueryExpr::Scan { + let shared = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: "dimensions".into(), }, predicates: vec![], schema: Schema::new(vec![Field::plain("id", DataType::Int64, false)]), - }); - let root = Rc::new(QueryExpr::Join { + })) + .unwrap(); + let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { kind: JoinKind::Inner, - pred: Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + pred: Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(0)), op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(1)), - })), + right: Box::new(ScalarExpr::Column(1)), + semantics: ExprSemantics::Sql, + }), left: Rc::clone(&shared), right: Rc::clone(&shared), - }); + })) + .unwrap(); let scan_selection = coverage( Source::Table { table_ref: "dimensions".into(), @@ -1781,16 +1790,19 @@ mod tests { )) ); - let invalid = Rc::new(QueryExpr::Join { - kind: JoinKind::Inner, - pred: Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(0)), - })), - left: Rc::clone(&shared), - right: Rc::clone(&shared), - }); + let invalid = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { + kind: JoinKind::Inner, + pred: Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(0)), + op: CompareOpKind::Eq, + right: Box::new(ScalarExpr::Column(0)), + semantics: ExprSemantics::Sql, + }), + left: Rc::clone(&shared), + right: Rc::clone(&shared), + })) + .unwrap(); assert_eq!( lower_query_physical_dag(&invalid, &shared_scope, &shared_provider), Err(AnalyticalCostError::UnsupportedQueryOperator) @@ -1800,62 +1812,73 @@ mod tests { #[test] fn query_lowering_covers_relational_unary_operators() { use asap_types::pre_asap::{DataType, Field, ScalarValue, Schema}; - use asap_types::pre_asap::{ - GroupKeys, Predicate, QueryExpr, SortKey, Source, TimeShift, WindowFuncKind, - }; - use std::rc::Rc; + use asap_types::pre_asap::{GroupKeys, Source, TimeShift, WindowFuncKind}; - let scan = Rc::new(QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: "events".into(), }, predicates: vec![], schema: Schema::new(vec![Field::plain("id", DataType::Int64, false)]), - }); - let filter = Rc::new(QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - child: scan, - }); - let project = Rc::new(QueryExpr::Project { - cols: vec![], - qualifier: None, - child: filter, - }); - let dedup = Rc::new(QueryExpr::Dedup { + })) + .unwrap(); + let filter = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: scan, + })) + .unwrap(); + let project = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { + cols: vec![], + qualifier: None, + child: filter, + })) + .unwrap(); + let dedup = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { cols: vec![0], child: project, - }); - let window = Rc::new(QueryExpr::SQLWindowFunc { - func: WindowFuncKind::RowNumber, - args: vec![], - partition_by: GroupKeys::none(), - order_by: vec![SortKey { - expr: QueryExpr::Column(0), - ascending: true, - nulls_first: false, - }], - frame: None, - output_name: "rn".into(), - child: dedup, - }); - let sort = Rc::new(QueryExpr::Sort { + })) + .unwrap(); + let window = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::SQLWindowFunc { + func: WindowFuncKind::RowNumber, + args: vec![], + partition_by: GroupKeys::none(), + order_by: vec![SortKey { + expr: ScalarExpr::Column(0), + ascending: true, + nulls_first: false, + }], + frame: None, + output_name: "rn".into(), + child: dedup, + }, + )) + .unwrap(); + let sort = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Sort { keys: vec![SortKey { - expr: QueryExpr::Column(0), + expr: ScalarExpr::Column(0), ascending: true, nulls_first: false, }], partition_by: GroupKeys::by(vec![0]), child: window, - }); - let limit = Rc::new(QueryExpr::Limit { - n: 20, + })) + .unwrap(); + let limit = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(20), offset: 0, + partition_by: GroupKeys::none(), child: sort, - }); - let root = Rc::new(QueryExpr::TimeShift { - shift: TimeShift::default(), - child: limit, - }); + })) + .unwrap(); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeShift { + shift: TimeShift::default(), + child: limit, + })) + .unwrap(); let scan_selection = coverage( Source::Table { @@ -1974,22 +1997,25 @@ mod tests { #[test] fn query_lowering_maps_concat_and_union_all_but_rejects_distinct_set_ops() { use asap_types::pre_asap::{DataType, Field, Schema}; - use asap_types::pre_asap::{QueryExpr, RelationalSetOpKind, Source}; - use std::rc::Rc; + use asap_types::pre_asap::{RelationalSetOpKind, Source}; - let scan = |name: &str| QueryExpr::Scan { - source: Source::Table { - table_ref: name.into(), - }, - predicates: vec![], - schema: Schema::new(vec![Field::plain("id", DataType::Int64, false)]), + let scan = |name: &str| { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { + source: Source::Table { + table_ref: name.into(), + }, + predicates: vec![], + schema: Schema::new(vec![Field::plain("id", DataType::Int64, false)]), + })) + .unwrap() }; - let union = Rc::new(QueryExpr::SetOp { + let union = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::SetOp { kind: RelationalSetOpKind::Union, all: true, - left: Rc::new(scan("a")), - right: Rc::new(scan("b")), - }); + left: scan("a"), + right: scan("b"), + })) + .unwrap(); let scope = scope(vec![ coverage( Source::Table { @@ -2033,19 +2059,23 @@ mod tests { )) ); - let concat = Rc::new(QueryExpr::Concat { - children: vec![scan("a"), scan("b")], - discriminator_unique_key: None, - }); + let concat = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Concat { + children: vec![scan("a"), scan("b")], + discriminator_unique_key: None, + })) + .unwrap(); let dag = lower_query_physical_dag(&concat, &scope, &scripted(&provided)).unwrap(); assert_eq!(dag.nodes.last().unwrap().operator, PhysicalOperator::Concat); - let distinct_union = Rc::new(QueryExpr::SetOp { - kind: RelationalSetOpKind::Union, - all: false, - left: Rc::new(scan("a")), - right: Rc::new(scan("b")), - }); + let distinct_union = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::SetOp { + kind: RelationalSetOpKind::Union, + all: false, + left: scan("a"), + right: scan("b"), + })) + .unwrap(); assert_eq!( lower_query_physical_dag(&distinct_union, &scope, &scripted(&provided)), Err(AnalyticalCostError::UnsupportedQueryOperator) @@ -2054,22 +2084,24 @@ mod tests { #[test] fn query_lowering_fails_closed_for_missing_or_inconsistent_statistics() { + use asap_types::pre_asap::Source; use asap_types::pre_asap::{DataType, Field, Schema}; - use asap_types::pre_asap::{QueryExpr, Source}; - use std::rc::Rc; - let scan = Rc::new(QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: "events".into(), }, predicates: vec![], schema: Schema::new(vec![Field::plain("id", DataType::Int64, false)]), - }); - let root = Rc::new(QueryExpr::Project { - cols: vec![], - qualifier: None, - child: scan, - }); + })) + .unwrap(); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { + cols: vec![], + qualifier: None, + child: scan, + })) + .unwrap(); let comparison_scope = scope(vec![coverage( Source::Table { @@ -2171,25 +2203,29 @@ mod tests { #[test] fn query_lowering_accepts_a_consistently_empty_edge() { use asap_types::pre_asap::{DataType, Field, ScalarValue, Schema}; - use asap_types::pre_asap::{Predicate, QueryExpr, Source}; - use std::rc::Rc; + use asap_types::pre_asap::{GroupKeys, Source}; - let scan = Rc::new(QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: "events".into(), }, predicates: vec![], schema: Schema::new(vec![Field::plain("id", DataType::Int64, false)]), - }); - let filter = Rc::new(QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(false)))), - child: scan, - }); - let root = Rc::new(QueryExpr::Limit { - n: 10, + })) + .unwrap(); + let filter = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(false))), + child: scan, + })) + .unwrap(); + let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(10), offset: 0, + partition_by: GroupKeys::none(), child: filter, - }); + })) + .unwrap(); let scope = scope(vec![coverage( Source::Table { @@ -2230,59 +2266,70 @@ mod tests { #[test] fn query_lowering_rejects_aggregates_without_a_hash_implementation() { - use asap_types::pre_asap::{ - AggIntent, GroupKeys, QueryExpr, Reduction, Source, WindowFuncKind, - }; + use asap_types::pre_asap::{AggIntent, GroupKeys, Reduction, Source, WindowFuncKind}; use asap_types::pre_asap::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; - use std::rc::Rc; let scan = || { - Rc::new(QueryExpr::Scan { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: "events".into(), }, predicates: vec![], schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), - }) + })) + .unwrap() }; - let exact_quantile = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::Quantile { - col: Some(0), - q: 0.99, - accuracy: AccuracyTarget::Exact, - }], - output_names: vec![], - filters: vec![], - having: None, - child: scan(), - }); - let empty_sort_limit = Rc::new(QueryExpr::Limit { - n: 10, - offset: 0, - child: Rc::new(QueryExpr::Sort { - keys: vec![], + let exact_quantile = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![]), + measures: vec![AggIntent::Quantile { + col: Some(0), + q: 0.99, + accuracy: AccuracyTarget::Exact, + }], + output_names: vec![], + filters: vec![], + having: None, + child: scan(), + })) + .unwrap(); + let empty_sort_limit = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(10), + offset: 0, partition_by: GroupKeys::none(), + child: OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::Sort { + keys: vec![], + partition_by: GroupKeys::none(), + child: scan(), + }, + )) + .unwrap(), + })) + .unwrap(); + let unsupported_window = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::SQLWindowFunc { + func: WindowFuncKind::Lag, + args: vec![ScalarExpr::Column(0)], + partition_by: GroupKeys::none(), + order_by: vec![], + frame: None, + output_name: "lag".into(), child: scan(), - }), - }); - let unsupported_window = Rc::new(QueryExpr::SQLWindowFunc { - func: WindowFuncKind::Lag, - args: vec![QueryExpr::Column(0)], - partition_by: GroupKeys::none(), - order_by: vec![], - frame: None, - output_name: "lag".into(), - child: scan(), - }); - let shifted = Rc::new(QueryExpr::TimeShift { - shift: asap_types::pre_asap::TimeShift { - offset_ms: 60_000, - at: None, }, - child: scan(), - }); + )) + .unwrap(); + let shifted = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeShift { + shift: asap_types::pre_asap::TimeShift { + offset_ms: 60_000, + at: None, + }, + child: scan(), + })) + .unwrap(); let scope = scope(vec![coverage( Source::Table { table_ref: "events".into(), @@ -2305,40 +2352,42 @@ mod tests { #[test] fn scalar_work_counts_every_local_predicate_operation() { - use asap_types::pre_asap::{CompareOpKind, QueryExpr, ScalarValue}; + use asap_types::pre_asap::{CompareOpKind, ScalarValue}; - let comparison = || QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + let comparison = || ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(0)), op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Literal(ScalarValue::Int64(1))), + right: Box::new(ScalarExpr::Literal(ScalarValue::Int64(1))), + semantics: ExprSemantics::Sql, }; - let predicate = QueryExpr::BoolAnd(vec![comparison(), comparison()]); + let predicate = ScalarExpr::BoolAnd(vec![comparison(), comparison()]); assert_eq!(scalar_operation_count(&predicate), Ok(3)); } #[test] fn promql_presence_is_lowered_with_a_per_step_output_bound() { - use asap_types::pre_asap::{ - AggIntent, DataType, Field, QueryExpr, Reduction, Schema, Source, - }; + use asap_types::pre_asap::{AggIntent, DataType, Field, Reduction, Schema, Source}; let source = Source::TimeSeries { metric: "missing".into(), }; - let scan = Rc::new(QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: source.clone(), predicates: vec![], schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), - }); - let root = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![AggIntent::Absent], - output_names: vec![], - filters: vec![], - having: None, - child: scan, - }); + })) + .unwrap(); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::PerEntity, + measures: vec![AggIntent::Absent], + output_names: vec![], + filters: vec![], + having: None, + child: scan, + })) + .unwrap(); let vector = promql_edge(0, 2, PromqlValueKind::Vector); let scan_statistics = OperatorStatistics::Scan { edges: promql_unary_edges(edge(0, 0), edge(0, 0), vector, vector), @@ -2387,24 +2436,31 @@ mod tests { #[test] fn promql_range_and_subquery_preserve_internal_steps() { - use asap_types::pre_asap::{DataType, Field, QueryExpr, Schema, Source}; + use asap_types::pre_asap::{DataType, Field, Schema, Source}; use std::time::Duration; let source = Source::TimeSeries { metric: "m".into() }; - let scan = Rc::new(QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: source.clone(), predicates: vec![], schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), - }); - let range = Rc::new(QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: scan, - }); - let root = Rc::new(QueryExpr::PromqlSubquery { - range: Duration::from_secs(300), - resolution: Some(Duration::from_secs(60)), - child: range, - }); + })) + .unwrap(); + let range = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { + range: Duration::from_secs(300), + kind: TimeRangeKind::Range, + child: scan, + })) + .unwrap(); + let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::PromqlSubquery { + range: Duration::from_secs(300), + resolution: Some(Duration::from_secs(60)), + child: range, + }, + )) + .unwrap(); let vector = promql_edge(10, 6, PromqlValueKind::Vector); let range_vector = promql_edge(10, 6, PromqlValueKind::RangeVector); let outer_range = promql_edge(10, 1, PromqlValueKind::RangeVector); @@ -2462,32 +2518,40 @@ mod tests { #[test] fn promql_binary_lowering_keeps_operation_and_matching_cardinality() { use asap_types::pre_asap::{ - ArithmeticOpKind, BinaryOpKind, DataType, Field, GroupSide, QueryExpr, Schema, Source, + ArithmeticOpKind, BinaryOpKind, DataType, Field, GroupSide, Schema, Source, VectorGrouping, VectorMatch, VectorMatchKind, }; let left_source = Source::TimeSeries { metric: "a".into() }; let right_source = Source::TimeSeries { metric: "b".into() }; let scan = |source| { - Rc::new(QueryExpr::Scan { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source, predicates: vec![], schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), - }) + })) + .unwrap() }; - let root = Rc::new(QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), - lhs: scan(left_source.clone()), - rhs: scan(right_source.clone()), - vector_match: Some(VectorMatch { - kind: VectorMatchKind::On, - labels: vec!["service".into()], - grouping: Some(VectorGrouping { - side: GroupSide::Left, - labels: vec!["region".into()], - }), - }), - }); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), + vector_match: Some(VectorMatch { + kind: VectorMatchKind::On, + labels: vec!["service".into()], + grouping: Some(VectorGrouping { + side: GroupSide::Left, + labels: vec!["region".into()], + }), + }), + checked_relative_division: false, + checked_finite_division: false, + }, + return_bool: false, + lhs: scan(left_source.clone()), + rhs: scan(right_source.clone()), + })) + .unwrap(); let left_promql = promql_edge(10, 10, PromqlValueKind::Vector); let right_promql = promql_edge(5, 10, PromqlValueKind::Vector); let output_promql = promql_edge(8, 10, PromqlValueKind::Vector); @@ -2545,36 +2609,45 @@ mod tests { #[test] fn promql_relabel_sample_and_per_series_lower_as_a_complete_chain() { use asap_types::pre_asap::{ - AggIntent, DataType, Field, GroupKeys, QueryExpr, Reduction, SampleKind, ScalarValue, - Schema, Source, + AggIntent, DataType, Field, GroupKeys, Reduction, SampleKind, ScalarValue, Schema, + Source, }; let source = Source::TimeSeries { metric: "requests".into(), }; - let scan = Rc::new(QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: source.clone(), predicates: vec![], schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), - }); - let relabel = Rc::new(QueryExpr::PromqlRelabel { - dst: "service".into(), - value: Rc::new(QueryExpr::Literal(ScalarValue::Utf8("api".into()))), - child: scan, - }); - let sample = Rc::new(QueryExpr::PromqlSeriesSample { - by: GroupKeys::none(), - kind: SampleKind::LimitK(5), - child: relabel, - }); - let root = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![AggIntent::Sum { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: sample, - }); + })) + .unwrap(); + let relabel = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::PromqlRelabel { + dst: "service".into(), + value: ScalarExpr::Literal(ScalarValue::Utf8("api".into())), + child: scan, + }, + )) + .unwrap(); + let sample = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::PromqlSeriesSample { + by: GroupKeys::none(), + kind: SampleKind::LimitK(5), + child: relabel, + }, + )) + .unwrap(); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::PerEntity, + measures: vec![AggIntent::Sum { col: None }], + output_names: vec![], + filters: vec![], + having: None, + child: sample, + })) + .unwrap(); let input = edge(100, 1_600); let sampled = edge(50, 800); diff --git a/crates/asap-aware-mapping/src/recurrence.rs b/crates/asap-aware-mapping/src/recurrence.rs index 660787cdb..835a35a65 100644 --- a/crates/asap-aware-mapping/src/recurrence.rs +++ b/crates/asap-aware-mapping/src/recurrence.rs @@ -394,7 +394,7 @@ pub enum RootRecurrence { // ── Explanation ────────────────────────────────────────────────────────── -/// The full readout [`CostModel::cse_share_decision_with_recurrence`] +/// The full evaluation [`CostModel::cse_share_decision_with_recurrence`] /// returns: which alternative was selected, both compared cost rates /// (and, when a [`Horizon`] was supplied, both compared totals), every /// input that went into them, their units, and provenance — meant to be @@ -779,17 +779,20 @@ mod tests { // ── decide (structural fallback) ───────────────────────────────────── use crate::cost_model::CseCandidate; + use asap_types::ir::operator_properties::{Reduction, Source}; + use asap_types::ir::{ + ASAPOp, BinaryOperator, ExprSemantics, NonASAPOp, OperatorNode, Predicate, ScalarExpr, + }; use asap_types::post_asap::{ ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, ResultGuarantee, Schema, - SummaryExpr, SummaryNode, }; use asap_types::pre_asap::expr_ir::ColumnRef; - use asap_types::pre_asap::query_expr::{QueryExpr, Reduction, Source}; use asap_types::pre_asap::schema::DataType; + use std::rc::Rc; - fn scan() -> QueryExpr { - QueryExpr::Scan { + fn scan() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: Schema::with_time_index( @@ -800,28 +803,34 @@ mod tests { 0, vec![], ), - } + })) + .unwrap() } - fn summary_node(family: FieldDataType) -> SummaryNode { - SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::new(scan())), - schema: Schema::lifted(vec![], None), - guarantee: Some(ResultGuarantee::exact("KeepPreAsap")), + /// A summary of `family` over the kept pre-ASAP scan. + fn summary_node(family: FieldDataType) -> Rc { + let kept = Rc::new( + scan() + .as_ref() + .clone() + .with_guarantee(Some(ResultGuarantee::exact("RetainedExact"))), + ); + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: kept, + family: family.clone(), + input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( + "value".into(), + )), + reduction: Reduction::by(vec![]), + grouping: GroupingStrategy::default(), + filter: None, }), - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( - "value".into(), - )), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }, - schema: Schema::lifted(vec![Field::new("state", family, false)], None), - guarantee: None, - } + Schema::lifted(vec![Field::new("state", family, false)], None), + ) + .with_guarantee(None), + ) } #[test] @@ -1120,9 +1129,9 @@ mod tests { // ── multiple roots sharing a sub-DAG, via CandidateLogicalASAPDAGs ────────────────── use crate::replacement::search_workload; + use asap_types::ir::operator_properties::Reduction as QueryReduction; use asap_types::pre_asap::agg_intent::AggIntent; - use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::{Predicate, Reduction as QueryReduction}; + use asap_types::pre_asap::expr_ir::{CompareOpKind, ScalarValue}; /// Like `scan()`, plus a "job" label column to group by — CSE's /// sharing legality gate requires a provable unique key @@ -1132,8 +1141,8 @@ mod tests { /// real one, matching the pattern /// `replacement.rs`'s own CSE fixtures already use (`metric_scan`/`agg` /// grouped by a label column). - fn labeled_scan() -> QueryExpr { - QueryExpr::Scan { + fn labeled_scan() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: Schema::with_time_index( @@ -1145,18 +1154,20 @@ mod tests { 0, vec![], ), - } + })) + .unwrap() } - fn sum_agg() -> QueryExpr { - QueryExpr::Aggregate { + fn sum_agg() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: QueryReduction::by(vec![2]), measures: vec![AggIntent::Sum { col: Some(1) }], output_names: vec![], filters: vec![], having: None, - child: Rc::new(labeled_scan()), - } + child: labeled_scan(), + })) + .unwrap() } /// A root wrapping a fresh, independently-built (but structurally @@ -1167,13 +1178,19 @@ mod tests { /// `shared_aggregate_across_two_roots_gets_both_strategies_candidates`'s /// own doc) while letting `share_common_sub_dags` unify their /// identical `sum_agg()` children onto one shared `Rc`. - fn filtered_root(distinguishing_literal: i64) -> QueryExpr { - QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64( - distinguishing_literal, - )))), - child: Rc::new(sum_agg()), - } + fn filtered_root(distinguishing_literal: i64) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(1)), + op: CompareOpKind::Gt, + right: Box::new(ScalarExpr::Literal(ScalarValue::Int64( + distinguishing_literal, + ))), + semantics: ExprSemantics::Sql, + }), + child: sum_agg(), + })) + .unwrap() } /// Three workload roots share one underlying `sum_agg()` sub-DAG: two @@ -1185,10 +1202,10 @@ mod tests { /// roots sharing a sub-DAG" acceptance criteria. #[test] fn recurrence_profiles_aggregates_mixed_intervals_across_roots_sharing_a_subdag() { - let roots: Vec<(&str, Rc)> = vec![ - ("root_a", Rc::new(filtered_root(1))), - ("root_b", Rc::new(filtered_root(2))), - ("root_c", Rc::new(filtered_root(3))), + let roots: Vec<(&str, Rc)> = vec![ + ("root_a", filtered_root(1)), + ("root_b", filtered_root(2)), + ("root_c", filtered_root(3)), ]; let space = search_workload(roots); @@ -1206,7 +1223,7 @@ mod tests { ); let shared_group = space .target_subdag_candidates() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .expect("the shared sum_agg() is a discovered target"); assert_eq!(shared_group.consumer_count, 3, "shared by all 3 roots"); @@ -1250,14 +1267,11 @@ mod tests { #[test] fn plan_selection_uses_recurrence_profiles_for_cse_choices() { - let roots = vec![ - ("a", Rc::new(filtered_root(1))), - ("b", Rc::new(filtered_root(2))), - ]; + let roots = vec![("a", filtered_root(1)), ("b", filtered_root(2))]; let space = search_workload(roots); let shared = space .target_subdag_candidates() - .find(|group| matches!(group.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .expect("the aggregate is shared by both roots"); let update_rate = Some(UpdateRate(10.0)); @@ -1315,8 +1329,8 @@ mod tests { #[test] fn recurrence_profiles_rejects_an_invalid_evaluation_rate() { - let root = Rc::new(scan()); - let roots: Vec<(&str, Rc)> = vec![("only", root)]; + let root = scan(); + let roots: Vec<(&str, Rc)> = vec![("only", root)]; let space = search_workload(roots); let err = space .recurrence_profiles(&[RootRecurrence::Repeating(EvaluationRate(f64::NAN))], None) @@ -1329,8 +1343,8 @@ mod tests { /// signature promises a `Result`. #[test] fn recurrence_profiles_reports_a_root_count_mismatch_as_an_error_not_a_panic() { - let root = Rc::new(scan()); - let roots: Vec<(&str, Rc)> = vec![("only", root)]; + let root = scan(); + let roots: Vec<(&str, Rc)> = vec![("only", root)]; let space = search_workload(roots); let err = space.recurrence_profiles(&[], None).unwrap_err(); assert_eq!( @@ -1344,8 +1358,8 @@ mod tests { #[test] fn recurrence_profiles_rejects_an_invalid_update_rate() { - let root = Rc::new(scan()); - let roots: Vec<(&str, Rc)> = vec![("only", root)]; + let root = scan(); + let roots: Vec<(&str, Rc)> = vec![("only", root)]; let space = search_workload(roots); let err = space .recurrence_profiles( @@ -1373,23 +1387,25 @@ mod tests { /// `consumer_count`. #[test] fn recurrence_profiles_does_not_stamp_update_rate_on_a_site_unreachable_from_any_root() { - let avg_root = QueryExpr::Aggregate { - reduction: QueryReduction::by(vec![]), - measures: vec![AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(scan()), - }; - let roots: Vec<(&str, Rc)> = vec![("q", Rc::new(avg_root))]; + let avg_root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: QueryReduction::by(vec![]), + measures: vec![AggIntent::Avg { col: None }], + output_names: vec![], + filters: vec![], + having: None, + child: scan(), + })) + .unwrap(); + let roots: Vec<(&str, Rc)> = vec![("q", avg_root)]; let space = search_workload(roots); let count_group = space .target_subdag_candidates() .find(|g| { matches!( - g.target.as_ref(), - QueryExpr::Aggregate { measures, .. } + g.target.non_asap(), + Some(NonASAPOp::Aggregate { measures, .. }) if measures.iter().any(|m| matches!(m, AggIntent::Count { .. })) ) }) @@ -1428,19 +1444,26 @@ mod tests { /// reachability-set walk would (wrongly) collapse it to. #[test] fn recurrence_profiles_credits_a_direct_repeated_reference_by_its_multiplicity() { - let root = QueryExpr::BinaryOp { - op: asap_types::pre_asap::query_expr::BinaryOpKind::Compare( - asap_types::pre_asap::expr_ir::CompareOpKind::Eq, - ), - lhs: Rc::new(sum_agg()), - rhs: Rc::new(sum_agg()), - vector_match: None, - }; - let space = search_workload(vec![("q", Rc::new(root))]); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: asap_types::ir::operator_properties::BinaryOpKind::Compare( + CompareOpKind::Eq, + ), + vector_match: None, + }, + return_bool: false, + lhs: sum_agg(), + rhs: sum_agg(), + })) + .unwrap(); + let space = search_workload(vec![("q", root)]); let shared_group = space .target_subdag_candidates() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .expect("sum_agg() should merge onto one shared Rc, referenced twice from BinaryOp"); assert_eq!( shared_group.consumer_count, 2, @@ -1463,7 +1486,7 @@ mod tests { let scan_group = space .target_subdag_candidates() - .find(|group| matches!(group.target.as_ref(), QueryExpr::Scan { .. })) + .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Scan { .. }))) .expect("the shared aggregate has a scan descendant"); assert_eq!( profiles diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 44ffef189..a3ff4dbd4 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -3,9 +3,9 @@ //! under "Key concepts (not yet implemented)", implemented for real (issue //! #251, part of #33). //! -//! ## One step, not two: `SketchAlgorithmStrategy::replacements()` decides *and* builds +//! ## One step, not two: `ASAPStrategies::replacements()` decides *and* builds //! -//! For a bindable `Aggregate`, `SketchAlgorithmStrategy::replacements()` is the +//! For a bindable `Aggregate`, `ASAPStrategies::replacements()` is the //! single place this crate both decides what an `AggIntent` may become and //! turns each of those candidates into a real, executable //! [`ReplacementSubDAG`]: @@ -18,8 +18,8 @@ //! `CostModel::size_params`, not a placeholder filled in later). //! 2. **Build**: for each candidate in that list, [`construct_summary`] //! mechanically turns the already-decided `(kind, params)` into a real -//! [`SummaryNode`] — derives the child schema, resolves the summarized -//! column, builds the readout query, recurses into the child (via +//! [`OperatorNode`] — derives the child schema, resolves the summarized +//! column, builds the evaluation query, recurses into the child (via //! [`realize_child`], so a nested aggregate gets its own //! independent enumeration, never the outer target's forced choice), and //! assembles the `SummaryAgg`/`SummaryEstimate` node. @@ -31,13 +31,13 @@ //! has to run regardless of how `(kind, params)` were chosen, so it lives //! directly inside the one method that needs it. //! -//! - [`TargetSubDAG`] — a reference to a pre-ASAP [`QueryExpr`] node that is a +//! - [`TargetSubDAG`] — a reference to a pre-ASAP [`OperatorNode`] that is a //! candidate for replacement, plus how many places in the workload already //! reference it (its `consumer_count`) — the one piece of cross-node //! context [`SharedSubDAGStrategy`] needs that a bare node reference alone //! doesn't carry. //! - [`ReplacementSubDAG`] — one candidate replacement for a `TargetSubDAG`: -//! either a fully bound [`SummaryNode`] or a pre-ASAP [`QueryExpr`] rewrite +//! either a fully bound summary sub-DAG or a pre-ASAP logical rewrite //! (still logical, structurally different from the target but semantically //! equivalent) — see [`Replacement`] — plus a human-readable `rationale`. //! - [`ReplacementStrategy`] — `matches` + `replacements`, the same @@ -72,7 +72,7 @@ //! //! ## The two strategies, and why these two //! -//! - [`SketchAlgorithmStrategy`] wraps [`realizations_for_intent`]'s exhaustive, +//! - [`ASAPStrategies`] wraps [`realizations_for_intent`]'s exhaustive, //! ranked list directly: for the same bindable-`Aggregate` shape this crate //! binds (single intent, no `HAVING`), every entry becomes its own bound //! candidate. @@ -80,7 +80,7 @@ //! `asap_types::pre_asap::cse::share_common_sub_dags`'s sharing decision. //! Wherever a [`TargetSubDAG`] already has two or more consumers (i.e. //! `share_common_sub_dags` already collapsed two or more workload -//! locations onto the same `Rc` — [`discover_targets`] below +//! locations onto the same `Rc` — [`discover_targets`] below //! does the identical workload-wide discovery for [`search_workload_with`]; //! this module's own tests reuse the same dedup logic to build realistic //! fixtures), it reports the two-way candidate CSE's own detection pass @@ -98,7 +98,7 @@ //! unchanged.** Same inputs still produce the same exhaustive, ranked //! list — only its home moved (from a separate `implementation` module //! into this one) and its own visibility dropped to module-private, since -//! [`SketchAlgorithmStrategy`] is now its only caller. +//! [`ASAPStrategies`] is now its only caller. //! //! ## Workload-wide search — merged in from the former `search.rs` (issue #252, part of #33) //! @@ -143,12 +143,12 @@ //! //! 1. **Per-target candidates, not flat plans.** [`TargetSubDAGCandidates`] //! stores the alternatives for one distinct [`TargetSubDAG`] (identified by -//! its own `Rc` pointer identity — the same currency +//! its own `Rc` pointer identity — the same currency //! [`asap_types::pre_asap::cse::share_common_sub_dags`] already //! established across the workload) holding every //! [`ReplacementSubDAG`] alternative discovered for it. [`CandidateLogicalASAPDAGs`] is //! a collection of these groups, keyed by `TargetSubDAG` — a candidate -//! "plan" is never materialized as a distinct top-level `Rc` +//! "plan" is never materialized as a distinct top-level `Rc` //! at all; two logically-different overall choices at two different //! targets are just two different entries in two different groups, //! sharing every other node in the workload by construction (they *are* @@ -157,7 +157,7 @@ //! discipline.** [`asap_types::pre_asap::cse::structural_hash`] (made //! `pub` for exactly this reuse) is only ever a candidate-narrowing //! filter; [`TargetSubDAGCandidates::add_candidate`]'s actual duplicate check is -//! `QueryExpr`'s derived `PartialEq` — the same "hash is a filter, +//! `OperatorNode`'s derived `PartialEq` — the same "hash is a filter, //! `PartialEq` is the decision, no exceptions" rule `cse.rs`'s own //! "Correctness" section states and this module inherits rather than //! reinvents. See [`is_duplicate_rewrite`] for the one deliberate @@ -212,10 +212,10 @@ //! an alternative *for* the target just processed, not a new target of its //! own; see [`discover_new_descendant_targets`]) are scanned for pointers //! not already known, and any found become next round's frontier. Both shipped -//! strategies are idempotent in exactly this sense: [`SketchAlgorithmStrategy`] -//! produces terminal [`Replacement::Summary`] candidates (no `QueryExpr` -//! children to scan at all), and [`SharedSubDAGStrategy`]'s two -//! [`Replacement::Rewrite`] candidates both reuse the target's own +//! strategies are idempotent in exactly this sense: [`ASAPStrategies`] +//! produces terminal bound-summary [`Replacement::SubDAG`] candidates (no +//! logical-rewrite children to scan at all), and [`SharedSubDAGStrategy`]'s +//! two logical-rewrite [`Replacement::SubDAG`] candidates both reuse the target's own //! already-known child `Rc`s verbatim (`Rc::clone`/a shallow top-level //! `.clone()` — see that strategy's own doc). So for both, the frontier is //! always empty after round one: real workloads converge in exactly one @@ -248,7 +248,7 @@ //! share-vs-recompute pair is ranked by calling //! [`CostModel::cse_share_decision`] via this module's own //! [`cse_preference`] — rather than re-deriving a competing comparison. -//! - A group whose candidates are [`SketchAlgorithmStrategy`]'s sketch-family +//! - A group whose candidates are [`ASAPStrategies`]'s sketch-family //! candidates is ranked via [`CostModel::rank_candidates`] (the same hook //! `realizations_for_intent` itself consults), applied to the //! candidates' own [`SketchAlgorithm`]s. @@ -317,8 +317,8 @@ //! documented follow-up rather than silently overclaimed: //! //! - [`CostModel::rank_candidates`]/[`CostModel::size_params`] — the hooks -//! [`SketchAlgorithmStrategy`] groups rank by — take no `consumer_count` -//! parameter at all today, so a `SketchAlgorithmStrategy` group's selection +//! [`ASAPStrategies`] groups rank by — take no `consumer_count` +//! parameter at all today, so a `ASAPStrategies` group's selection //! here still falls back to [`rank_group`]'s ordinary (consumer-count- //! blind) local ranking, even though its own //! [`TargetSubDAGSelection::effective_consumer_count`] is computed and exposed @@ -345,31 +345,33 @@ use crate::accuracy::estimators::{ cms::{cms_depth, cms_width}, saturating_ceil, }; +use asap_types::ir::non_asap::any_measure_filtered; +use asap_types::pre_asap::resolve_column_ref; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; +use asap_types::ir::cse::{share_common_sub_dags, structural_hash, HashCache}; +use asap_types::ir::operator_properties::{BinaryOpKind, JoinKind, Reduction}; +use asap_types::ir::timing::validate_default; +use asap_types::ir::SchemaDerivationError; +use asap_types::ir::{ + ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode, Predicate, ProjectItem, ScalarExpr, + SortKey, +}; +use asap_types::post_asap::{AccuracyError, CompositionOperator, GuaranteeSource, ResultGuarantee}; use asap_types::post_asap::{ - validate_execution_data_states_at, EntityIdentity, ExactKind, ExactOperation, - ExactOperationSchemaError, ExactParams, ExecutionDataState, ExecutionDataStateError, + EntityIdentity, ExactKind, ExactOperationSchemaError, ExactParams, ExecutionDataStateError, ExecutionTiming, Field, FieldDataType, GroupingStrategy, NonNegativeWeightProof, SamplingKind, SamplingParams, Schema, SketchAlgorithm, SketchKind, SketchParams, - SketchStatistic as PostAsapSketchStatistic, StatModelKind, StatModelParams, SummaryExpr, - SummaryInputExpr, SummaryNode, SummaryUpdate, ValueOperation, WaveletKind, WaveletParams, - WeightDomain, + SketchStatistic as PostAsapSketchStatistic, StatModelKind, StatModelParams, SummaryInputExpr, + SummaryUpdate, WaveletKind, WaveletParams, WeightDomain, }; -use asap_types::post_asap::{AccuracyError, CompositionOperator, GuaranteeSource, ResultGuarantee}; use asap_types::pre_asap::agg_intent::{agg_is_mergeable, AggIntent}; -use asap_types::pre_asap::column_resolution::resolve_column_ref; -use asap_types::pre_asap::cse::{share_common_sub_dags, structural_hash, HashCache}; use asap_types::pre_asap::expr_ir::{ArithmeticOpKind, ColumnRef}; -use asap_types::pre_asap::query_expr::any_measure_filtered; -use asap_types::pre_asap::query_expr::{ - BinaryOpKind, Predicate, QueryExpr, QueryExprError, Reduction, -}; use asap_types::pre_asap::schema::ColumnId; use asap_types::types::AccuracyTarget; use asap_types::workload::{DataWorkload, QueryRecurrence, QueryWorkload, RepeatedDemand}; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use thiserror::Error; use crate::accuracy::reconciliation::AccuracyReconciliationStrategy; @@ -391,20 +393,20 @@ use crate::rollup::RollupStrategy; use crate::topk_reuse::TopKLimitReuseStrategy; /// Errors from the pre-ASAP → post-ASAP replacement/construction path -/// ([`realize_child`] and [`keep_pre_asap`]). Moved here from the former +/// ([`realize_child`] and [`retain_exact`]). Moved here from the former /// `bind.rs` (issue #251): this is what a [`ReplacementStrategy`] /// implementor's own construction path can realistically fail with — -/// schema derivation over a pre-ASAP [`QueryExpr`] — not something specific -/// to workload-wide orchestration. +/// schema derivation over a pre-ASAP [`OperatorNode`] sub-DAG — not +/// something specific to workload-wide orchestration. #[derive(Debug, Error)] pub enum RealizationError { /// Schema derivation failed while lifting an edge to `Schema`. #[error("schema derivation failed during pre-ASAP → post-ASAP binding: {0}")] - Schema(#[from] QueryExprError), + Schema(#[from] SchemaDerivationError), /// The candidate is accuracy-illegal (issue #172): its composed /// guarantee has no sound propagation rule, or misses the applicable /// `AccuracyTarget`. Fail-closed — the candidate is never constructed - /// with the child "treated as exact". [`SketchAlgorithmStrategy::propose`] + /// with the child "treated as exact". [`ASAPStrategies::propose`] /// records it as a [`RejectedCandidate`] instead of a candidate. #[error("accuracy-illegal candidate: {0}")] Accuracy(#[from] AccuracyError), @@ -414,8 +416,8 @@ pub enum RealizationError { /// would change its semantics. #[error("unsupported physical summary realization: {0}")] PhysicalRealization(&'static str), - /// A constructed plan violates the update/readout phase contract - /// (issue #171) — e.g. a summary readout placed beneath a maintained + /// A constructed plan violates the update/evaluation phase contract + /// (issue #171) — e.g. a summary evaluation placed beneath a maintained /// `SummaryAgg`. Detected at construction, never at runtime. #[error("execution-data_state violation in post-ASAP plan: {0}")] ExecutionDataState(#[from] ExecutionDataStateError), @@ -427,10 +429,10 @@ pub enum RealizationError { /// A pre-ASAP sub-DAG a [`ReplacementStrategy`] knows how to replace. /// -/// `root` is a reference into the workload's own [`QueryExpr`] DAG (an -/// `Rc`, the same currency [`search_workload`] and -/// `asap_types::pre_asap::cse::share_common_sub_dags` already thread through -/// this crate's public API — not a bare `&QueryExpr` — so a strategy that +/// `root` is a reference into the workload's own [`OperatorNode`] DAG (an +/// `Rc`, the same currency [`search_workload`] and +/// `asap_types::ir::cse::share_common_sub_dags` already thread through +/// this crate's public API — not a bare `&OperatorNode` — so a strategy that /// needs the node's own `Rc` identity, not just its shape, has it available /// without the caller re-deriving it). /// @@ -439,16 +441,16 @@ pub enum RealizationError { /// [`search_workload_with`] computes the workload-wide value during target /// discovery. [`TargetSubDAG::new`] defaults it to `1` for callers invoking a /// strategy against one node in isolation. A strategy that only cares about -/// `root`'s shape (for example, [`SketchAlgorithmStrategy`]) can ignore the +/// `root`'s shape (for example, [`ASAPStrategies`]) can ignore the /// count; [`SharedSubDAGStrategy`] consults it directly. /// /// `strictest_sibling_accuracy` is the strictest accuracy among workload /// siblings that read the same summary input as `root`, when stricter than -/// `root`'s own. [`search_workload_with`] sets it; [`SketchAlgorithmStrategy`] +/// `root`'s own. [`search_workload_with`] sets it; [`ASAPStrategies`] /// also sizes a candidate to it. #[derive(Debug, Clone, Copy)] pub struct TargetSubDAG<'a> { - pub root: &'a Rc, + pub root: &'a Rc, pub consumer_count: usize, pub strictest_sibling_accuracy: Option<&'a AccuracyTarget>, } @@ -456,7 +458,7 @@ pub struct TargetSubDAG<'a> { impl<'a> TargetSubDAG<'a> { /// A target assumed to have exactly one consumer — the common case for a /// caller that isn't already tracking cross-workload sharing. - pub fn new(root: &'a Rc) -> Self { + pub fn new(root: &'a Rc) -> Self { Self { root, consumer_count: 1, @@ -466,7 +468,7 @@ impl<'a> TargetSubDAG<'a> { /// A target with an explicit `consumer_count`, used by workload discovery /// and by callers that already know how many locations reference `root`. - pub fn with_consumer_count(root: &'a Rc, consumer_count: usize) -> Self { + pub fn with_consumer_count(root: &'a Rc, consumer_count: usize) -> Self { Self { root, consumer_count, @@ -482,25 +484,35 @@ impl<'a> TargetSubDAG<'a> { /// — into "one candidate among several", each with its own /// [`ReplacementSubDAG`]. #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] // Keep the public strategy API value-based. pub enum Replacement { - /// A fully bound post-ASAP summary decision, for one particular - /// candidate realization of the target. - Summary(Rc), - /// A pre-ASAP rewrite: still a logical [`QueryExpr`], structurally - /// different from the target's own `root` (e.g. sharing vs. not sharing - /// a sub-DAG) but semantically equivalent to it. - Rewrite(Rc), + /// A sub-DAG that replaces the target: either a bound summary decision + /// (a DAG containing ASAP operators, for one particular candidate + /// realization of the target) or a pre-ASAP rewrite (a logical sub-DAG + /// with no ASAP operator, structurally different from the target's own + /// `root` — e.g. sharing vs. not sharing a sub-DAG — but semantically + /// equivalent to it). [`is_logical_rewrite`] tells the two apart. + SubDAG(Rc), /// An exact operator composed over another target's *own* selected - /// decision across an explicit update/readout boundary (issue #171): - /// `ValueOperationAtQueryTime` over a child's summary readout, or + /// decision across an explicit update/evaluation boundary (issue #171): + /// `ValueOperationAtQueryTime` over a child's summary evaluation, or /// `ValueOperationAtIngestionTime` feeding a maintained summary above. Carries only a /// reference to the child target — [`CandidateLogicalASAPDAGs::global_selection`] /// commits the compatible parent/child pair and /// [`GlobalSelection::assemble_selected_dag`] links it into one validated - /// `SummaryNode`. See [`crate::exact_composition`]. + /// `OperatorNode` DAG. See [`crate::exact_composition`]. ExactComposition(ExactComposition), } +/// Whether a [`Replacement::SubDAG`] is a pure logical rewrite: a sub-DAG +/// with no ASAP operator and no guarantee established yet (the shape every +/// front end emits and every rewrite strategy builds). A bound summary +/// decision contains an ASAP operator, or is a kept pre-ASAP sub-DAG that +/// already carries its exact guarantee. +pub fn is_logical_rewrite(node: &OperatorNode) -> bool { + node.guarantee.is_none() && !node.contains_asap() +} + /// One candidate replacement for a [`TargetSubDAG`], plus a human-readable /// `rationale` explaining why it's a valid candidate (meant for a /// report/log/debugging a search engine's choices, not machine parsing — @@ -523,11 +535,12 @@ pub struct ReplacementSubDAG { impl ReplacementSubDAG { /// Whether this summary still needs accuracy/domain evidence before it can /// be treated as certified. A missing guarantee on any summary candidate - /// is unknown; exact `KeepPreAsap` carries an explicit exact guarantee. + /// (a sub-DAG whose root is an ASAP operator) is unknown; a kept + /// pre-ASAP sub-DAG carries an explicit exact guarantee. pub fn has_missing_accuracy_evidence(&self) -> bool { matches!( &self.replacement, - Replacement::Summary(node) if has_missing_accuracy_evidence(node) + Replacement::SubDAG(node) if !is_logical_rewrite(node) && has_missing_accuracy_evidence(node) ) } @@ -539,8 +552,12 @@ impl ReplacementSubDAG { Replacement::ExactComposition(composition) => { cost_model.value_operation_support_evidence(&composition.op, composition.placement) } - Replacement::Summary(node) => cost_model.summary_support_evidence(node), - Replacement::Rewrite(_) => Some(true), + // Any summary decision, including one rooted in a relational + // operator above its evaluations, asks the deployment for support. + Replacement::SubDAG(node) if !is_logical_rewrite(node) => { + cost_model.summary_support_evidence(node) + } + Replacement::SubDAG(_) => Some(true), } } } @@ -574,7 +591,7 @@ pub enum ReplacementProvenance { /// A finalized whole-query result over rows carrying the PromQL series /// identity, which the logical root does not expose (see /// [`ReplacementStrategy::propose_for_root`]). Default selection never - /// commits it, because its readout must be validated and priced by + /// commits it, because its evaluation must be validated and priced by /// deployment; otherwise it would silently replace the logical plan. RootPhysicalRealization, } @@ -615,7 +632,7 @@ pub struct Proposals { /// of this trait or any existing strategy required. /// /// `replacements` is only meaningful when `matches` would return `true` for -/// the same target; both [`SketchAlgorithmStrategy`] and [`SharedSubDAGStrategy`] +/// the same target; both [`ASAPStrategies`] and [`SharedSubDAGStrategy`] /// return an empty `Vec` rather than panicking when called on a target they /// don't match, so a caller that skips the `matches` check first still gets a /// safe (merely uninformative) answer instead of a crash. @@ -657,7 +674,7 @@ pub trait ReplacementStrategy { /// (for example, the PromQL series identity), so /// [`search_workload_with_targets`] asks only workload roots, once each. /// They decide what to compute, never placement. Default: none. - fn propose_for_root(&self, _root: &Rc, _target: &AccuracyTarget) -> Proposals { + fn propose_for_root(&self, _root: &Rc, _target: &AccuracyTarget) -> Proposals { Proposals::default() } } @@ -674,41 +691,41 @@ pub trait ReplacementStrategy { /// [`realizations_for_intent`] is where every valid realization gets /// enumerated, exhaustive and ranked (most-preferred first) — this crate has /// no separate function that computes just "the one" `Realization` -/// independently of that list. [`SketchAlgorithmStrategy`] is the sole +/// independently of that list. [`ASAPStrategies`] is the sole /// consumer: it wraps every entry of this list into its own bound -/// [`SummaryNode`] and returns all of them, ranked — a caller wanting a +/// [`OperatorNode`] and returns all of them, ranked — a caller wanting a /// single answer keeps the first one itself (see the module docs above). #[derive(Debug, Clone, PartialEq)] pub enum Realization { /// An exact **mergeable** accumulator (partial state ≡ the value /// itself: `Sum` / `Count` / `Min` / `Max` / `Rate` / `Increase`). The - /// built state *is* the answer already — no `SummaryEstimate` readout + /// built state *is* the answer already — no `SummaryEstimate` evaluation /// step. ExactAggregate { kind: ExactKind, params: ExactParams, }, /// An approximate sketch sized to the intent's [`AccuracyTarget`]. - /// Needs a `SummaryEstimate` readout to recover a value. Already + /// Needs a `SummaryEstimate` evaluation to recover a value. Already /// classified into its [`SketchKind`] category (`SketchKind::new` /// having been called) — construction always goes through that /// classifier, never this variant directly. Sketch(SketchKind), /// A sampling-based summary (a retained row subset). Needs a - /// `SummaryEstimate` readout. Not chosen by any core `AggIntent` + /// `SummaryEstimate` evaluation. Not chosen by any core `AggIntent` /// dispatch today — see the module docs. Sample { kind: SamplingKind, params: SamplingParams, }, - /// A wavelet-transform summary. Needs a `SummaryEstimate` readout. Not + /// A wavelet-transform summary. Needs a `SummaryEstimate` evaluation. Not /// chosen by any core `AggIntent` dispatch today — see the module docs. Wavelet { kind: WaveletKind, params: WaveletParams, }, /// A fitted statistical/parametric-model summary. Needs a - /// `SummaryEstimate` readout. Not chosen by any core `AggIntent` + /// `SummaryEstimate` evaluation. Not chosen by any core `AggIntent` /// dispatch today — see the module docs. StatModel { kind: StatModelKind, @@ -827,7 +844,7 @@ pub fn accuracy_target(intent: &AggIntent) -> Option<&AccuracyTarget> { /// (most-preferred first via `cost_model`) — the *only* place this crate /// decides what an `AggIntent` may become. Nothing in this crate computes /// "the one" `Realization` independently of this list: -/// [`SketchAlgorithmStrategy`] keeps every entry as a candidate, and a caller +/// [`ASAPStrategies`] keeps every entry as a candidate, and a caller /// that wants a single executable answer takes the head of *that* strategy's /// output itself. /// @@ -835,7 +852,7 @@ pub fn accuracy_target(intent: &AggIntent) -> Option<&AccuracyTarget> { /// explicit realization is a compile error, and the coverage-matrix test pins /// each variant's category. /// -/// `pub(crate)`: [`SketchAlgorithmStrategy::replacements`] is this module's +/// `pub(crate)`: [`ASAPStrategies::replacements`] is this module's /// own caller; `grouping::HydraGroupingStrategy` (issue #256) is the one /// caller outside it, needing the exact same already-ranked candidate list /// to find the `Realization::Sketch` matching the Hydra-eligible kind it @@ -1190,9 +1207,9 @@ pub fn posterior_aware_size_params( } } -// ── SketchAlgorithmStrategy ───────────────────────────────────────────────── +// ── ASAPStrategies ───────────────────────────────────────────────── -/// A single static instance so [`SketchAlgorithmStrategy::default_cost_model`] +/// A single static instance so [`ASAPStrategies::default_cost_model`] /// can hand out a `&'static dyn CostModel` without heap-allocating one — /// `DefaultCostModel` is a unit struct with no state, so one instance serves /// every caller. @@ -1227,14 +1244,17 @@ impl<'a> CandidatePlanningInputs<'a> { } } -/// Wraps [`realizations_for_intent`]'s exhaustive, ranked list directly: for -/// a bindable `Aggregate`, every valid candidate summary realization as its -/// own [`ReplacementSubDAG`]. +/// Proposes the supported ASAP realizations for a bindable aggregate, including +/// exact accumulators, approximate sketches, and supported maintained populations. +/// Each valid realization becomes its own [`ReplacementSubDAG`]. +/// +/// [`realizations_for_intent`] enumerates summary families; extension hooks can +/// supply additional supported families. This is not limited to sketch algorithms. /// /// Ranked (only to *order the enumeration*, never to drop a candidate) via a /// [`CostModel`] — [`DefaultCostModel`] unless constructed with -/// [`SketchAlgorithmStrategy::new`] — so a deployment-specific cost model's -/// other hooks (`size_params`, `realize_extension`, `readout_extension`) are +/// [`ASAPStrategies::new`] — so a deployment-specific cost model's +/// other hooks (`size_params`, `realize_extension`, `evaluation_extension`) are /// still consulted while binding each candidate. /// /// The one thing that *does* drop a candidate is accuracy legality (issue @@ -1245,11 +1265,11 @@ impl<'a> CandidatePlanningInputs<'a> { /// [`ReplacementStrategy::propose`] as a [`RejectedCandidate`]. See /// [`crate::accuracy`]'s module docs for the rules and the precedence /// between root and per-node targets. -pub struct SketchAlgorithmStrategy<'a> { +pub struct ASAPStrategies<'a> { planning_inputs: CandidatePlanningInputs<'a>, } -impl SketchAlgorithmStrategy<'static> { +impl ASAPStrategies<'static> { /// A strategy that ranks/binds via the built-in [`DefaultCostModel`] — /// what a deployment gets with no custom cost model plugged in. pub fn default_cost_model() -> Self { @@ -1259,7 +1279,7 @@ impl SketchAlgorithmStrategy<'static> { } } -impl<'a> SketchAlgorithmStrategy<'a> { +impl<'a> ASAPStrategies<'a> { /// A strategy that ranks/binds via `cost_model` instead of the built-in /// static preference order — the same customization point /// [`realizations_for_intent`] already offers. Accuracy legality stays @@ -1314,34 +1334,33 @@ impl<'a> SketchAlgorithmStrategy<'a> { /// treats a range of historical samples as the instant vector. pub fn current_series_topk_candidates( &self, - root: &Rc, + root: &Rc, accuracy: &AccuracyTarget, ) -> Proposals { - let QueryExpr::Limit { - n, + let Some(NonASAPOp::Limit { + n: Some(n), offset: 0, child, - } = root.as_ref() + .. + }) = root.non_asap() else { return Proposals::default(); }; - let QueryExpr::Sort { + let Some(NonASAPOp::Sort { keys, partition_by, child, - } = child.as_ref() + }) = child.non_asap() else { return Proposals::default(); }; let [key] = keys.as_slice() else { return Proposals::default(); }; - let QueryExpr::Column(value) = key.expr else { - return Proposals::default(); - }; - let Ok(schema) = child.output_schema() else { + let ScalarExpr::Column(value) = key.expr else { return Proposals::default(); }; + let schema = &child.schema; if key.ascending || key.nulls_first || partition_by.is_without() @@ -1354,20 +1373,107 @@ impl<'a> SketchAlgorithmStrategy<'a> { { return Proposals::default(); } - let ranked = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::Reduce(partition_by.clone()), - measures: vec![AggIntent::TopK { - k: *n, - accuracy: accuracy.clone(), - }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(child), - }); + let Ok(ranked) = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::Reduce(partition_by.clone()), + measures: vec![AggIntent::TopK { + k: *n, + accuracy: accuracy.clone(), + }], + output_names: vec![], + filters: vec![], + having: None, + child: Rc::clone(child), + })) + else { + return Proposals::default(); + }; self.propose_with(&ranked, None, None) } + /// Fixed-window maintenance can finalize each series' counter state and + /// build a fresh heap or grouped Sum for that evaluation window. Deployment must provide + /// a complete, synchronized population and bind the matching window; this + /// candidate never incrementally adds one window's rates to another. + /// + pub fn fixed_window_rate_candidates(&self, root: &Rc) -> Proposals { + fn place(node: &Rc) -> Option> { + retime_rate_finalize(node, ExecutionTiming::IngestionTime, true) + } + let timed = |node: &Rc| { + asap_types::ir::timing::apply_lifecycle_timings( + node, + &asap_types::ir::timing::LifecycleAssignment::default_maintained(), + &mut asap_types::ir::timing::TimingMemo::new(), + ) + .ok() + .and_then(|timed| { + asap_types::ir::physical_export::compile_physical_asap_dag(&timed).ok() + }) + }; + let mut proposals = self.propose_with(root, None, None); + proposals.candidates.retain_mut(|candidate| { + let Replacement::SubDAG(node) = &candidate.replacement else { + return false; + }; + let Some(dag) = timed(node) else { + return false; + }; + if !dag.nodes.iter().any(|node| match &node.payload { + asap_types::ir::physical_export::PhysicalASAPOperatorPayload::ASAP( + asap_types::ir::ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }, + ) => matches!( + kind.algorithm(), + SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap + ), + asap_types::ir::physical_export::PhysicalASAPOperatorPayload::ASAP( + asap_types::ir::ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(ExactKind::Sum, _), + .. + }, + ) => true, + _ => false, + }) { + return false; + } + let Some(placed) = place(node) else { + return false; + }; + if timed(&placed).is_none() { + return false; + } + let Ok(placed) = finalize_query_candidate(placed, root) else { + return false; + }; + candidate.replacement = Replacement::SubDAG(placed); + candidate + .rationale + .push_str("; fixed-window precompute over complete per-series counter states"); + true + }); + proposals + } + + /// Retain grouped Sum after a per-series Rate evaluation as a query-time + /// candidate alongside its complete-window maintenance placement. + /// + pub fn query_time_rate_aggregation_candidates(&self, root: &Rc) -> Proposals { + let mut proposals = self.fixed_window_rate_candidates(root); + proposals.candidates.retain_mut(|candidate| { + let Replacement::SubDAG(node) = &candidate.replacement else { return false }; + if !matches!(&node.operator, Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) + if matches!(&child.operator, Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Sum, _), .. }))) { return false; } + let Some(query_time) = retime_rate_finalize(node, ExecutionTiming::QueryTime, false) else { return false }; + candidate.replacement = Replacement::SubDAG(query_time); + candidate.rationale = "query-time grouped Sum over complete per-series Rate evaluations".into(); + true + }); + proposals + } + pub(crate) fn from_planning_inputs(planning_inputs: CandidatePlanningInputs<'a>) -> Self { Self { planning_inputs } } @@ -1380,20 +1486,21 @@ impl<'a> SketchAlgorithmStrategy<'a> { /// with the sibling that needs it. fn propose_with( &self, - root: &Rc, + root: &Rc, intent_override: Option<&AggIntent>, strictest_sibling: Option<&AccuracyTarget>, ) -> Proposals { let mut proposals = Proposals::default(); - // A selected logical rewrite otherwise remains KeepPreAsap during DAG - // assembly. Also expose its concrete summary realization for selection. + // A selected logical rewrite otherwise stays a kept pre-ASAP sub-DAG + // during DAG assembly. Also expose its concrete summary realization + // for selection. if intent_override.is_none() { if let Some(rewritten) = crate::rewrite::composed_aggregate_rewrite(root) { if let Ok(node) = realize_child_with(&rewritten, self.planning_inputs, None) { - if !matches!(node.expr, SummaryExpr::KeepPreAsap(_)) { + if node.contains_asap() { proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::Summary(node), - strategy: "SketchAlgorithmStrategy", + replacement: Replacement::SubDAG(node), + strategy: "ASAPStrategies", provenance: ReplacementProvenance::SummaryRealization, rationale: "realize a schema-preserving composition of temporal and grouped accumulators".into(), }); @@ -1403,8 +1510,8 @@ impl<'a> SketchAlgorithmStrategy<'a> { } if let Ok(Some(node)) = exact_topk_over_temporal_values(root, self.planning_inputs) { proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::Summary(node), - strategy: "SketchAlgorithmStrategy", + replacement: Replacement::SubDAG(node), + strategy: "ASAPStrategies", provenance: ReplacementProvenance::SummaryRealization, rationale: "select exact Top-K from independently maintained temporal values" .into(), @@ -1413,8 +1520,8 @@ impl<'a> SketchAlgorithmStrategy<'a> { if intent_override.is_none() { if let Ok(Some(node)) = realize_temporal_average(root, self.planning_inputs, None) { proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::Summary(node), - strategy: "SketchAlgorithmStrategy", + replacement: Replacement::SubDAG(node), + strategy: "ASAPStrategies", provenance: ReplacementProvenance::SummaryRealization, rationale: "read temporal average from sum/count only within the finite arithmetic domain; otherwise execute the original average".into(), }); @@ -1428,8 +1535,8 @@ impl<'a> SketchAlgorithmStrategy<'a> { "preserve exact PromQL arithmetic over independently realized summary operands" }; proposals.candidates.push(ReplacementSubDAG { - replacement: Replacement::Summary(node), - strategy: "SketchAlgorithmStrategy", + replacement: Replacement::SubDAG(node), + strategy: "ASAPStrategies", provenance: ReplacementProvenance::SummaryRealization, rationale: rationale.into(), }); @@ -1516,17 +1623,17 @@ impl<'a> SketchAlgorithmStrategy<'a> { let Some(child) = aggregate_child(root) else { continue; }; - let QueryExpr::Aggregate { reduction, .. } = root.as_ref() else { + let Some(NonASAPOp::Aggregate { reduction, .. }) = root.non_asap() else { continue; }; let Ok(input) = realize_physical_summary_input(intent, &family, reduction, child) else { continue; }; - let readout_query = readout(intent, &input.input, planning_inputs.cost); + let evaluation_query = evaluation(intent, &input.input, planning_inputs.cost); let Some(local) = planning_inputs .accuracy - .local_guarantee(&family, &readout_query) + .local_guarantee(&family, &evaluation_query) else { continue; }; @@ -1537,7 +1644,7 @@ impl<'a> SketchAlgorithmStrategy<'a> { let allocations = planning_inputs.allocator.allocations(target, &shape); if allocations.is_empty() { proposals.rejected.push(RejectedCandidate { - strategy: "SketchAlgorithmStrategy", + strategy: "ASAPStrategies", description: rationale.clone(), error: AccuracyError::NoLegalAllocation { target: target.clone(), @@ -1591,10 +1698,10 @@ impl<'a> SketchAlgorithmStrategy<'a> { } if proposals.candidates.is_empty() { if let Some(error) = &proposals.domain_error { - if let Ok(node) = keep_pre_asap(root) { + if let Ok(node) = retain_exact(root) { proposals.candidates.push(ReplacementSubDAG { - strategy: "SketchAlgorithmStrategy", - replacement: Replacement::Summary(node), + strategy: "ASAPStrategies", + replacement: Replacement::SubDAG(node), provenance: ReplacementProvenance::SummaryRealization, rationale: format!( "{} stays pre-ASAP because summary construction crosses an illegal \ @@ -1613,16 +1720,16 @@ impl Proposals { /// File one construction attempt: a legal node becomes a candidate, an /// [`RealizationError::Accuracy`] becomes a [`RejectedCandidate`], and a /// schema-derivation failure is skipped exactly as it always was. - fn record(&mut self, rationale: String, built: Result, RealizationError>) { + fn record(&mut self, rationale: String, built: Result, RealizationError>) { match built { Ok(node) => self.candidates.push(ReplacementSubDAG { - strategy: "SketchAlgorithmStrategy", - replacement: Replacement::Summary(node), + strategy: "ASAPStrategies", + replacement: Replacement::SubDAG(node), provenance: ReplacementProvenance::SummaryRealization, rationale, }), Err(RealizationError::Accuracy(error)) => self.rejected.push(RejectedCandidate { - strategy: "SketchAlgorithmStrategy", + strategy: "ASAPStrategies", description: rationale, error, }), @@ -1639,14 +1746,14 @@ impl Proposals { } /// The `child` of a [`bindable_intent`]-shaped `Aggregate`. -fn aggregate_child(node: &QueryExpr) -> Option<&Rc> { - match node { - QueryExpr::Aggregate { child, .. } => Some(child), +fn aggregate_child(node: &OperatorNode) -> Option<&Rc> { + match node.non_asap() { + Some(NonASAPOp::Aggregate { child, .. }) => Some(child), _ => None, } } -impl ReplacementStrategy for SketchAlgorithmStrategy<'_> { +impl ReplacementStrategy for ASAPStrategies<'_> { fn matches(&self, target: &TargetSubDAG<'_>) -> bool { bindable_intent(target.root).is_some() || is_supported_exact_binary(target.root) } @@ -1665,24 +1772,24 @@ impl ReplacementStrategy for SketchAlgorithmStrategy<'_> { /// for the identity-carrying root. Placement variants (for example, /// fixed-window or query-time Rate aggregation) are not listed here: the /// lifecycle assigns timing and the physical compiler reads it. - fn propose_for_root(&self, root: &Rc, target: &AccuracyTarget) -> Proposals { - let Ok(typed) = asap_types::pre_asap::schema::with_promql_series_identity(root) else { + fn propose_for_root(&self, root: &Rc, target: &AccuracyTarget) -> Proposals { + let Ok(typed) = asap_types::ir::schema_support::with_promql_series_identity(root) else { return Proposals::default(); }; - let typed = Rc::new(typed); + let mut proposals = self.current_series_topk_candidates(&typed, target); for mut candidate in std::mem::take(&mut proposals.candidates) { - let Replacement::Summary(node) = candidate.replacement else { + let Replacement::SubDAG(node) = candidate.replacement else { continue; }; let Ok(node) = finalize_query_candidate(node, &typed) else { continue; }; let duplicate = proposals.candidates.iter().any(|existing| { - matches!(&existing.replacement, Replacement::Summary(other) if *other == node) + matches!(&existing.replacement, Replacement::SubDAG(other) if *other == node) }); if !duplicate { - candidate.replacement = Replacement::Summary(node); + candidate.replacement = Replacement::SubDAG(node); candidate.provenance = ReplacementProvenance::RootPhysicalRealization; proposals.candidates.push(candidate); } @@ -1755,11 +1862,11 @@ pub(crate) fn describe_intent(intent: &AggIntent) -> String { } } -// ── realize_child / keep_pre_asap: rank-and-take-first, and its fallback ── +// ── realize_child / retain_exact: rank-and-take-first, and its fallback ── -/// Rank-and-take-first selector for a single [`QueryExpr`] node: enumerate -/// every candidate via [`SketchAlgorithmStrategy::replacements`], keep the -/// `cost_model`-preferred (first) one, and fall back to [`keep_pre_asap`] +/// Rank-and-take-first selector for a single [`OperatorNode`]: enumerate +/// every candidate via [`ASAPStrategies::replacements`], keep the +/// `cost_model`-preferred (first) one, and fall back to [`retain_exact`] /// when there's no candidate at all — **not** a general single-answer API /// for a whole workload. Use [`CandidateLogicalASAPDAGs::global_selection`] and DAG assembly /// for coordinated logical selection; physical deployment remains downstream. @@ -1771,16 +1878,16 @@ pub(crate) fn describe_intent(intent: &AggIntent) -> String { /// ([`construct_summary_agg`], so a nested aggregate gets its own /// independent enumeration instead of inheriting the parent's forced /// candidate), from this module's own [`realize_one`] (the representative -/// bound `SummaryNode` [`cse_preference`] needs for a +/// bound `OperatorNode` [`cse_preference`] needs for a /// [`CostModel::cse_share_decision`] comparison), and from /// [`crate::cost_model::DefaultCostModel::estimate_cost`] (the same /// representative-node need, for a [`Replacement::Rewrite`] candidate's own /// cost estimate). Every other caller goes through -/// [`SketchAlgorithmStrategy::replacements`] directly and decides for itself. +/// [`ASAPStrategies::replacements`] directly and decides for itself. pub(crate) fn realize_child( - root: &Rc, + root: &Rc, cost_model: &dyn CostModel, -) -> Result, RealizationError> { +) -> Result, RealizationError> { realize_child_with( root, CandidatePlanningInputs::with_default_accuracy(cost_model), @@ -1797,17 +1904,17 @@ pub(crate) fn realize_child( /// budget. A child whose declared target is `Exact` keeps it: an allocation /// never approximates something the caller declared exact. fn exact_topk_over_temporal_values( - root: &Rc, + root: &Rc, planning_inputs: CandidatePlanningInputs<'_>, -) -> Result>, RealizationError> { - let QueryExpr::Aggregate { +) -> Result>, RealizationError> { + let Some(NonASAPOp::Aggregate { reduction, measures, output_names: _, filters, having: None, child, - } = root.as_ref() + }) = root.non_asap() else { return Ok(None); }; @@ -1817,19 +1924,19 @@ fn exact_topk_over_temporal_values( let [AggIntent::TopK { k, .. }] = measures.as_slice() else { return Ok(None); }; - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, child: input, .. - } = child.as_ref() + }) = child.non_asap() else { return Ok(None); }; - if !matches!(input.as_ref(), QueryExpr::TimeRange { .. }) { + if !matches!(input.non_asap(), Some(NonASAPOp::TimeRange { .. })) { return Ok(None); } let values = realize_child_with(child, planning_inputs, Some(&AccuracyTarget::Exact))?; - if matches!(values.expr, SummaryExpr::KeepPreAsap(_)) + if !values.contains_asap() || !values .guarantee .as_ref() @@ -1845,61 +1952,63 @@ fn exact_topk_over_temporal_values( ))? .clone(); let score = ranking_score_index(child, &values.schema)?; - let sorted = Rc::new(SummaryNode { - guarantee: values.guarantee.clone(), - schema: values.schema.clone(), - expr: SummaryExpr::ValueOperation { - child: values, - operation: ValueOperation::Sort { - keys: vec![asap_types::pre_asap::SortKey { - expr: QueryExpr::Column(score), + let guarantee = values.guarantee.clone(); + let schema = values.schema.clone(); + let sorted = Rc::new( + OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::Sort { + keys: vec![SortKey { + expr: ScalarExpr::Column(score), ascending: false, nulls_first: false, }], partition_by: partition_by.clone(), - }, - timing: ExecutionTiming::QueryTime, - }, - }); - let node = Rc::new(SummaryNode { - guarantee: sorted.guarantee.clone(), - schema: sorted.schema.clone(), - expr: SummaryExpr::ValueOperation { - child: sorted, - operation: ValueOperation::Limit { - n: *k, + child: values, + }), + schema.clone(), + ) + .with_guarantee(guarantee.clone()), + ); + let node = Rc::new( + OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::Limit { + n: Some(*k), offset: 0, partition_by, - }, - timing: ExecutionTiming::QueryTime, - }, - }); - validate_execution_data_states_at(&node, ExecutionDataState::QUERY_ROWS)?; + child: sorted, + }), + schema, + ) + .with_guarantee(guarantee), + ); + validate_default(&node, ExecutionTiming::QueryTime)?; Ok(Some(node)) } fn realize_temporal_average( - root: &Rc, + root: &Rc, planning_inputs: CandidatePlanningInputs<'_>, target: Option<&AccuracyTarget>, -) -> Result>, RealizationError> { +) -> Result>, RealizationError> { let Some(components) = crate::rewrite::temporal_average_components(root) else { return Ok(None); }; let mut node = realize_child_with(&components, planning_inputs, target)?; - let SummaryExpr::BinaryOp { operator, .. } = &mut Rc::make_mut(&mut node).expr else { + let Operator::NonASAP(NonASAPOp::BinaryOp { operator, .. }) = + &mut Rc::make_mut(&mut node).operator + else { return Ok(None); }; operator.checked_finite_division = true; - validate_execution_data_states_at(&node, ExecutionDataState::QUERY_ROWS)?; + validate_default(&node, ExecutionTiming::QueryTime)?; Ok(Some(node)) } pub(crate) fn realize_child_with( - root: &Rc, + root: &Rc, planning_inputs: CandidatePlanningInputs<'_>, end_to_end_target: Option<&AccuracyTarget>, -) -> Result, RealizationError> { +) -> Result, RealizationError> { if let Some(node) = realize_temporal_average(root, planning_inputs, end_to_end_target)? { return Ok(node); } @@ -1913,29 +2022,29 @@ pub(crate) fn realize_child_with( Some(_) => Some(override_accuracy(declared, target)), } }); - match SketchAlgorithmStrategy::from_planning_inputs(planning_inputs) + match ASAPStrategies::from_planning_inputs(planning_inputs) .propose_with(root, overridden.as_ref(), None) .candidates .into_iter() .next() { Some(ReplacementSubDAG { - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), .. }) => Ok(node), Some(ReplacementSubDAG { - replacement: Replacement::Rewrite(_) | Replacement::ExactComposition(_), + replacement: Replacement::ExactComposition(_), .. }) => { - unreachable!("SketchAlgorithmStrategy never returns a Rewrite/composition candidate") + unreachable!("ASAPStrategies never returns a composition candidate") } // No candidate at all: `root` isn't `bindable_intent` shape (or its // intent has no realization `realizations_for_intent` can't // produce — never happens, that match is exhaustive), or every // candidate was accuracy-illegal — either way the same conservative - // fallback `SketchAlgorithmStrategy::matches` uses: keep the + // fallback `ASAPStrategies::matches` uses: keep the // pre-ASAP sub-DAG, executed exactly. - None => keep_pre_asap(root), + None => retain_exact(root), } } @@ -1944,29 +2053,24 @@ pub(crate) fn realize_child_with( /// accelerated, return `None` so the caller keeps the whole query exact; /// mixed raw/summary snapshots are never constructed. fn realize_binary( - root: &Rc, + root: &Rc, planning_inputs: CandidatePlanningInputs<'_>, end_to_end_target: Option<&AccuracyTarget>, -) -> Result>, RealizationError> { - let QueryExpr::BinaryOp { - op, +) -> Result>, RealizationError> { + let Some(NonASAPOp::BinaryOp { + operator, + return_bool, lhs, rhs, - vector_match, - } = root.as_ref() + }) = root.non_asap() else { return Ok(None); }; + let (op, vector_match) = (&operator.kind, &operator.vector_match); if !matches!(op, BinaryOpKind::Arithmetic(_)) || vector_match.is_some() { return Ok(None); } - let lhs_scalar = is_promql_scalar(lhs); - let rhs_scalar = is_promql_scalar(rhs); - if lhs_scalar && rhs_scalar { - return Ok(None); - } - let mut lhs_node = realize_binary_operand(lhs, planning_inputs, None)?; let mut rhs_node = realize_binary_operand(rhs, planning_inputs, None)?; @@ -2085,8 +2189,8 @@ fn realize_binary( return Ok(None); } - let lhs_accelerated = lhs_scalar || !matches!(lhs_node.expr, SummaryExpr::KeepPreAsap(_)); - let rhs_accelerated = rhs_scalar || !matches!(rhs_node.expr, SummaryExpr::KeepPreAsap(_)); + let lhs_accelerated = lhs_node.contains_asap(); + let rhs_accelerated = rhs_node.contains_asap(); if !lhs_accelerated || !rhs_accelerated { return Ok(None); } @@ -2133,47 +2237,106 @@ fn realize_binary( return Ok(None); } - Ok(Some(Rc::new(SummaryNode { - expr: SummaryExpr::BinaryOp { - timing: ExecutionTiming::QueryTime, - lhs: lhs_node, - rhs: rhs_node, - operator: asap_types::post_asap::BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: op.clone(), - vector_match: vector_match.clone(), - }, - }, - schema: lift(&root.output_schema()?), + Ok(Some(Rc::new( + OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: op.clone(), + vector_match: vector_match.clone(), + }, + return_bool: *return_bool, + lhs: lhs_node, + rhs: rhs_node, + }), + root.schema.clone(), + ) // Exact arithmetic does not erase approximation error. Until the // accuracy algebra has an operator-specific rule (and any value-range // evidence needed by multiplication/division), unknown stays unknown. - guarantee, - }))) + .with_guarantee(guarantee), + ))) +} + +/// Rebuild the summary chain above a per-series `Rate` accumulator with its +/// `FinalizeExactAccumulator` placed at `timing`. `strict` additionally +/// requires the fixed-window shape (a `PerEntity` Rate over a `TimeRange`); +/// `None` when no such boundary exists (strict only). +fn retime_rate_finalize( + node: &Rc, + timing: ExecutionTiming, + strict: bool, +) -> Option> { + let is_rate_boundary = |child: &OperatorNode| match &child.operator { + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(ExactKind::Rate, _), + reduction, + child: source, + .. + }) => { + !strict + || (matches!(reduction, Reduction::PerEntity) + && matches!(source.non_asap(), Some(NonASAPOp::TimeRange { .. }))) + } + _ => false, + }; + let rebuilt = |operator: Operator, timing: Option| { + let mut copy = node.as_ref().clone(); + copy.operator = operator; + copy.timing = timing; + Rc::new(copy) + }; + match &node.operator { + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) if is_rate_boundary(child) => { + Some(rebuilt(node.operator.clone(), Some(timing))) + } + Operator::ASAP( + ASAPOp::FinalizeExactAccumulator { child } + | ASAPOp::SummaryAgg { child, .. } + | ASAPOp::SummaryEstimate { + summary_input: child, + .. + }, + ) => { + let placed = match retime_rate_finalize(child, timing, strict) { + Some(placed) => placed, + None if strict => return None, + None => return Some(Rc::clone(node)), + }; + let operator = node.operator.map_children(|_| Rc::clone(&placed)); + Some(rebuilt(operator, node.timing)) + } + _ if strict => None, + _ => Some(Rc::clone(node)), + } } /// Put an explicit read boundary between maintained exact state and a /// query-time value consumer. Approximate summaries must already carry a /// `SummaryEstimate`, so they deliberately do not pass this predicate. pub fn finalize_query_candidate( - node: Rc, - logical_output: &QueryExpr, -) -> Result, RealizationError> { - finalize_exact_accumulator_at(node, logical_output, ExecutionTiming::QueryTime) -} - -fn finalize_exact_accumulator_at( - node: Rc, - logical_output: &QueryExpr, - timing: ExecutionTiming, -) -> Result, RealizationError> { + node: Rc, + logical_output: &OperatorNode, +) -> Result, RealizationError> { + finalize_exact_accumulator(node, logical_output, ExecutionTiming::QueryTime) +} + +/// The read boundary's placement is fixed here, where the candidate's +/// semantics decide it (a fresh query-time summary over this evaluation's +/// finalized values vs. finalized values feeding maintenance); the lifecycle +/// timing pass honors it. +fn finalize_exact_accumulator( + node: Rc, + logical_output: &OperatorNode, + placement: ExecutionTiming, +) -> Result, RealizationError> { let is_exact_state = matches!( - node.expr, - SummaryExpr::SummaryAgg { + node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(..), .. - } + }) ); if !is_exact_state { return Ok(node); @@ -2182,41 +2345,36 @@ fn finalize_exact_accumulator_at( // boundary produces the logical operator's ordinary values. Preserve the // canonical pre-ASAP output types instead of leaking ExactAggregate into // query-time operators that follow this node. - let schema = lift(&logical_output.output_schema()?); + let schema = logical_output.schema.clone(); let guarantee = node.guarantee.clone(); - Ok(Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child: node, - operation: ValueOperation::FinalizeExactAccumulator, - timing, - }, - schema, - guarantee, - })) + Ok(Rc::new( + OperatorNode::with_schema( + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: node }), + schema, + ) + .with_guarantee(guarantee) + .with_timing(Some(placement)), + )) } -fn is_supported_exact_binary(root: &QueryExpr) -> bool { +fn is_supported_exact_binary(root: &OperatorNode) -> bool { matches!( - root, - QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(_), - vector_match: None, + root.non_asap(), + Some(NonASAPOp::BinaryOp { + operator: BinaryOperator { + kind: BinaryOpKind::Arithmetic(_), + vector_match: None, + .. + }, .. - } - ) -} - -fn is_promql_scalar(expr: &QueryExpr) -> bool { - matches!( - expr, - QueryExpr::PromqlScalarBridge(_) | QueryExpr::Literal(_) + }) ) } /// Quantile operands inherit one workload target. A temporal mean is exact /// on its checked finite domain and needs no approximation budget. -fn shared_quantile_target(lhs: &QueryExpr, rhs: &QueryExpr) -> Option { - let quantile_target = |expr: &QueryExpr| match bindable_intent(expr) { +fn shared_quantile_target(lhs: &OperatorNode, rhs: &OperatorNode) -> Option { + let quantile_target = |expr: &OperatorNode| match bindable_intent(expr) { Some(AggIntent::Quantile { accuracy, q, .. }) if q.is_finite() && (0.0..=1.0).contains(q) => { @@ -2254,18 +2412,18 @@ fn ddsketch_ratio_operand_target(target: &AccuracyTarget) -> Option Option { - let SummaryExpr::SummaryEstimate { +fn ddsketch_quantile_alpha(node: &OperatorNode) -> Option { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query: PostAsapSketchStatistic::Quantile { .. }, - } = &node.expr + }) = &node.operator else { return None; }; - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } = &summary_input.expr + }) = &summary_input.operator else { return None; }; @@ -2275,7 +2433,7 @@ fn ddsketch_quantile_alpha(node: &SummaryNode) -> Option { } } -fn has_missing_accuracy_evidence(node: &SummaryNode) -> bool { +fn has_missing_accuracy_evidence(node: &OperatorNode) -> bool { node.guarantee .as_ref() .is_none_or(ResultGuarantee::has_unknown) @@ -2284,10 +2442,10 @@ fn has_missing_accuracy_evidence(node: &SummaryNode) -> bool { /// A direct ratio has an operator-specific DDSketch proof, so it must select /// DDSketch rather than the cost model's generally preferred KLL candidate. fn realize_ddsketch_quantile_operand( - operand: &Rc, + operand: &Rc, planning_inputs: CandidatePlanningInputs<'_>, target: &AccuracyTarget, -) -> Result, RealizationError> { +) -> Result, RealizationError> { let intent = bindable_intent(operand).and_then(|intent| match intent { AggIntent::Quantile { .. } => Some(override_accuracy(intent, target)), _ => None, @@ -2306,17 +2464,10 @@ fn realize_ddsketch_quantile_operand( } fn realize_binary_operand( - operand: &Rc, + operand: &Rc, planning_inputs: CandidatePlanningInputs<'_>, end_to_end_target: Option<&AccuracyTarget>, -) -> Result, RealizationError> { - if is_promql_scalar(operand) { - return Ok(Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::clone(operand)), - schema: Schema::lifted(Vec::new(), None), - guarantee: Some(ResultGuarantee::exact("PromQL scalar")), - })); - } +) -> Result, RealizationError> { realize_child_with(operand, planning_inputs, end_to_end_target) } @@ -2334,43 +2485,77 @@ fn override_accuracy(intent: &AggIntent, target: &AccuracyTarget) -> AggIntent { out } -/// Wrap an unrewritten pre-ASAP sub-DAG, lifting its schema with every column -/// `FieldDataType::Plain`. `pub` so a caller can fall back to this -/// explicitly — e.g. when `SketchAlgorithmStrategy::replacements()` returns no -/// candidate for a target, or a deployment wants to force a node its own -/// runtime can't actually implement — through the same fallback this -/// crate's own dispatch uses, without duplicating the schema-lift logic. -pub fn keep_pre_asap(expr: &Rc) -> Result, RealizationError> { - keep_pre_asap_rc(Rc::clone(expr)) -} - -fn keep_pre_asap_rc(expr: Rc) -> Result, RealizationError> { - let schema = expr.output_schema()?; - Ok(Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(expr), - schema: lift(&schema), - // A kept pre-ASAP sub-DAG is executed exactly by the runtime - // (`Realization::PassThrough`'s contract) — zero error. - guarantee: Some(ResultGuarantee::exact("KeepPreAsap")), - })) +/// Keep an unrewritten pre-ASAP sub-DAG as it is. There is no wrapper node: +/// the sub-DAG itself is the plan, carrying an exact guarantee. The same +/// `Rc` is returned when the node already has a guarantee; otherwise a copy +/// with `guarantee = exact("RetainedExact")` — only for a sub-DAG with no +/// ASAP operator (a sub-DAG containing one keeps whatever its construction +/// established). `pub` so a caller can fall back to this explicitly — e.g. +/// when `ASAPStrategies::replacements()` returns no candidate for a +/// target, or a deployment wants to force a node its own runtime can't +/// actually implement — through the same fallback this crate's own dispatch +/// uses. +pub fn retain_exact(expr: &Rc) -> Result, RealizationError> { + retain_exact_rc(Rc::clone(expr)) +} + +fn retain_exact_rc(expr: Rc) -> Result, RealizationError> { + if expr.guarantee.is_some() || expr.contains_asap() { + return Ok(expr); + } + // Keeping the same sub-DAG twice (e.g. one `Scan` read by an exact + // aggregate and by a sketch, or by two candidates) must yield one node: + // sharing is pointer identity. Memoize the kept copy per input node while + // both are alive; weak references keep the memo from extending lifetimes + // or matching a reused address. + type KeptMemo = HashMap<*const OperatorNode, (Weak, Weak)>; + thread_local! { + static KEPT: RefCell = RefCell::new(HashMap::new()); + } + let key = Rc::as_ptr(&expr); + if let Some(kept) = KEPT.with(|memo| { + memo.borrow().get(&key).and_then(|(input, kept)| { + input + .upgrade() + .filter(|input| Rc::ptr_eq(input, &expr)) + .and_then(|_| kept.upgrade()) + }) + }) { + return Ok(kept); + } + let kept = Rc::new( + expr.as_ref() + .clone() + // A kept pre-ASAP sub-DAG is executed exactly by the runtime + // (`Realization::PassThrough`'s contract) — zero error. + .with_guarantee(Some(ResultGuarantee::exact("RetainedExact"))), + ); + KEPT.with(|memo| { + let mut memo = memo.borrow_mut(); + if memo.len() > 4096 { + memo.retain(|_, (input, kept)| input.strong_count() > 0 && kept.strong_count() > 0); + } + memo.insert(key, (Rc::downgrade(&expr), Rc::downgrade(&kept))); + }); + Ok(kept) } -// ── Construction: turn one already-decided Realization into a SummaryNode ─ +// ── Construction: turn one already-decided Realization into an OperatorNode ─ -/// The bindable shape [`SketchAlgorithmStrategy`] targets: a single intent, no +/// The bindable shape [`ASAPStrategies`] targets: a single intent, no /// `HAVING`. A multi-intent node (SQL `SELECT SUM(a), AVG(b)`), or one with a /// `HAVING` predicate (the filter would need the estimate first), stays -/// logical. Unsupported logical parents still conservatively become one -/// [`SummaryExpr::KeepPreAsap`] sub-DAG. Composable query-time value -/// operators (`Project`, `Filter`, `Sort`, and `Limit`) are retained during final -/// DAG assembly so their independently planned children remain visible. -pub fn bindable_intent(node: &QueryExpr) -> Option<&AggIntent> { - if let QueryExpr::Aggregate { +/// logical. Unsupported logical parents are conservatively kept as pre-ASAP +/// sub-DAGs ([`retain_exact`]). Relational operators are retained during +/// final DAG assembly so their independently planned children remain +/// visible. +pub fn bindable_intent(node: &OperatorNode) -> Option<&AggIntent> { + if let Some(NonASAPOp::Aggregate { measures, filters, having, .. - } = node + }) = node.non_asap() { if let ([intent], None) = (measures.as_slice(), having) { if !any_measure_filtered(filters) { @@ -2382,7 +2567,7 @@ pub fn bindable_intent(node: &QueryExpr) -> Option<&AggIntent> { } /// `expr` must still be the [`bindable_intent`] shape for `realization` to -/// have any effect; anything else falls back to [`keep_pre_asap`]. +/// have any effect; anything else falls back to [`retain_exact`]. /// Only `expr`'s own top-level decision is forced — recursion into `expr`'s /// child goes back through [`realize_child`] (fresh candidate /// enumeration, not a forced pick), so choosing one candidate for a target @@ -2390,10 +2575,10 @@ pub fn bindable_intent(node: &QueryExpr) -> Option<&AggIntent> { /// /// `pub(crate)`: `grouping::HydraGroupingStrategy` (issue #256) is the one /// caller outside this module — the same first-class, -/// one-candidate-at-a-time primitive [`SketchAlgorithmStrategy`] itself +/// one-candidate-at-a-time primitive [`ASAPStrategies`] itself /// calls once per candidate, reused rather than duplicated so a Hydra /// candidate gets exactly the same schema derivation/column -/// resolution/readout construction as every other candidate, patching only +/// resolution/evaluation construction as every other candidate, patching only /// the `grouping` field this axis owns. /// Construct a summary with every model explicit (issue #172). `intent` /// is `expr`'s own [`bindable_intent`], or a copy of it with an allocated @@ -2404,13 +2589,13 @@ pub fn bindable_intent(node: &QueryExpr) -> Option<&AggIntent> { /// fail-closed answer for a composition with no sound rule or one that /// misses `intent`'s target. pub(crate) fn construct_summary_with( - expr: &QueryExpr, + expr: &OperatorNode, intent: &AggIntent, realization: Realization, planning_inputs: CandidatePlanningInputs<'_>, child_target: Option<&AccuracyTarget>, allocation: Option, -) -> Result, RealizationError> { +) -> Result, RealizationError> { let local_target = match allocation.as_ref() { Some(GuaranteeSource::BudgetAllocation { local_target, .. }) => Some(local_target), _ => accuracy_target(intent), @@ -2427,9 +2612,9 @@ pub(crate) fn construct_summary_with( }, other => other, }; - if let QueryExpr::Aggregate { + if let Some(NonASAPOp::Aggregate { reduction, child, .. - } = expr + }) = expr.non_asap() { // `bindable_intent` already established the shape: exactly one // intent, no HAVING. (Multi-intent nodes and HAVING stay logical.) @@ -2454,28 +2639,28 @@ pub(crate) fn construct_summary_with( } } } - keep_pre_asap_rc(Rc::new(expr.clone())) + retain_exact_rc(Rc::new(expr.clone())) } fn finish_weighted_topk( - candidate: Rc, - logical: &QueryExpr, + candidate: Rc, + logical: &OperatorNode, intent: &AggIntent, -) -> Result, RealizationError> { +) -> Result, RealizationError> { let AggIntent::TopK { k, .. } = intent else { unreachable!() }; - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(groups), child, .. - } = logical + }) = logical.non_asap() else { return Err(RealizationError::PhysicalRealization( "TopK requires explicit grouping", )); }; - let schema = lift(&child.output_schema()?); + let schema = child.schema.clone(); let score = ranking_score_index(child, &schema)?; let cols = schema .fields @@ -2502,84 +2687,81 @@ fn finish_weighted_topk( } } }; - Ok(asap_types::pre_asap::query_expr::ProjectItem { + Ok(ProjectItem { alias: Some(field.name.clone()), - expr: QueryExpr::Column(source), + expr: ScalarExpr::Column(source), }) }) .collect::, _>>()?; let guarantee = candidate.guarantee.clone(); - let projected = Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child: candidate, - operation: ValueOperation::Project { + let projected = Rc::new( + OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::Project { cols, qualifier: None, - }, - timing: ExecutionTiming::QueryTime, - }, - schema: schema.clone(), - guarantee: guarantee.clone(), - }); - let sorted = Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child: projected, - operation: ValueOperation::Sort { - keys: vec![asap_types::pre_asap::SortKey { - expr: QueryExpr::Column(score), + child: candidate, + }), + schema.clone(), + ) + .with_guarantee(guarantee.clone()), + ); + let sorted = Rc::new( + OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::Sort { + keys: vec![SortKey { + expr: ScalarExpr::Column(score), ascending: false, nulls_first: false, }], partition_by: groups.clone(), - }, - timing: ExecutionTiming::QueryTime, - }, - schema: schema.clone(), - guarantee: guarantee.clone(), - }); - let result = Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child: sorted, - operation: ValueOperation::Limit { - n: *k, + child: projected, + }), + schema.clone(), + ) + .with_guarantee(guarantee.clone()), + ); + let result = Rc::new( + OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::Limit { + n: Some(*k), offset: 0, partition_by: groups.clone(), - }, - timing: ExecutionTiming::QueryTime, - }, - schema, - guarantee, - }); - validate_execution_data_states_at(&result, ExecutionDataState::QUERY_ROWS)?; + child: sorted, + }), + schema, + ) + .with_guarantee(guarantee), + ); + validate_default(&result, ExecutionTiming::QueryTime)?; Ok(result) } -fn is_current_series_source(child: &QueryExpr) -> bool { - let source = match child { - QueryExpr::TimeRange { child, .. } => child.as_ref(), - source => source, +fn is_current_series_source(child: &OperatorNode) -> bool { + let source = match child.non_asap() { + Some(NonASAPOp::TimeRange { child, .. }) => child.as_ref(), + _ => child, }; - matches!(source, QueryExpr::Scan { + matches!(source.non_asap(), Some(NonASAPOp::Scan { source: asap_types::pre_asap::Source::TimeSeries { .. }, schema, .. - } if schema.has_promql_series_identity()) + }) if schema.has_promql_series_identity()) } -fn is_snapshot_weighted_topk(intent: &AggIntent, child: &QueryExpr) -> bool { +fn is_snapshot_weighted_topk(intent: &AggIntent, child: &OperatorNode) -> bool { matches!(intent, AggIntent::TopK { .. }) && (is_current_series_source(child) - || matches!(child, - QueryExpr::Aggregate { measures, child, .. } + || matches!(child.non_asap(), + Some(NonASAPOp::Aggregate { measures, child, .. }) if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase]) || (matches!(measures.as_slice(), [AggIntent::Sum { .. }]) - && matches!(child.as_ref(), QueryExpr::Aggregate { measures, .. } + && matches!(child.non_asap(), Some(NonASAPOp::Aggregate { measures, .. }) if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase]))))) } /// Translate an [`Realization`] into the `(family, needs a -/// SummaryEstimate readout)` pair [`construct_summary_agg`] needs, or `None` -/// for `PassThrough` (the caller falls back to [`keep_pre_asap`]). +/// SummaryEstimate evaluation)` pair [`construct_summary_agg`] needs, or `None` +/// for `PassThrough` (the caller falls back to [`retain_exact`]). /// -/// Every family's partial state needs a readout to recover a value, except +/// Every family's partial state needs a evaluation to recover a value, except /// `ExactAggregate` — its partial state *is* the value already, so no /// estimate step follows it. fn summary_family(realization: Realization) -> Option<(FieldDataType, bool)> { @@ -2603,7 +2785,7 @@ fn summary_family(realization: Realization) -> Option<(FieldDataType, bool)> { /// input value. Composite realizations can instead consume a larger /// logical sub-DAG and bind a different key or value. struct PhysicalSummaryInput { - child: Rc, + child: Rc, input: SummaryUpdate, } @@ -2614,7 +2796,7 @@ enum PhysicalSummaryInputRuleResult { } type PhysicalSummaryInputRule = - fn(&AggIntent, &FieldDataType, &Reduction, &Rc) -> PhysicalSummaryInputRuleResult; + fn(&AggIntent, &FieldDataType, &Reduction, &Rc) -> PhysicalSummaryInputRuleResult; /// Ordered physical-realization rules for realizations that consume more /// than the immediate logical input. New composite primitives add a rule here @@ -2631,7 +2813,7 @@ fn realize_value_frequency_summary_input( intent: &AggIntent, family: &FieldDataType, _reduction: &Reduction, - child: &Rc, + child: &Rc, ) -> PhysicalSummaryInputRuleResult { // Frequency counts hash sample values as items but add one per observation. // Using the sample as a weight would turn counts into sums and admit signed CMS updates. @@ -2642,11 +2824,7 @@ fn realize_value_frequency_summary_input( { return PhysicalSummaryInputRuleResult::NotApplicable; } - let Ok(schema) = child.output_schema() else { - return PhysicalSummaryInputRuleResult::Unsupported( - "value frequency input needs a valid schema", - ); - }; + let schema = &child.schema; // One item per observation is a single value stream. `summary_candidates` // already withholds UnivMon from a distinct-tuple count; refused here too // so the invariant does not rest on that table alone. @@ -2658,7 +2836,7 @@ fn realize_value_frequency_summary_input( PhysicalSummaryInputRuleResult::Realized(PhysicalSummaryInput { child: Rc::clone(child), input: SummaryUpdate { - item: Some(SummaryInputExpr::Column(summarised_column(intent, &schema))), + item: Some(SummaryInputExpr::Column(summarised_column(intent, schema))), weight: SummaryInputExpr::Constant(1.0), weight_domain: WeightDomain::NonNegative { proof: NonNegativeWeightProof::UnitCount, @@ -2671,7 +2849,7 @@ fn realize_physical_summary_input( intent: &AggIntent, family: &FieldDataType, reduction: &Reduction, - child: &Rc, + child: &Rc, ) -> Result { for rule in PHYSICAL_SUMMARY_INPUT_RULES { match rule(intent, family, reduction, child) { @@ -2683,7 +2861,7 @@ fn realize_physical_summary_input( } } - let child_schema = child.output_schema()?; + let child_schema = &child.schema; if matches!(intent, AggIntent::TopK { .. }) { return Err(RealizationError::PhysicalRealization( "Top-K needs an explicit item identity and additive update input", @@ -2693,76 +2871,70 @@ fn realize_physical_summary_input( child: Rc::clone(child), input: SummaryUpdate { item: None, - weight: summarised_input(intent, &child_schema)?, + weight: summarised_input(intent, child_schema)?, weight_domain: WeightDomain::UnknownOrSigned, }, }) } /// Emit `SummaryAgg` (recursively binding the child), plus the -/// `SummaryEstimate` readout when `estimate` is set. +/// `SummaryEstimate` evaluation when `estimate` is set. // Retain the exact expression and schema while placing its value production -// on the update path. This is the initial layout for values feeding a summary; -// lifecycle timing is authoritative. Read-time consumers keep their original -// shared nodes. -fn maintenance_exact_values(node: Rc) -> Option> { - let expr = match &node.expr { +// on the update path (a node runs when its consumer runs, so beneath a +// maintained summary this value production is ingestion-time work). +// Read-time consumers keep their original shared nodes. +fn maintenance_exact_values(node: Rc) -> Option> { + let operator = match &node.operator { // These guards can fall back at read time, but cannot recover a parent // sketch after an invalid value has entered its maintained state. - SummaryExpr::BinaryOp { operator, .. } + Operator::NonASAP(NonASAPOp::BinaryOp { operator, .. }) if operator.checked_finite_division || operator.checked_relative_division => { return None; } - SummaryExpr::BinaryOp { - lhs, rhs, operator, .. - } if operator.vector_match.is_none() - && matches!( - operator.kind, - asap_types::pre_asap::BinaryOpKind::Arithmetic(_) - ) + Operator::NonASAP(NonASAPOp::BinaryOp { + lhs, + rhs, + operator, + return_bool, + }) if operator.vector_match.is_none() + && matches!(operator.kind, BinaryOpKind::Arithmetic(_)) && node .guarantee .as_ref() .is_some_and(ResultGuarantee::is_exact) => { - SummaryExpr::BinaryOp { + Operator::NonASAP(NonASAPOp::BinaryOp { lhs: maintenance_exact_values(lhs.clone())?, rhs: maintenance_exact_values(rhs.clone())?, operator: operator.clone(), - timing: ExecutionTiming::IngestionTime, - } + return_bool: *return_bool, + }) } - SummaryExpr::ValueOperation { - child, - operation: ValueOperation::FinalizeExactAccumulator, - .. - } if matches!( - child.expr, - SummaryExpr::SummaryAgg { - family: FieldDataType::ExactAggregate(..), - .. - } - ) => + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) + if matches!( + child.operator, + Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(..), + .. + }) + ) => { - SummaryExpr::ValueOperation { + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: child.clone(), - operation: ValueOperation::FinalizeExactAccumulator, - timing: ExecutionTiming::IngestionTime, - } + }) } _ => return Some(node), }; - Some(Rc::new(SummaryNode { - expr, - schema: node.schema.clone(), - guarantee: node.guarantee.clone(), - })) + Some(Rc::new( + OperatorNode::with_schema(operator, node.schema.clone()) + .with_guarantee(node.guarantee.clone()), + )) } #[allow(clippy::too_many_arguments)] fn construct_summary_agg( - node: &QueryExpr, + node: &OperatorNode, reduction: &Reduction, intent: &AggIntent, input: PhysicalSummaryInput, @@ -2771,7 +2943,7 @@ fn construct_summary_agg( planning_inputs: CandidatePlanningInputs<'_>, child_target: Option<&AccuracyTarget>, allocation: Option, -) -> Result, RealizationError> { +) -> Result, RealizationError> { // The single canonical pre-ASAP derivation (per-series vs cross-series, // name overrides) already computes the row shape; binding only retypes // the summary state column. @@ -2781,7 +2953,7 @@ fn construct_summary_agg( FieldDataType::Sketch(kind, _) if matches!(kind.algorithm(), SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap) ); - let snapshot_weighted = matches!(node, QueryExpr::Aggregate { child, .. } + let snapshot_weighted = matches!(node.non_asap(), Some(NonASAPOp::Aggregate { child, .. }) if is_snapshot_weighted_topk(intent, child)); let mut family = family; let score_population = if snapshot_weighted { @@ -2809,10 +2981,10 @@ fn construct_summary_agg( None }; let physical_reduction = if snapshot_weighted { - let QueryExpr::Aggregate { child, .. } = node else { + let Some(NonASAPOp::Aggregate { child, .. }) = node.non_asap() else { unreachable!() }; - let source = input.child.output_schema()?; + let source = &input.child.schema; let Reduction::Reduce(keys) = reduction else { return Err(RealizationError::PhysicalRealization( "TopK requires explicit partitions", @@ -2850,19 +3022,19 @@ fn construct_summary_agg( } else { reduction.clone() }; - let out_schema = node.output_schema()?; - let measures = match node { - QueryExpr::Aggregate { measures, .. } => measures.len(), + let out_schema = &node.schema; + let measures = match node.non_asap() { + Some(NonASAPOp::Aggregate { measures, .. }) => measures.len(), _ => 1, }; - let state_idx = summary_col_index(&out_schema, reduction, measures); + let state_idx = summary_col_index(out_schema, reduction, measures); - let readout_schema = if keyed_heap - && matches!(node, QueryExpr::Aggregate { child, .. } if is_snapshot_weighted_topk(intent, child)) + let evaluation_schema = if keyed_heap + && matches!(node.non_asap(), Some(NonASAPOp::Aggregate { child, .. }) if is_snapshot_weighted_topk(intent, child)) { - keyed_heap_readout_schema(&input, node)? + keyed_heap_evaluation_schema(&input, node)? } else { - lift(&out_schema) + out_schema.clone() }; let summary_input = input.input; @@ -2879,15 +3051,15 @@ fn construct_summary_agg( }; } } - readout(intent, &summary_input, planning_inputs.cost) + evaluation(intent, &summary_input, planning_inputs.cost) }); - let mut state_schema = lift(&out_schema); + let mut state_schema = out_schema.clone(); if keyed_heap { let mut state = state_schema.fields[state_idx].clone(); state.dtype = family.clone(); let mut fields = if snapshot_weighted { - readout_schema.fields[..reduction.group_keys().map_or(0, |keys| keys.len())].to_vec() + evaluation_schema.fields[..reduction.group_keys().map_or(0, |keys| keys.len())].to_vec() } else { Vec::new() }; @@ -2895,6 +3067,7 @@ fn construct_summary_agg( state_schema = Schema::lifted(fields, None); } else if let Some(field) = state_schema.fields.get_mut(state_idx) { field.dtype = family.clone(); + field.nullable = false; if matches!(&family, FieldDataType::Sketch(kind, _) if kind.algorithm() == &SketchAlgorithm::UnivMon) { // State identity is independent of which statistic reads it. @@ -2902,9 +3075,9 @@ fn construct_summary_agg( } else if let (AggIntent::Quantile { .. }, SummaryInputExpr::Column(col)) = (intent, &summary_input.weight) { - // The quantile is a readout parameter: name the state after the + // The quantile is a evaluation parameter: name the state after the // column it summarizes, not after the query's output column. - let child_schema = input.child.output_schema()?; + let child_schema = input.child.schema.clone(); if let Ok(i) = resolve_column_ref(col, &child_schema) { field.name = child_schema.fields[i].name.clone(); } @@ -2931,9 +3104,9 @@ fn construct_summary_agg( .ok_or(RealizationError::PhysicalRealization( "snapshot ranking requires a supported current-series population", ))?; - let SummaryExpr::ValueOperation { child, .. } = &population.expr else { + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &population.operator else { return Err(RealizationError::PhysicalRealization( - "missing population readout", + "missing population evaluation", )); }; Rc::clone(child) @@ -2943,27 +3116,14 @@ fn construct_summary_agg( // initial layout; a retained summary's lifecycle moves it to ingestion. finalize_query_candidate(bound_child, &input.child)? } else { - let child = finalize_exact_accumulator_at( - bound_child, - &input.child, - ExecutionTiming::IngestionTime, - )?; - let child = maintenance_exact_values(child).unwrap_or(keep_pre_asap(&input.child)?); - // Maintenance arithmetic must satisfy the ingestion contract; e.g. a - // per-series sum over different selectors has no exact aligned - // layout, so this candidate fails closed and exact execution remains. - // Unlike checked division, it does not fall back to `keep_pre_asap`: - // that retains the range expression at ingestion time, where range - // functions cannot run (they need a query evaluation time). - if matches!(child.expr, SummaryExpr::BinaryOp { .. }) { - validate_execution_data_states_at(&child, ExecutionDataState::INGESTION_ROWS)?; - } - child + let child = + finalize_exact_accumulator(bound_child, &input.child, ExecutionTiming::IngestionTime)?; + maintenance_exact_values(child).unwrap_or(retain_exact(&input.child)?) }; // ── Guarantee (issue #172) ────────────────────────────────────────── // Derived *before* the node exists, so an illegal composition is never - // materialized: the local guarantee of this family's readout (or exact + // materialized: the local guarantee of this family's evaluation (or exact // accumulator) composed over the child's, under the operator this // family applies to the child's values. let local_target = match allocation.as_ref() { @@ -2976,7 +3136,7 @@ fn construct_summary_agg( local_target, ); let membership_query = if snapshot_weighted { - Some(readout(intent, &summary_input, planning_inputs.cost)) + Some(evaluation(intent, &summary_input, planning_inputs.cost)) } else { query.clone() }; @@ -3054,56 +3214,61 @@ fn construct_summary_agg( // a genuine empty-`by` reduction apart from a per-entity shape with no // grouping concept at all (issue #163). `construct_summary_agg` is the // single place that decides this; nothing downstream re-derives it. - let agg = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { + let agg = OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { child: bound_child, family, input: summary_input, reduction: physical_reduction, grouping: GroupingStrategy::default(), filter: None, - }, - schema: state_schema, + }), + state_schema, + ) + .with_guarantee( // Summary *state* carries no caller-visible guarantee; only a // finalized value does. An exact accumulator's state is its value. - guarantee: if estimate { None } else { guarantee.clone() }, - }); + if estimate { None } else { guarantee.clone() }, + ); + let agg = std::rc::Rc::new(agg); match query { - // The readout: downstream of the estimate the schema is the plain + // The evaluation: downstream of the estimate the schema is the plain // pre-ASAP row shape again (the summary-state type does not // propagate). - Some(query) => Ok(Rc::new(SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input: agg, - query, - }, - schema: readout_schema, - guarantee, - })), + Some(query) => Ok(std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: agg, + query, + }), + evaluation_schema, + ) + .with_guarantee(guarantee), + )), None => Ok(agg), } } -// Heap readout rows contain the encoded item identity, subpopulation keys, +// Heap evaluation rows contain the encoded item identity, subpopulation keys, // and an estimated score. They never inherit the exact-value producer's schema. -fn keyed_heap_readout_schema( +fn keyed_heap_evaluation_schema( input: &PhysicalSummaryInput, - node: &QueryExpr, + node: &OperatorNode, ) -> Result { - let source = input.child.output_schema()?; + let source = &input.child.schema; let mut refs = Vec::new(); - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction, child, .. - } = node + }) = node.non_asap() else { return Err(RealizationError::PhysicalRealization( - "heap readout requires an aggregate", + "heap evaluation requires an aggregate", )); }; if let Reduction::Reduce(groups) = reduction { if groups.is_without() { return Err(RealizationError::PhysicalRealization( - "heap readout requires explicit grouping", + "heap evaluation requires explicit grouping", )); } for index in groups.iter() { @@ -3161,7 +3326,7 @@ fn keyed_heap_readout_schema( .ok_or(RealizationError::PhysicalRealization( "heap item identity is missing", ))?, - &source, + source, &mut refs, )?; let mut fields = Vec::::new(); @@ -3197,7 +3362,7 @@ fn keyed_heap_readout_schema( } if fields.is_empty() { return Err(RealizationError::PhysicalRealization( - "heap readout has no identity columns", + "heap evaluation has no identity columns", )); } fields.push(Field::new( @@ -3208,7 +3373,7 @@ fn keyed_heap_readout_schema( Ok(Schema::lifted(fields, None)) } -fn ranking_score_index(logical: &QueryExpr, values: &Schema) -> Result { +fn ranking_score_index(logical: &OperatorNode, values: &Schema) -> Result { if is_current_series_source(logical) { return values .fields @@ -3221,11 +3386,11 @@ fn ranking_score_index(logical: &QueryExpr, values: &Schema) -> Result, + child: &Rc, ) -> PhysicalSummaryInputRuleResult { if !matches!(intent, AggIntent::TopK { .. }) || !matches!(family, FieldDataType::Sketch(kind, _) if matches!(kind.algorithm(), SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap)) - || !matches!(child.as_ref(), QueryExpr::Aggregate { reduction: Reduction::PerEntity, measures, .. } if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase])) + || !matches!(child.non_asap(), Some(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures, .. }) if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase])) { return PhysicalSummaryInputRuleResult::NotApplicable; } - let Ok(schema) = child.output_schema() else { - return PhysicalSummaryInputRuleResult::Unsupported( - "counter ranking needs a valid value schema", - ); - }; + let schema = &child.schema; if !schema.closed { return PhysicalSummaryInputRuleResult::Unsupported( "counter ranking needs the complete resolved series identity", @@ -3332,7 +3493,7 @@ fn realize_current_series_summary_input( intent: &AggIntent, family: &FieldDataType, output_reduction: &Reduction, - child: &Rc, + child: &Rc, ) -> PhysicalSummaryInputRuleResult { if !matches!(intent, AggIntent::TopK { .. }) || !is_current_series_source(child) { return PhysicalSummaryInputRuleResult::NotApplicable; @@ -3359,11 +3520,7 @@ fn realize_current_series_summary_input( "snapshot ranking requires resolved partitions", ); } - let Ok(schema) = child.output_schema() else { - return PhysicalSummaryInputRuleResult::Unsupported( - "snapshot ranking requires a valid source schema", - ); - }; + let schema = &child.schema; let items = schema .fields .iter() @@ -3393,7 +3550,7 @@ fn realize_keyed_additive_summary_input( intent: &AggIntent, family: &FieldDataType, output_reduction: &Reduction, - child: &Rc, + child: &Rc, ) -> PhysicalSummaryInputRuleResult { if !matches!(intent, AggIntent::TopK { .. }) { return PhysicalSummaryInputRuleResult::NotApplicable; @@ -3408,18 +3565,18 @@ fn realize_keyed_additive_summary_input( ) { return PhysicalSummaryInputRuleResult::NotApplicable; } - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction, measures, having: None, child: raw_child, .. - } = child.as_ref() + }) = child.non_asap() else { return PhysicalSummaryInputRuleResult::NotApplicable; }; let counter_input = matches!(measures.as_slice(), [AggIntent::Sum { .. }]) - && matches!(raw_child.as_ref(), QueryExpr::Aggregate { measures, .. } + && matches!(raw_child.non_asap(), Some(NonASAPOp::Aggregate { measures, .. }) if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase])); let weight = match measures.as_slice() { [AggIntent::Count { .. }] => SummaryInputExpr::Constant(1.0), @@ -3511,9 +3668,8 @@ fn realize_keyed_additive_summary_input( }) } -fn schema_column_ref(child: &QueryExpr, index: usize) -> Option { - let schema = child.output_schema().ok()?; - let column = schema.fields.get(index)?; +fn schema_column_ref(child: &OperatorNode, index: usize) -> Option { + let column = child.schema.fields.get(index)?; Some(match &column.table { Some(table) => ColumnRef::Qualified { table: table.clone(), @@ -3535,7 +3691,7 @@ fn schema_column_ref(child: &QueryExpr, index: usize) -> Option { fn compose_guarantee( family: &FieldDataType, query: Option<&PostAsapSketchStatistic>, - child: &SummaryNode, + child: &OperatorNode, intent: &AggIntent, accuracy: &dyn AccuracyModel, evidence: &dyn AccuracyEvidenceProvider, @@ -3685,8 +3841,8 @@ fn summarised_input( )) } -/// The `SummaryEstimate` readout for a summary-bound intent. -fn readout( +/// The `SummaryEstimate` evaluation for a summary-bound intent. +fn evaluation( intent: &AggIntent, input: &SummaryUpdate, cost_model: &dyn CostModel, @@ -3706,13 +3862,15 @@ fn readout( value: None, }, // Core doesn't know the shape of a deployment-specific `Extension` - // intent, so it can't build its readout either — delegate to the + // intent, so it can't build its evaluation either — delegate to the // same `CostModel` that decided (via `realize_extension`) this - // intent gets a summary realization at all. See `readout_extension`'s + // intent gets a summary realization at all. See `evaluation_extension`'s // doc for the invariant this depends on. AggIntent::Extension { ext_kind, payload } => match &input.weight { - SummaryInputExpr::Column(col) => cost_model.readout_extension(ext_kind, payload, col), - _ => unreachable!("extension readout requires one column"), + SummaryInputExpr::Column(col) => { + cost_model.evaluation_extension(ext_kind, payload, col) + } + _ => unreachable!("extension evaluation requires one column"), }, other => { unreachable!("no summary realization for {other:?} (realizations_for_intent)") @@ -3720,16 +3878,9 @@ fn readout( } } -/// Lift a pre-ASAP [`Schema`] to a [`Schema`] with every column -/// `FieldDataType::Plain` — shared by [`construct_summary_agg`] and -/// [`keep_pre_asap`], both in this module. -fn lift(schema: &Schema) -> Schema { - Schema::lifted(schema.fields.clone(), schema.time_index) -} - // ── SharedSubDAGStrategy ──────────────────────────────────────────────── -/// Wraps `asap_types::pre_asap::cse::share_common_sub_dags`'s sharing +/// Wraps `asap_types::ir::cse::share_common_sub_dags`'s sharing /// decision as an explicit candidate pair, wherever a [`TargetSubDAG`] /// already has two or more consumers. /// @@ -3762,11 +3913,11 @@ impl ReplacementStrategy for SharedSubDAGStrategy { strategy: "SharedSubDAGStrategy", // The already-interned `Rc` itself: reusing it verbatim *is* // "build once and share" — no new node to construct. - replacement: Replacement::Rewrite(Rc::clone(target.root)), + replacement: Replacement::SubDAG(Rc::clone(target.root)), provenance: ReplacementProvenance::CseShare, rationale: format!( "build once and share: share_common_sub_dags already interned this \ - sub-DAG once and reused it across {count} consumers — one build can \ + sub_dag once and reused it across {count} consumers — one build can \ answer all of them instead of computing it {count} times" ), }, @@ -3775,11 +3926,11 @@ impl ReplacementStrategy for SharedSubDAGStrategy { // A structurally-identical but freshly-allocated `Rc`: same // value (`PartialEq`), deliberately *not* the same pointer, // representing "undo the sharing and recompute independently". - replacement: Replacement::Rewrite(Rc::new((**target.root).clone())), + replacement: Replacement::SubDAG(Rc::new((**target.root).clone())), provenance: ReplacementProvenance::CseRecompute, rationale: format!( "build independently: undo the sharing share_common_sub_dags found and \ - recompute this sub-DAG separately at each of its {count} consumers — \ + recompute this sub_dag separately at each of its {count} consumers — \ worth it only when independence outweighs the shared-maintenance cost, \ a CostModel's call (e.g. CostModel::cse_share_decision) and not this \ strategy's" @@ -3798,14 +3949,14 @@ impl ReplacementStrategy for SharedSubDAGStrategy { /// A generous, documented backstop against a hypothetically ill-behaved /// future [`ReplacementStrategy`] (see the module docs' "Termination" /// section) — not a bound either shipped strategy could ever approach. -/// [`SketchAlgorithmStrategy`] and [`SharedSubDAGStrategy`] both converge in +/// [`ASAPStrategies`] and [`SharedSubDAGStrategy`] both converge in /// exactly 2 passes over a fixed target set, regardless of workload size. pub const MAX_SEARCH_ITERATIONS: usize = 1_000; // ── TargetSubDAGCandidates ────────────────────────────────────────────── /// Candidates for one distinct [`TargetSubDAG`] (its -/// own `target` `Rc`, keyed by pointer identity in +/// own `target` `Rc`, keyed by pointer identity in /// [`CandidateLogicalASAPDAGs`]'s internal map — never re-derived by value) plus every /// [`ReplacementSubDAG`] alternative any registered [`ReplacementStrategy`] /// proposed for it. @@ -3818,7 +3969,7 @@ pub const MAX_SEARCH_ITERATIONS: usize = 1_000; #[derive(Debug, Clone)] pub struct TargetSubDAGCandidates { /// The target sub-DAG this group is for. - pub target: Rc, + pub target: Rc, /// How many operator-child positions across the whole workload /// reference this exact `Rc` — see [`discover_targets`]. pub consumer_count: usize, @@ -3835,7 +3986,7 @@ pub struct TargetSubDAGCandidates { } impl TargetSubDAGCandidates { - fn new(target: Rc, consumer_count: usize) -> Self { + fn new(target: Rc, consumer_count: usize) -> Self { Self { target, consumer_count, @@ -3852,10 +4003,12 @@ impl TargetSubDAGCandidates { fn add_candidate(&mut self, candidate: ReplacementSubDAG) -> bool { let is_duplicate = self.candidates.iter().any(|existing| { match (&existing.replacement, &candidate.replacement) { - (Replacement::Rewrite(existing_rc), Replacement::Rewrite(rc)) => { + (Replacement::SubDAG(existing_rc), Replacement::SubDAG(rc)) + if is_logical_rewrite(existing_rc) && is_logical_rewrite(rc) => + { is_duplicate_rewrite(existing_rc, rc, &self.target) } - (Replacement::Summary(existing_node), Replacement::Summary(node)) => { + (Replacement::SubDAG(existing_node), Replacement::SubDAG(node)) => { is_duplicate_summary(existing_node, node) } ( @@ -3876,11 +4029,11 @@ impl TargetSubDAGCandidates { } } -/// Are `existing` and `candidate` the same [`Replacement::Rewrite`] -/// candidate for a group targeting `target`? +/// Are `existing` and `candidate` the same logical-rewrite +/// [`Replacement::SubDAG`] candidate for a group targeting `target`? /// -/// Structural (`QueryExpr`) value equality alone is *not* enough here: this -/// module's one shipped multi-candidate `Replacement::Rewrite` source, +/// Structural (`OperatorNode`) value equality alone is *not* enough here: +/// this module's one shipped multi-candidate logical-rewrite source, /// [`SharedSubDAGStrategy`], deliberately returns **two** candidates that /// are value-equal to each other (`build once and share` vs. `build /// independently` — see that strategy's own doc) but represent genuinely @@ -3898,7 +4051,7 @@ impl TargetSubDAGCandidates { /// So: two candidates whose "is this the target's own `Rc`?" bit disagrees /// are never duplicates of each other, full stop. Only when that bit /// *agrees* does this fall through to the real dedup discipline — -/// [`structural_hash`] as a candidate-narrowing filter, `QueryExpr`'s +/// [`structural_hash`] as a candidate-narrowing filter, `OperatorNode`'s /// derived `PartialEq` as the actual decision — protecting against the /// (currently hypothetical, since neither shipped strategy causes it) /// case of the exact same alternative being proposed twice. A fresh @@ -3907,9 +4060,9 @@ impl TargetSubDAGCandidates { /// wider traversal to amortize the cache across the way `InternTable`'s own /// use of `structural_hash` does. fn is_duplicate_rewrite( - existing: &Rc, - candidate: &Rc, - target: &Rc, + existing: &Rc, + candidate: &Rc, + target: &Rc, ) -> bool { let existing_is_target = Rc::ptr_eq(existing, target); let candidate_is_target = Rc::ptr_eq(candidate, target); @@ -3921,16 +4074,16 @@ fn is_duplicate_rewrite( && existing == candidate } -/// Are `existing` and `candidate` the same [`Replacement::Summary`] -/// candidate? +/// Are `existing` and `candidate` the same bound-summary +/// [`Replacement::SubDAG`] candidate? /// -/// [`SummaryNode`] derives neither `PartialEq` nor `Hash` (it embeds -/// `SketchParams`/`f64`-bearing accuracy targets deep inside `SummaryExpr`, -/// the same reason `QueryExpr` can't derive `Hash` either — see -/// [`structural_hash`]'s own doc). Per this module's inherited "hash is a -/// filter, `PartialEq` is the decision, no exceptions" rule, there is no -/// real equality check to back a dedup *decision* here — and skipping the -/// check is the only choice that rule permits: never merging two candidates +/// A bound summary embeds `SketchParams`/`f64`-bearing accuracy targets and +/// guarantees, so value equality is not a dedup decision this module is +/// willing to make (see [`structural_hash`]'s own doc on `f64` hashing). +/// Per this module's inherited "hash is a filter, `PartialEq` is the +/// decision, no exceptions" rule, there is no real equality check to back a +/// dedup *decision* here — and skipping the check is the only choice that +/// rule permits: never merging two candidates /// is harmless (at worst, a redundant entry in a group's candidate list), /// while comparing by some proxy this module can't actually verify (e.g. /// `Debug` text, or `ReplacementSubDAG::rationale` — documented elsewhere in @@ -3939,7 +4092,7 @@ fn is_duplicate_rewrite( /// shipped today already return a structurally distinct candidate for every /// entry of one `replacements()` call, so this is future-proofing against a /// hypothetical repeat call, not a gap either strategy's own tests exercise. -fn is_duplicate_summary(_existing: &Rc, _candidate: &Rc) -> bool { +fn is_duplicate_summary(_existing: &Rc, _candidate: &Rc) -> bool { false } @@ -3948,34 +4101,34 @@ fn is_duplicate_summary(_existing: &Rc, _candidate: &Rc` whose -/// group holds its alternatives. +/// caller can still map a `Root`'s `Id` back to the `Rc` whose +/// group holds its alternatives. Memos are keyed by `*const OperatorNode`. pub struct CandidateLogicalASAPDAGs { /// The workload's roots, after the one `share_common_sub_dags` pass /// [`search_workload_with`] runs up front — the same post-CSE roots /// every `TargetSubDAG` in `groups` was discovered from. - pub roots: Vec<(Id, Rc)>, - groups: HashMap<*const QueryExpr, TargetSubDAGCandidates>, + pub roots: Vec<(Id, Rc)>, + groups: HashMap<*const OperatorNode, TargetSubDAGCandidates>, /// Discovery order — stable iteration for [`CandidateLogicalASAPDAGs::target_subdag_candidates`]/ /// [`CandidateLogicalASAPDAGs::cost_sorted`], since `HashMap` iteration order isn't. - order: Vec<*const QueryExpr>, + order: Vec<*const OperatorNode>, /// Composition proofs are computed with the search model, then retained /// through costing and DAG assembly so no later default can replace it. composition_plans: Vec, } struct PreparedComposition { - target: *const QueryExpr, + target: *const OperatorNode, operation: ExactComposition, - child: Rc, - plan: Rc, + child: Rc, + plan: Rc, } impl CandidateLogicalASAPDAGs { fn prepare_compositions( &mut self, accuracy: &dyn AccuracyModel, - targets: &HashMap<*const QueryExpr, Vec>, + targets: &HashMap<*const OperatorNode, Vec>, ) { self.composition_plans.clear(); for group in self.groups.values() { @@ -3990,14 +4143,16 @@ impl CandidateLogicalASAPDAGs { .into_iter() .flat_map(|g| &g.candidates) .filter_map(|c| match &c.replacement { - Replacement::Summary(child) if operation.accepts_child(child) => { + Replacement::SubDAG(child) + if !is_logical_rewrite(child) && operation.accepts_child(child) => + { Some(Rc::clone(child)) } _ => None, }) .collect(), OperationPlacement::Maintenance => { - keep_pre_asap(&operation.child_target).into_iter().collect() + retain_exact(&operation.child_target).into_iter().collect() } }; for child in children { @@ -4035,11 +4190,11 @@ impl CandidateLogicalASAPDAGs { /// never a silently truncated inventory presented as exhaustive. #[derive(Debug)] pub struct CandidateDAGInventory { - pub candidates: Vec)>>, + pub candidates: Vec)>>, pub rejected_assemblies: Vec, } -type CandidateDAGChoice<'a> = (Option<&'a ReplacementSubDAG>, Option>); +type CandidateDAGChoice<'a> = (Option<&'a ReplacementSubDAG>, Option>); impl CandidateLogicalASAPDAGs { pub fn enumerate_candidate_dags( @@ -4074,7 +4229,7 @@ impl CandidateLogicalASAPDAGs { fn enumerate_candidate_roots( &self, - roots: &[(Id, Rc)], + roots: &[(Id, Rc)], expansion_limit: usize, ) -> Result, RealizationError> { let mut reachable = Vec::new(); @@ -4090,8 +4245,10 @@ impl CandidateLogicalASAPDAGs { cursor += 1; if let Some(group) = self.groups.get(&ptr) { for candidate in &group.candidates { - if let Replacement::Rewrite(rewritten) = &candidate.replacement { - walk(rewritten, &mut reachable, &mut nodes, &mut counts); + if let Replacement::SubDAG(rewritten) = &candidate.replacement { + if is_logical_rewrite(rewritten) { + walk(rewritten, &mut reachable, &mut nodes, &mut counts); + } } } } @@ -4185,77 +4342,12 @@ impl CandidateLogicalASAPDAGs { .collect::, _>>(); match roots { Ok(roots) => { - let roots = asap_types::post_asap::share_common_summary_sub_dags(roots); + let roots = share_common_sub_dags(roots); use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); - let mut pending = roots - .iter() - .map(|(_, node)| node.as_ref()) - .collect::>(); - while let Some(node) = pending.pop() { - std::mem::discriminant(&node.expr).hash(&mut hash); - let raw = match &node.expr { - SummaryExpr::KeepPreAsap(raw) => Some(raw.as_ref()), - _ => None, - }; - let operation = match &node.expr { - SummaryExpr::ValueOperation { - timing, operation, .. - } => serde_json::json!((timing, operation)), - SummaryExpr::BinaryOp { - timing, operator, .. - } => serde_json::json!((timing, operator)), - SummaryExpr::SummaryMerge { timing, .. } => serde_json::json!(timing), - _ => serde_json::Value::Null, - }; - let mut value = - serde_json::to_value((&node.schema, &node.guarantee, raw, operation)) - .map_err(|_| { - RealizationError::PhysicalRealization( - "candidate identity serialization failed", - ) - })?; - fn normalize(value: &mut serde_json::Value) { - match value { - serde_json::Value::Number(number) - if number.as_f64() == Some(0.0) => - { - *value = serde_json::json!(0); - } - serde_json::Value::Array(values) => { - values.iter_mut().for_each(normalize) - } - serde_json::Value::Object(values) => { - values.values_mut().for_each(normalize) - } - _ => {} - } - } - normalize(&mut value); - value.sort_all_objects(); - value.to_string().hash(&mut hash); - match &node.expr { - SummaryExpr::KeepPreAsap(_) => {} - SummaryExpr::BinaryOp { lhs, rhs, .. } => { - pending.extend([lhs.as_ref(), rhs.as_ref()]) - } - SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::SummarySubtract { left, right } => { - pending.extend([left.as_ref(), right.as_ref()]) - } - SummaryExpr::ValueOperation { child, .. } - | SummaryExpr::SummaryAgg { child, .. } => pending.push(child.as_ref()), - SummaryExpr::SummaryJoin { outer, inner, .. } => { - pending.extend([outer.as_ref(), inner.as_ref()]) - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - pending.push(summary_input.as_ref()) - } - SummaryExpr::SummaryMerge { children, .. } => { - pending.extend(children.iter().map(|child| child.as_ref())) - } - } + let mut cache = HashCache::new(); + for (_, node) in &roots { + structural_hash(node, &mut cache).hash(&mut hash); } let bucket = seen.entry(hash.finish()).or_default(); if !bucket @@ -4281,25 +4373,25 @@ impl CandidateLogicalASAPDAGs { /// Lifecycle-aware whole-subplan costs keyed by target and candidate identity. #[derive(Default, Clone)] pub(crate) struct CandidateCostOverrides { - costs: HashMap<(*const QueryExpr, *const ReplacementSubDAG), Cost>, - raw_costs: HashMap<*const QueryExpr, Cost>, + costs: HashMap<(*const OperatorNode, *const ReplacementSubDAG), Cost>, + raw_costs: HashMap<*const OperatorNode, Cost>, /// Targets for which the caller requested an atomic raw-vs-summary /// decision. Other memo groups continue through ordinary CSE selection. - finalized_targets: HashSet<*const QueryExpr>, + finalized_targets: HashSet<*const OperatorNode>, } impl CandidateCostOverrides { - pub(crate) fn finalize_target(&mut self, target: &Rc) { + pub(crate) fn finalize_target(&mut self, target: &Rc) { self.finalized_targets.insert(Rc::as_ptr(target)); } - fn finalizes(&self, target: &Rc) -> bool { + fn finalizes(&self, target: &Rc) -> bool { self.finalized_targets.contains(&Rc::as_ptr(target)) } pub(crate) fn insert( &mut self, - target: &Rc, + target: &Rc, candidate: &ReplacementSubDAG, cost: Cost, ) { @@ -4307,17 +4399,17 @@ impl CandidateCostOverrides { .insert((Rc::as_ptr(target), candidate as *const _), cost); } - fn get(&self, target: &Rc, candidate: &ReplacementSubDAG) -> Option { + fn get(&self, target: &Rc, candidate: &ReplacementSubDAG) -> Option { self.costs .get(&(Rc::as_ptr(target), candidate as *const _)) .copied() } - pub(crate) fn insert_raw(&mut self, target: &Rc, cost: Cost) { + pub(crate) fn insert_raw(&mut self, target: &Rc, cost: Cost) { self.raw_costs.insert(Rc::as_ptr(target), cost); } - fn raw(&self, target: &Rc) -> Option { + fn raw(&self, target: &Rc) -> Option { self.raw_costs.get(&Rc::as_ptr(target)).copied() } } @@ -4334,7 +4426,7 @@ impl CandidateLogicalASAPDAGs { } /// Whether no targets were discovered at all (an empty workload, or one - /// with no `QueryExpr` nodes reachable from any root — never true for a + /// with no `OperatorNode`s reachable from any root — never true for a /// non-empty `roots`, since every root is itself a target). pub fn is_empty(&self) -> bool { self.groups.is_empty() @@ -4343,7 +4435,10 @@ impl CandidateLogicalASAPDAGs { /// The candidate set for `target`, if `target`'s own `Rc` is a discovered /// `TargetSubDAG` (i.e. `Rc::ptr_eq` to some node reachable from /// `roots`). - pub fn candidates_for_target(&self, target: &Rc) -> Option<&TargetSubDAGCandidates> { + pub fn candidates_for_target( + &self, + target: &Rc, + ) -> Option<&TargetSubDAGCandidates> { self.groups.get(&Rc::as_ptr(target)) } @@ -4465,17 +4560,17 @@ impl CandidateLogicalASAPDAGs { /// half of issue #287. Looked up by `Rc` pointer identity, the same /// currency [`CandidateLogicalASAPDAGs::candidates_for_target`]/[`GlobalSelection::for_target`] already /// use. -/// Holds an owned `Rc` clone alongside each profile (not just its -/// raw pointer) so this map keeps every node it describes alive for as long -/// as the map itself lives — a `RecurrenceProfileMap` is safe to outlive the -/// `CandidateLogicalASAPDAGs` it was built from. Without this, a raw `*const QueryExpr` key +/// Holds an owned `Rc` clone alongside each profile (not just +/// its raw pointer) so this map keeps every node it describes alive for as +/// long as the map itself lives — a `RecurrenceProfileMap` is safe to outlive +/// the `CandidateLogicalASAPDAGs` it was built from. Without this, a raw `*const OperatorNode` key /// could, after the originating `CandidateLogicalASAPDAGs` (the only other owner of those /// `Rc`s) is dropped, collide with an unrelated, later allocation that /// happens to reuse the same freed address — silently returning a stale /// profile for the wrong node (issue #287 review, bug 4). #[derive(Debug, Clone)] pub struct RecurrenceProfileMap { - profiles: HashMap<*const QueryExpr, (Rc, RecurrenceProfile)>, + profiles: HashMap<*const OperatorNode, (Rc, RecurrenceProfile)>, } impl RecurrenceProfileMap { @@ -4484,7 +4579,7 @@ impl RecurrenceProfileMap { /// in the [`CandidateLogicalASAPDAGs`] this map was built from (or carried no /// recurring/one-shot/update-rate metadata at all) — always a valid, /// "no metadata" answer, never a panic. - pub fn for_target(&self, target: &Rc) -> RecurrenceProfile { + pub fn for_target(&self, target: &Rc) -> RecurrenceProfile { self.profiles .get(&Rc::as_ptr(target)) .map(|(_, profile)| *profile) @@ -4504,7 +4599,7 @@ impl CandidateLogicalASAPDAGs { /// `self.roots[i]` — the same order [`search_workload`]/ /// [`search_workload_with`] were originally called with (post-CSE /// dedup preserves both root count and order — see - /// `asap_types::pre_asap::cse::share_common_sub_dags`'s own + /// `asap_types::ir::cse::share_common_sub_dags`'s own /// `.map(...).collect()` body). This keeps `Id` fully opaque (no `Eq`/ /// `Hash`/`Clone` bound needed on it at all — issue #287's "keep /// caller/query identifiers opaque" requirement) at the cost of the @@ -4581,12 +4676,12 @@ impl CandidateLogicalASAPDAGs { } } - let mut rates: HashMap<*const QueryExpr, f64> = HashMap::new(); - let mut one_shot_counts: HashMap<*const QueryExpr, usize> = HashMap::new(); + let mut rates: HashMap<*const OperatorNode, f64> = HashMap::new(); + let mut one_shot_counts: HashMap<*const OperatorNode, usize> = HashMap::new(); // Sites actually reached by at least one root's own recurrence tag // during the walk below — see this method's own "Unreachable // sites" doc. - let mut reached: HashSet<*const QueryExpr> = HashSet::new(); + let mut reached: HashSet<*const OperatorNode> = HashSet::new(); for ((_, root), recurrence) in self.roots.iter().zip(root_recurrence) { let recurrence = *recurrence; @@ -4596,7 +4691,7 @@ impl CandidateLogicalASAPDAGs { // recomputed occurrence is evaluated twice as well; stopping // expansion after the first pointer visit undercounts exactly // the effective-consumer rate recurrence-aware costing needs. - let mut queue: VecDeque<(*const QueryExpr, usize)> = VecDeque::new(); + let mut queue: VecDeque<(*const OperatorNode, usize)> = VecDeque::new(); queue.push_back((root_ptr, 1)); while let Some((ptr, path_count)) = queue.pop_front() { @@ -4740,7 +4835,7 @@ impl CandidateLogicalASAPDAGs { &self, workload: &QueryWorkload, root_workload_entries: &[usize], - ) -> Result>, RecurrenceError> { + ) -> Result>, RecurrenceError> { let entry_count = workload.entries().count(); if root_workload_entries.len() != self.roots.len() { return Err(RecurrenceError::RootCountMismatch { @@ -4748,7 +4843,7 @@ impl CandidateLogicalASAPDAGs { got: root_workload_entries.len(), }); } - let mut bindings: HashMap<*const QueryExpr, HashSet> = HashMap::new(); + let mut bindings: HashMap<*const OperatorNode, HashSet> = HashMap::new(); for ((_, root), &entry_index) in self.roots.iter().zip(root_workload_entries) { if entry_index >= entry_count { return Err(RecurrenceError::InvalidWorkloadEntry { @@ -4790,12 +4885,12 @@ impl CandidateLogicalASAPDAGs { /// child always has `edge_count >= 1` in practice, but this keeps the /// helper correct regardless). fn contribute( - ptr: *const QueryExpr, + ptr: *const OperatorNode, times: usize, recurrence: RootRecurrence, - rates: &mut HashMap<*const QueryExpr, f64>, - one_shot_counts: &mut HashMap<*const QueryExpr, usize>, - reached: &mut HashSet<*const QueryExpr>, + rates: &mut HashMap<*const OperatorNode, f64>, + one_shot_counts: &mut HashMap<*const OperatorNode, usize>, + reached: &mut HashSet<*const OperatorNode>, ) { if times == 0 { return; @@ -4816,7 +4911,7 @@ fn contribute( /// [`CandidateLogicalASAPDAGs::cost_sorted`]. #[derive(Debug)] pub struct RankedTargetSubDAGCandidates<'a> { - pub target: &'a Rc, + pub target: &'a Rc, pub consumer_count: usize, pub candidates: Vec<&'a ReplacementSubDAG>, /// `costs[i]` is `candidates[i]`'s own grouping-state cost when available, @@ -4864,7 +4959,7 @@ fn rank_group<'a>( // estimate, compare N independent states with the shared grid directly. let target = TargetSubDAG::with_consumer_count(&group.target, group.consumer_count); let has_hydra = ranked.iter().any(|candidate| { - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { return false; }; summary_grouping(node).is_some_and(|grouping| { @@ -4896,7 +4991,7 @@ fn rank_group<'a>( return ranked; } - // Shape 3: `SketchAlgorithmStrategy`'s sketch-family candidates (every + // Shape 3: `ASAPStrategies`'s sketch-family candidates (every // candidate is a `Summary` that realizes a `SketchAlgorithm`) — rank via // `CostModel::rank_candidates`, the same hook `realizations_for_intent` // itself consults. @@ -4904,16 +4999,16 @@ fn rank_group<'a>( let kinds: Option> = ranked .iter() .map(|c| match &c.replacement { - Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, }) .collect(); if let Some(kinds) = kinds { let order = crate::cost_model::validated_candidate_ranking(cost_model, intent, &kinds); ranked.sort_by_key(|c| { let kind = match &c.replacement { - Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, }; kind.and_then(|k| order.iter().position(|o| *o == k)) .unwrap_or(usize::MAX) @@ -4970,21 +5065,21 @@ fn cse_preference(group: &TargetSubDAGCandidates, cost_model: &dyn CostModel) -> }) } -/// [`cse_preference`] only needs one representative bound [`SummaryNode`] +/// [`cse_preference`] only needs one representative bound [`OperatorNode`] /// for `target` (to build a [`CseCandidate`] for /// [`CostModel::cse_share_decision`]), not the full ranked candidate list -/// [`SketchAlgorithmStrategy::replacements`] returns — so this just reuses +/// [`ASAPStrategies::replacements`] returns — so this just reuses /// [`realize_child`], the same rank-and-take-first helper /// `construct_summary_agg`'s own recursion and /// [`crate::cost_model::DefaultCostModel::estimate_cost`] already use, /// wrapped to swallow the (here, uninteresting) error into `None`. -fn realize_one(target: &Rc, cost_model: &dyn CostModel) -> Option> { +fn realize_one(target: &Rc, cost_model: &dyn CostModel) -> Option> { realize_child(target, cost_model).ok() } -/// The `SketchAlgorithm` a bound [`Replacement::Summary`] candidate ultimately +/// The `SketchAlgorithm` a bound [`Replacement::SubDAG`] candidate ultimately /// realizes, if any (`None` for an `ExactAggregate`/pass-through -/// `Summary` — nothing to rank against another `SketchAlgorithm`). +/// sub-DAG — nothing to rank against another `SketchAlgorithm`). /// /// Mirrors this module's own `#[cfg(test)]`-only `summary_family_algorithm` /// helper (in the test module below), which does the identical @@ -4993,23 +5088,27 @@ fn realize_one(target: &Rc, cost_model: &dyn CostModel) -> Option Option { - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => sketch_kind_of(summary_input), - SummaryExpr::SummaryAgg { +fn sketch_kind_of(node: &OperatorNode) -> Option { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + sketch_kind_of(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } => Some(kind.algorithm().clone()), + }) => Some(kind.algorithm().clone()), _ => None, } } /// The grouping strategy used by a bound summary candidate, unwrapping its -/// readout node when necessary. -fn summary_grouping(node: &SummaryNode) -> Option<&GroupingStrategy> { - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => summary_grouping(summary_input), - SummaryExpr::SummaryAgg { grouping, .. } => Some(grouping), +/// evaluation node when necessary. +fn summary_grouping(node: &OperatorNode) -> Option<&GroupingStrategy> { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + summary_grouping(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { grouping, .. }) => Some(grouping), _ => None, } } @@ -5034,7 +5133,7 @@ fn summary_grouping(node: &SummaryNode) -> Option<&GroupingStrategy> { #[derive(Debug)] pub struct TargetSubDAGSelection<'a> { /// The target sub-DAG this selection is for. - pub target: &'a Rc, + pub target: &'a Rc, /// [`TargetSubDAGCandidates::consumer_count`] — how many operator-child positions /// directly reference `target`, ignoring every ancestor's own choice. pub consumer_count: usize, @@ -5065,18 +5164,18 @@ pub struct TargetSubDAGSelection<'a> { #[derive(Debug)] pub struct CompositionDecision<'a> { /// The exact child/operation pair validated by the search accuracy model. - pub plan: Rc, + pub plan: Rc, /// The child target the composed operator consumes. - pub child_target: &'a Rc, + pub child_target: &'a Rc, /// For a read-time operation: the child's own candidate committed alongside - /// (the summary readout the operator folds). `None` for an update-path + /// (the summary evaluation the operator folds). `None` for an update-path /// transform, whose input is raw update data — its cost is charged to /// the maintained summary *above* it instead. pub child_candidate: Option<&'a ReplacementSubDAG>, /// The composed plan's recurring rate — `read_operation_plan_cost_rate` /// or `maintenance_operation_plan_cost_rate`. pub cost_rate: CostRate, - /// `raw_recompute_cost_rate` — the `KeepPreAsap` baseline it beat. + /// `raw_recompute_cost_rate` — the kept-sub-DAG baseline it beat. pub baseline_rate: CostRate, /// The statistics (and their provenance) both rates were computed from. pub inputs: ExactCompositionCostInputs, @@ -5087,12 +5186,13 @@ pub struct CompositionDecision<'a> { /// [`CandidateLogicalASAPDAGs::cost_sorted`] use. #[derive(Debug)] pub struct GlobalSelection<'a> { - order: Vec<*const QueryExpr>, - groups: HashMap<*const QueryExpr, TargetSubDAGSelection<'a>>, + order: Vec<*const OperatorNode>, + groups: HashMap<*const OperatorNode, TargetSubDAGSelection<'a>>, /// [`Self::assemble_selected_dag`]'s memo — one bound node per target for the /// life of this selection, so two parents composing over one shared - /// child get the *same* `Rc`. - assembled_nodes: RefCell>>, + /// child get the *same* `Rc` (a kept pre-ASAP sub-DAG + /// shared by two parents stays one `Rc` the same way). + assembled_nodes: RefCell>>, } fn normalize_cross_input_equi_predicate( @@ -5100,15 +5200,17 @@ fn normalize_cross_input_equi_predicate( left_width: usize, total_width: usize, ) -> Option { - let QueryExpr::Compare { + let ScalarExpr::Compare { left, op: asap_types::pre_asap::CompareOpKind::Eq, right, - } = pred.0.as_ref() + semantics, + } = &pred.0 else { return None; }; - let (QueryExpr::Column(left_id), QueryExpr::Column(right_id)) = (left.as_ref(), right.as_ref()) + let (ScalarExpr::Column(left_id), ScalarExpr::Column(right_id)) = + (left.as_ref(), right.as_ref()) else { return None; }; @@ -5121,20 +5223,12 @@ fn normalize_cross_input_equi_predicate( } else { return None; }; - Some(Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(left_id)), + Some(Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(left_id)), op: asap_types::pre_asap::CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(right_id)), - }))) -} - -fn relational_join_guarantee( - left: Option<&ResultGuarantee>, - right: Option<&ResultGuarantee>, -) -> Option { - left.zip(right) - .filter(|(left, right)| left.is_exact() && right.is_exact()) - .map(|_| ResultGuarantee::exact("RelationalJoin over exact inputs")) + right: Box::new(ScalarExpr::Column(right_id)), + semantics: *semantics, + })) } impl<'a> GlobalSelection<'a> { @@ -5146,47 +5240,52 @@ impl<'a> GlobalSelection<'a> { /// The selection for `target`, if `target`'s own `Rc` is a discovered /// site (i.e. `Rc::ptr_eq` to some node reachable from the workload's /// roots). - pub fn for_target(&self, target: &Rc) -> Option<&TargetSubDAGSelection<'a>> { + pub fn for_target(&self, target: &Rc) -> Option<&TargetSubDAGSelection<'a>> { self.groups.get(&Rc::as_ptr(target)) } /// Link this selection's per-site decisions into one data_state-validated /// post-ASAP DAG rooted at `target` — the one place a committed - /// composition's child *reference* becomes an actual `Rc` + /// composition's child *reference* becomes an actual `Rc` /// edge (issue #171). `None` if `target` is not a discovered site. /// /// Per site: a [`Replacement::ExactComposition`] uses its validated /// operation/child plan, retaining the search model's guarantee; - /// a [`Replacement::Summary`] is + /// a bound-summary [`Replacement::SubDAG`] is /// re-linked so its `SummaryAgg` child is the child target's own /// DAG assembly whenever that is phase-legal beneath maintenance /// (so a child that chose an `ValueOperationAtIngestionTime` actually ends up under - /// the summary); a [`Replacement::Rewrite`] or an unmatched site stays - /// the conservative `KeepPreAsap`. Memoized by target identity, so a - /// shared inner summary is one `Rc` no matter how many roots reach it. + /// the summary); a logical-rewrite [`Replacement::SubDAG`] is kept + /// as it is (exact); an unmatched site keeps its own operator with each + /// child assembled independently ([`Self::assemble_residual`]). + /// Memoized by target identity, so a shared inner summary is one `Rc` + /// no matter how many roots reach it. pub fn assemble_selected_dag( &self, - target: &Rc, - ) -> Result>, RealizationError> { + target: &Rc, + ) -> Result>, RealizationError> { if !self.groups.contains_key(&Rc::as_ptr(target)) { return Ok(None); } self.assemble_target(target).map(Some) } - /// Assemble a complete query result, including an exact-state readout when + /// Assemble a complete query result, including an exact-state evaluation when /// needed. `assemble_selected_dag` also serves internal state frontiers; /// callers exposing query results must use this boundary instead. pub fn assemble_selected_query( &self, - target: &Rc, - ) -> Result>, RealizationError> { + target: &Rc, + ) -> Result>, RealizationError> { self.assemble_selected_dag(target)? .map(|node| finalize_query_candidate(node, target)) .transpose() } - fn assemble_target(&self, target: &Rc) -> Result, RealizationError> { + fn assemble_target( + &self, + target: &Rc, + ) -> Result, RealizationError> { let ptr = Rc::as_ptr(target); if let Some(node) = self.assembled_nodes.borrow().get(&ptr) { return Ok(Rc::clone(node)); @@ -5198,10 +5297,12 @@ impl<'a> GlobalSelection<'a> { .groups .get(&ptr) .and_then(|sel| sel.chosen) - .is_some_and(|candidate| matches!(&candidate.replacement, - Replacement::Summary(node) if matches!(&node.expr, - SummaryExpr::SummaryAgg { child, .. } - if !matches!(&child.expr, SummaryExpr::KeepPreAsap(raw) if contains_aggregate(raw))))); + .is_some_and(|candidate| { + matches!(&candidate.replacement, + Replacement::SubDAG(node) if matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) + if child.contains_asap() || !contains_aggregate(child))) + }); let node = if query_time_nested_sum(target) && !selected_composed_summary { self.assemble_residual(target)? } else { @@ -5212,8 +5313,10 @@ impl<'a> GlobalSelection<'a> { .map(|c| &c.replacement) { None => self.assemble_residual(target)?, - Some(Replacement::Rewrite(rewritten)) => keep_pre_asap(rewritten)?, - Some(Replacement::Summary(node)) => self.relink_summary(node, target)?, + Some(Replacement::SubDAG(node)) if node.contains_asap() => { + self.relink_summary(node, target)? + } + Some(Replacement::SubDAG(kept)) => retain_exact(kept)?, Some(Replacement::ExactComposition(_)) => Rc::clone( &self.groups[&ptr] .composition @@ -5229,130 +5332,124 @@ impl<'a> GlobalSelection<'a> { Ok(node) } - /// Preserve composable query-time value operators in post-ASAP form even - /// when the operator itself has no summary realization. Its child is - /// assembled independently, so a selected summary remains visible - /// beneath `Project`/`Filter`/`Sort`/`Limit` instead of being swallowed by - /// one opaque `KeepPreAsap` sub-DAG. + /// Keep `target`'s own operator and assemble each child independently, + /// so a selected summary remains visible beneath a relational operator + /// that has no summary realization of its own instead of being + /// swallowed by one opaque kept sub-DAG. Every child that is a + /// discovered target is assembled (and finalized to query-time values); + /// any other child is kept as it is. The guarantee is composed from the + /// assembled children: all exact → exact; exactly one child → that + /// child's guarantee; otherwise unknown. An inner `Join` first has its + /// cross-input equi-predicate normalized; any other join is kept whole. fn assemble_residual( &self, - target: &Rc, - ) -> Result, RealizationError> { - if let QueryExpr::Join { + target: &Rc, + ) -> Result, RealizationError> { + if target.children().is_empty() { + // A leaf has nothing to assemble beneath it: keep it as it is. + return retain_exact(target); + } + let mut operator = target.operator.clone(); + if let Operator::NonASAP(NonASAPOp::Join { left, right, kind, pred, - } = target.as_ref() + }) = &mut operator { - let left_width = left.output_schema()?.fields.len(); - let total_width = left_width + right.output_schema()?.fields.len(); - let normalized_pred = matches!(kind, asap_types::pre_asap::JoinKind::Inner) + let left_width = left.schema.fields.len(); + let total_width = left_width + right.schema.fields.len(); + let normalized_pred = matches!(kind, JoinKind::Inner) .then(|| normalize_cross_input_equi_predicate(pred, left_width, total_width)) .flatten(); - let Some(pred) = normalized_pred else { - return keep_pre_asap(target); + let Some(normalized) = normalized_pred else { + return retain_exact(target); }; - let left = finalize_query_candidate(self.assemble_target(left)?, left)?; - let right = finalize_query_candidate(self.assemble_target(right)?, right)?; - let guarantee = - relational_join_guarantee(left.guarantee.as_ref(), right.guarantee.as_ref()); - let node = Rc::new(SummaryNode { - expr: SummaryExpr::RelationalJoin { - left, - right, - kind: kind.clone(), - pred, - pruning: None, - }, - schema: lift(&target.output_schema()?), - guarantee, - }); - validate_execution_data_states_at(&node, ExecutionDataState::QUERY_ROWS)?; - return Ok(node); + *pred = normalized; } - let (child_target, operation) = match target.as_ref() { - QueryExpr::Project { - cols, - qualifier, - child, - } => ( - child, - ValueOperation::Project { - cols: cols.clone(), - qualifier: qualifier.clone(), - }, - ), - QueryExpr::Filter { pred, child } => { - (child, ValueOperation::Filter { pred: pred.clone() }) + let mut failure = None; + let mut children = Vec::new(); + let operator = operator.map_children(|child| { + if failure.is_some() { + return Rc::clone(child); } - QueryExpr::Sort { - keys, - partition_by, - child, - } => ( - child, - ValueOperation::Sort { - keys: keys.clone(), - partition_by: partition_by.clone(), - }, - ), - QueryExpr::Limit { n, offset, child } => ( - child, - ValueOperation::Limit { - n: *n, - offset: *offset, - partition_by: match child.as_ref() { - QueryExpr::Sort { partition_by, .. } => partition_by.clone(), - _ => Default::default(), - }, - }, - ), - QueryExpr::Aggregate { - reduction, - measures, - output_names, - filters, - having, - child, - } if query_time_nested_sum(target) => ( - child, - ValueOperation::Exact(ExactOperation::Aggregate { - reduction: reduction.clone(), - measures: measures.clone(), - output_names: output_names.clone(), - filters: filters.clone(), - having: having.clone(), - }), - ), - _ => return keep_pre_asap(target), - }; - let child = finalize_query_candidate(self.assemble_target(child_target)?, child_target)?; - let guarantee = child.guarantee.clone(); - let node = Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child, - operation, - timing: ExecutionTiming::QueryTime, - }, - schema: lift(&target.output_schema()?), - guarantee, + let assembled = if self.groups.contains_key(&Rc::as_ptr(child)) { + self.assemble_target(child) + .and_then(|node| finalize_query_candidate(node, child)) + } else { + Ok(Rc::clone(child)) + }; + match assembled { + Ok(node) => { + children.push(Rc::clone(&node)); + node + } + Err(error) => { + failure = Some(error); + Rc::clone(child) + } + } + }); + if let Some(error) = failure { + return Err(error); + } + // An operator that computes new values from its input rows has no + // sound accuracy composition over an approximate input (e.g. `max` + // over a quantile evaluation's rank error). Without a selected + // composition such a node stays an exact pre-ASAP sub-DAG; only the + // read-time nested SUM keeps its assembled children. + let computes_values = matches!( + target.non_asap(), + Some( + NonASAPOp::Aggregate { .. } + | NonASAPOp::BinaryOp { .. } + | NonASAPOp::SQLWindowFunc { .. } + ) + ) && !query_time_nested_sum(target); + let approximate_input = children.iter().any(|child| { + !child + .guarantee + .as_ref() + .is_some_and(ResultGuarantee::is_exact) }); - validate_execution_data_states_at(&node, ExecutionDataState::QUERY_ROWS)?; + if computes_values && approximate_input { + return retain_exact(target); + } + let guarantee = match children.as_slice() { + [child] => child.guarantee.clone(), + children + if children.iter().all(|child| { + child + .guarantee + .as_ref() + .is_some_and(ResultGuarantee::is_exact) + }) => + { + Some(ResultGuarantee::exact(format!( + "{} over exact inputs", + target.operator.kind_name() + ))) + } + _ => None, + }; + let node = Rc::new( + OperatorNode::with_schema(operator, target.schema.clone()).with_guarantee(guarantee), + ); + validate_default(&node, ExecutionTiming::QueryTime)?; Ok(node) } - /// Re-link a bound `Summary` candidate's `SummaryAgg` child to the + /// Re-link a bound summary candidate's `SummaryAgg` child to the /// child target's own DAG assembly when that is legal beneath /// maintenance; otherwise keep the candidate exactly as constructed. fn relink_summary( &self, - node: &Rc, - target: &Rc, - ) -> Result, RealizationError> { - let QueryExpr::Aggregate { + node: &Rc, + target: &Rc, + ) -> Result, RealizationError> { + let Some(NonASAPOp::Aggregate { child: pre_child, .. - } = target.as_ref() + }) = target.non_asap() else { return Ok(Rc::clone(node)); }; @@ -5377,16 +5474,16 @@ impl<'a> GlobalSelection<'a> { /// A mergeable outer SUM over a relationally wrapped aggregate is a read-time /// reduction of the inner summary values. Maintaining the outer SUM directly -/// would hide that inner temporal aggregate inside `KeepPreAsap` and lose its -/// independently selected summary. -fn query_time_nested_sum(target: &QueryExpr) -> bool { - let QueryExpr::Aggregate { +/// would hide that inner temporal aggregate inside one kept sub-DAG and lose +/// its independently selected summary. +fn query_time_nested_sum(target: &OperatorNode) -> bool { + let Some(NonASAPOp::Aggregate { measures, filters, having: None, child, .. - } = target + }) = target.non_asap() else { return false; }; @@ -5395,13 +5492,15 @@ fn query_time_nested_sum(target: &QueryExpr) -> bool { && contains_aggregate(child) } -fn contains_aggregate(expr: &QueryExpr) -> bool { - match expr { - QueryExpr::Aggregate { .. } => true, - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } => contains_aggregate(child), +fn contains_aggregate(expr: &OperatorNode) -> bool { + match expr.non_asap() { + Some(NonASAPOp::Aggregate { .. }) => true, + Some( + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. }, + ) => contains_aggregate(child), _ => false, } } @@ -5409,50 +5508,53 @@ fn contains_aggregate(expr: &QueryExpr) -> bool { /// Rebuild `node` (a `SummaryAgg`, possibly under a `SummaryEstimate`) with /// `new_child` as the `SummaryAgg`'s child, if the result still validates /// as maintained state; otherwise return `node` unchanged. -fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc { - match &node.expr { - SummaryExpr::SummaryEstimate { +fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } => { + }) => { let inner = relink_agg_child(summary_input, new_child); if Rc::ptr_eq(&inner, summary_input) { return Rc::clone(node); } - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input: inner, - query: query.clone(), - }, - schema: node.schema.clone(), - guarantee: node.guarantee.clone(), - }) + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: inner, + query: query.clone(), + }), + node.schema.clone(), + ) + .with_guarantee(node.guarantee.clone()), + ) } - SummaryExpr::SummaryAgg { + Operator::ASAP(ASAPOp::SummaryAgg { child, family, input, reduction, grouping, filter, - } => { + }) => { if Rc::ptr_eq(child, new_child) { return Rc::clone(node); } - let rebuilt = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: Rc::clone(new_child), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: filter.clone(), - }, - schema: node.schema.clone(), - guarantee: node.guarantee.clone(), - }); - match validate_execution_data_states_at(&rebuilt, ExecutionDataState::INGESTION_SUMMARY) - { + let rebuilt = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: Rc::clone(new_child), + family: family.clone(), + input: input.clone(), + reduction: reduction.clone(), + grouping: grouping.clone(), + filter: filter.clone(), + }), + node.schema.clone(), + ) + .with_guarantee(node.guarantee.clone()), + ); + match validate_default(&rebuilt, ExecutionTiming::IngestionTime) { Ok(_) => rebuilt, Err(_) => Rc::clone(node), } @@ -5461,13 +5563,15 @@ fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc) -> Option<&Rc> { - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => maintained_summary(summary_input), - SummaryExpr::SummaryAgg { .. } => Some(node), +fn maintained_summary(node: &Rc) -> Option<&Rc> { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + maintained_summary(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => Some(node), _ => None, } } @@ -5484,10 +5588,10 @@ struct CompositionContext { /// child target ptr → the child's candidate an ancestor's composition /// already committed to (a later parent must compose with the *same* /// one, and the child's own selection is forced to it). - committed_child: HashMap<*const QueryExpr, *const ReplacementSubDAG>, + committed_child: HashMap<*const OperatorNode, *const ReplacementSubDAG>, /// site ptr → the maintained `SummaryAgg` directly above it, when its - /// parent chose a bound `Summary` — what an `ValueOperationAtIngestionTime` here feeds. - maintaining_parent: HashMap<*const QueryExpr, Rc>, + /// parent chose a bound summary — what an `ValueOperationAtIngestionTime` here feeds. + maintaining_parent: HashMap<*const OperatorNode, Rc>, } /// One eligible composed alternative at a site, before the cheapest wins. @@ -5500,10 +5604,10 @@ struct CompositionOption<'a> { /// composed-plan rate is *known* and beats the raw-recompute baseline — /// costed against each compatible child candidate already in `CandidateLogicalASAPDAGs` /// (or the one an earlier parent committed). Unknown statistics yield no -/// option at all: the conservative `KeepPreAsap` path stays. +/// option at all: the conservative kept-sub-DAG path stays. fn composition_options<'a>( group: &'a TargetSubDAGCandidates, - groups: &'a HashMap<*const QueryExpr, TargetSubDAGCandidates>, + groups: &'a HashMap<*const OperatorNode, TargetSubDAGCandidates>, effective: usize, cost_model: &dyn CostModel, context: &CompositionContext, @@ -5522,7 +5626,7 @@ fn composition_options<'a>( continue; }; let already_committed = context.committed_child.get(&child_ptr).copied(); - let cost = |summary: &SummaryNode, shared: bool| { + let cost = |summary: &OperatorNode, shared: bool| { let request = ExactCompositionCostRequest { target: &group.target, composition, @@ -5558,10 +5662,10 @@ fn composition_options<'a>( if !is_automatically_selectable(child_candidate, cost_model) { continue; } - let Replacement::Summary(summary) = &child_candidate.replacement else { + let Replacement::SubDAG(summary) = &child_candidate.replacement else { continue; }; - if !composition.accepts_child(summary) { + if is_logical_rewrite(summary) || !composition.accepts_child(summary) { continue; } let Some(prepared) = plans.iter().find(|p| { @@ -5670,8 +5774,8 @@ impl CandidateLogicalASAPDAGs { let topo = topological_order(&self.order, &dag); let mut effective_uses = dag.external_root_uses.clone(); - let mut chosen_share: HashMap<*const QueryExpr, ShareDecision> = HashMap::new(); - let mut groups: HashMap<*const QueryExpr, TargetSubDAGSelection<'_>> = HashMap::new(); + let mut chosen_share: HashMap<*const OperatorNode, ShareDecision> = HashMap::new(); + let mut groups: HashMap<*const OperatorNode, TargetSubDAGSelection<'_>> = HashMap::new(); let mut context = CompositionContext::default(); for ptr in &topo { @@ -5898,8 +6002,8 @@ impl CandidateLogicalASAPDAGs { // Record the maintained summary this site's bound candidate // builds, for a child that may compose an `ValueOperationAtIngestionTime` // beneath it. - if let (Some(Replacement::Summary(node)), QueryExpr::Aggregate { child, .. }) = - (chosen.map(|c| &c.replacement), group.target.as_ref()) + if let (Some(Replacement::SubDAG(node)), Some(NonASAPOp::Aggregate { child, .. })) = + (chosen.map(|c| &c.replacement), group.target.non_asap()) { if let Some(summary) = maintained_summary(node) { context @@ -5911,7 +6015,7 @@ impl CandidateLogicalASAPDAGs { let outgoing_multiplier = multiplier(*ptr, &effective_uses, &chosen_share); match chosen { Some(ReplacementSubDAG { - replacement: Replacement::Rewrite(source), + replacement: Replacement::SubDAG(source), provenance: ReplacementProvenance::AccuracyReconciliation, .. }) => { @@ -5924,8 +6028,10 @@ impl CandidateLogicalASAPDAGs { } _ => { let selected_rewrite = match chosen.map(|candidate| &candidate.replacement) { - Some(Replacement::Rewrite(rewrite)) => rewrite, - Some(Replacement::Summary(_) | Replacement::ExactComposition(_)) | None => { + Some(Replacement::SubDAG(rewrite)) if is_logical_rewrite(rewrite) => { + rewrite + } + Some(Replacement::SubDAG(_) | Replacement::ExactComposition(_)) | None => { &group.target } }; @@ -5991,9 +6097,9 @@ fn is_automatically_selectable(candidate: &ReplacementSubDAG, cost_model: &dyn C /// ancestor sits anywhere on the path from a root to a site — see the /// module docs' "Whole-plan (cross-group) selection" section. fn multiplier( - parent_ptr: *const QueryExpr, - effective_uses: &HashMap<*const QueryExpr, usize>, - chosen_share: &HashMap<*const QueryExpr, ShareDecision>, + parent_ptr: *const OperatorNode, + effective_uses: &HashMap<*const OperatorNode, usize>, + chosen_share: &HashMap<*const OperatorNode, ShareDecision>, ) -> usize { let effective = *effective_uses.get(&parent_ptr).expect( "topological_order guarantees a parent is processed (and its effective_consumer_count \ @@ -6017,7 +6123,7 @@ fn cse_candidate_pair( for candidate in &group.candidates { match candidate.provenance { ReplacementProvenance::CseShare => { - let Replacement::Rewrite(rc) = &candidate.replacement else { + let Replacement::SubDAG(rc) = &candidate.replacement else { return None; }; if !Rc::ptr_eq(rc, &group.target) || share.replace(candidate).is_some() { @@ -6025,7 +6131,7 @@ fn cse_candidate_pair( } } ReplacementProvenance::CseRecompute => { - let Replacement::Rewrite(rc) = &candidate.replacement else { + let Replacement::SubDAG(rc) = &candidate.replacement else { return None; }; if Rc::ptr_eq(rc, &group.target) @@ -6103,26 +6209,25 @@ fn pick_shared_sub_dag_candidate( /// The parent/child structure [`CandidateLogicalASAPDAGs::global_selection`]'s DP walks — /// built separately from [`discover_targets`]'s own `order`/`nodes`/`counts` /// maps (which only track *aggregate* reference counts, not per-parent -/// breakdown or direction) rather than extending that already-reviewed, -/// already-tested pass. Same "small duplicated traversal over reshaping -/// proven code" call as [`is_shared_subtree_group`]. +/// breakdown or direction). Selection needs per-parent edge counts to +/// distinguish shared producers from repeated uses within one consumer. struct ReferenceDAG { /// child ptr -> `(parent ptr, edge count from that one parent)`, for /// every direct operator-child edge in the relational-skeleton scope /// [`walk_children`] itself uses (an edge count above 1 happens when /// one parent references the same child from two different fields, /// e.g. a `Join`'s `left`/`right` both being the same `Rc`). - parents_of: HashMap<*const QueryExpr, Vec<(*const QueryExpr, usize)>>, + parents_of: HashMap<*const OperatorNode, Vec<(*const OperatorNode, usize)>>, /// parent ptr -> every distinct child ptr it directly references — the /// reverse of `parents_of`, for [`topological_order`]'s Kahn's-algorithm /// traversal. - children_of: HashMap<*const QueryExpr, Vec<*const QueryExpr>>, + children_of: HashMap<*const OperatorNode, Vec<*const OperatorNode>>, /// How many of the workload's own `roots` point directly at each node — /// a node's "external" use. Nothing inside the DAG decides this (it /// isn't a reference from another discovered site), so it's never /// subject to any ancestor's Share/Recompute choice — it's the base /// case [`CandidateLogicalASAPDAGs::global_selection`]'s recurrence starts from. - external_root_uses: HashMap<*const QueryExpr, usize>, + external_root_uses: HashMap<*const OperatorNode, usize>, } /// Build an ordering DAG containing every edge that could be selected: @@ -6145,7 +6250,10 @@ fn reference_dag(space: &CandidateLogicalASAPDAGs) -> ReferenceDAG { let group = &space.groups[ptr]; record_possible_edges(*ptr, &group.target, &mut dag); for candidate in &group.candidates { - if let Replacement::Rewrite(rewrite) = &candidate.replacement { + if let Replacement::SubDAG(rewrite) = &candidate.replacement { + if !is_logical_rewrite(rewrite) { + continue; + } if candidate.provenance == ReplacementProvenance::AccuracyReconciliation { add_edge(*ptr, Rc::as_ptr(rewrite), 1, &mut dag); } else { @@ -6161,8 +6269,8 @@ fn reference_dag(space: &CandidateLogicalASAPDAGs) -> ReferenceDAG { /// [`ReferenceDAG`]'s fields), retaining the greatest multiplicity seen /// when the target and alternative rewrites expose the same edge. fn add_edge( - parent_ptr: *const QueryExpr, - child_ptr: *const QueryExpr, + parent_ptr: *const OperatorNode, + child_ptr: *const OperatorNode, edge_count: usize, dag: &mut ReferenceDAG, ) { @@ -6177,7 +6285,11 @@ fn add_edge( } } -fn record_possible_edges(parent_ptr: *const QueryExpr, node: &QueryExpr, dag: &mut ReferenceDAG) { +fn record_possible_edges( + parent_ptr: *const OperatorNode, + node: &OperatorNode, + dag: &mut ReferenceDAG, +) { for (child_ptr, edge_count) in direct_child_counts(node) { add_edge(parent_ptr, child_ptr, edge_count, dag); } @@ -6185,8 +6297,8 @@ fn record_possible_edges(parent_ptr: *const QueryExpr, node: &QueryExpr, dag: &m /// Direct relational-skeleton children and their edge multiplicities. /// `Concat` is transparent, matching [`walk_children`]'s site scope. -fn direct_child_counts(node: &QueryExpr) -> Vec<(*const QueryExpr, usize)> { - fn push(children: &mut Vec<(*const QueryExpr, usize)>, child: &Rc) { +fn direct_child_counts(node: &OperatorNode) -> Vec<(*const OperatorNode, usize)> { + fn push(children: &mut Vec<(*const OperatorNode, usize)>, child: &Rc) { let ptr = Rc::as_ptr(child); match children.iter_mut().find(|(existing, _)| *existing == ptr) { Some((_, count)) => *count += 1, @@ -6194,57 +6306,19 @@ fn direct_child_counts(node: &QueryExpr) -> Vec<(*const QueryExpr, usize)> { } } - fn collect(node: &QueryExpr, children: &mut Vec<(*const QueryExpr, usize)>) { - use QueryExpr::*; - match node { - Scan { .. } | PromqlScalarBridge(_) | EvalTimestamp | CurrentTimestamp => {} - PromqlVectorFromScalar(c) | PromqlScalarFromVector(c) => { - push(children, c); - } - PromqlRelabel { child, .. } - | PromqlInfoEnrich { child, .. } - | PromqlSeriesSample { child, .. } - | Filter { child, .. } - | Project { child, .. } - | Aggregate { child, .. } - | Dedup { child, .. } - | PromqlSubquery { child, .. } - | TimeRange { child, .. } - | TimeShift { child, .. } - | SQLWindowFunc { child, .. } - | Sort { child, .. } - | Limit { child, .. } => { - push(children, child); - } - Concat { - children: concat_children, - .. - } => { - for c in concat_children { - collect(c, children); - } - } - Join { left, right, .. } | SetOp { left, right, .. } => { - push(children, left); - push(children, right); - } - BinaryOp { lhs, rhs, .. } => { - push(children, lhs); - push(children, rhs); + fn collect(node: &OperatorNode, children: &mut Vec<(*const OperatorNode, usize)>) { + if let Some(NonASAPOp::Concat { + children: concat_children, + .. + }) = node.non_asap() + { + for c in concat_children { + collect(c, children); } - Column(_) - | Literal(_) - | Compare { .. } - | BoolAnd(_) - | BoolOr(_) - | Not(_) - | IsNull(_) - | IsNotNull(_) - | Cast { .. } - | InList { .. } - | FunctionCall { .. } - | Arithmetic { .. } - | Case { .. } => {} + return; + } + for child in node.children() { + push(children, child); } } @@ -6260,14 +6334,17 @@ fn direct_child_counts(node: &QueryExpr) -> Vec<(*const QueryExpr, usize)> { /// different root paths can have a parent that's discovered *after* it (see /// this function's own test for a worked diamond example), which is exactly /// backwards for [`CandidateLogicalASAPDAGs::global_selection`]'s recurrence. -fn topological_order(order: &[*const QueryExpr], dag: &ReferenceDAG) -> Vec<*const QueryExpr> { - let mut in_degree: HashMap<*const QueryExpr, usize> = HashMap::new(); +fn topological_order( + order: &[*const OperatorNode], + dag: &ReferenceDAG, +) -> Vec<*const OperatorNode> { + let mut in_degree: HashMap<*const OperatorNode, usize> = HashMap::new(); for ptr in order { let degree = dag.parents_of.get(ptr).map(Vec::len).unwrap_or(0); in_degree.insert(*ptr, degree); } - let mut queue: VecDeque<*const QueryExpr> = order + let mut queue: VecDeque<*const OperatorNode> = order .iter() .copied() .filter(|ptr| in_degree[ptr] == 0) @@ -6291,9 +6368,9 @@ fn topological_order(order: &[*const QueryExpr], dag: &ReferenceDAG) -> Vec<*con assert_eq!( topo.len(), order.len(), - "topological_order: the discovered-site reference DAG has a cycle — every QueryExpr \ - node is built from Rc children, which can't form one, so this indicates a bug in \ - reference_dag rather than a real cyclic workload", + "topological_order: the discovered-site reference dag has a cycle — every \ + OperatorNode is built from Rc children, which can't form one, so this indicates a bug \ + in reference_dag rather than a real cyclic workload", ); topo } @@ -6319,13 +6396,13 @@ fn topological_order(order: &[*const QueryExpr], dag: &ReferenceDAG) -> Vec<*con /// the target itself) exactly like [`SharedSubDAGStrategy`], so it belongs /// in this list rather than being derived per-workload the way /// [`RollupStrategy`] is. Rewriting `avg` into `sum`/`count` upfront is what -/// lets [`SketchAlgorithmStrategy`] and [`SharedSubDAGStrategy`] see a +/// lets [`ASAPStrategies`] and [`SharedSubDAGStrategy`] see a /// mergeable accumulator to sketch or share at all — see that module's own /// doc comment for why a bare `avg` node otherwise never becomes a /// [`ReplacementStrategy`] target for anything. pub fn default_strategies() -> Vec> { vec![ - Box::new(SketchAlgorithmStrategy::default_cost_model()), + Box::new(ASAPStrategies::default_cost_model()), Box::new(HydraGroupingStrategy::default_cost_model()), Box::new(SharedSubDAGStrategy), Box::new(crate::rewrite::AvgToSumOverCountStrategy), @@ -6333,14 +6410,14 @@ pub fn default_strategies() -> Vec> { ] } -/// Like [`default_strategies`], but [`SketchAlgorithmStrategy`] ranks/binds via +/// Like [`default_strategies`], but [`ASAPStrategies`] ranks/binds via /// `cost_model` instead of the built-in [`DefaultCostModel`] — the same -/// customization point [`SketchAlgorithmStrategy::new`] itself offers. +/// customization point [`ASAPStrategies::new`] itself offers. pub fn default_strategies_with<'a>( cost_model: &'a dyn CostModel, ) -> Vec> { vec![ - Box::new(SketchAlgorithmStrategy::new(cost_model)), + Box::new(ASAPStrategies::new(cost_model)), Box::new(HydraGroupingStrategy::new(cost_model)), Box::new(SharedSubDAGStrategy), Box::new(crate::rewrite::SemanticEquivalentRewriteStrategy), @@ -6350,23 +6427,21 @@ pub fn default_strategies_with<'a>( /// Default context-free strategies with both deployment costing and typed /// planning-time accuracy evidence. This is the production counterpart of -/// constructing [`SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence`] and +/// constructing [`ASAPStrategies::new_with_planning_inputs_and_evidence`] and /// [`HydraGroupingStrategy::new_with_planning_inputs_and_evidence`] separately. pub fn default_strategies_with_evidence<'a>( cost_model: &'a dyn CostModel, evidence: &'a dyn AccuracyEvidenceProvider, ) -> Vec> { vec![ + Box::new(ASAPStrategies::new_with_planning_inputs_and_evidence( + cost_model, + &DEFAULT_ACCURACY_MODEL, + &DEFAULT_ALLOCATOR, + evidence, + )), Box::new( - SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( - cost_model, - &DEFAULT_ACCURACY_MODEL, - &DEFAULT_ALLOCATOR, - evidence, - ), - ), - Box::new( - HydraGroupingStrategy::new_with_planning_inputs_and_evidence( + HydraGroupingStrategy::new_with_planning_inputs_and_evidence( cost_model, &DEFAULT_ACCURACY_MODEL, &DEFAULT_ALLOCATOR, @@ -6384,12 +6459,12 @@ pub fn default_strategies_with_evidence<'a>( /// Search a whole workload's pre-ASAP roots for every candidate replacement /// [`default_strategies`] can find, deduped into a [`CandidateLogicalASAPDAGs`]. Candidate /// *generation* uses the built-in [`DefaultCostModel`] (via -/// [`default_strategies`], the same way [`SketchAlgorithmStrategy::default_cost_model`] +/// [`default_strategies`], the same way [`ASAPStrategies::default_cost_model`] /// does); call [`CandidateLogicalASAPDAGs::cost_sorted`] on the result for the final /// `sorted_by(cost_model)` step. Use [`search_workload_with`] to plug in a /// custom strategy set (e.g. built via [`default_strategies_with`] for a /// deployment-specific [`CostModel`]). -pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogicalASAPDAGs { +pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogicalASAPDAGs { search_workload_with(roots, &default_strategies()) } @@ -6409,10 +6484,10 @@ pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogicalA /// section). Deduping candidate plans this way needs no /// [`CostModel`] at all — that only enters at two well-defined points: each /// [`ReplacementStrategy`] in `strategies` may already carry its own (e.g. -/// [`SketchAlgorithmStrategy::new`]'s), and [`CandidateLogicalASAPDAGs::cost_sorted`]'s final +/// [`ASAPStrategies::new`]'s), and [`CandidateLogicalASAPDAGs::cost_sorted`]'s final /// ranking step takes one explicitly. pub fn search_workload_with<'s, Id>( - roots: Vec<(Id, Rc)>, + roots: Vec<(Id, Rc)>, strategies: &[Box], ) -> CandidateLogicalASAPDAGs { let mut space = search_cse_workload_with(cse_workload(roots), strategies); @@ -6423,7 +6498,7 @@ pub fn search_workload_with<'s, Id>( /// [`search_workload_with`] plus a per-root end-to-end `AccuracyTarget` /// (issue #172) — the workload's `QueryRequirements.accuracy`, threaded /// alongside each root. After the search, every root that carries a target -/// has its group's bound [`Replacement::Summary`] candidates checked with +/// has its group's bound-summary [`Replacement::SubDAG`] candidates checked with /// `accuracy_model`'s [`AccuracyModel::satisfies`]: a candidate whose /// guarantee is fully known and misses the target is moved from /// [`TargetSubDAGCandidates::candidates`] to [`TargetSubDAGCandidates::rejected`] *before* @@ -6431,16 +6506,16 @@ pub fn search_workload_with<'s, Id>( /// group. A constructible candidate with unknown accuracy remains visible for /// downstream review under an approximate target, but default whole-plan /// selection does not commit it. An exact target cannot accept an unknown -/// approximate summary. A `KeepPreAsap` candidate is +/// approximate summary. A kept pre-ASAP candidate is /// exact and always survives — the raw/pre-ASAP alternative is what an -/// unsatisfiable root keeps. Logical [`Replacement::Rewrite`] candidates -/// are not bound values and are left alone; the targets *inside* a rewrite -/// are their own groups. +/// unsatisfiable root keeps. Logical-rewrite [`Replacement::SubDAG`] +/// candidates are not bound values and are left alone; the targets *inside* +/// a rewrite are their own groups. /// /// Precedence against per-node `AggIntent.accuracy` is documented in /// [`crate::accuracy`]'s module docs. pub fn search_workload_with_targets<'s, Id>( - roots: Vec<(Id, Rc, Option)>, + roots: Vec<(Id, Rc, Option)>, strategies: &[Box], accuracy_model: &dyn AccuracyModel, ) -> CandidateLogicalASAPDAGs { @@ -6454,7 +6529,7 @@ pub fn search_workload_with_targets<'s, Id>( .collect(); let mut space = search_cse_workload_with(cse_workload(roots), strategies); // `cse_workload` preserves root order, so targets zip by position. - let root_ptrs: Vec<(*const QueryExpr, AccuracyTarget)> = space + let root_ptrs: Vec<(*const OperatorNode, AccuracyTarget)> = space .roots .iter() .zip(targets) @@ -6496,11 +6571,11 @@ pub fn search_workload_with_targets<'s, Id>( .candidates .drain(..) .partition(|candidate| match &candidate.replacement { - Replacement::Summary(node) => node.guarantee.as_ref().map_or_else( + Replacement::SubDAG(node) if is_logical_rewrite(node) => true, + Replacement::SubDAG(node) => node.guarantee.as_ref().map_or_else( || !matches!(target, AccuracyTarget::Exact), |g| accuracy_model.satisfies(&g.optimistic_floor(), &target), ), - Replacement::Rewrite(_) => true, // A composition's guarantee depends on the concrete child; // prepare_compositions checks those pairs after all roots. Replacement::ExactComposition(_) => true, @@ -6508,7 +6583,7 @@ pub fn search_workload_with_targets<'s, Id>( group.candidates = legal; group.rejected.extend(illegal.into_iter().map(|candidate| { let (metric, bound, failure_probability) = match &candidate.replacement { - Replacement::Summary(node) => node + Replacement::SubDAG(node) => node .guarantee .as_ref() .map(|g| { @@ -6523,7 +6598,6 @@ pub fn search_workload_with_targets<'s, Id>( None, None, )), - Replacement::Rewrite(_) => unreachable!("rewrites are never rejected here"), Replacement::ExactComposition(_) => ( asap_types::post_asap::ErrorMetric::AbsoluteValue, None, @@ -6548,22 +6622,22 @@ pub fn search_workload_with_targets<'s, Id>( /// The strictest accuracy among `siblings` that read the same summary input /// as `root` — same child, grouping and filters, and the same intent apart -/// from its accuracy (and a quantile's rank, a readout parameter) — when +/// from its accuracy (and a quantile's rank, a evaluation parameter) — when /// stricter than `root`'s own. One summary sized for the strictest consumer /// serves every sibling: #509's summary-capability rule. fn strictest_sibling_accuracy( - root: &QueryExpr, - siblings: &[Rc], + root: &OperatorNode, + siblings: &[Rc], ) -> Option { fn approximate(intent: &AggIntent) -> Option<&AccuracyTarget> { accuracy_target(intent).filter(|accuracy| !matches!(accuracy, AccuracyTarget::Exact)) } - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction, filters, child, .. - } = root + }) = root.non_asap() else { return None; }; @@ -6571,12 +6645,12 @@ fn strictest_sibling_accuracy( let own = accuracy_budget(approximate(intent)?); let (mut eps, mut delta) = own; for sibling in siblings { - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction: sibling_reduction, filters: sibling_filters, child: sibling_child, .. - } = sibling.as_ref() + }) = sibling.non_asap() else { continue; }; @@ -6614,48 +6688,45 @@ fn strictest_sibling_accuracy( } } -fn cse_workload(roots: Vec<(Id, Rc)>) -> Vec<(Id, Rc)> { - // `share_common_sub_dags` wants owned `QueryExpr`s, not already-`Rc` - // roots — the same `Rc::try_unwrap`-with-clone-fallback pattern - // `asap_types::pre_asap::cse::intern_child` itself uses to recover an - // owned node without cloning in the common (uniquely-owned) case. - let owned_roots: Vec<(Id, QueryExpr)> = roots - .into_iter() - .map(|(id, rc)| { - let expr = Rc::try_unwrap(rc).unwrap_or_else(|shared| (*shared).clone()); - (id, expr) - }) - .collect(); - share_common_sub_dags(owned_roots) +fn cse_workload(roots: Vec<(Id, Rc)>) -> Vec<(Id, Rc)> { + share_common_sub_dags(roots) } fn search_cse_workload_with<'s, Id>( - cse_roots: Vec<(Id, Rc)>, + cse_roots: Vec<(Id, Rc)>, strategies: &[Box], ) -> CandidateLogicalASAPDAGs { + for (_, root) in &cse_roots { + assert!( + !root.contains_asap(), + "search_workload: a workload root already contains an ASAP operator \ + ({}); replacement search takes the front end's pre-ASAP DAG only", + root.operator.kind_name() + ); + } let mut order = Vec::new(); let mut nodes = HashMap::new(); - let mut counts: HashMap<*const QueryExpr, usize> = HashMap::new(); + let mut counts: HashMap<*const OperatorNode, usize> = HashMap::new(); discover_targets(&cse_roots, &mut order, &mut nodes, &mut counts); - let siblings: Vec> = order + let siblings: Vec> = order .iter() .filter_map(|ptr| { let node = &nodes[ptr]; - matches!(node.as_ref(), QueryExpr::Aggregate { .. }).then(|| Rc::clone(node)) + matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })).then(|| Rc::clone(node)) }) .collect(); let rollup_strategy = RollupStrategy::new(&siblings); let accuracy_reconciliation_strategy = AccuracyReconciliationStrategy::new(&siblings); - let limits: Vec> = order + let limits: Vec> = order .iter() .filter_map(|ptr| { let node = &nodes[ptr]; - matches!(node.as_ref(), QueryExpr::Limit { .. }).then(|| Rc::clone(node)) + matches!(node.non_asap(), Some(NonASAPOp::Limit { .. })).then(|| Rc::clone(node)) }) .collect(); let topk_reuse_strategy = TopKLimitReuseStrategy::new(&limits); - let mut groups: HashMap<*const QueryExpr, TargetSubDAGCandidates> = HashMap::new(); + let mut groups: HashMap<*const OperatorNode, TargetSubDAGCandidates> = HashMap::new(); for ptr in &order { groups.insert( *ptr, @@ -6678,7 +6749,7 @@ fn search_cse_workload_with<'s, Id>( "search_workload: fixpoint search did not converge within {MAX_SEARCH_ITERATIONS} \ rounds — a registered ReplacementStrategy's Replacement::Rewrite candidates keep \ exposing new, never-before-seen descendant structure every round. \ - SketchAlgorithmStrategy/SharedSubDAGStrategy never do this (see replacement.rs's \ + ASAPStrategies/SharedSubDAGStrategy never do this (see replacement.rs's \ module docs' \"Termination\" section); check any custom strategies passed to \ search_workload_with.", ); @@ -6741,8 +6812,10 @@ fn search_cse_workload_with<'s, Id>( } for candidate in &proposed { - if let Replacement::Rewrite(rc) = &candidate.replacement { - discover_new_descendant_targets(rc, &mut order, &mut nodes, &mut counts); + if let Replacement::SubDAG(rc) = &candidate.replacement { + if is_logical_rewrite(rc) { + discover_new_descendant_targets(rc, &mut order, &mut nodes, &mut counts); + } } } @@ -6782,10 +6855,11 @@ fn search_cse_workload_with<'s, Id>( /// ancestor is recomputed. We only do this when an ordinary repeated group /// proves that `SharedSubDAGStrategy` is part of this search's strategy set. fn add_effective_count_cse_candidates( - order: &[*const QueryExpr], - groups: &mut HashMap<*const QueryExpr, TargetSubDAGCandidates>, + order: &[*const OperatorNode], + groups: &mut HashMap<*const OperatorNode, TargetSubDAGCandidates>, ) { - let mut possible_children: HashMap<*const QueryExpr, Vec<*const QueryExpr>> = HashMap::new(); + let mut possible_children: HashMap<*const OperatorNode, Vec<*const OperatorNode>> = + HashMap::new(); for ptr in order { let group = &groups[ptr]; let children = possible_children.entry(*ptr).or_default(); @@ -6795,7 +6869,10 @@ fn add_effective_count_cse_candidates( } } for candidate in &group.candidates { - if let Replacement::Rewrite(rewrite) = &candidate.replacement { + if let Replacement::SubDAG(rewrite) = &candidate.replacement { + if !is_logical_rewrite(rewrite) { + continue; + } for (child, _) in direct_child_counts(rewrite) { if !children.contains(&child) { children.push(child); @@ -6853,10 +6930,10 @@ fn add_effective_count_cse_candidates( /// `Rc` and its real `consumer_count` — see the module docs' "Where /// `TargetSubDAG` discovery comes from" section for the full rationale. fn discover_targets( - roots: &[(Id, Rc)], - order: &mut Vec<*const QueryExpr>, - nodes: &mut HashMap<*const QueryExpr, Rc>, - counts: &mut HashMap<*const QueryExpr, usize>, + roots: &[(Id, Rc)], + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, ) { for (_, root) in roots { walk(root, order, nodes, counts); @@ -6865,17 +6942,17 @@ fn discover_targets( /// Scan `candidate`'s **children** (deliberately never `candidate`'s own /// top-level pointer — see the module docs' "Termination" section: a -/// [`Replacement::Rewrite`]'s value is an alternative *for* the target that +/// logical rewrite's value is an alternative *for* the target that /// proposed it, never a new target of its own) for any `Rc` not already /// known, appending each to `order`/`nodes`/`counts` so /// [`search_workload_with`]'s next round processes it. A no-op when every /// child is already known — the case both shipped strategies always produce /// (see that section). fn discover_new_descendant_targets( - candidate: &Rc, - order: &mut Vec<*const QueryExpr>, - nodes: &mut HashMap<*const QueryExpr, Rc>, - counts: &mut HashMap<*const QueryExpr, usize>, + candidate: &Rc, + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, ) { walk_children(candidate, order, nodes, counts); } @@ -6883,10 +6960,10 @@ fn discover_new_descendant_targets( /// Visit `node`: count this occurrence, and — the first time this exact /// `Rc` is seen — record it as a target and recurse into its children. fn walk( - node: &Rc, - order: &mut Vec<*const QueryExpr>, - nodes: &mut HashMap<*const QueryExpr, Rc>, - counts: &mut HashMap<*const QueryExpr, usize>, + node: &Rc, + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, ) { let ptr = Rc::as_ptr(node); let already_visited = counts.contains_key(&ptr); @@ -6898,61 +6975,25 @@ fn walk( } } -/// `node`'s own **relational-skeleton** operator children — the same scope -/// `asap_types::pre_asap::cse::share_common_sub_dags`/`rebuild_children` -/// itself uses (see that module's "Algorithm" section) and -/// `tests::count_consumers` mirrors for its own fixtures. Exhaustive over -/// every `QueryExpr` variant: a new variant fails to compile here until this -/// match is extended too. +/// `node`'s own operator children ([`OperatorNode::children`]: operator +/// inputs plus the operator nodes its scalar expressions read), the same +/// scope `asap_types::ir::cse::share_common_sub_dags` itself uses and +/// `tests::count_consumers` mirrors for its own fixtures. `Concat` is +/// transparent: its branches are walked in place of it. fn walk_children( - node: &QueryExpr, - order: &mut Vec<*const QueryExpr>, - nodes: &mut HashMap<*const QueryExpr, Rc>, - counts: &mut HashMap<*const QueryExpr, usize>, + node: &OperatorNode, + order: &mut Vec<*const OperatorNode>, + nodes: &mut HashMap<*const OperatorNode, Rc>, + counts: &mut HashMap<*const OperatorNode, usize>, ) { - use QueryExpr::*; - match node { - Scan { .. } | PromqlScalarBridge(_) | EvalTimestamp | CurrentTimestamp => {} - PromqlVectorFromScalar(c) | PromqlScalarFromVector(c) => walk(c, order, nodes, counts), - PromqlRelabel { child, .. } - | PromqlInfoEnrich { child, .. } - | PromqlSeriesSample { child, .. } - | Filter { child, .. } - | Project { child, .. } - | Aggregate { child, .. } - | Dedup { child, .. } - | PromqlSubquery { child, .. } - | TimeRange { child, .. } - | TimeShift { child, .. } - | SQLWindowFunc { child, .. } - | Sort { child, .. } - | Limit { child, .. } => walk(child, order, nodes, counts), - Concat { children, .. } => { - for c in children { - walk_children(c, order, nodes, counts); - } - } - Join { left, right, .. } | SetOp { left, right, .. } => { - walk(left, order, nodes, counts); - walk(right, order, nodes, counts); - } - BinaryOp { lhs, rhs, .. } => { - walk(lhs, order, nodes, counts); - walk(rhs, order, nodes, counts); - } - Column(_) - | Literal(_) - | Compare { .. } - | BoolAnd(_) - | BoolOr(_) - | Not(_) - | IsNull(_) - | IsNotNull(_) - | Cast { .. } - | InList { .. } - | FunctionCall { .. } - | Arithmetic { .. } - | Case { .. } => {} + if let Some(NonASAPOp::Concat { children, .. }) = node.non_asap() { + for c in children { + walk_children(c, order, nodes, counts); + } + return; + } + for child in node.children() { + walk(child, order, nodes, counts); } } @@ -6961,17 +7002,19 @@ mod tests { use super::*; use crate::accuracy::PropagationStats; use crate::cost_model::Cost; - use crate::test_support::lower_promql; + use crate::test_support::{agg, agg_per_entity, lower_promql, metric_scan, timed}; + use asap_types::ir::operator_properties::{Reduction as ReductionTy, Source}; + use asap_types::ir::TimeRangeKind; use asap_types::pre_asap::agg_intent::{ agg_is_exact, default_cardinality, default_quantile, MathFunc, TimeFunc, }; - use asap_types::pre_asap::query_expr::{Reduction as ReductionTy, Source}; use asap_types::pre_asap::schema::{DataType, Field, Schema as SchemaTy}; + use asap_types::types::AccuracyTarget; use std::collections::HashMap; // Candidate shape without execution timing: what is computed, not where. - fn timing_free_shape(node: &Rc) -> serde_json::Value { + fn timing_free_shape(node: &Rc) -> serde_json::Value { fn strip(value: &mut serde_json::Value) { match value { serde_json::Value::Object(fields) => { @@ -6982,9 +7025,10 @@ mod tests { _ => {} } } - let mut shape = - serde_json::to_value(asap_types::post_asap::compile_post_asap_dag(node).unwrap()) - .unwrap(); + let mut shape = serde_json::to_value( + asap_types::ir::physical_export::compile_physical_asap_dag(&timed(node)).unwrap(), + ) + .unwrap(); strip(&mut shape); shape } @@ -6996,7 +7040,7 @@ mod tests { ("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact), ("topk by(job)(2, rate(m[1m]))", AccuracyTarget::Epsilon(0.1)), ] { - let root = Rc::new(lower_promql(query, accuracy)); + let root = lower_promql(query, accuracy); let inventory = search_workload(vec![(0usize, root)]) .enumerate_candidate_dags(4096) .unwrap(); @@ -7011,37 +7055,32 @@ mod tests { } } - // Grouped Sum over Rate readouts stays a summary state in the inventory, + // Grouped Sum over Rate evaluations stays a summary state in the inventory, // so lifecycle assignment can place it in precompute or at query time. #[test] fn grouped_rate_sum_inventory_keeps_sum_state_for_lifecycle_placement() { - let root = Rc::new(lower_promql( - "sum by(job)(rate(m[1m]))", - AccuracyTarget::Exact, - )); + let root = lower_promql("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact); let inventory = search_workload(vec![(0usize, root)]) .enumerate_candidate_dags(4096) .unwrap(); - let is_exact = |node: &SummaryNode, kind: ExactKind| { - matches!(&node.expr, SummaryExpr::SummaryAgg { + let is_exact = |node: &OperatorNode, kind: ExactKind| { + matches!(&node.operator, Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(k, _), .. - } if *k == kind) + }) if *k == kind) }; assert!(inventory.candidates.iter().any(|forest| { - let SummaryExpr::ValueOperation { child: sum, .. } = &forest[0].1.expr else { + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: sum }) = &forest[0].1.operator else { return false; }; - let SummaryExpr::SummaryAgg { child: rate, .. } = &sum.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child: rate, .. }) = &sum.operator else { return false; }; is_exact(sum, ExactKind::Sum) - && matches!(&rate.expr, SummaryExpr::ValueOperation { - child, operation: ValueOperation::FinalizeExactAccumulator, .. - } if is_exact(child, ExactKind::Rate)) + && matches!(&rate.operator, Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) if is_exact(child, ExactKind::Rate)) })); } - // Every exposed query result has a readout; internal accumulator frontiers stay states. + // Every exposed query result has a evaluation; internal accumulator frontiers stay states. #[test] fn query_candidate_roots_do_not_leak_exact_accumulator_state() { for query in [ @@ -7049,13 +7088,13 @@ mod tests { "sum by(job)(m)", "sum_over_time(m[1m])", ] { - let root = Rc::new(lower_promql(query, AccuracyTarget::Exact)); + let root = lower_promql(query, AccuracyTarget::Exact); let space = search_workload(vec![(0usize, root.clone())]); let inventory = space.enumerate_candidate_dags(4096).unwrap(); assert!(!inventory.candidates.is_empty()); - let strategy = SketchAlgorithmStrategy::new(&DefaultCostModel); + let strategy = ASAPStrategies::new(&DefaultCostModel); for candidate in strategy.propose(&TargetSubDAG::new(&root)).candidates { - if let Replacement::Summary(node) = candidate.replacement { + if let Replacement::SubDAG(node) = candidate.replacement { let output = finalize_query_candidate(node, &root).unwrap(); assert!( output @@ -7092,7 +7131,7 @@ mod tests { #[test] fn unpriced_inventory_retains_quantile_families_and_raw_execution() { - let query = Rc::new(agg(vec![2], default_quantile(0.9), metric_scan(&["job"]))); + let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); let space = search_workload(vec![(0usize, query)]); let inventory = space.enumerate_candidate_dags(4096).unwrap(); let roots = inventory @@ -7105,7 +7144,7 @@ mod tests { assert!(inventory .candidates .iter() - .any(|forest| matches!(forest[0].1.expr, SummaryExpr::KeepPreAsap(_)))); + .any(|forest| !forest[0].1.contains_asap())); } // Independent roots must not require materializing their Cartesian product. @@ -7115,11 +7154,11 @@ mod tests { .map(|id| { ( id, - Rc::new(agg( + agg( vec![2], default_quantile((id + 1) as f64 / 25.0), metric_scan(&["job"]), - )), + ), ) }) .collect(); @@ -7141,7 +7180,7 @@ mod tests { assert!(inventory .candidates .iter() - .any(|forest| matches!(forest[0].1.expr, SummaryExpr::KeepPreAsap(_)))); + .any(|forest| !forest[0].1.contains_asap())); } assert!(space.enumerate_candidate_dags_for_root(&24, 4096).is_err()); assert!(space.enumerate_candidate_dags_for_root(&0, 0).is_err()); @@ -7154,11 +7193,11 @@ mod tests { .map(|id| { ( id, - Rc::new(agg( + agg( vec![2], default_quantile(0.5 + id as f64 * 0.4), metric_scan(&["job"]), - )), + ), ) }) .collect(); @@ -7180,30 +7219,31 @@ mod tests { #[test] fn inventory_budget_never_returns_a_silent_partial_search() { - let query = Rc::new(agg(vec![2], default_quantile(0.9), metric_scan(&["job"]))); + let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); let space = search_workload(vec![(0usize, query)]); assert!(space.enumerate_candidate_dags(0).is_err()); } fn equi_pred(left: ColumnId, right: ColumnId) -> Predicate { - Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(left)), + Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(left)), op: asap_types::pre_asap::CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(right)), - })) + right: Box::new(ScalarExpr::Column(right)), + semantics: asap_types::ir::ExprSemantics::Sql, + }) } // Finite samples can overflow a sum although their native average is finite. #[test] fn temporal_average_requires_finite_division_guard() { - let root = Rc::new(lower_promql("avg_over_time(a[5m])", AccuracyTarget::Exact)); + let root = lower_promql("avg_over_time(a[5m])", AccuracyTarget::Exact); let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); let operator = candidates .iter() .find_map(|c| match &c.replacement { - Replacement::Summary(node) => match &node.expr { - SummaryExpr::BinaryOp { operator, .. } => Some(operator), + Replacement::SubDAG(node) => match &node.operator { + Operator::NonASAP(NonASAPOp::BinaryOp { operator, .. }) => Some(operator), _ => None, }, _ => None, @@ -7221,20 +7261,20 @@ mod tests { // Approximate requests also admit exact temporal ranking candidates. #[test] fn approximate_temporal_topk_admits_exact_maintained_values() { - let root = Rc::new(lower_promql( + let root = lower_promql( "topk by(job)(1,count_over_time(a[5m]))", AccuracyTarget::EpsilonDelta { epsilon: 0.01, delta: 0.01, }, - )); + ); let planning_inputs = CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); let node = exact_topk_over_temporal_values(&root, planning_inputs) .unwrap() .expect("exact ranking is legal for an approximate request"); assert!(node.guarantee.as_ref().unwrap().is_exact()); - asap_types::post_asap::compile_post_asap_dag(&node).unwrap(); + crate::test_support::time_and_export(&node).unwrap(); } // Exact Top-K consumes the Planner's maintained temporal values. @@ -7244,7 +7284,7 @@ mod tests { "topk(5, sum_over_time(a[5m]))", "topk by(job)(5, count_over_time(a[5m]))", ] { - let root = Rc::new(lower_promql(query, AccuracyTarget::Exact)); + let root = lower_promql(query, AccuracyTarget::Exact); let planning_inputs = CandidatePlanningInputs::with_default_accuracy( &crate::cost_model::DefaultCostModel, ); @@ -7252,29 +7292,21 @@ mod tests { .unwrap() .expect("exact Top-K candidate"); assert!(node.guarantee.as_ref().unwrap().is_exact()); - let SummaryExpr::ValueOperation { + let Operator::NonASAP(NonASAPOp::Limit { child: sorted, - operation: - ValueOperation::Limit { - n, - offset, - partition_by, - }, - .. - } = &node.expr + n, + offset, + partition_by, + }) = &node.operator else { panic!("temporal TopK must compose Sort and Limit"); }; - assert_eq!((*n, *offset), (5, 0)); - let SummaryExpr::ValueOperation { - operation: - ValueOperation::Sort { - keys, - partition_by: sort_groups, - }, + assert_eq!((*n, *offset), (Some(5), 0)); + let Operator::NonASAP(NonASAPOp::Sort { + keys, + partition_by: sort_groups, child: values, - .. - } = &sorted.expr + }) = &sorted.operator else { panic!("Limit must consume sorted temporal values"); }; @@ -7286,7 +7318,7 @@ mod tests { assert_eq!(keys.len(), 1); assert!(!keys[0].ascending); assert_eq!(node.schema, values.schema); - asap_types::post_asap::compile_post_asap_dag(&node).unwrap(); + crate::test_support::time_and_export(&node).unwrap(); } } @@ -7297,7 +7329,7 @@ mod tests { impl AccuracyEvidenceProvider for Domain { fn quantile_input_domain( &self, - _: &QueryExpr, + _: &OperatorNode, ) -> Option { Some(crate::accuracy::QuantileInputDomain { lower: 1.0, @@ -7319,7 +7351,7 @@ mod tests { "avg_over_time(a[5m]) / quantile_over_time(0.5,a[5m])", "quantile_over_time(0.5,a[5m]) / avg_over_time(a[5m])", ] { - let root = Rc::new(lower_promql(query, target.clone())); + let root = lower_promql(query, target.clone()); let node = realize_binary(&root, inputs, Some(&target)) .unwrap() .expect("bounded ratio candidate"); @@ -7334,10 +7366,10 @@ mod tests { epsilon: 0.01, delta: 0.01, }; - let root = Rc::new(lower_promql( + let root = lower_promql( "quantile_over_time(0.5,a[5m]) / quantile_over_time(0.9,a[5m])", target.clone(), - )); + ); let planning_inputs = CandidatePlanningInputs::with_default_accuracy(&crate::cost_model::DefaultCostModel); let candidate = realize_binary(&root, planning_inputs, Some(&target)) @@ -7345,10 +7377,10 @@ mod tests { .expect("direct quantile ratio candidate"); assert!(candidate.guarantee.is_none()); - let other = Rc::new(lower_promql( + let other = lower_promql( "avg_over_time(a[5m]) / quantile_over_time(0.5,a[5m])", target.clone(), - )); + ); assert!(realize_binary(&other, planning_inputs, Some(&target)) .unwrap() .is_none()); @@ -7367,11 +7399,65 @@ mod tests { #[test] fn relational_join_is_exact_only_when_both_inputs_are_exact() { - let exact = ResultGuarantee::exact("test exact input"); - assert!(relational_join_guarantee(Some(&exact), Some(&exact)) - .is_some_and(|guarantee| guarantee.is_exact())); - assert!(relational_join_guarantee(Some(&exact), None).is_none()); - assert!(relational_join_guarantee(None, Some(&exact)).is_none()); + // `relational_join_guarantee` folded into assembly's generic + // "keep the operator, assemble its children" branch: an assembled + // inner equi-`Join` is exact exactly when both assembled inputs are. + let join = |left_intent: AggIntent, right_intent: AggIntent| { + let left = agg(vec![2], left_intent, metric_scan(&["job"])); + let right = agg( + vec![2], + right_intent, + crate::test_support::scan("n", metric_scan(&["job"]).schema.clone()), + ); + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Join { + kind: asap_types::ir::operator_properties::JoinKind::Inner, + pred: equi_pred(0, 2), + left, + right, + })) + .unwrap() + }; + let is_exact = |node: &OperatorNode| { + node.guarantee + .as_ref() + .is_some_and(ResultGuarantee::is_exact) + }; + for (root, both_exact_expected) in [ + ( + join(AggIntent::Sum { col: None }, AggIntent::Sum { col: None }), + true, + ), + ( + join(AggIntent::Sum { col: None }, quantile_eps_intent(0.5, 0.05)), + false, + ), + ] { + let space = search_workload(vec![(0usize, Rc::clone(&root))]); + let assembled = space + .global_selection(&DefaultCostModel) + .assemble_selected_query(&space.roots[0].1) + .unwrap() + .unwrap(); + let Some(NonASAPOp::Join { left, right, .. }) = assembled.non_asap() else { + panic!("the join is kept and its inputs assembled: {assembled:?}"); + }; + assert_eq!( + is_exact(&assembled), + is_exact(left) && is_exact(right), + "join guarantee must be exact iff both inputs are exact" + ); + if both_exact_expected { + assert!(is_exact(&assembled), "exact inputs give an exact join"); + } + } + } + + fn quantile_eps_intent(q: f64, e: f64) -> AggIntent { + AggIntent::Quantile { + col: None, + q, + accuracy: AccuracyTarget::Epsilon(e), + } } fn eps(e: f64) -> AccuracyTarget { @@ -7921,68 +8007,44 @@ mod tests { ); } - // ── SketchAlgorithmStrategy / SharedSubDAGStrategy fixtures ─────────── - - fn metric_scan(labels: &[&str]) -> QueryExpr { - 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)), - ); - QueryExpr::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: SchemaTy::with_time_index(columns, 0, vec![]), - } - } - - fn agg(by: Vec, intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { - reduction: ReductionTy::by(by), - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(child), - } - } + // ── ASAPStrategies / SharedSubDAGStrategy fixtures ─────────── - // ── SketchAlgorithmStrategy ───────────────────────────────────────────── + // ── ASAPStrategies ───────────────────────────────────────────── #[test] fn matches_a_bindable_aggregate() { - let q = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - assert!(SketchAlgorithmStrategy::default_cost_model().matches(&target)); + assert!(ASAPStrategies::default_cost_model().matches(&target)); } #[test] fn does_not_match_a_multi_intent_or_having_aggregate() { - let strategy = SketchAlgorithmStrategy::default_cost_model(); - - let multi = Rc::new(QueryExpr::Aggregate { - reduction: ReductionTy::by(vec![2]), - measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(metric_scan(&["job"])), - }); + let strategy = ASAPStrategies::default_cost_model(); + + let multi = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: ReductionTy::by(vec![2]), + measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], + output_names: vec![], + filters: vec![], + having: None, + child: metric_scan(&["job"]), + })) + .unwrap(); let target = TargetSubDAG::new(&multi); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); - let mut having_q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - if let QueryExpr::Aggregate { having, .. } = &mut having_q { - *having = Some(asap_types::pre_asap::query_expr::Predicate(Rc::new( - QueryExpr::Literal(asap_types::pre_asap::expr_ir::ScalarValue::Boolean(true)), - ))); - } - let having_q = Rc::new(having_q); + let having_q = crate::test_support::aggregate( + ReductionTy::by(vec![2]), + vec![default_quantile(0.99)], + vec![], + Some(asap_types::ir::Predicate(ScalarExpr::Literal( + asap_types::pre_asap::expr_ir::ScalarValue::Boolean(true), + ))), + metric_scan(&["job"]), + ); let target = TargetSubDAG::new(&having_q); assert!(!strategy.matches(&target)); assert!(strategy.replacements(&target).is_empty()); @@ -7990,10 +8052,10 @@ mod tests { #[test] fn does_not_match_a_non_aggregate_node() { - let scan = Rc::new(metric_scan(&["job"])); + let scan = metric_scan(&["job"]); let target = TargetSubDAG::new(&scan); - assert!(!SketchAlgorithmStrategy::default_cost_model().matches(&target)); - assert!(SketchAlgorithmStrategy::default_cost_model() + assert!(!ASAPStrategies::default_cost_model().matches(&target)); + assert!(ASAPStrategies::default_cost_model() .replacements(&target) .is_empty()); } @@ -8001,11 +8063,11 @@ mod tests { #[test] fn approximate_quantile_enumerates_every_summary_candidate() { // Quantile's candidate list is [Kll, DDSketch] (summary_candidates) — - // every entry must come back as its own bound SummaryNode candidate, + // every entry must come back as its own bound summary candidate, // not just Kll (the CostModel-ranked head realizations_for_intent commits to). - let q = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = SketchAlgorithmStrategy::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); assert_eq!( replacements.len(), 2, @@ -8015,8 +8077,8 @@ mod tests { let kinds: Vec = replacements .iter() .map(|r| match &r.replacement { - Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { panic!("expected a Summary replacement") } }) @@ -8031,14 +8093,14 @@ mod tests { #[test] fn cardinality_epsilon_delta_keeps_unknown_accuracy_candidates() { - let q = Rc::new(agg(vec![2], default_cardinality(), metric_scan(&["job"]))); + let q = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = SketchAlgorithmStrategy::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); let kinds: Vec = replacements .iter() .map(|r| match &r.replacement { - Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { panic!("expected a Summary replacement") } }) @@ -8053,7 +8115,7 @@ mod tests { ] ); - let q = Rc::new(agg( + let q = agg( vec![2], AggIntent::Cardinality { cols: vec![], @@ -8063,13 +8125,13 @@ mod tests { }, }, metric_scan(&["job"]), - )); - let kinds: Vec<_> = SketchAlgorithmStrategy::default_cost_model() + ); + let kinds: Vec<_> = ASAPStrategies::default_cost_model() .replacements(&TargetSubDAG::new(&q)) .iter() .map(|r| match &r.replacement { - Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { panic!("expected a Summary replacement") } }) @@ -8094,35 +8156,28 @@ mod tests { q: 0.99, accuracy: AccuracyTarget::Exact, }; - let q = Rc::new(agg(vec![2], intent, metric_scan(&["job"]))); + let q = agg(vec![2], intent, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = SketchAlgorithmStrategy::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); assert_eq!(replacements.len(), 1, "{replacements:?}"); assert!(matches!( &replacements[0].replacement, - Replacement::Summary(node) if matches!( - node.expr, - asap_types::post_asap::SummaryExpr::KeepPreAsap(_) - ) + Replacement::SubDAG(node) if !node.contains_asap() )); assert!(replacements[0].rationale.contains("only realization")); } #[test] fn exact_mergeable_intent_yields_exactly_one_accumulator_candidate() { - let q = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); - let replacements = SketchAlgorithmStrategy::default_cost_model().replacements(&target); + let replacements = ASAPStrategies::default_cost_model().replacements(&target); assert_eq!(replacements.len(), 1, "{replacements:?}"); assert!(matches!( &replacements[0].replacement, - Replacement::Summary(node) if matches!( - node.expr, - asap_types::post_asap::SummaryExpr::SummaryAgg { .. } + Replacement::SubDAG(node) if matches!( + node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { .. }) ) )); } @@ -8149,15 +8204,15 @@ mod tests { #[test] fn custom_cost_model_still_enumerates_every_candidate_not_just_its_own_pick() { - let q = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let target = TargetSubDAG::new(&q); let custom = PreferDDSketch; - let replacements = SketchAlgorithmStrategy::new(&custom).replacements(&target); + let replacements = ASAPStrategies::new(&custom).replacements(&target); let kinds: Vec = replacements .iter() .map(|r| match &r.replacement { - Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + Replacement::SubDAG(node) => summary_family_algorithm(node), + Replacement::ExactComposition(_) => { panic!("expected a Summary replacement") } }) @@ -8181,9 +8236,9 @@ mod tests { // so this test injects `RankAdditiveModel` to admit the composition // and keep exercising the per-node enumeration property it is about. let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["job"])); - let outer = Rc::new(agg(vec![], default_quantile(0.99), inner)); + let outer = agg(vec![], default_quantile(0.99), inner); let target = TargetSubDAG::new(&outer); - let replacements = SketchAlgorithmStrategy::new_with_planning_inputs( + let replacements = ASAPStrategies::new_with_planning_inputs( &DefaultCostModel, &RankAdditiveModel, &EqualSplitAllocator, @@ -8191,9 +8246,9 @@ mod tests { .replacements(&target); assert_eq!(replacements.len(), 2, "{replacements:?}"); - assert!(replacements - .iter() - .all(|candidate| { matches!(candidate.replacement, Replacement::Summary(_)) })); + assert!(replacements.iter().all(|candidate| { + matches!(&candidate.replacement, Replacement::SubDAG(n) if n.contains_asap()) + })); // The inner target is still independently enumerated and ranked — // a custom cost model that prefers DDSketch for it is honored, and // nothing about the outer target's choice reaches it. @@ -8201,7 +8256,7 @@ mod tests { vec![("q", Rc::clone(&outer))], &default_strategies_with(&PreferDDSketchViaCostModel), ); - let QueryExpr::Aggregate { child, .. } = space.roots[0].1.as_ref() else { + let Some(NonASAPOp::Aggregate { child, .. }) = space.roots[0].1.non_asap() else { unreachable!() }; let inner_group = space @@ -8211,7 +8266,7 @@ mod tests { .candidates .iter() .filter_map(|c| match &c.replacement { - Replacement::Summary(node) => sketch_kind_of(node), + Replacement::SubDAG(node) => sketch_kind_of(node), _ => None, }) .collect(); @@ -8225,12 +8280,12 @@ mod tests { /// The `FieldDataType`'s committed `SketchAlgorithm`, from the top /// `SummaryAgg` reachable under a (possibly `SummaryEstimate`-wrapped) /// bound root. - fn summary_family_algorithm(node: &SummaryNode) -> SketchAlgorithm { - match &node.expr { - asap_types::post_asap::SummaryExpr::SummaryEstimate { summary_input, .. } => { + fn summary_family_algorithm(node: &OperatorNode) -> SketchAlgorithm { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { summary_family_algorithm(summary_input) } - asap_types::post_asap::SummaryExpr::SummaryAgg { family, .. } => match family { + Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) => match family { asap_types::post_asap::FieldDataType::Sketch(kind, _) => kind.algorithm().clone(), other => panic!("expected a Sketch family, got {other:?}"), }, @@ -8242,11 +8297,7 @@ mod tests { #[test] fn does_not_match_a_single_consumer_target() { - let q = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); assert_eq!(target.consumer_count, 1); assert!(!SharedSubDAGStrategy.matches(&target)); @@ -8255,11 +8306,7 @@ mod tests { #[test] fn two_or_more_consumers_yields_the_share_vs_independent_pair() { - let q = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let target = TargetSubDAG::with_consumer_count(&q, 2); assert!(SharedSubDAGStrategy.matches(&target)); @@ -8267,7 +8314,7 @@ mod tests { assert_eq!(replacements.len(), 2, "{replacements:?}"); let shared = match &replacements[0].replacement { - Replacement::Rewrite(rc) => rc, + Replacement::SubDAG(rc) => rc, other => panic!("expected a Rewrite replacement, got {other:?}"), }; assert!( @@ -8277,7 +8324,7 @@ mod tests { assert!(replacements[0].rationale.contains("build once and share")); let independent = match &replacements[1].replacement { - Replacement::Rewrite(rc) => rc, + Replacement::SubDAG(rc) => rc, other => panic!("expected a Rewrite replacement, got {other:?}"), }; assert!( @@ -8293,11 +8340,7 @@ mod tests { #[test] fn three_consumers_are_reported_verbatim_in_both_rationales() { - let q = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let target = TargetSubDAG::with_consumer_count(&q, 3); let replacements = SharedSubDAGStrategy.replacements(&target); assert!(replacements[0].rationale.contains('3')); @@ -8307,13 +8350,13 @@ mod tests { /// Builds realistic multi-consumer `TargetSubDAG`s the same way this /// module's own [`discover_targets`]/`walk` does: dedup by `Rc::as_ptr`, /// walking only the relational-skeleton operator children - /// `asap_types::pre_asap::cse::share_common_sub_dags` itself scopes to, + /// `asap_types::ir::cse::share_common_sub_dags` itself scopes to, /// so a shared node nested below another shared node is only ever /// counted at the highest (maximal) point sharing starts. Test-only: /// this module deliberately does not ship a workload-wide discovery /// pass of its own (see the module docs' "Non-goals"). - fn count_consumers(roots: &[Rc]) -> HashMap<*const QueryExpr, usize> { - fn walk(node: &Rc, counts: &mut HashMap<*const QueryExpr, usize>) { + fn count_consumers(roots: &[Rc]) -> HashMap<*const OperatorNode, usize> { + fn walk(node: &Rc, counts: &mut HashMap<*const OperatorNode, usize>) { let ptr = Rc::as_ptr(node); let already_visited = counts.contains_key(&ptr); *counts.entry(ptr).or_insert(0) += 1; @@ -8321,50 +8364,15 @@ mod tests { walk_children(node, counts); } } - fn walk_children(node: &QueryExpr, counts: &mut HashMap<*const QueryExpr, usize>) { - use QueryExpr::*; - match node { - Scan { .. } | PromqlScalarBridge(_) | EvalTimestamp | CurrentTimestamp => {} - PromqlVectorFromScalar(c) | PromqlScalarFromVector(c) => walk(c, counts), - PromqlRelabel { child, .. } - | PromqlInfoEnrich { child, .. } - | PromqlSeriesSample { child, .. } - | Filter { child, .. } - | Project { child, .. } - | Aggregate { child, .. } - | Dedup { child, .. } - | PromqlSubquery { child, .. } - | TimeRange { child, .. } - | TimeShift { child, .. } - | SQLWindowFunc { child, .. } - | Sort { child, .. } - | Limit { child, .. } => walk(child, counts), - Concat { children, .. } => { - for c in children { - walk_children(c, counts); - } - } - Join { left, right, .. } | SetOp { left, right, .. } => { - walk(left, counts); - walk(right, counts); - } - BinaryOp { lhs, rhs, .. } => { - walk(lhs, counts); - walk(rhs, counts); + fn walk_children(node: &OperatorNode, counts: &mut HashMap<*const OperatorNode, usize>) { + if let Some(NonASAPOp::Concat { children, .. }) = node.non_asap() { + for c in children { + walk_children(c, counts); } - Column(_) - | Literal(_) - | Compare { .. } - | BoolAnd(_) - | BoolOr(_) - | Not(_) - | IsNull(_) - | IsNotNull(_) - | Cast { .. } - | InList { .. } - | FunctionCall { .. } - | Arithmetic { .. } - | Case { .. } => {} + return; + } + for child in node.children() { + walk(child, counts); } } @@ -8382,13 +8390,13 @@ mod tests { // Sum aggregate over the same scan, built independently at each root. let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let shared = asap_types::pre_asap::cse::share_common_sub_dags(vec![("a", a), ("b", b)]); + let shared = asap_types::ir::cse::share_common_sub_dags(vec![("a", a), ("b", b)]); let [(_, ra), (_, rb)] = shared.as_slice() else { panic!("expected 2 roots"); }; assert!(Rc::ptr_eq(ra, rb), "fixture sanity: the two roots merged"); - let roots: Vec> = shared.into_iter().map(|(_, rc)| rc).collect(); + let roots: Vec> = shared.into_iter().map(|(_, rc)| rc).collect(); let counts = count_consumers(&roots); let count = counts[&Rc::as_ptr(&roots[0])]; assert_eq!(count, 2); @@ -8416,7 +8424,7 @@ mod tests { delta: 0.01, }, }; - let root = Rc::new(agg(vec![2], intent, metric_scan(&["job"]))); + let root = agg(vec![2], intent, metric_scan(&["job"])); let space = search_workload(vec![("q", root)]); // One group for the Aggregate, one for its Scan child. @@ -8424,7 +8432,7 @@ mod tests { let agg_group = space .target_subdag_candidates() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .expect("an Aggregate group must be discovered"); assert_eq!(agg_group.consumer_count, 1); assert_eq!( @@ -8436,24 +8444,26 @@ mod tests { assert!(agg_group .candidates .iter() - .all(|c| matches!(c.replacement, Replacement::Summary(_)))); + .all(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap()))); assert_eq!( agg_group .candidates .iter() .filter(|candidate| { - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { return false; }; - let SummaryExpr::SummaryEstimate { summary_input, .. } = &node.expr else { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = + &node.operator + else { return false; }; matches!( - &summary_input.expr, - SummaryExpr::SummaryAgg { + &summary_input.operator, + Operator::ASAP(ASAPOp::SummaryAgg { grouping: GroupingStrategy::SharedMultiSubpopulation { .. }, .. - } + }) ) }) .count(), @@ -8477,7 +8487,7 @@ mod tests { let scan_group = space .target_subdag_candidates() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Scan { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Scan { .. }))) .expect("a Scan group must be discovered"); assert_eq!(scan_group.consumer_count, 1); assert!( @@ -8488,20 +8498,20 @@ mod tests { #[test] fn cardinality_group_keeps_all_four_candidates() { - let root = Rc::new(agg(vec![2], default_cardinality(), metric_scan(&["job"]))); + let root = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let space = search_workload(vec![("q", root)]); let agg_group = space .target_subdag_candidates() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .unwrap(); assert_eq!(agg_group.candidates.len(), 4); assert!(agg_group.candidates.iter().any(|candidate| matches!( &candidate.replacement, - Replacement::Summary(node) if node.guarantee.is_none() + Replacement::SubDAG(node) if node.guarantee.is_none() && candidate.has_missing_accuracy_evidence() ))); - let root = Rc::new(agg(vec![2], default_cardinality(), metric_scan(&["job"]))); + let root = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let targeted = search_workload_with_targets( vec![( "q", @@ -8522,7 +8532,7 @@ mod tests { .iter() .any(|candidate| matches!( &candidate.replacement, - Replacement::Summary(node) if node.guarantee.is_none() + Replacement::SubDAG(node) if node.guarantee.is_none() && candidate.has_missing_accuracy_evidence() ))); assert!(!targeted @@ -8535,7 +8545,7 @@ mod tests { let exact_target = search_workload_with_targets( vec![( "q", - Rc::new(agg(vec![2], default_cardinality(), metric_scan(&["job"]))), + agg(vec![2], default_cardinality(), metric_scan(&["job"])), Some(AccuracyTarget::Exact), )], &default_strategies(), @@ -8554,11 +8564,11 @@ mod tests { // Two independently-built, structurally identical Sum aggregates: // share_common_sub_dags (run inside search_workload) collapses them // onto one Rc with consumer_count 2, so this single group should - // carry SketchAlgorithmStrategy's one ExactAggregate candidate *and* + // carry ASAPStrategies's one ExactAggregate candidate *and* // SharedSubDAGStrategy's share-vs-recompute pair. let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let space = search_workload(vec![("a", Rc::new(a)), ("b", Rc::new(b))]); + let space = search_workload(vec![("a", a), ("b", b)]); // roots[0] and roots[1] must have merged onto the same Rc. assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); @@ -8572,15 +8582,17 @@ mod tests { group.candidates ); + // Old `Replacement::Summary` ↔ a `Subtree` containing an ASAP node; + // old `Replacement::Rewrite` ↔ a pure pre-ASAP `Subtree`. let summary_count = group .candidates .iter() - .filter(|c| matches!(c.replacement, Replacement::Summary(_))) + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap())) .count(); let rewrite_count = group .candidates .iter() - .filter(|c| matches!(c.replacement, Replacement::Rewrite(_))) + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap())) .count(); assert_eq!(summary_count, 1); assert_eq!(rewrite_count, 2); @@ -8589,11 +8601,11 @@ mod tests { // (the "false-positive dedup" failure mode `is_duplicate_rewrite` // exists to prevent). let one_is_the_target = group.candidates.iter().any( - |c| matches!(&c.replacement, Replacement::Rewrite(rc) if Rc::ptr_eq(rc, &group.target)), + |c| matches!(&c.replacement, Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target)), + ); + let one_is_not = group.candidates.iter().any( + |c| matches!(&c.replacement, Replacement::SubDAG(rc) if !Rc::ptr_eq(rc, &group.target)), ); - let one_is_not = group.candidates.iter().any(|c| { - matches!(&c.replacement, Replacement::Rewrite(rc) if !Rc::ptr_eq(rc, &group.target)) - }); assert!(one_is_the_target && one_is_not); } @@ -8604,27 +8616,27 @@ mod tests { // walking the whole DAG, not just root-level pointer identity // (a naive whole-root-only consumer-count pass would miss this; // this module's discover_targets must not). + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; - let shared = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); // Different predicates so the two Filter *parents* stay distinct // (don't themselves merge under CSE) — only their shared `child` // should collapse onto one `Rc`. - let root_a = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64(1)))), - child: Rc::clone(&shared), - }; - let root_b = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64(2)))), - child: Rc::clone(&shared), - }; + let root_a = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), + child: Rc::clone(&shared), + })) + .unwrap(); + let root_b = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), + child: Rc::clone(&shared), + })) + .unwrap(); - let space = search_workload(vec![("a", Rc::new(root_a)), ("b", Rc::new(root_b))]); + let space = search_workload(vec![("a", root_a), ("b", root_b)]); assert_eq!( space.len(), 4, @@ -8638,17 +8650,17 @@ mod tests { // pointer as) the pre-search `shared` variable. Recover it from the // post-CSE root's own `child` field instead of the stale `shared` // handle. - let QueryExpr::Filter { + let Some(NonASAPOp::Filter { child: post_cse_shared_a, .. - } = space.roots[0].1.as_ref() + }) = space.roots[0].1.non_asap() else { panic!("expected a Filter root"); }; - let QueryExpr::Filter { + let Some(NonASAPOp::Filter { child: post_cse_shared_b, .. - } = space.roots[1].1.as_ref() + }) = space.roots[1].1.non_asap() else { panic!("expected a Filter root"); }; @@ -8674,14 +8686,10 @@ mod tests { #[test] fn add_candidate_rejects_a_true_rewrite_duplicate() { // SharedSubDAGStrategy's `Replacement::Rewrite` candidates are - // real `QueryExpr` values with `PartialEq`, so `add_candidate` can + // real `OperatorNode` values with `PartialEq`, so `add_candidate` can // (and must) actually reject a genuine repeat — unlike the // `Replacement::Summary` case (see the test below). - let root = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let root = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let mut group = TargetSubDAGCandidates::new(Rc::clone(&root), 2); let target = TargetSubDAG::with_consumer_count(&root, 2); let mut inserted = 0; @@ -8712,8 +8720,8 @@ mod tests { #[test] fn add_candidate_never_dedups_summary_candidates() { - // Documented, deliberate consequence of `SummaryNode` deriving no - // `PartialEq` (see `is_duplicate_summary`'s own doc): re-proposing + // Documented, deliberate consequence of `is_duplicate_summary` + // refusing value equality on `f64`-bearing summaries: re-proposing // the same `Replacement::Summary` candidates DOES grow the group — // this module refuses to guess at an equality check it can't back // with a real `PartialEq`. `search_workload_with` never actually @@ -8721,9 +8729,9 @@ mod tests { // module docs' "Termination" section), so this test exists to pin // the documented behavior, not to endorse calling `replacements` // twice for the same target. - let root = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let mut group = TargetSubDAGCandidates::new(Rc::clone(&root), 1); - let strategy = SketchAlgorithmStrategy::default_cost_model(); + let strategy = ASAPStrategies::default_cost_model(); let target = TargetSubDAG::new(&root); for candidate in strategy.replacements(&target) { group.add_candidate(candidate); @@ -8742,11 +8750,7 @@ mod tests { #[test] fn is_duplicate_rewrite_never_merges_share_with_recompute() { - let target = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let target = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let share = Rc::clone(&target); let recompute = Rc::new((*target).clone()); assert!(!Rc::ptr_eq(&share, &recompute)); @@ -8760,11 +8764,7 @@ mod tests { #[test] fn is_duplicate_rewrite_catches_a_real_repeat() { - let target = Rc::new(agg( - vec![2], - AggIntent::Sum { col: None }, - metric_scan(&["job"]), - )); + let target = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let first_recompute = Rc::new((*target).clone()); let second_recompute = Rc::new((*target).clone()); assert!(!Rc::ptr_eq(&first_recompute, &second_recompute)); @@ -8785,7 +8785,7 @@ mod tests { let mut roots = Vec::new(); let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); for i in 0..20 { - roots.push((i, Rc::new(shared.clone()))); + roots.push((i, Rc::new((*shared).clone()))); } let space = search_workload(roots); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); @@ -8798,18 +8798,18 @@ mod tests { .unwrap(); assert!(matches!( &ranked_group.candidates[0].replacement, - Replacement::Rewrite(rc) if Rc::ptr_eq(rc, &group.target) + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, &group.target) )); let rewrites: Vec<&ReplacementSubDAG> = ranked_group .candidates .iter() - .filter(|c| matches!(c.replacement, Replacement::Rewrite(_))) + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap())) .copied() .collect(); assert_eq!(rewrites.len(), 2); let first_shares_target = match &rewrites[0].replacement { - Replacement::Rewrite(rc) => Rc::ptr_eq(rc, &group.target), - Replacement::Summary(_) | Replacement::ExactComposition(_) => false, + Replacement::SubDAG(rc) => Rc::ptr_eq(rc, &group.target), + Replacement::ExactComposition(_) => false, }; assert!( first_shares_target, @@ -8835,17 +8835,17 @@ mod tests { } } - let root = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let space = search_workload(vec![("q", root)]); let ranked = space.cost_sorted(&PreferDDSketch); let agg_group = ranked .iter() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .unwrap(); assert_eq!(agg_group.candidates.len(), 2); let first_kind = match &agg_group.candidates[0].replacement { - Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, }; assert_eq!(first_kind, Some(SketchAlgorithm::DDSketch)); } @@ -8863,7 +8863,7 @@ mod tests { candidates.to_vec() } - fn estimated_subpopulation_count(&self, _target: &QueryExpr) -> Option { + fn estimated_subpopulation_count(&self, _target: &OperatorNode) -> Option { Some(self.0) } } @@ -8876,19 +8876,15 @@ mod tests { delta: 0.01, }, }; - let root = Rc::new(agg( - vec![2, 3], - intent, - metric_scan(&["tenant_id", "endpoint"]), - )); + let root = agg(vec![2, 3], intent, metric_scan(&["tenant_id", "endpoint"])); let strategies = default_strategies_with(&model); let space = search_workload_with(vec![("tenant_endpoint_count", root)], &strategies); let ranked = space.cost_sorted(&model); let aggregate = ranked .iter() - .find(|group| matches!(group.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .expect("aggregate group"); - let Replacement::Summary(node) = &aggregate.candidates[0].replacement else { + let Replacement::SubDAG(node) = &aggregate.candidates[0].replacement else { panic!("grouping candidate must be a summary") }; summary_grouping(node) @@ -8912,12 +8908,12 @@ mod tests { /// and target produces, not some other (or stale) number. #[test] fn cost_sorted_pairs_each_candidate_with_its_own_estimate_cost() { - let root = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let space = search_workload(vec![("q", root)]); let ranked = space.cost_sorted(&DefaultCostModel); let agg_group = ranked .iter() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .unwrap(); assert_eq!( agg_group.costs.len(), @@ -8939,7 +8935,7 @@ mod tests { // ── global_selection (issue #271) ─────────────────────────────────── - /// A `CostModel` with a constant, `sub_dag`-independent recompute cost + /// A `CostModel` with a constant, `sub-DAG`-independent recompute cost /// and shared-maintenance cost, chosen (40 recompute-per-use, 100 /// maintenance) so that a `SharedSubDAGStrategy` group's /// `cse_share_decision` flips exactly between a consumer count of 2 @@ -8996,7 +8992,7 @@ mod tests { } } - let aggregate = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let aggregate = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let space = search_workload(vec![("left", Rc::clone(&aggregate)), ("right", aggregate)]); let root = &space.roots[0].1; assert!(cse_candidate_pair(space.candidates_for_target(root).unwrap()).is_some()); @@ -9011,7 +9007,7 @@ mod tests { // must equal the group's own raw consumer_count, and its `chosen` // candidate must be cost_sorted's top pick, for both the sketch // group and its child Scan. - let root = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let space = search_workload(vec![("q", root)]); let ranked = space.cost_sorted(&DefaultCostModel); @@ -9038,12 +9034,12 @@ mod tests { // A bare Scan: no registered strategy has an opinion on it, so it // gets a group with an empty candidate list (see TargetSubDAGCandidates's own // doc) — global_selection must not invent a candidate for it. - let root = Rc::new(metric_scan(&["job"])); + let root = metric_scan(&["job"]); let space = search_workload(vec![("q", root)]); let selected = space.global_selection(&DefaultCostModel); let scan_group = selected .target_selections() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Scan { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Scan { .. }))) .unwrap(); assert!(scan_group.chosen.is_none()); assert_eq!(scan_group.effective_consumer_count, 1); @@ -9051,7 +9047,7 @@ mod tests { #[test] fn global_selection_falls_back_to_local_ranking_for_sketch_family_groups() { - // SketchAlgorithmStrategy groups have no cross-group-aware cost hook + // ASAPStrategies groups have no cross-group-aware cost hook // (rank_candidates takes no consumer_count) — global_selection must // still return cost_sorted's own top pick for them (documented in // the module docs' "Whole-plan (cross-group) selection" section), @@ -9076,16 +9072,16 @@ mod tests { } } - let root = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let space = search_workload(vec![("q", root)]); let selected = space.global_selection(&PreferDDSketch); let agg_group = selected .target_selections() - .find(|g| matches!(g.target.as_ref(), QueryExpr::Aggregate { .. })) + .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) .unwrap(); let kind = match &agg_group.chosen.unwrap().replacement { - Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, + Replacement::SubDAG(node) => sketch_kind_of(node), + Replacement::ExactComposition(_) => None, }; assert_eq!(kind, Some(SketchAlgorithm::DDSketch)); @@ -9109,24 +9105,29 @@ mod tests { #[test] fn mixed_rewrite_group_keeps_and_selects_its_explicit_cse_pair() { - let target = Rc::new(metric_scan(&["job"])); + let target = metric_scan(&["job"]); let mut group = TargetSubDAGCandidates::new(Rc::clone(&target), 2); group.candidates = vec![ ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Rewrite(Rc::clone(&target)), + replacement: Replacement::SubDAG(Rc::clone(&target)), provenance: ReplacementProvenance::CseShare, rationale: "share".into(), }, ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Rewrite(Rc::new(target.as_ref().clone())), + replacement: Replacement::SubDAG(Rc::new(target.as_ref().clone())), provenance: ReplacementProvenance::CseRecompute, rationale: "recompute".into(), }, ReplacementSubDAG { strategy: "TestStrategy", - replacement: Replacement::Rewrite(Rc::new(QueryExpr::CurrentTimestamp)), + replacement: Replacement::SubDAG( + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::PromqlVectorFromScalar(ScalarExpr::EvalTimestamp), + )) + .unwrap(), + ), provenance: ReplacementProvenance::LogicalRewrite, rationale: "different rewrite strategy".into(), }, @@ -9166,7 +9167,7 @@ mod tests { // `a` and `c` are both non-`Aggregate` nodes (`Filter`/`Dedup`) so // neither is bindable — each group is a *clean* two-candidate // SharedSubDAGStrategy share-vs-recompute pair, with no - // SketchAlgorithmStrategy `Summary` candidate mixed in to complicate + // ASAPStrategies `Summary` candidate mixed in to complicate // ranking (see `shared_aggregate_across_two_roots_gets_both_strategies_candidates` // for what a *mixed*-shape group looks like — deliberately avoided // here to isolate the SharedSubDAGStrategy-only interaction). @@ -9182,23 +9183,25 @@ mod tests { // which flips its own decision to Share. Only global_selection, // which folds `a`'s decision into `c`'s effective_consumer_count // before deciding `c`, gets this right. + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; - let c = || QueryExpr::Dedup { - cols: vec![0], - child: Rc::new(metric_scan(&["job"])), + let c = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap() }; - let a = || QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - child: Rc::new(c()), + let a = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: c(), + })) + .unwrap() }; - let space = search_workload(vec![ - ("root1", Rc::new(a())), - ("root2", Rc::new(a())), - ("root3", Rc::new(c())), - ]); + let space = search_workload(vec![("root1", a()), ("root2", a()), ("root3", c())]); // Fixture sanity: root1/root2 merged onto one shared `a`, and `c` // (root1/root2's shared child, and root3 itself) merged onto one @@ -9206,7 +9209,7 @@ mod tests { // (non-mixed) two-candidate SharedSubDAGStrategy pairs. assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); let a_rc = &space.roots[0].1; - let QueryExpr::Filter { child: c_via_a, .. } = a_rc.as_ref() else { + let Some(NonASAPOp::Filter { child: c_via_a, .. }) = a_rc.non_asap() else { panic!("expected root1/root2 to still be a Filter"); }; assert!(Rc::ptr_eq(c_via_a, &space.roots[2].1)); @@ -9240,7 +9243,7 @@ mod tests { .unwrap(); let c_top_shares = matches!( &c_ranked.candidates[0].replacement, - Replacement::Rewrite(rc) if Rc::ptr_eq(rc, c_via_a) + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, c_via_a) ); assert!( !c_top_shares, @@ -9261,7 +9264,7 @@ mod tests { ); let a_shares = matches!( &a_selected.chosen.unwrap().replacement, - Replacement::Rewrite(rc) if Rc::ptr_eq(rc, a_rc) + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, a_rc) ); assert!( !a_shares, @@ -9274,7 +9277,7 @@ mod tests { ); let c_shares = matches!( &c_selected.chosen.unwrap().replacement, - Replacement::Rewrite(rc) if Rc::ptr_eq(rc, c_via_a) + Replacement::SubDAG(rc) if Rc::ptr_eq(rc, c_via_a) ); assert!( c_shares, @@ -9312,10 +9315,12 @@ mod tests { } } - let shared = Rc::new(QueryExpr::Dedup { - cols: vec![0], - child: Rc::new(metric_scan(&["job"])), - }); + let shared = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap(); let space = search_workload(vec![ ("left", Rc::clone(&shared)), ("right", Rc::clone(&shared)), @@ -9328,20 +9333,26 @@ mod tests { #[test] fn effective_repetition_materializes_a_cse_choice_for_a_single_edge_child() { + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; - let c = || QueryExpr::Dedup { - cols: vec![0], - child: Rc::new(metric_scan(&["job"])), + let c = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap() }; - let a = || QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - child: Rc::new(c()), + let a = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: c(), + })) + .unwrap() }; - let space = search_workload(vec![("root1", Rc::new(a())), ("root2", Rc::new(a()))]); + let space = search_workload(vec![("root1", a()), ("root2", a())]); let a_rc = &space.roots[0].1; - let QueryExpr::Filter { child: c_rc, .. } = a_rc.as_ref() else { + let Some(NonASAPOp::Filter { child: c_rc, .. }) = a_rc.non_asap() else { panic!("expected Filter root"); }; @@ -9356,8 +9367,8 @@ mod tests { #[test] fn shared_ancestor_keeps_a_single_use_cse_descendant_selected() { + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; struct AlwaysShare; impl CostModel for AlwaysShare { @@ -9378,22 +9389,25 @@ mod tests { } } - let child = || QueryExpr::Dedup { - cols: vec![0], - child: Rc::new(metric_scan(&["job"])), + let child = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["job"]), + })) + .unwrap() }; - let parent = || QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - child: Rc::new(child()), + let parent = || { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: child(), + })) + .unwrap() }; - let space = search_workload(vec![ - ("root1", Rc::new(parent())), - ("root2", Rc::new(parent())), - ]); + let space = search_workload(vec![("root1", parent()), ("root2", parent())]); let parent_rc = &space.roots[0].1; - let QueryExpr::Filter { + let Some(NonASAPOp::Filter { child: child_rc, .. - } = parent_rc.as_ref() + }) = parent_rc.non_asap() else { panic!("expected Filter root"); }; @@ -9417,38 +9431,44 @@ mod tests { #[test] fn global_selection_propagates_uses_through_the_selected_rewrite() { + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; struct ReplaceFilterChild; impl ReplacementStrategy for ReplaceFilterChild { fn matches(&self, target: &TargetSubDAG<'_>) -> bool { - matches!(target.root.as_ref(), QueryExpr::Filter { .. }) + matches!(target.root.non_asap(), Some(NonASAPOp::Filter { .. })) } fn replacements(&self, _target: &TargetSubDAG<'_>) -> Vec { vec![ReplacementSubDAG { strategy: "ReplaceFilterChild", - replacement: Replacement::Rewrite(Rc::new(QueryExpr::Dedup { - cols: vec![0], - child: Rc::new(metric_scan(&["replacement"])), - })), + replacement: Replacement::SubDAG( + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::Dedup { + cols: vec![0], + child: metric_scan(&["replacement"]), + }, + )) + .unwrap(), + ), provenance: ReplacementProvenance::LogicalRewrite, rationale: "replace the Filter and its input".into(), }] } } - let original_child = Rc::new(metric_scan(&["original"])); - let root = Rc::new(QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), + let original_child = metric_scan(&["original"]); + let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), child: Rc::clone(&original_child), - }); + })) + .unwrap(); let strategies: Vec> = vec![Box::new(ReplaceFilterChild)]; let space = search_workload_with(vec![("q", root)], &strategies); let root = &space.roots[0].1; let selected = space.global_selection(&DefaultCostModel); - let Replacement::Rewrite(rewrite) = &selected + let Replacement::SubDAG(rewrite) = &selected .for_target(root) .unwrap() .chosen @@ -9457,17 +9477,17 @@ mod tests { else { panic!("expected logical rewrite"); }; - let QueryExpr::Dedup { + let Some(NonASAPOp::Dedup { child: replacement_child, .. - } = rewrite.as_ref() + }) = rewrite.non_asap() else { panic!("expected Dedup rewrite"); }; - let QueryExpr::Filter { + let Some(NonASAPOp::Filter { child: original_child, .. - } = root.as_ref() + }) = root.non_asap() else { panic!("expected Filter root"); }; @@ -9503,11 +9523,11 @@ mod tests { fn candidate_cost(&self, _: &ReplacementSubDAG, _: &TargetSubDAG<'_>) -> Option { Some(Cost(1.0)) } - fn summary_support_evidence(&self, _: &SummaryNode) -> Option { + fn summary_support_evidence(&self, _: &OperatorNode) -> Option { Some(false) } } - let root = Rc::new(lower_promql("sum_over_time(a[1m])", AccuracyTarget::Exact)); + let root = lower_promql("sum_over_time(a[1m])", AccuracyTarget::Exact); let space = search_workload(vec![("q", root)]); let selected = space.global_selection(&Unsupported); assert!(selected @@ -9520,18 +9540,15 @@ mod tests { // Composable temporal/grouped Sum must be executable as one producer. #[test] fn grouped_temporal_sum_has_one_summary_producer_candidate() { - let root = Rc::new(lower_promql( - "sum by(job)(sum_over_time(a[1m]))", - AccuracyTarget::Exact, - )); + let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); assert!(candidates .iter() .any(|candidate| matches!(&candidate.replacement, - Replacement::Summary(node) if matches!(&node.expr, - SummaryExpr::SummaryAgg { reduction: Reduction::Reduce(_), child, .. } - if matches!(child.expr, SummaryExpr::KeepPreAsap(_)))))); + Replacement::SubDAG(node) if matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) + if !child.contains_asap())))); struct PreferComposed; impl CostModel for PreferComposed { fn rank_candidates( @@ -9548,9 +9565,9 @@ mod tests { ) -> Option { Some(Cost( if matches!(&candidate.replacement, - Replacement::Summary(node) if matches!(&node.expr, - SummaryExpr::SummaryAgg { reduction: Reduction::Reduce(_), child, .. } - if matches!(child.expr, SummaryExpr::KeepPreAsap(_)))) + Replacement::SubDAG(node) if matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) + if !child.contains_asap())) { 1.0 } else { @@ -9562,9 +9579,9 @@ mod tests { let space = search_workload(vec![("q", root.clone())]); let selected = space.global_selection(&PreferComposed); let node = selected.assemble_target(&space.roots[0].1).unwrap(); - assert!(matches!(&node.expr, - SummaryExpr::SummaryAgg { reduction: Reduction::Reduce(_), child, .. } - if matches!(child.expr, SummaryExpr::KeepPreAsap(_)))); + assert!(matches!(&node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { reduction: Reduction::Reduce(_), child, .. }) + if !child.contains_asap())); } // Mixed candidate ranking must honor explicit costs, not legacy estimates. @@ -9593,10 +9610,7 @@ mod tests { )) } } - let root = Rc::new(lower_promql( - "sum by(job)(sum_over_time(a[1m]))", - AccuracyTarget::Exact, - )); + let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); let space = search_workload(vec![("q", root)]); let selection = space.global_selection(&ExplicitCosts); let selected = selection @@ -9632,16 +9646,8 @@ mod tests { } } - let a = Rc::new(agg( - vec![2], - AggIntent::Avg { col: None }, - metric_scan(&["job"]), - )); - let b = Rc::new(agg( - vec![2], - AggIntent::Avg { col: None }, - metric_scan(&["job"]), - )); + let a = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); + let b = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); let space = search_workload(vec![("a", a), ("b", b)]); let root = &space.roots[0].1; let selected = space.global_selection(&PreferLogicalRewrite); @@ -9657,26 +9663,30 @@ mod tests { #[test] fn topological_order_puts_a_later_discovered_parent_before_its_child() { - // Mirrors nested_shared_sub_dag_below_an_unshared_parent_is_still_discovered's + // Mirrors nested_shared_sub-DAG_below_an_unshared_parent_is_still_discovered's // diamond fixture: discover_targets's own `order` visits root_b (a // parent of `shared`) *after* `shared` itself, because `shared` was // already fully walked via root_a first. A naive "process // discover_targets's own order" DP would see root_b's child edge // after already processing `shared` — topological_order must not // make that mistake. + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; let shared = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let root_a = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64(1)))), - child: Rc::new(shared.clone()), - }; - let root_b = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64(2)))), - child: Rc::new(shared), - }; - let roots = vec![("a", Rc::new(root_a)), ("b", Rc::new(root_b))]; + let root_a = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(1))), + child: shared.clone(), + })) + .unwrap(); + let root_b = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(2))), + child: shared, + })) + .unwrap(); + let roots = vec![("a", root_a), ("b", root_b)]; let mut order = Vec::new(); let mut nodes = HashMap::new(); @@ -9702,10 +9712,10 @@ mod tests { // Discovery-order sanity: root_b comes after the shared child in // discover_targets's own order (the exact non-topological case this // test exists to cover). - let QueryExpr::Filter { + let Some(NonASAPOp::Filter { child: shared_via_a, .. - } = space.roots[0].1.as_ref() + }) = space.roots[0].1.non_asap() else { panic!("expected a Filter root"); }; @@ -9738,7 +9748,7 @@ mod tests { // module docs — so this always converges in exactly 2 passes). let a = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let b = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("a", Rc::new(a)), ("b", Rc::new(b))]); + let space = search_workload(vec![("a", a), ("b", b)]); assert!(!space.is_empty()); } @@ -9764,19 +9774,23 @@ mod tests { fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { let n = self.next.get(); self.next.set(n + 1); + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; - let fresh_inner_layer = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Int64(n)))), - child: Rc::clone(target.root), - }; - let outer_wrapper = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - child: Rc::new(fresh_inner_layer), - }; + let fresh_inner_layer = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Int64(n))), + child: Rc::clone(target.root), + })) + .unwrap(); + let outer_wrapper = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), + child: fresh_inner_layer, + })) + .unwrap(); vec![ReplacementSubDAG { strategy: "AlwaysGrowingStrategy", - replacement: Replacement::Rewrite(Rc::new(outer_wrapper)), + replacement: Replacement::SubDAG(outer_wrapper), provenance: ReplacementProvenance::LogicalRewrite, rationale: format!("pathological candidate #{n}"), }] @@ -9786,13 +9800,13 @@ mod tests { #[test] #[should_panic(expected = "did not converge")] fn a_pathologically_growing_strategy_trips_the_iteration_cap() { - let root = Rc::new(metric_scan(&["job"])); + let root = metric_scan(&["job"]); let strategies: Vec> = vec![Box::new(AlwaysGrowingStrategy { next: std::cell::Cell::new(0), })]; let _ = search_workload_with(vec![("q", root)], &strategies); } - // ── realize_child / keep_pre_asap: end-to-end single-target realization ── + // ── realize_child / retain_exact: end-to-end single-target realization ── // // Moved from the former `bind.rs` (issue #251): `bind.rs`'s own // workload-wide orchestration (`implement_workload`/ @@ -9805,17 +9819,6 @@ mod tests { // pattern by hand since `realize_child` is `pub(crate)`), these tests // call `realize_child` directly. - fn agg_per_entity(intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { - reduction: ReductionTy::PerEntity, - measures: vec![intent], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(child), - } - } - fn field<'a>(schema: &'a Schema, name: &str) -> &'a Field { schema .fields @@ -9825,29 +9828,29 @@ mod tests { } fn realize_first( - expr: &QueryExpr, + expr: &OperatorNode, cost_model: &dyn CostModel, - ) -> Result, RealizationError> { + ) -> Result, RealizationError> { realize_child(&Rc::new(expr.clone()), cost_model) } - fn realize(expr: &QueryExpr) -> Result, RealizationError> { + fn realize(expr: &OperatorNode) -> Result, RealizationError> { realize_first(expr, &DefaultCostModel) } #[test] fn quantile_realizes_kll_wrapped_in_estimate() { // quantile by (job) (m) at ε=0.01 → Estimate(Quantile) over - // SummaryAgg(Kll{k:269}) over KeepPreAsap(Scan). job = col 2. + // SummaryAgg(Kll{k:269}) over the kept Scan. job = col 2. let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let root = realize(&q).unwrap(); - let SummaryExpr::SummaryEstimate { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } = &root.expr + }) = &root.operator else { - panic!("expected SummaryEstimate root, got {:?}", root.expr); + panic!("expected SummaryEstimate root, got {:?}", root.operator); }; assert!(matches!(query, PostAsapSketchStatistic::Quantile { q } if *q == 0.99)); // Estimate edge: plain row shape — group key + Float64 answer. @@ -9860,15 +9863,15 @@ mod tests { FieldDataType::Plain(DataType::Utf8) ); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { child, family, input, reduction, .. - } = &summary_input.expr + }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!( family, @@ -9888,8 +9891,9 @@ mod tests { GroupingStrategy::default() ) ); - assert!(matches!(child.expr, SummaryExpr::KeepPreAsap(ref e) - if matches!(**e, QueryExpr::Scan { .. }))); + // The kept pre-ASAP leaf is the Scan node itself (no wrapper). + assert!(matches!(child.non_asap(), Some(NonASAPOp::Scan { .. }))); + assert!(!child.contains_asap()); } /// A deployment-supplied [`CostModel`] can override the default KLL @@ -9919,11 +9923,15 @@ mod tests { // Default: KLL (see `quantile_realizes_kll_wrapped_in_estimate` above). let default_root = realize(&q).unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &default_root.expr else { - panic!("expected SummaryEstimate root, got {:?}", default_root.expr); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &default_root.operator + else { + panic!( + "expected SummaryEstimate root, got {:?}", + default_root.operator + ); }; - let SummaryExpr::SummaryAgg { family, .. } = &summary_input.expr else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &summary_input.operator else { + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert!(matches!( family, @@ -9932,11 +9940,15 @@ mod tests { // With `PreferDDSketchViaCostModel`: DDSketch instead, same query. let custom_root = realize_first(&q, &PreferDDSketchViaCostModel).unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &custom_root.expr else { - panic!("expected SummaryEstimate root, got {:?}", custom_root.expr); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &custom_root.operator + else { + panic!( + "expected SummaryEstimate root, got {:?}", + custom_root.operator + ); }; - let SummaryExpr::SummaryAgg { family, .. } = &summary_input.expr else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &summary_input.operator else { + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!( family, @@ -9953,8 +9965,8 @@ mod tests { /// A deployment-supplied `CostModel` can realize an `AggIntent::Extension` /// intent as a real sketch instead of the default `PassThrough` (issue /// #150) — `realizations_for_intent` must consult `realize_extension` - /// for the `Extension` arm, and `readout` must consult - /// `readout_extension` to build its `SketchStatistic` without panicking. + /// for the `Extension` arm, and `evaluation` must consult + /// `evaluation_extension` to build its `SketchStatistic` without panicking. struct FrequencyCostModel; impl CostModel for FrequencyCostModel { @@ -9980,7 +9992,7 @@ mod tests { } } - fn readout_extension( + fn evaluation_extension( &self, ext_kind: &str, payload: &serde_json::Value, @@ -10006,7 +10018,7 @@ mod tests { }; let q = agg(vec![], intent, metric_scan(&[])); let root = realize(&q).unwrap(); - assert!(matches!(root.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!root.contains_asap()); } #[test] @@ -10018,12 +10030,12 @@ mod tests { let q = agg(vec![], intent, metric_scan(&[])); let root = realize_first(&q, &FrequencyCostModel).unwrap(); - let SummaryExpr::SummaryEstimate { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } = &root.expr + }) = &root.operator else { - panic!("expected SummaryEstimate root, got {:?}", root.expr); + panic!("expected SummaryEstimate root, got {:?}", root.operator); }; assert!(matches!( query, @@ -10031,8 +10043,8 @@ mod tests { if k == "item" && v == "checkout" )); - let SummaryExpr::SummaryAgg { family, .. } = &summary_input.expr else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &summary_input.operator else { + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!( family, @@ -10053,10 +10065,10 @@ mod tests { fn exact_sum_realizes_accumulator_without_estimate() { let q = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let root = realize(&q).unwrap(); - let SummaryExpr::SummaryAgg { family, .. } = &root.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &root.operator else { panic!( "expected bare SummaryAgg (no estimate), got {:?}", - root.expr + root.operator ); }; assert_eq!( @@ -10076,14 +10088,16 @@ mod tests { use std::time::Duration; let q = agg_per_entity( AggIntent::Rate, - QueryExpr::TimeRange { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { + kind: TimeRangeKind::Range, range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["job"])), - }, + child: metric_scan(&["job"]), + })) + .unwrap(), ); let root = realize(&q).unwrap(); - let SummaryExpr::SummaryAgg { family, .. } = &root.expr else { - panic!("expected SummaryAgg, got {:?}", root.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &root.operator else { + panic!("expected SummaryAgg, got {:?}", root.operator); }; assert_eq!( family, @@ -10114,17 +10128,19 @@ mod tests { use std::time::Duration; let q = agg_per_entity( default_quantile(0.99), - QueryExpr::TimeRange { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { + kind: TimeRangeKind::Range, range: Duration::from_secs(10), - child: Rc::new(metric_scan(&["job"])), - }, + child: metric_scan(&["job"]), + })) + .unwrap(), ); let root = realize(&q).unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - panic!("expected estimate root, got {:?}", root.expr); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { + panic!("expected estimate root, got {:?}", root.operator); }; - let SummaryExpr::SummaryAgg { reduction, .. } = &summary_input.expr else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { reduction, .. }) = &summary_input.operator else { + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!(reduction, &ReductionTy::PerEntity); } @@ -10142,11 +10158,11 @@ mod tests { }; let q = agg(vec![], intent, metric_scan(&["job"])); let root = realize(&q).unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - panic!("expected estimate root, got {:?}", root.expr); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { + panic!("expected estimate root, got {:?}", root.operator); }; - let SummaryExpr::SummaryAgg { reduction, .. } = &summary_input.expr else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { reduction, .. }) = &summary_input.operator else { + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!(reduction, &ReductionTy::by(vec![])); } @@ -10158,39 +10174,41 @@ mod tests { // accumulator. let inner = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let outer = agg(vec![], default_quantile(0.9), inner); - let root = realize(&outer).unwrap(); + // Timing is not stored during realization: time the candidate under + // the default lifecycle assignment to read the maintenance boundary. + let root = timed(&realize(&outer).unwrap()); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - panic!("expected estimate root, got {:?}", root.expr); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { + panic!("expected estimate root, got {:?}", root.operator); }; - let SummaryExpr::SummaryAgg { child, family, .. } = &summary_input.expr else { - panic!("expected outer SummaryAgg, got {:?}", summary_input.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { child, family, .. }) = &summary_input.operator + else { + panic!( + "expected outer SummaryAgg, got {:?}", + summary_input.operator + ); }; assert!(matches!( family, FieldDataType::Sketch(kind, _) if kind.algorithm() == &SketchAlgorithm::Kll )); - let SummaryExpr::ValueOperation { - child, - operation: ValueOperation::FinalizeExactAccumulator, - timing: ExecutionTiming::IngestionTime, - } = &child.expr - else { - panic!("expected explicit maintenance readout"); + assert_eq!(child.timing, Some(ExecutionTiming::IngestionTime)); + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) = &child.operator else { + panic!("expected explicit maintenance evaluation"); }; - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family: inner_family, child: leaf, .. - } = &child.expr + }) = &child.operator else { - panic!("expected inner SummaryAgg, got {:?}", child.expr); + panic!("expected inner SummaryAgg, got {:?}", child.operator); }; assert_eq!( inner_family, &FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) ); - assert!(matches!(leaf.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!leaf.contains_asap()); } /// Issue #115: the summary is built over the intent's own input columns. @@ -10225,13 +10243,15 @@ mod tests { } } - /// The update expression of the first `SummaryAgg` in the DAG. - fn find_summary_input(node: &SummaryNode) -> Option { - match &node.expr { - SummaryExpr::SummaryAgg { input, .. } if input.item.is_none() => { + /// The update expression of the first `SummaryAgg` in the tree. + fn find_summary_input(node: &OperatorNode) -> Option { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) if input.item.is_none() => { Some(input.weight.clone()) } - SummaryExpr::SummaryEstimate { summary_input, .. } => find_summary_input(summary_input), + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + find_summary_input(summary_input) + } _ => None, } } @@ -10252,59 +10272,62 @@ mod tests { ] { let q = agg(vec![2], intent.clone(), metric_scan(&["job"])); let root = realize(&q).unwrap(); + // Kept pass-through: the pre-ASAP node itself, not a wrapper. assert!( - matches!(root.expr, SummaryExpr::KeepPreAsap(ref e) if **e == q), - "expected KeepPreAsap passthrough for {intent:?}" + !root.contains_asap() && root.operator == q.operator && root.schema == q.schema, + "expected kept pre-ASAP passthrough for {intent:?}" ); } } #[test] fn logical_parent_subsumes_bindable_child() { - // Filter over a bindable quantile: `KeepPreAsap` has no post-ASAP - // children, so the conservative fallback keeps the whole sub-DAG + // Filter over a bindable quantile: a kept non-ASAP sub-DAG has no + // summary children, so the conservative fallback keeps the whole sub-DAG // logical. + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::{CompareOpKind, ScalarValue}; - use asap_types::pre_asap::query_expr::Predicate; - let q = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(0)), op: CompareOpKind::Gt, - right: Rc::new(QueryExpr::Literal(ScalarValue::Float64(0.5))), - })), - child: Rc::new(agg(vec![], default_quantile(0.99), metric_scan(&[]))), - }; + right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(0.5))), + semantics: asap_types::ir::ExprSemantics::Promql, + }), + child: agg(vec![], default_quantile(0.99), metric_scan(&[])), + })) + .unwrap(); let root = realize(&q).unwrap(); - assert!(matches!(root.expr, SummaryExpr::KeepPreAsap(ref e) if **e == q)); + assert!( + !root.contains_asap() && root.operator == q.operator && root.schema == q.schema, + "expected the whole Filter sub_dag kept pre-ASAP" + ); } #[test] fn having_and_multi_intent_stay_logical() { + use asap_types::ir::Predicate; use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; - let mut q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - if let QueryExpr::Aggregate { having, .. } = &mut q { - *having = Some(Predicate(Rc::new(QueryExpr::Literal( - ScalarValue::Boolean(true), - )))); - } - assert!(matches!( - realize(&q).unwrap().expr, - SummaryExpr::KeepPreAsap(_) - )); - - let multi = QueryExpr::Aggregate { - reduction: ReductionTy::by(vec![2]), - measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(metric_scan(&["job"])), - }; - assert!(matches!( - realize(&multi).unwrap().expr, - SummaryExpr::KeepPreAsap(_) - )); + let q = crate::test_support::aggregate( + ReductionTy::by(vec![2]), + vec![default_quantile(0.99)], + vec![], + Some(Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true)))), + metric_scan(&["job"]), + ); + assert!(!realize(&q).unwrap().contains_asap()); + + let multi = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: ReductionTy::by(vec![2]), + measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], + output_names: vec![], + filters: vec![], + having: None, + child: metric_scan(&["job"]), + })) + .unwrap(); + assert!(!realize(&multi).unwrap().contains_asap()); } // No binding rule applies a per-measure `FILTER` (#466), so the @@ -10312,18 +10335,16 @@ mod tests { #[test] fn filtered_measure_stays_logical() { use asap_types::pre_asap::expr_ir::ScalarValue; - use asap_types::pre_asap::query_expr::Predicate; let mut q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - if let QueryExpr::Aggregate { filters, .. } = &mut q { - *filters = vec![Some(Predicate(Rc::new(QueryExpr::Literal( - ScalarValue::Boolean(true), + if let Operator::NonASAP(NonASAPOp::Aggregate { filters, .. }) = + &mut Rc::make_mut(&mut q).operator + { + *filters = vec![Some(Predicate(ScalarExpr::Literal(ScalarValue::Boolean( + true, ))))]; } assert!(bindable_intent(&q).is_none()); - assert!(matches!( - realize(&q).unwrap().expr, - SummaryExpr::KeepPreAsap(_) - )); + assert!(!realize(&q).unwrap().contains_asap()); } #[test] @@ -10337,7 +10358,7 @@ mod tests { metric_scan(&["job"]), ); let root = realize(&q).unwrap(); - assert!(matches!(root.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!root.contains_asap()); } #[test] @@ -10349,20 +10370,20 @@ mod tests { }, metric_scan(&["job"]), ); - let root = Rc::new(agg( + let root = agg( vec![], AggIntent::TopK { k: 5, accuracy: AccuracyTarget::Epsilon(0.01), }, inner, - )); + ); let proposals = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); assert!(!proposals.is_empty()); assert!(proposals.iter().any(|candidate| matches!( &candidate.replacement, - Replacement::Summary(node) if node.guarantee.as_ref().is_some_and(|g| + Replacement::SubDAG(node) if node.guarantee.as_ref().is_some_and(|g| g.bound.evaluate().is_none() && g.failure_probability.evaluate().is_none()) ))); @@ -10402,7 +10423,7 @@ mod tests { }, metric_scan(&["job"]), ); - let q = Rc::new(agg( + let q = agg( vec![], AggIntent::TopK { k: 5, @@ -10412,8 +10433,8 @@ mod tests { }, }, inner, - )); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + ); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -10423,7 +10444,7 @@ mod tests { assert!(!replacements.is_empty()); assert!(replacements.iter().all(|candidate| matches!( &candidate.replacement, - Replacement::Summary(node) + Replacement::SubDAG(node) if node.guarantee.as_ref().is_some_and(|g| g.metric == ErrorMetric::TopKMembership && g.failure_probability.evaluate() == Some(0.005)) @@ -10439,15 +10460,15 @@ mod tests { }, metric_scan(&["service"]), ); - let outer = Rc::new(agg( + let outer = agg( vec![], AggIntent::TopK { k: 10, accuracy: AccuracyTarget::Epsilon(0.01), }, inner, - )); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + ); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -10457,26 +10478,26 @@ mod tests { let node = candidates .iter() .find_map(|candidate| match &candidate.replacement { - Replacement::Summary(node) if candidate.rationale.contains("CmsWithHeap") => { + Replacement::SubDAG(node) if candidate.rationale.contains("CmsWithHeap") => { Some(node) } _ => None, }) .expect("CmsWithHeap candidate"); - let SummaryExpr::SummaryEstimate { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } = &node.expr + }) = &node.operator else { - panic!("expected Top-K readout") + panic!("expected Top-K evaluation") }; assert!(matches!(query, PostAsapSketchStatistic::TopK { k: 10 })); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { child, family, input, .. - } = &summary_input.expr + }) = &summary_input.operator else { panic!("expected fused summary aggregation") }; @@ -10496,7 +10517,7 @@ mod tests { FieldDataType::Sketch(kind, _) if kind.algorithm() == &SketchAlgorithm::CmsWithHeap )); - assert!(matches!(child.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!child.contains_asap()); } #[test] @@ -10506,15 +10527,15 @@ mod tests { AggIntent::Sum { col: None }, metric_scan(&["service"]), ); - let outer = Rc::new(agg( + let outer = agg( vec![], AggIntent::TopK { k: 5, accuracy: AccuracyTarget::Epsilon(0.01), }, inner, - )); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + ); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -10531,7 +10552,7 @@ mod tests { let node = candidates .iter() .find_map(|candidate| match &candidate.replacement { - Replacement::Summary(node) + Replacement::SubDAG(node) if candidate.rationale.contains("CountSketchWithHeap") => { Some(node) @@ -10539,15 +10560,16 @@ mod tests { _ => None, }) .expect("CountSketchWithHeap candidate"); - let SummaryExpr::SummaryEstimate { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } = &node.expr + }) = &node.operator else { - panic!("expected Top-K readout") + panic!("expected Top-K evaluation") }; assert!(matches!(query, PostAsapSketchStatistic::TopK { k: 5 })); - let SummaryExpr::SummaryAgg { child, input, .. } = &summary_input.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child, input, .. }) = &summary_input.operator + else { panic!("expected fused summary aggregation") }; assert!(matches!( @@ -10558,31 +10580,37 @@ mod tests { input.weight, SummaryInputExpr::Column(ColumnRef::SampleValue) ); - assert!(matches!(child.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!child.contains_asap()); } #[test] fn temporal_per_entity_topk_uses_series_identity_and_sample_value() { - let inner = QueryExpr::Aggregate { - reduction: ReductionTy::PerEntity, - measures: vec![AggIntent::Sum { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(QueryExpr::TimeRange { - range: std::time::Duration::from_secs(60), - child: Rc::new(metric_scan(&["service"])), - }), - }; - let outer = Rc::new(agg( + let inner = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: ReductionTy::PerEntity, + measures: vec![AggIntent::Sum { col: None }], + output_names: vec![], + filters: vec![], + having: None, + child: OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::TimeRange { + kind: TimeRangeKind::Range, + range: std::time::Duration::from_secs(60), + child: metric_scan(&["service"]), + }, + )) + .unwrap(), + })) + .unwrap(); + let outer = agg( vec![2], AggIntent::TopK { k: 5, accuracy: AccuracyTarget::Epsilon(0.01), }, inner, - )); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + ); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -10590,13 +10618,14 @@ mod tests { ); let candidates = strategy.replacements(&TargetSubDAG::new(&outer)); let input = candidates.iter().find_map(|candidate| { - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { return None; }; - let SummaryExpr::SummaryEstimate { summary_input, .. } = &node.expr else { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator + else { return None; }; - let SummaryExpr::SummaryAgg { input, .. } = &summary_input.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &summary_input.operator else { return None; }; input.item.is_some().then_some(input) @@ -10625,15 +10654,15 @@ mod tests { }, metric_scan(&["service", "region"]), ); - let outer = Rc::new(agg( + let outer = agg( vec![], AggIntent::TopK { k: 10, accuracy: AccuracyTarget::Epsilon(0.01), }, inner, - )); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + ); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -10644,10 +10673,10 @@ mod tests { .replacements(&TargetSubDAG::new(&outer)) .into_iter() .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) => match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => { - match &summary_input.expr { - SummaryExpr::SummaryAgg { input, .. } => Some(input.clone()), + Replacement::SubDAG(node) => match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + match &summary_input.operator { + Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) => Some(input.clone()), _ => None, } } @@ -10678,15 +10707,15 @@ mod tests { ); // The inner aggregate outputs its grouping keys first, so column 2 is // `region`. Each region is a separate Top-K subpopulation. - let outer = Rc::new(agg( + let outer = agg( vec![2], AggIntent::TopK { k: 10, accuracy: AccuracyTarget::Epsilon(0.01), }, inner, - )); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + ); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -10696,12 +10725,12 @@ mod tests { .replacements(&TargetSubDAG::new(&outer)) .into_iter() .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) => match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => { - match &summary_input.expr { - SummaryExpr::SummaryAgg { + Replacement::SubDAG(node) => match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + match &summary_input.operator { + Operator::ASAP(ASAPOp::SummaryAgg { input, reduction, .. - } => Some((input.clone(), reduction.clone())), + }) => Some((input.clone(), reduction.clone())), _ => None, } } @@ -10726,7 +10755,7 @@ mod tests { fn sql_reducer_resolves_named_input_column() { // SUM(bytes) over a tabular scan: `col` resolves positionally to the // named column, not the PromQL sample value. - let scan = QueryExpr::Scan { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: "t".into(), }, @@ -10740,11 +10769,12 @@ mod tests { unique_keys: vec![], closed: true, }, - }; + })) + .unwrap(); let q = agg(vec![0], AggIntent::Sum { col: Some(1) }, scan); let root = realize(&q).unwrap(); - let SummaryExpr::SummaryAgg { input, .. } = &root.expr else { - panic!("expected SummaryAgg, got {:?}", root.expr); + let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &root.operator else { + panic!("expected SummaryAgg, got {:?}", root.operator); }; let SummaryInputExpr::Column(col) = &input.weight else { panic!("expected observation column") @@ -10809,10 +10839,12 @@ mod tests { } } - fn summary_child(node: &SummaryNode) -> &Rc { - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => summary_child(summary_input), - SummaryExpr::SummaryAgg { child, .. } => child, + fn summary_child(node: &OperatorNode) -> &Rc { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + summary_child(summary_input) + } + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => child, other => panic!("expected a SummaryAgg, got {other:?}"), } } @@ -10823,9 +10855,8 @@ mod tests { // registered rule, so every outer sketch candidate is refused with a // typed reason and the raw/pre-ASAP alternative is what remains. let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["job"])); - let outer = Rc::new(agg(vec![], default_quantile(0.99), inner)); - let proposals = - SketchAlgorithmStrategy::default_cost_model().propose(&TargetSubDAG::new(&outer)); + let outer = agg(vec![], default_quantile(0.99), inner); + let proposals = ASAPStrategies::default_cost_model().propose(&TargetSubDAG::new(&outer)); assert!( proposals.candidates.is_empty(), "no outer sketch may be proposed over an approximate child without a rule: {:?}", @@ -10848,7 +10879,7 @@ mod tests { } // Fallback keeps the whole sub-DAG pre-ASAP — executed exactly. let realized = realize_child(&outer, &DefaultCostModel).unwrap(); - assert!(matches!(realized.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!realized.contains_asap()); assert!(realized .guarantee .as_ref() @@ -10856,9 +10887,8 @@ mod tests { // Cross-metric: a quantile over a cardinality estimate. let inner = agg(vec![2], default_cardinality(), metric_scan(&["job"])); - let outer = Rc::new(agg(vec![], default_quantile(0.99), inner)); - let proposals = - SketchAlgorithmStrategy::default_cost_model().propose(&TargetSubDAG::new(&outer)); + let outer = agg(vec![], default_quantile(0.99), inner); + let proposals = ASAPStrategies::default_cost_model().propose(&TargetSubDAG::new(&outer)); assert!(proposals.candidates.is_empty()); assert!(proposals.rejected.iter().all(|r| matches!( &r.error, @@ -10870,14 +10900,14 @@ mod tests { #[test] fn exact_child_contributes_zero_error() { // quantile(0.9, sum by (job) (m)): KLL over an exact Sum accumulator - // — the readout's guarantee is exactly KLL's own local guarantee. + // — the evaluation's guarantee is exactly KLL's own local guarantee. let inner = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let outer = agg(vec![], default_quantile(0.9), inner); let root = realize(&outer).unwrap(); let guarantee = root .guarantee .as_ref() - .expect("a readout carries a guarantee"); + .expect("a evaluation carries a guarantee"); assert_eq!(guarantee.metric, ErrorMetric::Rank); assert_eq!( guarantee.bound.evaluate(), @@ -10894,7 +10924,7 @@ mod tests { ))); // The sketch *state* node carries no guarantee; the exact // accumulator's state is its value and does. - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { panic!() }; assert!(summary_input.guarantee.is_none()); @@ -10905,16 +10935,19 @@ mod tests { } #[test] - fn exact_sum_can_consume_an_approximate_readout() { + fn exact_sum_can_consume_an_approximate_evaluation() { // sum(count_distinct by (job) (m)) is an outer exact summary over - // the inner HLL readout. Both summary levels remain explicit. + // the inner HLL evaluation. Both summary levels remain explicit. let inner = agg(vec![2], default_cardinality(), metric_scan(&["job"])); let outer = agg(vec![], AggIntent::Sum { col: None }, inner); let root = realize(&outer).unwrap(); - let SummaryExpr::SummaryAgg { child, .. } = &root.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &root.operator else { panic!("outer exact sum should remain a SummaryAgg") }; - assert!(matches!(child.expr, SummaryExpr::SummaryEstimate { .. })); + assert!(matches!( + child.operator, + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + )); assert!(root.guarantee.is_some()); // count(...) over the same child is exact: a row count does not @@ -10935,12 +10968,12 @@ mod tests { } #[test] - fn equal_split_allocation_supports_nested_summary_readouts() { + fn equal_split_allocation_supports_nested_summary_evaluations() { // A registered rank-additive rule and valid budget split make both // summary levels explicit while preserving the composed guarantee. let inner = agg(vec![2], quantile_eps(0.5, 0.1), metric_scan(&["job"])); - let outer = Rc::new(agg(vec![], quantile_eps(0.99, 0.1), inner)); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs( + let outer = agg(vec![], quantile_eps(0.99, 0.1), inner); + let strategy = ASAPStrategies::new_with_planning_inputs( &DefaultCostModel, &RankAdditiveModel, &EqualSplitAllocator, @@ -10949,13 +10982,15 @@ mod tests { assert!(!proposals.candidates.is_empty()); assert!(proposals.candidates.iter().all(|candidate| { - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { return false; }; - matches!(node.expr, SummaryExpr::SummaryEstimate { .. }) - && node.guarantee.as_ref().is_some_and(|guarantee| { - DefaultAccuracyModel.satisfies(guarantee, &AccuracyTarget::Epsilon(0.1)) - }) + matches!( + node.operator, + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + ) && node.guarantee.as_ref().is_some_and(|guarantee| { + DefaultAccuracyModel.satisfies(guarantee, &AccuracyTarget::Epsilon(0.1)) + }) })); } @@ -10964,9 +10999,9 @@ mod tests { // The same nested summary remains available through workload search // and global cost ranking. let inner = agg(vec![2], quantile_eps(0.5, 0.1), metric_scan(&["job"])); - let outer = Rc::new(agg(vec![], quantile_eps(0.99, 0.1), inner)); + let outer = agg(vec![], quantile_eps(0.99, 0.1), inner); let strategies: Vec> = - vec![Box::new(SketchAlgorithmStrategy::new_with_planning_inputs( + vec![Box::new(ASAPStrategies::new_with_planning_inputs( &DefaultCostModel, &RankAdditiveModel, &EqualSplitAllocator, @@ -10976,10 +11011,14 @@ mod tests { let group = space.candidates_for_target(root).unwrap(); assert!(!group.rejected.is_empty()); assert!(group.candidates.iter().all(|c| match &c.replacement { - Replacement::Summary(node) => node.guarantee.as_ref().is_some_and(|g| { - DefaultAccuracyModel.satisfies(g, &AccuracyTarget::Epsilon(0.1)) - }), - Replacement::Rewrite(_) => false, + // A summary candidate (old `Replacement::Summary`) contains an + // ASAP node; a logical rewrite (old `Replacement::Rewrite`) does not. + Replacement::SubDAG(node) if node.contains_asap() => { + node.guarantee.as_ref().is_some_and(|g| { + DefaultAccuracyModel.satisfies(g, &AccuracyTarget::Epsilon(0.1)) + }) + } + Replacement::SubDAG(_) => false, Replacement::ExactComposition(_) => false, })); let ranked = space.cost_sorted(&DefaultCostModel); @@ -10992,15 +11031,18 @@ mod tests { .unwrap() .chosen .expect("a nested summary candidate wins"); - let Replacement::Summary(node) = &chosen.replacement else { + let Replacement::SubDAG(node) = &chosen.replacement else { panic!() }; - assert!(matches!(node.expr, SummaryExpr::SummaryEstimate { .. })); + assert!(matches!( + node.operator, + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + )); } #[test] fn root_target_check_removes_candidates_before_cost_ranking() { - let q = Rc::new(agg(vec![2], default_quantile(0.99), metric_scan(&["job"]))); + let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); // A root target tighter than the node's own ε=0.01: every sketch // candidate misses it and is moved to `rejected`; nothing is left // for the cost model to rank. @@ -11014,7 +11056,7 @@ mod tests { assert!(group .candidates .iter() - .all(|c| matches!(c.replacement, Replacement::Rewrite(_)))); + .all(|c| matches!(&c.replacement, Replacement::SubDAG(n) if !n.contains_asap()))); assert!(group.rejected.iter().all(|r| matches!( r.error, AccuracyError::TargetNotSatisfied { target: AccuracyTarget::Epsilon(e), .. } if e == 0.001 @@ -11033,7 +11075,7 @@ mod tests { assert!(group .candidates .iter() - .any(|c| matches!(c.replacement, Replacement::Summary(_)))); + .any(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap()))); // An `Exact` root target admits only exact candidates. let space = search_workload_with_targets( @@ -11043,11 +11085,11 @@ mod tests { ); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); assert!(group.candidates.iter().all(|c| match &c.replacement { - Replacement::Summary(node) => node + Replacement::SubDAG(node) if node.contains_asap() => node .guarantee .as_ref() .is_some_and(ResultGuarantee::is_exact), - Replacement::Rewrite(_) => true, + Replacement::SubDAG(_) => true, Replacement::ExactComposition(_) => false, })); } @@ -11061,14 +11103,14 @@ mod tests { }, metric_scan(&["job"]), ); - let q = Rc::new(agg( + let q = agg( vec![], AggIntent::TopK { k: 10, accuracy: AccuracyTarget::Epsilon(0.01), }, inner, - )); + ); let space = search_workload_with_targets( vec![("q", Rc::clone(&q), Some(AccuracyTarget::Epsilon(0.01)))], &default_strategies(), @@ -11078,14 +11120,14 @@ mod tests { assert!(group.candidates.iter().any(|candidate| matches!( &candidate.replacement, - Replacement::Summary(node) if node.guarantee.as_ref().is_some_and(ResultGuarantee::has_unknown) + Replacement::SubDAG(node) if node.guarantee.as_ref().is_some_and(ResultGuarantee::has_unknown) ))); let candidate = group .candidates .iter() .find(|candidate| candidate.has_missing_accuracy_evidence()) .unwrap(); - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { unreachable!() }; let exported = asap_types::dag_export::export_summary(node); @@ -11109,13 +11151,13 @@ mod tests { fn scoped_hll_evidence_sizes_and_certifies_without_a_deployment_model() { use crate::accuracy::EstimatorContract; struct SourceEvidence { - expression: QueryExpr, + expression: OperatorNode, max_distinct: u32, } impl AccuracyEvidenceProvider for SourceEvidence { - fn estimator_contract(&self, expression: &QueryExpr) -> Option { + fn estimator_contract(&self, expression: &OperatorNode) -> Option { (expression == &self.expression).then_some(EstimatorContract::ClassicHll { - max_distinct_per_readout: self.max_distinct, + max_distinct_per_evaluation: self.max_distinct, }) } } @@ -11123,19 +11165,19 @@ mod tests { epsilon: 0.05, delta: 0.01, }; - let root = Rc::new(agg( + let root = agg( vec![], AggIntent::Cardinality { cols: vec![], accuracy: target.clone(), }, metric_scan(&[]), - )); + ); let evidence = SourceEvidence { expression: (*root).clone(), max_distinct: 128, }; - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -11145,7 +11187,7 @@ mod tests { let hll = candidates .iter() .find_map(|candidate| match &candidate.replacement { - Replacement::Summary(node) + Replacement::SubDAG(node) if summary_family_algorithm(node) == SketchAlgorithm::Hll => { Some(node) @@ -11155,13 +11197,13 @@ mod tests { .expect("HLL candidate"); assert!(DefaultAccuracyModel .satisfies(hll.guarantee.as_ref().expect("HLL confidence"), &target)); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &hll.expr else { - panic!("readout") + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &hll.operator else { + panic!("evaluation") }; - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } = &summary_input.expr + }) = &summary_input.operator else { panic!("HLL state") }; @@ -11175,9 +11217,8 @@ mod tests { precision: expected } ); - let absent = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); - assert!(!absent.iter().any(|candidate| matches!(&candidate.replacement, Replacement::Summary(node) + let absent = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + assert!(!absent.iter().any(|candidate| matches!(&candidate.replacement, Replacement::SubDAG(node) if summary_family_algorithm(node) == SketchAlgorithm::Hll && node.guarantee.as_ref().is_some_and(|g| DefaultAccuracyModel.satisfies(g, &target))))); // Invalid contracts, infeasible targets and evidence for another source // must never authorize a confidence-bearing HLL candidate. @@ -11191,30 +11232,30 @@ mod tests { epsilon: 0.05, delta, }; - let query = Rc::new(agg( + let query = agg( vec![], AggIntent::Cardinality { cols: vec![], accuracy: target.clone(), }, metric_scan(&[]), - )); + ); let evidence = SourceEvidence { expression: if wrong_scope { - metric_scan(&["other"]) + (*metric_scan(&["other"])).clone() } else { (*query).clone() }, max_distinct, }; - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &evidence, ); assert!(!strategy.replacements(&TargetSubDAG::new(&query)).iter().any(|candidate| - matches!(&candidate.replacement, Replacement::Summary(node) + matches!(&candidate.replacement, Replacement::SubDAG(node) if summary_family_algorithm(node) == SketchAlgorithm::Hll && node.guarantee.as_ref().is_some_and(|g| DefaultAccuracyModel.satisfies(g, &target))))); } } @@ -11222,40 +11263,42 @@ mod tests { // A value projection cannot consume an opaque exact accumulator edge. #[test] fn residual_projection_finalizes_selected_exact_state() { - let inner = Rc::new(agg(vec![], AggIntent::Sum { col: None }, metric_scan(&[]))); - let root = Rc::new(QueryExpr::Project { - cols: vec![asap_types::pre_asap::ProjectItem { - expr: QueryExpr::Column(0), - alias: Some("result".into()), - }], - qualifier: None, - child: inner.clone(), - }); + let inner = agg(vec![], AggIntent::Sum { col: None }, metric_scan(&[])); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { + cols: vec![ProjectItem { + expr: ScalarExpr::Column(0), + alias: Some("result".into()), + }], + qualifier: None, + child: inner.clone(), + })) + .unwrap(); let space = search_workload_with_targets( vec![("q", root.clone(), Some(AccuracyTarget::Exact))], &default_strategies(), &DefaultAccuracyModel, ); let selected = space.global_selection(&DefaultCostModel); + // CSE re-interns the workload, so the space's root/child `Rc`s are not + // the fixture's. Assembly only assembles children that are discovered + // targets, so seed the memo under the space's own child pointer. + let root = Rc::clone(&space.roots[0].1); + let Some(NonASAPOp::Project { child: inner, .. }) = root.non_asap() else { + unreachable!() + }; + assert!(space.candidates_for_target(inner).is_some()); selected .assembled_nodes .borrow_mut() - .insert(Rc::as_ptr(&inner), realize(inner.as_ref()).unwrap()); + .insert(Rc::as_ptr(inner), realize(inner.as_ref()).unwrap()); let node = selected.assemble_target(&root).unwrap(); - let SummaryExpr::ValueOperation { - child, - operation: ValueOperation::Project { .. }, - .. - } = &node.expr - else { + let Operator::NonASAP(NonASAPOp::Project { child, .. }) = &node.operator else { panic!("expected Project"); }; assert!(matches!( - child.expr, - SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - .. - } + child.operator, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) )); assert!(child .schema @@ -11267,18 +11310,16 @@ mod tests { #[test] fn ranking_uses_aggregate_output_position_not_first_numeric_column() { let logical = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["id"])); - let mut values = lift(&logical.output_schema().unwrap()); + let mut values = logical.schema.clone(); values.fields[0].dtype = FieldDataType::Plain(DataType::Int64); assert_eq!(ranking_score_index(&logical, &values).unwrap(), 1); } // A heap's key schema is derived from its encoded item, not all label columns. #[test] - fn heap_readout_preserves_numeric_item_identity() { - let mut raw = metric_scan(&["id", "description"]); - let QueryExpr::Scan { schema, .. } = &mut raw else { - unreachable!() - }; + fn heap_evaluation_preserves_numeric_item_identity() { + let mut schema = metric_scan(&["id", "description"]).schema.clone(); schema.fields[2].dtype = FieldDataType::Plain(DataType::Int64); + let raw = crate::test_support::scan("m", schema); let node = agg( vec![], AggIntent::TopK { @@ -11288,7 +11329,7 @@ mod tests { agg(vec![2], AggIntent::Sum { col: None }, raw.clone()), ); let input = PhysicalSummaryInput { - child: Rc::new(raw), + child: raw, input: SummaryUpdate { item: Some(SummaryInputExpr::Column(ColumnRef::Named("id".into()))), weight: SummaryInputExpr::Constant(1.0), @@ -11297,7 +11338,7 @@ mod tests { }, }, }; - let schema = keyed_heap_readout_schema(&input, &node).unwrap(); + let schema = keyed_heap_evaluation_schema(&input, &node).unwrap(); assert_eq!( schema .fields @@ -11311,4 +11352,44 @@ mod tests { FieldDataType::Plain(DataType::Int64) ); } + + // Every SummaryAgg a strategy proposes derives coverage of its whole + // source: an unrestricted selection over a definition reading that source. + #[test] + fn proposed_summary_states_cover_their_whole_source() { + let root = agg( + vec![], + AggIntent::Quantile { + q: 0.9, + col: None, + accuracy: AccuracyTarget::Epsilon(0.01), + }, + metric_scan(&["job"]), + ); + let source = Source::TimeSeries { metric: "m".into() }; + let proposals = ASAPStrategies::default_cost_model().propose(&TargetSubDAG::new(&root)); + let states: Vec<_> = proposals + .candidates + .iter() + .filter_map(|candidate| match &candidate.replacement { + Replacement::SubDAG(node) => Some(node), + _ => None, + }) + .flat_map(OperatorNode::reachable) + .filter(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. }))) + .collect(); + assert!(!states.is_empty()); + for state in states { + let coverage = state.coverage().expect("summary state has coverage"); + assert_eq!(coverage.selection, [Default::default()]); + let reads = OperatorNode::reachable(&coverage.definition) + .into_iter() + .filter_map(|node| match node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => Some(source.clone()), + _ => None, + }) + .collect::>(); + assert_eq!(reads, std::slice::from_ref(&source)); + } + } } diff --git a/crates/asap-aware-mapping/src/rewrite.rs b/crates/asap-aware-mapping/src/rewrite.rs index 94a3d638c..ca752a971 100644 --- a/crates/asap-aware-mapping/src/rewrite.rs +++ b/crates/asap-aware-mapping/src/rewrite.rs @@ -35,7 +35,7 @@ //! - **`without(...)` grouping** leaves an `Aggregate`'s own output schema //! *open* (`closed: false`, see `without_output_schema`), while the //! `Project` this strategy always wraps the rewrite in forces -//! `closed: true` (see `QueryExpr::output_schema`'s `Project` arm). Under +//! `closed: true` (see `NonASAPOp::output_schema`'s `Project` arm). Under //! `without(...)` the rewritten form's `closed` flag would silently flip //! relative to the original — exactly the kind of schema drift this //! module exists to avoid. @@ -43,7 +43,7 @@ //! Both are follow-ups (issue #253 itself scopes to "the concrete case in //! Peilin's comment"), not correctness bugs in what ships here — a node //! outside this scope simply doesn't `match`, the same "safe but -//! uninformative" fallback [`SketchAlgorithmStrategy`]/[`SharedSubDAGStrategy`] +//! uninformative" fallback [`ASAPStrategies`]/[`SharedSubDAGStrategy`] //! already use for shapes they don't have an opinion on. //! //! ## Non-goals (mirrors [`replacement`]'s own discipline) @@ -57,14 +57,15 @@ //! the rewritten form is actually worth picking, by letting the original //! and rewritten forms compete on cost — not this strategy. +use asap_types::ir::non_asap::any_measure_filtered; use std::rc::Rc; +use asap_types::ir::operator_properties::{BinaryOpKind, Reduction}; +use asap_types::ir::{BinaryOperator, NonASAPOp, OperatorNode, ProjectItem, ScalarExpr}; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::expr_ir::ArithmeticOpKind; -use asap_types::pre_asap::query_expr::{ - any_measure_filtered, BinaryOpKind, ProjectItem, QueryExpr, Reduction, -}; use asap_types::pre_asap::schema::{ColumnId, DataType}; + use asap_types::types::AccuracyTarget; use crate::replacement::{Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG}; @@ -74,15 +75,35 @@ use crate::replacement::{Replacement, ReplacementStrategy, ReplacementSubDAG, Ta /// the module docs' "Scope" for why `without(...)`/`PerEntity` are /// excluded). Returns the grouping key count and the summed column so /// [`build_rewrite`] doesn't have to re-match. -fn avg_rewrite_target(node: &QueryExpr) -> Option<(usize, Option)> { - let QueryExpr::Aggregate { +/// `a / b` with PromQL arithmetic semantics and no vector matching. +fn arithmetic( + op: ArithmeticOpKind, + lhs: Rc, + rhs: Rc, +) -> Option> { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: BinaryOpKind::Arithmetic(op), + vector_match: None, + }, + return_bool: false, + lhs, + rhs, + })) + .ok() +} + +fn avg_rewrite_target(node: &OperatorNode) -> Option<(usize, Option)> { + let Some(NonASAPOp::Aggregate { reduction, measures, filters, having: None, child, .. - } = node + }) = node.non_asap() else { return None; }; @@ -102,7 +123,7 @@ fn avg_rewrite_target(node: &QueryExpr) -> Option<(usize, Option)> { // therefore be decomposed through it only when the averaged input is // provably non-null; otherwise NULL rows would incorrectly contribute to // the denominator. - let input_schema = child.output_schema().ok()?; + let input_schema = &child.schema; let value_col = col .or_else(|| input_schema.column_id("value")) .or_else(|| (0..input_schema.fields.len()).find(|i| !by.contains(i)))?; @@ -129,21 +150,21 @@ fn avg_rewrite_target(node: &QueryExpr) -> Option<(usize, Option)> { /// exactly regardless of the summed column's own type (integer division /// would otherwise silently reappear whenever the input column is itself /// integer-typed: `Sum`'s output type tracks its input, `Count`'s is always -/// `Int64`, and `QueryExpr::output_schema`'s own `Arithmetic` type inference +/// `Int64`, and `ScalarExpr::scalar_type`'s own `Arithmetic` type inference /// types a `Div` of two `Int64` operands as `Int64` — the explicit operand /// `Cast` is what keeps both the division and rewritten `avg` column /// `Float64` the way the original always was, not an incidental extra step). // These are conditional physical components, never an unconditional Rewrite. // The caller must attach the finite-division execution guard before admission. -pub(crate) fn temporal_average_components(root: &Rc) -> Option> { - let QueryExpr::Aggregate { +pub(crate) fn temporal_average_components(root: &Rc) -> Option> { + let Some(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures, filters, child, having: None, .. - } = root.as_ref() + }) = root.non_asap() else { return None; }; @@ -153,10 +174,10 @@ pub(crate) fn temporal_average_components(root: &Rc) -> Option) -> Option) -> Option> { +fn build_rewrite(root: &Rc) -> Option> { let (group_count, col) = avg_rewrite_target(root)?; - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction, output_names, child, .. - } = root.as_ref() + }) = root.non_asap() else { unreachable!("avg_rewrite_target already confirmed an Aggregate shape"); }; // The original `avg` column's own name: `output_names[0]` if the // producing front end overrode it (SQL threading DataFusion's own - // generated name — see `QueryExpr::Aggregate::output_names`'s docs), + // generated name — see `NonASAPOp::Aggregate::output_names`'s docs), // else `AggIntent::Avg`'s synthetic default. Either way this is the // *only* thing about the original output column this rewrite needs to // reproduce — `AggIntent::Avg::output_column`'s `(Float64, nullable: @@ -210,77 +231,78 @@ fn build_rewrite(root: &Rc) -> Option> { .cloned() .unwrap_or_else(|| "avg".to_string()); - let sum_agg = Rc::new(QueryExpr::Aggregate { - reduction: reduction.clone(), - measures: vec![AggIntent::Sum { col }], - output_names: Vec::new(), - filters: vec![], - having: None, - child: Rc::clone(child), - }); - let count_agg = Rc::new(QueryExpr::Aggregate { - reduction: reduction.clone(), - measures: vec![AggIntent::Count { - accuracy: AccuracyTarget::Exact, - }], - output_names: Vec::new(), - filters: vec![], - having: None, - child: Rc::clone(child), - }); + let sum_agg = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: reduction.clone(), + measures: vec![AggIntent::Sum { col }], + output_names: Vec::new(), + filters: vec![], + having: None, + child: Rc::clone(child), + })) + .ok()?; + let count_agg = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: reduction.clone(), + measures: vec![AggIntent::Count { + accuracy: AccuracyTarget::Exact, + }], + output_names: Vec::new(), + filters: vec![], + having: None, + child: Rc::clone(child), + })) + .ok()?; let sum_idx = group_count; let mut cols: Vec = (0..group_count) .map(|i| ProjectItem { alias: None, - expr: QueryExpr::Column(i), + expr: ScalarExpr::Column(i), }) .collect(); cols.push(ProjectItem { alias: Some(avg_name), - expr: QueryExpr::Cast { - expr: Rc::new(QueryExpr::Column(sum_idx)), + expr: ScalarExpr::Cast { + expr: Box::new(ScalarExpr::Column(sum_idx)), to: DataType::Float64, try_cast: false, }, }); - let float_sum = Rc::new(QueryExpr::Project { - cols, - qualifier: None, - child: sum_agg, - }); - Some(Rc::new(QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), - lhs: float_sum, - rhs: count_agg, - vector_match: None, - })) + let float_sum = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { + cols, + qualifier: None, + child: sum_agg, + })) + .ok()?; + arithmetic(ArithmeticOpKind::Div, float_sum, count_agg) } /// Compose adjacent per-entity and cross-entity accumulators when their /// algebra, rather than a query-language spelling, proves equivalence. -pub(crate) fn composed_aggregate_rewrite(root: &Rc) -> Option> { - let original_schema = root.output_schema().ok()?; - let QueryExpr::Aggregate { +pub(crate) fn composed_aggregate_rewrite(root: &Rc) -> Option> { + let original_schema = &root.schema; + let Some(NonASAPOp::Aggregate { reduction: outer_reduction @ Reduction::Reduce(_), measures: outer_measures, output_names, filters: outer_filters, having: None, child, - } = root.as_ref() + }) = root.non_asap() else { return None; }; - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures: inner_measures, filters: inner_filters, having: None, child: inner_child, .. - } = child.as_ref() + }) = child.non_asap() else { return None; }; @@ -297,14 +319,16 @@ pub(crate) fn composed_aggregate_rewrite(root: &Rc) -> Option inner.clone(), _ => return None, }; - let aggregate = Rc::new(QueryExpr::Aggregate { - reduction: outer_reduction.clone(), - measures: vec![composed], - output_names: output_names.clone(), - filters: vec![], - having: None, - child: Rc::clone(inner_child), - }); + let aggregate = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: outer_reduction.clone(), + measures: vec![composed], + output_names: output_names.clone(), + filters: vec![], + having: None, + child: Rc::clone(inner_child), + })) + .ok()?; // The outer Sum sees PromQL's Float64 sample value, whereas the composed // Count accumulator is Int64. Keep the original observable type. @@ -321,7 +345,7 @@ pub(crate) fn composed_aggregate_rewrite(root: &Rc) -> Option = (0..by.keys().len()) .map(|i| ProjectItem { alias: None, - expr: QueryExpr::Column(i), + expr: ScalarExpr::Column(i), }) .collect(); cols.push(ProjectItem { @@ -332,17 +356,18 @@ pub(crate) fn composed_aggregate_rewrite(root: &Rc) -> Option) -> Option) -> Option QueryExpr { - 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)), - ); - QueryExpr::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index(columns, 0, vec![]), - } + use crate::test_support::metric_scan; + use asap_types::ir::TimeRangeKind; + + fn avg_agg( + by: Vec, + col: Option, + child: Rc, + ) -> Rc { + avg_agg_with(by, col, vec![], None, child) } - fn avg_agg(by: Vec, col: Option, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { + fn avg_agg_with( + by: Vec, + col: Option, + output_names: Vec, + having: Option, + child: Rc, + ) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(by), measures: vec![AggIntent::Avg { col }], - output_names: vec![], + output_names, filters: vec![], - having: None, - child: Rc::new(child), - } + having, + child, + })) + .unwrap() } // Temporal averages expose two single-measure children without closing labels. #[test] fn temporal_average_components_preserves_schema_and_exposes_sum_count() { - let root = Rc::new(lower_promql( - "avg_over_time(a{job=\"api\"}[5m])", - AccuracyTarget::Exact, - )); + let root = lower_promql("avg_over_time(a{job=\"api\"}[5m])", AccuracyTarget::Exact); assert!(SemanticEquivalentRewriteStrategy .replacements(&TargetSubDAG::new(&root)) .is_empty()); let rewritten = temporal_average_components(&root).expect("conditional sum/count components"); - assert_eq!( - root.output_schema().unwrap(), - rewritten.output_schema().unwrap() - ); - assert!(matches!(rewritten.as_ref(), QueryExpr::BinaryOp { .. })); + assert_eq!(root.schema.clone(), rewritten.schema.clone()); + assert!(matches!( + rewritten.non_asap(), + Some(NonASAPOp::BinaryOp { .. }) + )); } // ── matches ────────────────────────────────────────────────────────── #[test] fn matches_a_bare_avg_aggregate() { - let q = Rc::new(avg_agg(vec![], None, metric_scan(&[]))); + let q = avg_agg(vec![], None, metric_scan(&[])); let target = TargetSubDAG::new(&q); assert!(AvgToSumOverCountStrategy.matches(&target)); } #[test] fn matches_a_grouped_avg_aggregate() { - let q = Rc::new(avg_agg(vec![2], None, metric_scan(&["job"]))); + let q = avg_agg(vec![2], None, metric_scan(&["job"])); let target = TargetSubDAG::new(&q); assert!(AvgToSumOverCountStrategy.matches(&target)); } #[test] fn does_not_match_a_multi_measure_aggregate() { - let q = Rc::new(QueryExpr::Aggregate { + let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![2]), measures: vec![AggIntent::Sum { col: None }, AggIntent::Avg { col: None }], output_names: vec![], filters: vec![], having: None, - child: Rc::new(metric_scan(&["job"])), - }); + child: metric_scan(&["job"]), + })) + .unwrap(); let target = TargetSubDAG::new(&q); assert!(!AvgToSumOverCountStrategy.matches(&target)); assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); @@ -494,13 +518,15 @@ mod tests { #[test] fn does_not_match_a_having_bearing_avg_aggregate() { - let mut q = avg_agg(vec![2], None, metric_scan(&["job"])); - if let QueryExpr::Aggregate { having, .. } = &mut q { - *having = Some(asap_types::pre_asap::query_expr::Predicate(Rc::new( - QueryExpr::Literal(asap_types::pre_asap::expr_ir::ScalarValue::Boolean(true)), - ))); - } - let q = Rc::new(q); + let q = avg_agg_with( + vec![2], + None, + vec![], + Some(asap_types::ir::Predicate(ScalarExpr::Literal( + asap_types::pre_asap::expr_ir::ScalarValue::Boolean(true), + ))), + metric_scan(&["job"]), + ); let target = TargetSubDAG::new(&q); assert!(!AvgToSumOverCountStrategy.matches(&target)); assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); @@ -515,14 +541,16 @@ mod tests { }, AggIntent::Min { col: None }, ] { - let q = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![intent.clone()], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(metric_scan(&["job"])), - }); + let q = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![2]), + measures: vec![intent.clone()], + output_names: vec![], + filters: vec![], + having: None, + child: metric_scan(&["job"]), + })) + .unwrap(); let target = TargetSubDAG::new(&q); assert!( !AvgToSumOverCountStrategy.matches(&target), @@ -534,16 +562,17 @@ mod tests { #[test] fn does_not_match_a_without_grouped_avg_aggregate() { - let q = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::Reduce(asap_types::pre_asap::query_expr::GroupKeys::without( + let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::Reduce(asap_types::ir::operator_properties::GroupKeys::without( vec![2], )), measures: vec![AggIntent::Avg { col: None }], output_names: vec![], filters: vec![], having: None, - child: Rc::new(metric_scan(&["job"])), - }); + child: metric_scan(&["job"]), + })) + .unwrap(); let target = TargetSubDAG::new(&q); assert!(!AvgToSumOverCountStrategy.matches(&target)); assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); @@ -551,14 +580,15 @@ mod tests { #[test] fn does_not_match_a_per_entity_avg_aggregate() { - let q = Rc::new(QueryExpr::Aggregate { + let q = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures: vec![AggIntent::Avg { col: None }], output_names: vec![], filters: vec![], having: None, - child: Rc::new(metric_scan(&[])), - }); + child: metric_scan(&[]), + })) + .unwrap(); let target = TargetSubDAG::new(&q); assert!(!AvgToSumOverCountStrategy.matches(&target)); assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); @@ -566,7 +596,7 @@ mod tests { #[test] fn does_not_match_a_non_aggregate_node() { - let scan = Rc::new(metric_scan(&["job"])); + let scan = metric_scan(&["job"]); let target = TargetSubDAG::new(&scan); assert!(!AvgToSumOverCountStrategy.matches(&target)); assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); @@ -579,7 +609,7 @@ mod tests { #[test] fn avg_rewrites_and_schema_matches_exactly_when_ungrouped() { let original = avg_agg(vec![], None, metric_scan(&[])); - let original_rc = Rc::new(original.clone()); + let original_rc = Rc::clone(&original); let target = TargetSubDAG::new(&original_rc); let replacements = AvgToSumOverCountStrategy.replacements(&target); @@ -587,28 +617,26 @@ mod tests { assert!(!replacements[0].rationale.is_empty()); let rewritten = match &replacements[0].replacement { - Replacement::Rewrite(rc) => rc, + Replacement::SubDAG(rc) => rc, other => panic!("expected a Rewrite replacement, got {other:?}"), }; - let QueryExpr::BinaryOp { lhs, rhs, .. } = rewritten.as_ref() else { + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = rewritten.non_asap() else { panic!("expected sum/count BinaryOp, got {rewritten:?}"); }; - let QueryExpr::Project { child: sum, .. } = lhs.as_ref() else { + let Some(NonASAPOp::Project { child: sum, .. }) = lhs.non_asap() else { panic!("expected cast Project above Sum, got {lhs:?}"); }; - assert!(matches!( - sum.as_ref(), - QueryExpr::Aggregate { measures, .. } + assert!(matches!(sum.non_asap(), + Some(NonASAPOp::Aggregate { measures, .. }) if matches!(measures.as_slice(), [AggIntent::Sum { col: None }]) )); - assert!(matches!( - rhs.as_ref(), - QueryExpr::Aggregate { measures, .. } + assert!(matches!(rhs.non_asap(), + Some(NonASAPOp::Aggregate { measures, .. }) if matches!(measures.as_slice(), [AggIntent::Count { accuracy: AccuracyTarget::Exact }]) )); - let original_schema = original.output_schema().unwrap(); - let rewritten_schema = rewritten.output_schema().unwrap(); + let original_schema = original.schema.clone(); + let rewritten_schema = rewritten.schema.clone(); assert_eq!( original_schema, rewritten_schema, "the rewritten DAG must report exactly the same output schema as the original avg" @@ -620,20 +648,22 @@ mod tests { /// synthetic `"avg"` default. #[test] fn preserves_an_explicit_output_name_override() { - let mut q = avg_agg(vec![], None, metric_scan(&[])); - if let QueryExpr::Aggregate { output_names, .. } = &mut q { - *output_names = vec!["avg_latency".to_string()]; - } - let original_schema = q.output_schema().unwrap(); - let q = Rc::new(q); + let q = avg_agg_with( + vec![], + None, + vec!["avg_latency".to_string()], + None, + metric_scan(&[]), + ); + let original_schema = q.schema.clone(); let target = TargetSubDAG::new(&q); let replacements = AvgToSumOverCountStrategy.replacements(&target); let rewritten = match &replacements[0].replacement { - Replacement::Rewrite(rc) => rc, + Replacement::SubDAG(rc) => rc, other => panic!("expected a Rewrite replacement, got {other:?}"), }; - let rewritten_schema = rewritten.output_schema().unwrap(); + let rewritten_schema = rewritten.schema.clone(); assert_eq!(original_schema, rewritten_schema); assert_eq!(rewritten_schema.fields[0].name, "avg_latency"); } @@ -643,23 +673,23 @@ mod tests { #[test] fn grouped_avg_rewrite_preserves_the_whole_schema() { let original = avg_agg(vec![2], None, metric_scan(&["job"])); - let original_schema = original.output_schema().unwrap(); - let original_rc = Rc::new(original); + let original_schema = original.schema.clone(); + let original_rc = Rc::clone(&original); let target = TargetSubDAG::new(&original_rc); let replacements = AvgToSumOverCountStrategy.replacements(&target); let rewritten = match &replacements[0].replacement { - Replacement::Rewrite(rc) => rc, + Replacement::SubDAG(rc) => rc, other => panic!("expected a Rewrite replacement, got {other:?}"), }; - let rewritten_schema = rewritten.output_schema().unwrap(); + let rewritten_schema = rewritten.schema.clone(); assert_eq!(rewritten_schema, original_schema); } #[test] fn default_search_discovers_bindable_sum_and_count_targets() { - let root = Rc::new(avg_agg(vec![2], None, metric_scan(&["job"]))); + let root = avg_agg(vec![2], None, metric_scan(&["job"])); let space = crate::replacement::search_workload(vec![("avg", Rc::clone(&root))]); let avg_group = space @@ -672,7 +702,7 @@ mod tests { let mut found_sum = false; let mut found_count = false; for group in space.target_subdag_candidates() { - let QueryExpr::Aggregate { measures, .. } = group.target.as_ref() else { + let Some(NonASAPOp::Aggregate { measures, .. }) = group.target.non_asap() else { continue; }; let expected = matches!(measures.as_slice(), [AggIntent::Sum { .. }]) @@ -689,7 +719,8 @@ mod tests { group .candidates .iter() - .any(|candidate| matches!(candidate.replacement, Replacement::Summary(_))), + .any(|candidate| matches!(&candidate.replacement, + Replacement::SubDAG(node) if node.contains_asap())), "rewritten accumulator must be independently bindable: {measures:?}" ); found_sum |= matches!(measures.as_slice(), [AggIntent::Sum { .. }]); @@ -710,25 +741,26 @@ mod tests { Field::plain("job", DataType::Utf8, true), Field::plain("bytes", DataType::Int64, false), ]; - let child = QueryExpr::Scan { + let child = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: { let cols = std::mem::take(&mut schema_cols); Schema::with_time_index(cols, 0, vec![]) }, - }; + })) + .unwrap(); let original = avg_agg(vec![1], Some(2), child); - let original_schema = original.output_schema().unwrap(); - let original_rc = Rc::new(original); + let original_schema = original.schema.clone(); + let original_rc = Rc::clone(&original); let target = TargetSubDAG::new(&original_rc); let replacements = AvgToSumOverCountStrategy.replacements(&target); let rewritten = match &replacements[0].replacement { - Replacement::Rewrite(rc) => rc, + Replacement::SubDAG(rc) => rc, other => panic!("expected a Rewrite replacement, got {other:?}"), }; - let rewritten_schema = rewritten.output_schema().unwrap(); + let rewritten_schema = rewritten.schema.clone(); // The whole reason for the explicit `Cast` in `build_rewrite`: an // `Int64` input column (`bytes`) makes `Sum`'s own output `Int64` @@ -741,15 +773,15 @@ mod tests { DataType::Float64 ); - let QueryExpr::BinaryOp { lhs, .. } = rewritten.as_ref() else { + let Some(NonASAPOp::BinaryOp { lhs, .. }) = rewritten.non_asap() else { panic!("expected sum/count BinaryOp"); }; - let QueryExpr::Project { cols, .. } = lhs.as_ref() else { + let Some(NonASAPOp::Project { cols, .. }) = lhs.non_asap() else { panic!("expected cast Project above Sum"); }; assert!(matches!( &cols.last().unwrap().expr, - QueryExpr::Cast { + ScalarExpr::Cast { to: DataType::Float64, .. } @@ -758,7 +790,7 @@ mod tests { #[test] fn does_not_rewrite_avg_of_a_nullable_column_via_count_star() { - let child = QueryExpr::Scan { + let child = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: Schema::with_time_index( @@ -770,27 +802,34 @@ mod tests { 0, vec![], ), - }; - let q = Rc::new(avg_agg(vec![], Some(2), child)); + })) + .unwrap(); + let q = avg_agg(vec![], Some(2), child); let target = TargetSubDAG::new(&q); assert!(!AvgToSumOverCountStrategy.matches(&target)); assert!(AvgToSumOverCountStrategy.replacements(&target).is_empty()); } - fn nested_aggregate(outer: AggIntent, inner: AggIntent) -> Rc { - let temporal = QueryExpr::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![inner], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["service"])), - }), - }; - Rc::new(QueryExpr::Aggregate { + fn nested_aggregate(outer: AggIntent, inner: AggIntent) -> Rc { + let temporal = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::PerEntity, + measures: vec![inner], + output_names: vec![], + filters: vec![], + having: None, + child: OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::TimeRange { + kind: TimeRangeKind::Range, + range: Duration::from_secs(300), + child: metric_scan(&["service"]), + }, + )) + .unwrap(), + })) + .unwrap(); + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![2]), measures: vec![outer], // Match the PromQL front end: an empty entry selects the intent's @@ -798,8 +837,9 @@ mod tests { output_names: vec![String::new()], filters: vec![], having: None, - child: Rc::new(temporal), - }) + child: temporal, + })) + .unwrap() } #[test] @@ -820,34 +860,30 @@ mod tests { let [candidate] = candidates.as_slice() else { panic!("supported pair should produce exactly one rewrite") }; - let Replacement::Rewrite(rewritten) = &candidate.replacement else { + let Replacement::SubDAG(rewritten) = &candidate.replacement else { panic!("expected a logical rewrite") }; - assert_eq!( - original.output_schema().unwrap(), - rewritten.output_schema().unwrap() - ); - let aggregate = match rewritten.as_ref() { - QueryExpr::Aggregate { .. } => rewritten.as_ref(), - QueryExpr::Project { child, .. } => child.as_ref(), + assert_eq!(original.schema.clone(), rewritten.schema.clone()); + let aggregate = match rewritten.non_asap() { + Some(NonASAPOp::Aggregate { .. }) => rewritten.as_ref(), + Some(NonASAPOp::Project { child, .. }) => child.as_ref(), other => panic!("expected Aggregate or cast Project, got {other:?}"), }; - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), measures, child, .. - } = aggregate + }) = aggregate.non_asap() else { panic!("expected composed cross-entity aggregate") }; assert_eq!(by.keys(), &[2]); assert_eq!(measures, &[expected]); - assert!(matches!( - child.as_ref(), - QueryExpr::TimeRange { range, child } + assert!(matches!(child.non_asap(), + Some(NonASAPOp::TimeRange { range, child, .. }) if *range == Duration::from_secs(300) - && matches!(child.as_ref(), QueryExpr::Scan { .. }) + && matches!(child.non_asap(), Some(NonASAPOp::Scan { .. })) )); } } @@ -880,9 +916,9 @@ mod tests { .iter() .find(|candidate| candidate.strategy == "SemanticEquivalentRewriteStrategy") .expect("default search should run semantic rewrites"); - let Replacement::Rewrite(rewritten) = &candidate.replacement else { + let Replacement::SubDAG(rewritten) = &candidate.replacement else { panic!("expected logical rewrite") }; - assert_eq!(rewritten.output_schema().unwrap().fields[1].name, "sum"); + assert_eq!(rewritten.schema.clone().fields[1].name, "sum"); } } diff --git a/crates/asap-aware-mapping/src/rollup.rs b/crates/asap-aware-mapping/src/rollup.rs index ab6eff3d9..de96383c5 100644 --- a/crates/asap-aware-mapping/src/rollup.rs +++ b/crates/asap-aware-mapping/src/rollup.rs @@ -51,7 +51,7 @@ //! here, so it is deliberately left unhandled (see [`rollup_combinator`]). //! In practice this never matters: `Rate`/`Increase` are constructed with //! `Reduction::PerEntity` (per-series, no `by` at all — see -//! `AggIntent::is_per_series` and `asap_frontend_promql::promql::reduction_for`), +//! `AggIntent::is_per_series` and `asap_frontend_promql::unified::promql::reduction_for`), //! never `Reduction::Reduce`, so they never carry a `by` set for this //! strategy to compare in the first place; `PerEntity` nodes never match //! ([`bindable_grouped_aggregate`] returns `None` for them). `Increase`'s @@ -88,7 +88,7 @@ //! - **No materialized roll-up operator.** Actually building a pre-aggregated //! summary/scan leaf at execution time is separate, larger work outside //! `asap-aware-mapping`'s scope (see issue #254's own "Non-goal" section) -//! — this module only constructs the pre-ASAP [`QueryExpr::Aggregate`] +//! — this module only constructs the pre-ASAP `NonASAPOp::Aggregate` //! rewrite; a `CostModel`/search engine decides whether to prefer it. //! - **No cross-schema reconciliation** (see "`ColumnId` comparability" //! above) and **no `without(...)` grouping support** — `without`'s kept @@ -97,34 +97,37 @@ //! against a superset/subset relationship at all; [`is_legal_rollup_source`] //! declines both directions. +use asap_types::ir::non_asap::any_measure_filtered; use std::collections::HashSet; use std::rc::Rc; +use asap_types::ir::operator_properties::{GroupKeys, Reduction}; +use asap_types::ir::{NonASAPOp, OperatorNode}; use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::pre_asap::query_expr::{any_measure_filtered, GroupKeys, QueryExpr, Reduction}; use asap_types::pre_asap::schema::{ColumnId, Schema}; + use asap_types::types::AccuracyTarget; use crate::replacement::{Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG}; /// The `(by, intent, child)` shape this strategy operates on: a single /// measure, no `HAVING` — the same bindable shape -/// [`crate::replacement::SketchAlgorithmStrategy`] requires (see that module's +/// [`crate::replacement::ASAPStrategies`] requires (see that module's /// private `bindable_intent`) — **plus** a genuine [`Reduction::Reduce`] /// grouping to compare (not [`Reduction::PerEntity`], which has no `by` set /// at all). `None` for anything else, including a multi-measure or `HAVING` /// aggregate, a non-`Aggregate` node, or a `PerEntity` reduction. fn bindable_grouped_aggregate( - node: &QueryExpr, -) -> Option<(&GroupKeys, &AggIntent, &Rc)> { - let QueryExpr::Aggregate { + node: &OperatorNode, +) -> Option<(&GroupKeys, &AggIntent, &Rc)> { + let Some(NonASAPOp::Aggregate { reduction, measures, filters, having, child, .. - } = node + }) = node.non_asap() else { return None; }; @@ -263,7 +266,7 @@ fn is_strict_column_superset(finer: &[ColumnId], coarser: &[ColumnId]) -> bool { /// docs' "Non-goals" on why finding the full sibling set across a workload /// is a workload-wide traversal this strategy does not own. pub struct RollupStrategy { - siblings: Vec>, + siblings: Vec>, } impl RollupStrategy { @@ -271,7 +274,7 @@ impl RollupStrategy { /// each as a candidate roll-up source (or target) — typically the full set of `Aggregate` /// nodes a workload-wide discovery pass (issue #252) already found /// sharing at least one child `Rc` with something else. - pub fn new(siblings: &[Rc]) -> Self { + pub fn new(siblings: &[Rc]) -> Self { Self { siblings: siblings.to_vec(), } @@ -280,7 +283,7 @@ impl RollupStrategy { /// Every sibling that is a legal, strictly finer roll-up source for /// `target` — shared between `matches` and `replacements` so the two /// can never disagree about which siblings qualify. - fn finer_sources(&self, target: &TargetSubDAG<'_>) -> Vec<&Rc> { + fn finer_sources(&self, target: &TargetSubDAG<'_>) -> Vec<&Rc> { let Some((coarser_by, coarser_intent, coarser_child)) = bindable_grouped_aggregate(target.root) else { @@ -301,12 +304,9 @@ impl RollupStrategy { if !Rc::ptr_eq(finer_child, coarser_child) && finer_child != coarser_child { return false; } - let Ok(finer_schema) = candidate.output_schema() else { - return false; - }; is_legal_rollup_source( finer_by, - &finer_schema, + &candidate.schema, finer_intent, coarser_by, coarser_intent, @@ -325,7 +325,7 @@ impl ReplacementStrategy for RollupStrategy { let Some((coarser_by, coarser_intent, _)) = bindable_grouped_aggregate(target.root) else { return Vec::new(); }; - let QueryExpr::Aggregate { output_names, .. } = target.root.as_ref() else { + let Some(NonASAPOp::Aggregate { output_names, .. }) = target.root.non_asap() else { unreachable!("bindable_grouped_aggregate already confirmed Aggregate"); }; self.finer_sources(target) @@ -335,7 +335,7 @@ impl ReplacementStrategy for RollupStrategy { } } -/// Build the coarser replacement: a new `QueryExpr::Aggregate` grouped by +/// Build the coarser replacement: a new `NonASAPOp::Aggregate` grouped by /// `coarser_by`'s columns (repositioned into `finer`'s own output schema — /// see below), computing `rollup_combinator(intent, ..)` over `finer`'s own /// measure column, with `child = finer` instead of the original shared @@ -350,7 +350,7 @@ impl ReplacementStrategy for RollupStrategy { /// position in the shared child to its position in `finer`'s output: the /// index its `ColumnId` occupies within `finer_by`'s own ordered list. fn build_rollup( - finer: &Rc, + finer: &Rc, coarser_by: &GroupKeys, intent: &AggIntent, output_names: &[String], @@ -367,18 +367,20 @@ fn build_rollup( .map(|id| finer_by.keys().iter().position(|f| f == id)) .collect::>>()?; - let rewritten = QueryExpr::Aggregate { - reduction: Reduction::by(remapped_by), - measures: vec![combinator], - output_names: output_names.to_vec(), - filters: vec![], - having: None, - child: Rc::clone(finer), - }; + let rewritten = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(remapped_by), + measures: vec![combinator], + output_names: output_names.to_vec(), + filters: vec![], + having: None, + child: Rc::clone(finer), + })) + .ok()?; Some(ReplacementSubDAG { strategy: "RollupStrategy", - replacement: Replacement::Rewrite(Rc::new(rewritten)), + replacement: Replacement::SubDAG(rewritten), provenance: crate::replacement::ReplacementProvenance::LogicalRewrite, rationale: format!( "rolls up from the finer Aggregate grouped by {:?} (a strict superset of this \ @@ -394,13 +396,13 @@ fn build_rollup( #[cfg(test)] mod tests { use super::*; - use asap_types::pre_asap::query_expr::Source; + use asap_types::ir::operator_properties::Source; use asap_types::pre_asap::schema::{DataType, Field}; use asap_types::types::AccuracyTarget; /// `[ts(0), value(1), job(2), region(3)]`. - fn metric_scan() -> QueryExpr { - QueryExpr::Scan { + fn metric_scan() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: "m".into() }, predicates: vec![], schema: Schema::with_time_index( @@ -413,33 +415,36 @@ mod tests { 0, vec![], ), - } + })) + .unwrap() } - fn agg(by: Vec, intent: AggIntent, child: &Rc) -> Rc { - Rc::new(QueryExpr::Aggregate { + fn agg(by: Vec, intent: AggIntent, child: &Rc) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(by), measures: vec![intent], output_names: vec![], filters: vec![], having: None, child: Rc::clone(child), - }) + })) + .unwrap() } fn without_agg( excluded: Vec, intent: AggIntent, - child: &Rc, - ) -> Rc { - Rc::new(QueryExpr::Aggregate { + child: &Rc, + ) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::Reduce(GroupKeys::without(excluded)), measures: vec![intent], output_names: vec![], filters: vec![], having: None, child: Rc::clone(child), - }) + })) + .unwrap() } // ── is_legal_rollup_source (the standalone predicate) ─────────────── @@ -569,7 +574,7 @@ mod tests { #[test] fn superset_by_over_identical_mergeable_intent_and_shared_child_rolls_up() { - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); @@ -581,16 +586,16 @@ mod tests { let replacements = strategy.replacements(&target); assert_eq!(replacements.len(), 1, "{replacements:?}"); - let Replacement::Rewrite(rewritten) = &replacements[0].replacement else { + let Replacement::SubDAG(rewritten) = &replacements[0].replacement else { panic!("expected a Rewrite replacement"); }; - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction, measures, child, having, .. - } = rewritten.as_ref() + }) = rewritten.non_asap() else { panic!("expected an Aggregate rewrite, got {rewritten:?}"); }; @@ -620,7 +625,7 @@ mod tests { // Count is not self-combining (see the module docs) — the rewritten // measure must be Sum over the finer Count's own output column, not // Count reapplied. - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let fine = agg( vec![2, 3], AggIntent::Count { @@ -642,10 +647,10 @@ mod tests { let replacements = strategy.replacements(&target); assert_eq!(replacements.len(), 1, "{replacements:?}"); - let Replacement::Rewrite(rewritten) = &replacements[0].replacement else { + let Replacement::SubDAG(rewritten) = &replacements[0].replacement else { panic!("expected a Rewrite replacement"); }; - let QueryExpr::Aggregate { measures, .. } = rewritten.as_ref() else { + let Some(NonASAPOp::Aggregate { measures, .. }) = rewritten.non_asap() else { panic!("expected an Aggregate rewrite"); }; assert_eq!( @@ -657,7 +662,7 @@ mod tests { #[test] fn approximate_count_does_not_roll_up_via_sum() { - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let intent = AggIntent::Count { accuracy: AccuracyTarget::Epsilon(0.01), }; @@ -673,8 +678,8 @@ mod tests { #[test] fn default_workload_search_adds_rollup_for_two_query_workload() { - let fine_scan = Rc::new(metric_scan()); - let coarse_scan = Rc::new(metric_scan()); + let fine_scan = metric_scan(); + let coarse_scan = metric_scan(); let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &fine_scan); let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &coarse_scan); @@ -682,12 +687,11 @@ mod tests { let coarse_group = space .target_subdag_candidates() .find(|group| { - matches!( - group.target.as_ref(), - QueryExpr::Aggregate { + matches!(group.target.non_asap(), + Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), .. - } if by.keys() == [2] + }) if by.keys() == [2] ) }) .expect("coarser aggregate group"); @@ -696,19 +700,19 @@ mod tests { .candidates .iter() .find_map(|candidate| match &candidate.replacement { - Replacement::Rewrite(rewrite) => Some(rewrite), - Replacement::Summary(_) | Replacement::ExactComposition(_) => None, + // Old `Replacement::Rewrite`: a pure pre-ASAP sub-DAG. + Replacement::SubDAG(rewrite) if !rewrite.contains_asap() => Some(rewrite), + Replacement::SubDAG(_) | Replacement::ExactComposition(_) => None, }) .expect("default search must include the roll-up rewrite"); - let QueryExpr::Aggregate { child, .. } = rewrite.as_ref() else { + let Some(NonASAPOp::Aggregate { child, .. }) = rewrite.non_asap() else { panic!("expected aggregate rewrite, got {rewrite:?}"); }; - assert!(matches!( - child.as_ref(), - QueryExpr::Aggregate { + assert!(matches!(child.non_asap(), + Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), .. - } if by.keys() == [2, 3] + }) if by.keys() == [2, 3] )); } @@ -717,18 +721,17 @@ mod tests { let intent = AggIntent::Count { accuracy: AccuracyTarget::Epsilon(0.01), }; - let fine = agg(vec![2, 3], intent.clone(), &Rc::new(metric_scan())); - let coarse = agg(vec![2], intent, &Rc::new(metric_scan())); + let fine = agg(vec![2, 3], intent.clone(), &metric_scan()); + let coarse = agg(vec![2], intent, &metric_scan()); let space = crate::replacement::search_workload(vec![("fine", fine), ("coarse", coarse)]); let coarse_group = space .target_subdag_candidates() .find(|group| { - matches!( - group.target.as_ref(), - QueryExpr::Aggregate { + matches!(group.target.non_asap(), + Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), .. - } if by.keys() == [2] + }) if by.keys() == [2] ) }) .expect("coarser aggregate group"); @@ -736,32 +739,35 @@ mod tests { assert!(coarse_group .candidates .iter() - .all(|candidate| !matches!(candidate.replacement, Replacement::Rewrite(_)))); + .all(|candidate| !matches!(&candidate.replacement, + Replacement::SubDAG(rewrite) if !rewrite.contains_asap()))); } #[test] fn rollup_preserves_the_coarser_output_name() { - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); - let coarse = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![AggIntent::Sum { col: Some(1) }], - output_names: vec!["total_requests".into()], - filters: vec![], - having: None, - child: Rc::clone(&scan), - }); - let original_schema = coarse.output_schema().unwrap(); + let coarse = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![2]), + measures: vec![AggIntent::Sum { col: Some(1) }], + output_names: vec!["total_requests".into()], + filters: vec![], + having: None, + child: Rc::clone(&scan), + })) + .unwrap(); + let original_schema = coarse.schema.clone(); let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; let strategy = RollupStrategy::new(&siblings); let replacements = strategy.replacements(&TargetSubDAG::new(&coarse)); - let Replacement::Rewrite(rewritten) = &replacements[0].replacement else { + let Replacement::SubDAG(rewritten) = &replacements[0].replacement else { panic!("expected a Rewrite replacement"); }; - assert_eq!(rewritten.output_schema().unwrap(), original_schema); - let QueryExpr::Aggregate { output_names, .. } = rewritten.as_ref() else { + assert_eq!(rewritten.schema.clone(), original_schema); + let Some(NonASAPOp::Aggregate { output_names, .. }) = rewritten.non_asap() else { unreachable!(); }; assert_eq!(output_names, &vec!["total_requests".to_string()]); @@ -769,7 +775,7 @@ mod tests { #[test] fn non_mergeable_intent_does_not_roll_up() { - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let fine = agg(vec![2, 3], AggIntent::Avg { col: Some(1) }, &scan); let coarse = agg(vec![2], AggIntent::Avg { col: Some(1) }, &scan); @@ -789,12 +795,12 @@ mod tests { // numerically a superset of the coarser side's *kept* positions — // `is_legal_rollup_source` rejects any `without` grouping outright, // and would reject on the missing unique key regardless. - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let fine = without_agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); assert!( - !fine.output_schema().unwrap().has_unique_key(), + !fine.schema.clone().has_unique_key(), "fixture sanity: a without(...) aggregate has no provable unique key" ); @@ -809,7 +815,7 @@ mod tests { #[test] fn unrelated_by_sets_do_not_roll_up() { // Neither `[job]` nor `[region]` is a superset of the other. - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let a = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); let b = agg(vec![3], AggIntent::Sum { col: Some(1) }, &scan); @@ -830,7 +836,7 @@ mod tests { // Equal groupings are `SharedSubDAGStrategy`'s CSE-sharing // question (build once and share, or build independently) — a // roll-up requires a *strict* superset, not equality. - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let a = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); let b = agg(vec![2], AggIntent::Sum { col: Some(1) }, &scan); @@ -845,16 +851,8 @@ mod tests { // Scans without unique keys are deliberately not pointer-aliased by // CSE. Structural equality still proves identical schemas and makes // the two aggregates' positional ColumnIds comparable. - let fine = agg( - vec![2, 3], - AggIntent::Sum { col: Some(1) }, - &Rc::new(metric_scan()), - ); - let coarse = agg( - vec![2], - AggIntent::Sum { col: Some(1) }, - &Rc::new(metric_scan()), - ); + let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &metric_scan()); + let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &metric_scan()); let siblings = vec![Rc::clone(&fine), Rc::clone(&coarse)]; let strategy = RollupStrategy::new(&siblings); @@ -865,21 +863,23 @@ mod tests { #[test] fn does_not_match_a_multi_measure_or_having_aggregate() { - let scan = Rc::new(metric_scan()); + let scan = metric_scan(); let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &scan); - let multi = Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(vec![2]), - measures: vec![ - AggIntent::Sum { col: Some(1) }, - AggIntent::Count { - accuracy: AccuracyTarget::Exact, - }, - ], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::clone(&scan), - }); + let multi = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![2]), + measures: vec![ + AggIntent::Sum { col: Some(1) }, + AggIntent::Count { + accuracy: AccuracyTarget::Exact, + }, + ], + output_names: vec![], + filters: vec![], + having: None, + child: Rc::clone(&scan), + })) + .unwrap(); let siblings = vec![Rc::clone(&fine), Rc::clone(&multi)]; let strategy = RollupStrategy::new(&siblings); diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs index eac94a2cb..911d90275 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs @@ -1,7 +1,7 @@ use super::*; pub(super) fn estimate_heterogeneous_summary( - root: &SummaryNode, + root: &OperatorNode, deployments: &[CostedSummaryDeployment<'_>], evidence: &SummaryNodeEvidence, scope: &ComparisonScope, @@ -19,46 +19,6 @@ pub(super) fn estimate_heterogeneous_summary( .map(|(deployment, framework)| (deployment.summary as *const _, framework)) .collect(); validate_summary_edges_and_physical_ids(root, evidence, &frameworks_by_node)?; - fn summary_source_selections( - node: &SummaryNode, - seen: &mut HashSet<*const SummaryNode>, - out: &mut Vec, - ) -> Result<(), AnalyticalCostError> { - if !seen.insert(node as *const _) { - return Ok(()); - } - match &node.expr { - SummaryExpr::KeepPreAsap(query) => query_source_selections(query, out)?, - SummaryExpr::SummaryAgg { child, .. } | SummaryExpr::ValueOperation { child, .. } => { - summary_source_selections(child, seen, out)? - } - SummaryExpr::SummaryMerge { children, .. } => { - for child in children { - summary_source_selections(child, seen, out)?; - } - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => { - summary_source_selections(left, seen, out)?; - summary_source_selections(right, seen, out)?; - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - summary_source_selections(summary_input, seen, out)? - } - } - Ok(()) - } let evaluation_count = scope.validate()?; let by_node: HashMap<_, _> = deployments .iter() @@ -81,7 +41,7 @@ pub(super) fn estimate_heterogeneous_summary( let node_evidence = evidence .aggregation(deployment.summary) .ok_or(AnalyticalCostError::MissingOrStale("summary_agg"))?; - let SummaryExpr::SummaryAgg { child, .. } = &deployment.summary.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &deployment.summary.operator else { return Err(AnalyticalCostError::UnsupportedCandidate); }; let inputs = node_evidence.inputs.validate()?; @@ -95,7 +55,7 @@ pub(super) fn estimate_heterogeneous_summary( .ok_or(AnalyticalCostError::MissingComparisonScope( "summary scan selection", ))?; - if !matches!(&child.expr, SummaryExpr::KeepPreAsap(_)) + if !has_retained_subdag_evidence(child, evidence) || inputs.initial_input_rows != raw.planning_time_input_rows || inputs.initial_input_bytes != raw.planning_time_input_bytes || inputs.initial_source_scan_bytes != raw.planning_time_source_scan_bytes @@ -106,7 +66,7 @@ pub(super) fn estimate_heterogeneous_summary( )); } let mut actual_selections = Vec::new(); - summary_source_selections(child, &mut HashSet::new(), &mut actual_selections)?; + query_source_selections(child, &mut HashSet::new(), &mut actual_selections)?; let actual_selections = deduplicate_source_selections(actual_selections); let expected = ( declared.source.clone(), @@ -120,7 +80,7 @@ pub(super) fn estimate_heterogeneous_summary( } } None => { - if matches!(&child.expr, SummaryExpr::KeepPreAsap(_)) + if has_retained_subdag_evidence(child, evidence) || inputs.initial_source_scan_bytes != 0 || !node_evidence.bootstrap_read_identity.is_empty() { @@ -131,7 +91,7 @@ pub(super) fn estimate_heterogeneous_summary( } } validate_guarantee(deployment.guarantee, scope.data_arrival)?; - let logical_state = format!("{:?}", deployment.summary.expr); + let logical_state = format!("{:?}", deployment.summary.operator); let window_framework = (*frameworks_by_node .get(&(deployment.summary as *const _)) .ok_or(AnalyticalCostError::MissingOrStale( @@ -253,9 +213,9 @@ pub(super) fn estimate_heterogeneous_summary( #[expect(clippy::too_many_arguments, reason = "CPU and I/O traversal state")] fn visit_ops( - node: &SummaryNode, + node: &OperatorNode, seen: &mut HashSet, - by_node: &HashMap<*const SummaryNode, &CostedSummaryDeployment<'_>>, + by_node: &HashMap<*const OperatorNode, &CostedSummaryDeployment<'_>>, evidence: &SummaryNodeEvidence, scope: &ComparisonScope, evaluation_count: u64, @@ -266,13 +226,29 @@ pub(super) fn estimate_heterogeneous_summary( if !seen.insert(physical_id) { return Ok(()); } - match &node.expr { - SummaryExpr::BinaryOp { lhs, rhs, .. } - | SummaryExpr::RelationalJoin { + if has_retained_subdag_evidence(node, evidence) { + let retained = evidence + .retained_queries + .get(&(node as *const _)) + .ok_or(AnalyticalCostError::MissingOrStale("retain_exact"))?; + if !retained.preprocessing_cpu_ops_over_horizon.is_finite() + || retained.preprocessing_cpu_ops_over_horizon < 0.0 + { + return Err(AnalyticalCostError::InvalidOperationCost( + "retain_exact", + retained.preprocessing_cpu_ops_over_horizon, + )); + } + *cpu_ops += retained.preprocessing_cpu_ops_over_horizon; + return Ok(()); + } + match &node.operator { + Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) + | Operator::NonASAP(NonASAPOp::Join { left: lhs, right: rhs, .. - } => { + }) => { let operation = summary_operation_evidence(node, evidence)?.resource(); *cpu_ops += evaluation_count as f64 * validated_operator_executions("exact_binary", operation)? as f64 @@ -300,39 +276,31 @@ pub(super) fn estimate_heterogeneous_summary( )?; } - SummaryExpr::ValueOperation { child, .. } => { + Operator::NonASAP(_) + | Operator::ASAP( + ASAPOp::FinalizeExactAccumulator { .. } + | ASAPOp::MaintainPopulation { .. } + | ASAPOp::EvaluatePopulation { .. }, + ) => { let operation = summary_operation_evidence(node, evidence)?.resource(); *cpu_ops += evaluation_count as f64 * validated_operator_executions("value_operation", operation)? as f64 * validated_operator_cpu("value_operation", operation.cpu_ops)?; add_operator_io(io_bytes, operation, evaluation_count)?; - visit_ops( - child, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - SummaryExpr::KeepPreAsap(_) => { - let retained = evidence - .retained_queries - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("keep_pre_asap"))?; - if !retained.preprocessing_cpu_ops_over_horizon.is_finite() - || retained.preprocessing_cpu_ops_over_horizon < 0.0 - { - return Err(AnalyticalCostError::InvalidOperationCost( - "keep_pre_asap", - retained.preprocessing_cpu_ops_over_horizon, - )); + for child in node.children() { + visit_ops( + child, + seen, + by_node, + evidence, + scope, + evaluation_count, + cpu_ops, + io_bytes, + )?; } - *cpu_ops += retained.preprocessing_cpu_ops_over_horizon; } - SummaryExpr::SummaryAgg { child, .. } => { + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => { visit_ops( child, seen, @@ -344,7 +312,7 @@ pub(super) fn estimate_heterogeneous_summary( io_bytes, )?; } - SummaryExpr::SummaryMerge { children, .. } => { + Operator::ASAP(ASAPOp::SummaryMerge { children }) => { let operation = summary_operation_evidence(node, evidence)?.resource(); let merge = validated_operator_cpu("summary_merge", operation.cpu_ops)?; *cpu_ops += evaluation_count as f64 @@ -364,7 +332,7 @@ pub(super) fn estimate_heterogeneous_summary( )?; } } - SummaryExpr::SummarySubtract { left, right } => { + Operator::ASAP(ASAPOp::SummarySubtract { left, right }) => { let operation = summary_operation_evidence(node, evidence)?.resource(); *cpu_ops += evaluation_count as f64 * validated_operator_executions("summary_subtract", operation)? as f64 @@ -391,7 +359,7 @@ pub(super) fn estimate_heterogeneous_summary( io_bytes, )?; } - SummaryExpr::SummaryDelete { summary_input, .. } => { + Operator::ASAP(ASAPOp::SummaryDelete { summary_input, .. }) => { let delete = summary_operation_evidence(node, evidence)?; let SummaryOperatorEvidence::Delete { resource: operation, @@ -406,44 +374,18 @@ pub(super) fn estimate_heterogeneous_summary( .get(&(node as *const _)) .ok_or(AnalyticalCostError::MissingOrStale("summary_delete_owner"))?; fn collect_aggs( - node: &SummaryNode, - seen: &mut HashSet<*const SummaryNode>, - out: &mut Vec<*const SummaryNode>, + node: &OperatorNode, + seen: &mut HashSet<*const OperatorNode>, + out: &mut Vec<*const OperatorNode>, ) { if !seen.insert(node as *const _) { return; } - match &node.expr { - SummaryExpr::SummaryAgg { child, .. } => { - out.push(node as *const _); - collect_aggs(child, seen, out); - } - SummaryExpr::ValueOperation { child, .. } => collect_aggs(child, seen, out), - SummaryExpr::SummaryMerge { children, .. } => { - children - .iter() - .for_each(|child| collect_aggs(child, seen, out)); - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => { - collect_aggs(left, seen, out); - collect_aggs(right, seen, out); - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - collect_aggs(summary_input, seen, out) - } - SummaryExpr::KeepPreAsap(_) => {} + if matches!(node.operator, Operator::ASAP(ASAPOp::SummaryAgg { .. })) { + out.push(node as *const _); + } + for child in node.children() { + collect_aggs(child, seen, out); } } let mut reachable = Vec::new(); @@ -491,11 +433,11 @@ pub(super) fn estimate_heterogeneous_summary( io_bytes, )?; } - SummaryExpr::SummaryEstimate { summary_input, .. } => { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { let operation = summary_operation_evidence(node, evidence)?.resource(); *cpu_ops += evaluation_count as f64 - * validated_operator_executions("summary_readout", operation)? as f64 - * validated_operator_cpu("summary_readout", operation.cpu_ops)?; + * validated_operator_executions("summary_evaluation", operation)? as f64 + * validated_operator_cpu("summary_evaluation", operation.cpu_ops)?; add_operator_io(io_bytes, operation, evaluation_count)?; visit_ops( summary_input, @@ -508,7 +450,7 @@ pub(super) fn estimate_heterogeneous_summary( io_bytes, )?; } - SummaryExpr::SummaryJoin { outer, inner, .. } => { + Operator::ASAP(ASAPOp::SummaryJoin { outer, inner, .. }) => { let join = evidence .joins .get(&(node as *const _)) @@ -555,6 +497,9 @@ pub(super) fn estimate_heterogeneous_summary( io_bytes, )?; } + Operator::ASAP(ASAPOp::Extension { .. }) => { + return Err(AnalyticalCostError::UnsupportedCandidate); + } } Ok(()) } @@ -613,55 +558,37 @@ fn add_operator_io( } fn validate_summary_edges_and_physical_ids( - root: &SummaryNode, + root: &OperatorNode, evidence: &SummaryNodeEvidence, - frameworks_by_node: &HashMap<*const SummaryNode, &Option>, + frameworks_by_node: &HashMap<*const OperatorNode, &Option>, ) -> Result<(), AnalyticalCostError> { - fn children(node: &SummaryNode) -> Vec<&SummaryNode> { - match &node.expr { - SummaryExpr::KeepPreAsap(_) => vec![], - SummaryExpr::SummaryAgg { child, .. } | SummaryExpr::ValueOperation { child, .. } => { - vec![child] - } - SummaryExpr::SummaryMerge { children, .. } => { - children.iter().map(|child| child.as_ref()).collect() - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => vec![left, right], - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => vec![summary_input], - } + fn children<'a>( + node: &'a OperatorNode, + evidence: &SummaryNodeEvidence, + ) -> Vec<&'a OperatorNode> { + summary_children(node, evidence) } fn metadata( - node: &SummaryNode, + node: &OperatorNode, evidence: &SummaryNodeEvidence, ) -> Result<(String, Vec, EdgeStatistics), AnalyticalCostError> { - match &node.expr { - SummaryExpr::KeepPreAsap(_) => { - let retained = evidence - .retained_queries - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("keep_pre_asap"))?; - Ok((retained.physical_id.clone(), vec![], retained.output)) + if let Some(retained) = evidence.retained_queries.get(&(node as *const _)) { + if node.contains_asap() { + return Err(AnalyticalCostError::InvalidPhysicalDAG( + "retained sub-DAG evidence covers summary operators", + )); } - SummaryExpr::SummaryAgg { .. } => { + return Ok((retained.physical_id.clone(), vec![], retained.output)); + } + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => { let value = evidence .aggregations .get(&(node as *const _)) .ok_or(AnalyticalCostError::MissingOrStale("summary_agg"))?; Ok((value.physical_id.clone(), vec![value.input], value.output)) } - SummaryExpr::SummaryJoin { .. } => { + Operator::ASAP(ASAPOp::SummaryJoin { .. }) => { let value = evidence .joins .get(&(node as *const _)) @@ -683,16 +610,16 @@ fn validate_summary_edges_and_physical_ids( } } fn visit( - node: &SummaryNode, + node: &OperatorNode, evidence: &SummaryNodeEvidence, - frameworks_by_node: &HashMap<*const SummaryNode, &Option>, - seen: &mut HashSet<*const SummaryNode>, + frameworks_by_node: &HashMap<*const OperatorNode, &Option>, + seen: &mut HashSet<*const OperatorNode>, physical: &mut HashMap, EdgeStatistics, String)>, ) -> Result { if !seen.insert(node as *const _) { return metadata(node, evidence).map(|(_, _, output)| output); } - let child_nodes = children(node); + let child_nodes = children(node, evidence); let child_outputs = child_nodes .iter() .map(|child| visit(child, evidence, frameworks_by_node, seen, physical)) @@ -702,17 +629,18 @@ fn validate_summary_edges_and_physical_ids( .map(|child| summary_physical_id(child, evidence)) .collect::, _>>()?; let (id, inputs, output) = metadata(node, evidence)?; - let local_fingerprint = match &node.expr { - SummaryExpr::KeepPreAsap(_) => { - format!("{:?}", evidence.retained_queries.get(&(node as *const _))) - } - SummaryExpr::SummaryAgg { .. } => { - format!("{:?}", evidence.aggregations.get(&(node as *const _))) - } - SummaryExpr::SummaryJoin { .. } => { - format!("{:?}", evidence.joins.get(&(node as *const _))) + let local_fingerprint = if has_retained_subdag_evidence(node, evidence) { + format!("{:?}", evidence.retained_queries.get(&(node as *const _))) + } else { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => { + format!("{:?}", evidence.aggregations.get(&(node as *const _))) + } + Operator::ASAP(ASAPOp::SummaryJoin { .. }) => { + format!("{:?}", evidence.joins.get(&(node as *const _))) + } + _ => format!("{:?}", evidence.operations.get(&(node as *const _))), } - _ => format!("{:?}", evidence.operations.get(&(node as *const _))), }; // A provider identity names the complete physical operator, including // its inputs. Equal local widths/costs do not make operators consuming @@ -720,7 +648,7 @@ fn validate_summary_edges_and_physical_ids( let framework = frameworks_by_node.get(&(node as *const _)); let fingerprint = format!( "logical={:?}|framework={framework:?}|{local_fingerprint}|children={child_physical_ids:?}", - node.expr + node.operator ); if id.is_empty() || inputs != child_outputs @@ -756,20 +684,49 @@ fn validate_summary_edges_and_physical_ids( .map(|_| ()) } +/// Whether `node` is a retained non-ASAP sub-DAG costed as one unit: the +/// provider bound retained-query evidence to it instead of per-operator +/// evidence. Its children are then not visited. No ASAP descendant may be +/// hidden by this boundary; dag validation rejects such evidence. +fn has_retained_subdag_evidence(node: &OperatorNode, evidence: &SummaryNodeEvidence) -> bool { + evidence.retained_queries.contains_key(&(node as *const _)) && !node.contains_asap() +} + +/// The inputs the estimator visits below `node`: none for a retained +/// sub-DAG, every direct input otherwise. +fn summary_children<'a>( + node: &'a OperatorNode, + evidence: &SummaryNodeEvidence, +) -> Vec<&'a OperatorNode> { + if has_retained_subdag_evidence(node, evidence) { + vec![] + } else { + node.children() + .into_iter() + .map(|child| child.as_ref()) + .collect() + } +} + fn summary_physical_id( - node: &SummaryNode, + node: &OperatorNode, evidence: &SummaryNodeEvidence, ) -> Result { - match &node.expr { - SummaryExpr::KeepPreAsap(_) => evidence + if has_retained_subdag_evidence(node, evidence) { + return evidence .retained_queries .get(&(node as *const _)) - .map(|value| value.physical_id.clone()), - SummaryExpr::SummaryAgg { .. } => evidence + .map(|value| value.physical_id.clone()) + .ok_or(AnalyticalCostError::MissingOrStale( + "summary physical identity", + )); + } + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => evidence .aggregations .get(&(node as *const _)) .map(|value| value.physical_id.clone()), - SummaryExpr::SummaryJoin { .. } => evidence + Operator::ASAP(ASAPOp::SummaryJoin { .. }) => evidence .joins .get(&(node as *const _)) .map(|value| value.physical_id.clone()), @@ -786,45 +743,26 @@ fn summary_physical_id( /// child output buffers remain live until their final consumer executes; /// operator workspace and its output buffer coexist during that execution. pub(super) fn estimate_transient_liveness( - root: &SummaryNode, + root: &OperatorNode, evidence: &SummaryNodeEvidence, ) -> Result { - fn children(node: &SummaryNode) -> Vec<&SummaryNode> { - match &node.expr { - SummaryExpr::KeepPreAsap(_) => vec![], - SummaryExpr::SummaryAgg { child, .. } | SummaryExpr::ValueOperation { child, .. } => { - vec![child] - } - SummaryExpr::SummaryMerge { children, .. } => { - children.iter().map(|child| child.as_ref()).collect() - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => vec![left, right], - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => vec![summary_input], - } + fn children<'a>( + node: &'a OperatorNode, + evidence: &SummaryNodeEvidence, + ) -> Vec<&'a OperatorNode> { + summary_children(node, evidence) } fn visit<'a>( - node: &'a SummaryNode, + node: &'a OperatorNode, evidence: &SummaryNodeEvidence, seen: &mut HashSet, uses: &mut HashMap, - order: &mut Vec<&'a SummaryNode>, + order: &mut Vec<&'a OperatorNode>, ) -> Result<(), AnalyticalCostError> { if !seen.insert(summary_physical_id(node, evidence)?) { return Ok(()); } - for child in children(node) { + for child in children(node, evidence) { *uses .entry(summary_physical_id(child, evidence)?) .or_default() += 1; @@ -834,28 +772,24 @@ pub(super) fn estimate_transient_liveness( Ok(()) } fn memory( - node: &SummaryNode, + node: &OperatorNode, evidence: &SummaryNodeEvidence, ) -> Result<(u64, u64), AnalyticalCostError> { - match &node.expr { - SummaryExpr::KeepPreAsap(_) => evidence + if has_retained_subdag_evidence(node, evidence) { + return evidence .retained_queries .get(&(node as *const _)) .map(|value| (value.working_memory_bytes, value.output_buffer_bytes)) - .ok_or(AnalyticalCostError::MissingOrStale("keep_pre_asap")), - SummaryExpr::SummaryAgg { .. } => Ok((0, 0)), - SummaryExpr::SummaryJoin { .. } => evidence + .ok_or(AnalyticalCostError::MissingOrStale("retain_exact")); + } + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => Ok((0, 0)), + Operator::ASAP(ASAPOp::SummaryJoin { .. }) => evidence .joins .get(&(node as *const _)) .map(|value| (value.working_memory_bytes, value.output_buffer_bytes)) .ok_or(AnalyticalCostError::MissingOrStale("summary_join")), - SummaryExpr::SummaryMerge { .. } - | SummaryExpr::BinaryOp { .. } - | SummaryExpr::RelationalJoin { .. } - | SummaryExpr::ValueOperation { .. } - | SummaryExpr::SummarySubtract { .. } - | SummaryExpr::SummaryDelete { .. } - | SummaryExpr::SummaryEstimate { .. } => { + _ => { let value = summary_operation_evidence(node, evidence)?.resource(); Ok((value.working_memory_bytes, value.output_buffer_bytes)) } @@ -884,7 +818,7 @@ pub(super) fn estimate_transient_liveness( live = live .checked_add(output) .ok_or(AnalyticalCostError::Overflow)?; - for child in children(node) { + for child in children(node, evidence) { let child_id = summary_physical_id(child, evidence)?; let remaining = uses.get_mut(&child_id) @@ -902,50 +836,23 @@ pub(super) fn estimate_transient_liveness( Ok(peak) } #[cfg(test)] -pub(super) fn evidence_nodes(root: &SummaryNode) -> (Vec<&SummaryNode>, Vec<&SummaryNode>) { +pub(super) fn evidence_nodes(root: &OperatorNode) -> (Vec<&OperatorNode>, Vec<&OperatorNode>) { fn visit<'a>( - node: &'a SummaryNode, - seen: &mut HashSet<*const SummaryNode>, - aggregations: &mut Vec<&'a SummaryNode>, - joins: &mut Vec<&'a SummaryNode>, + node: &'a OperatorNode, + seen: &mut HashSet<*const OperatorNode>, + aggregations: &mut Vec<&'a OperatorNode>, + joins: &mut Vec<&'a OperatorNode>, ) { if !seen.insert(node as *const _) { return; } - match &node.expr { - SummaryExpr::SummaryAgg { child, .. } => { - aggregations.push(node); - visit(child, seen, aggregations, joins); - } - SummaryExpr::ValueOperation { child, .. } => visit(child, seen, aggregations, joins), - SummaryExpr::SummaryMerge { children, .. } => { - for child in children { - visit(child, seen, aggregations, joins); - } - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => { - if matches!(&node.expr, SummaryExpr::SummaryJoin { .. }) { - joins.push(node); - } - visit(left, seen, aggregations, joins); - visit(right, seen, aggregations, joins); - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - visit(summary_input, seen, aggregations, joins); - } - SummaryExpr::KeepPreAsap(_) => {} + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => aggregations.push(node), + Operator::ASAP(ASAPOp::SummaryJoin { .. }) => joins.push(node), + _ => {} + } + for child in node.children() { + visit(child, seen, aggregations, joins); } } let mut aggregations = Vec::new(); @@ -961,7 +868,7 @@ struct SummaryOperationCounts { merges_per_read: u64, subtracts_per_read: u64, deletes_per_update: u64, - readouts_per_read: u64, + evaluations_per_read: u64, joins_per_read: u64, } @@ -970,7 +877,7 @@ struct SummaryOperationCounts { /// once; explicit delete frequency comes from deletion evidence. #[cfg(test)] pub(super) fn estimate_incremental_summary_maintenance( - root: &SummaryNode, + root: &OperatorNode, guarantee: &SummaryMaintenanceLifecycleGuarantee, inputs: SummaryMaintenanceInputs, cpu: SummaryOperationCpuEvidence, @@ -980,7 +887,7 @@ pub(super) fn estimate_incremental_summary_maintenance( } #[cfg(test)] pub(super) fn estimate_incremental_summary_maintenance_with_join( - root: &SummaryNode, + root: &OperatorNode, guarantee: &SummaryMaintenanceLifecycleGuarantee, inputs: SummaryMaintenanceInputs, cpu: SummaryOperationCpuEvidence, @@ -1030,10 +937,10 @@ pub(super) fn estimate_incremental_summary_maintenance_with_join( .checked_mul(fanout) .ok_or(AnalyticalCostError::Overflow)? }; - let readout = required_cpu_when( - counts.readouts_per_read, - "readout_cpu_ops", - cpu.readout_cpu_ops, + let evaluation = required_cpu_when( + counts.evaluations_per_read, + "evaluation_cpu_ops", + cpu.evaluation_cpu_ops, )?; let join_cpu = match (counts.joins_per_read, join.as_ref()) { (0, _) => 0.0, @@ -1071,7 +978,7 @@ pub(super) fn estimate_incremental_summary_maintenance_with_join( + evaluations * counts.merges_per_read as f64 * instances * merge + evaluations * counts.subtracts_per_read as f64 * instances * subtract + delete_events as f64 * counts.deletes_per_update as f64 * delete - + evaluations * counts.readouts_per_read as f64 * instances * readout + + evaluations * counts.evaluations_per_read as f64 * instances * evaluation + evaluations * counts.joins_per_read as f64 * join_cpu; if !cpu_ops.is_finite() { return Err(AnalyticalCostError::Overflow); @@ -1238,25 +1145,23 @@ fn required_cpu_when( } #[cfg(test)] -fn count_operations(root: &SummaryNode) -> Result { +fn count_operations(root: &OperatorNode) -> Result { fn visit( - node: &SummaryNode, - seen: &mut HashSet<*const SummaryNode>, + node: &OperatorNode, + seen: &mut HashSet<*const OperatorNode>, counts: &mut SummaryOperationCounts, ) -> Result<(), AnalyticalCostError> { - if !seen.insert(node as *const SummaryNode) { + if !seen.insert(node as *const OperatorNode) { return Ok(()); } - match &node.expr { - SummaryExpr::KeepPreAsap(_) => {} - SummaryExpr::SummaryAgg { child, .. } => { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { .. }) => { counts.state_builds = counts .state_builds .checked_add(1) .ok_or(AnalyticalCostError::Overflow)?; - visit(child, seen, counts)?; } - SummaryExpr::SummaryMerge { children, .. } => { + Operator::ASAP(ASAPOp::SummaryMerge { children }) => { if children.is_empty() { return Err(AnalyticalCostError::InvalidPhysicalDAG( "summary merge has no children", @@ -1266,50 +1171,44 @@ fn count_operations(root: &SummaryNode) -> Result { + Operator::ASAP(ASAPOp::SummarySubtract { .. }) => { counts.subtracts_per_read = counts .subtracts_per_read .checked_add(1) .ok_or(AnalyticalCostError::Overflow)?; - visit(left, seen, counts)?; - visit(right, seen, counts)?; - } - SummaryExpr::BinaryOp { lhs, rhs, .. } => { - visit(lhs, seen, counts)?; - visit(rhs, seen, counts)?; } - SummaryExpr::RelationalJoin { left, right, .. } => { - visit(left, seen, counts)?; - visit(right, seen, counts)?; - } - - SummaryExpr::ValueOperation { child, .. } => visit(child, seen, counts)?, - SummaryExpr::SummaryDelete { summary_input, .. } => { + Operator::ASAP(ASAPOp::SummaryDelete { .. }) => { counts.deletes_per_update = counts .deletes_per_update .checked_add(1) .ok_or(AnalyticalCostError::Overflow)?; - visit(summary_input, seen, counts)?; } - SummaryExpr::SummaryEstimate { summary_input, .. } => { - counts.readouts_per_read = counts - .readouts_per_read + Operator::ASAP( + ASAPOp::SummaryEstimate { .. } | ASAPOp::FinalizeExactAccumulator { .. }, + ) => { + counts.evaluations_per_read = counts + .evaluations_per_read .checked_add(1) .ok_or(AnalyticalCostError::Overflow)?; - visit(summary_input, seen, counts)?; } - SummaryExpr::SummaryJoin { outer, inner, .. } => { + Operator::ASAP(ASAPOp::SummaryJoin { .. }) => { counts.joins_per_read = counts .joins_per_read .checked_add(1) .ok_or(AnalyticalCostError::Overflow)?; - visit(outer, seen, counts)?; - visit(inner, seen, counts)?; } + // Retained relational work, accumulator/population boundaries and + // exact query-time operators add no summary operation. + Operator::NonASAP(_) + | Operator::ASAP( + ASAPOp::MaintainPopulation { .. } + | ASAPOp::EvaluatePopulation { .. } + | ASAPOp::Extension { .. }, + ) => {} + } + for child in node.children() { + visit(child, seen, counts)?; } Ok(()) } diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs index 7c8607685..6ae5999bd 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs @@ -134,7 +134,7 @@ pub struct SummaryOperationCpuEvidence { pub delete_events_per_second: Option, /// Concrete state instances touched by one delete event. pub delete_routing_fanout: Option, - pub readout_cpu_ops: Option, + pub evaluation_cpu_ops: Option, } /// Physical evidence for one `SummaryJoin` implementation. Total work, @@ -185,7 +185,7 @@ pub struct SummaryOperatorResourceEvidence { } /// Evidence is structured by logical summary operation so delete-only facts -/// cannot be attached to merge, subtract, or readout nodes. +/// cannot be attached to merge, subtract, or evaluation nodes. #[derive(Debug, Clone, PartialEq)] pub enum SummaryOperatorEvidence { /// Exact query-time arithmetic over two independently realized operands. @@ -201,7 +201,7 @@ pub enum SummaryOperatorEvidence { events_per_second: f64, routing_fanout: u64, }, - Readout(SummaryOperatorResourceEvidence), + Evaluation(SummaryOperatorResourceEvidence), } impl SummaryOperatorEvidence { @@ -212,7 +212,7 @@ impl SummaryOperatorEvidence { | Self::Merge(resource) | Self::Subtract(resource) | Self::Delete { resource, .. } - | Self::Readout(resource) => resource, + | Self::Evaluation(resource) => resource, } } @@ -224,7 +224,7 @@ impl SummaryOperatorEvidence { | Self::Merge(resource) | Self::Subtract(resource) | Self::Delete { resource, .. } - | Self::Readout(resource) => resource, + | Self::Evaluation(resource) => resource, } } } @@ -247,27 +247,27 @@ pub struct RetainedSubDAGEvidence { /// structurally equal node is not silently treated as the same deployment. #[derive(Debug, Clone, Default)] pub struct SummaryNodeEvidence { - pub(super) aggregations: HashMap<*const SummaryNode, SummaryAggregateEvidence>, - pub(super) joins: HashMap<*const SummaryNode, SummaryJoinEvidence>, - pub(super) operations: HashMap<*const SummaryNode, SummaryOperatorEvidence>, - pub(super) operation_state_owners: HashMap<*const SummaryNode, *const SummaryNode>, - pub(super) retained_queries: HashMap<*const SummaryNode, RetainedSubDAGEvidence>, + pub(super) aggregations: HashMap<*const OperatorNode, SummaryAggregateEvidence>, + pub(super) joins: HashMap<*const OperatorNode, SummaryJoinEvidence>, + pub(super) operations: HashMap<*const OperatorNode, SummaryOperatorEvidence>, + pub(super) operation_state_owners: HashMap<*const OperatorNode, *const OperatorNode>, + pub(super) retained_queries: HashMap<*const OperatorNode, RetainedSubDAGEvidence>, } impl SummaryNodeEvidence { pub fn insert_aggregation( &mut self, - node: &Rc, + node: &Rc, evidence: SummaryAggregateEvidence, ) { self.aggregations.insert(Rc::as_ptr(node), evidence); } - pub fn insert_join(&mut self, node: &Rc, evidence: SummaryJoinEvidence) { + pub fn insert_join(&mut self, node: &Rc, evidence: SummaryJoinEvidence) { self.joins.insert(Rc::as_ptr(node), evidence); } - pub fn insert_operation(&mut self, node: &Rc, evidence: SummaryOperatorEvidence) { + pub fn insert_operation(&mut self, node: &Rc, evidence: SummaryOperatorEvidence) { self.operations.insert(Rc::as_ptr(node), evidence); } @@ -275,8 +275,8 @@ impl SummaryNodeEvidence { /// aggregation deployment whose active interval it follows. pub fn insert_state_operation( &mut self, - node: &Rc, - state: &Rc, + node: &Rc, + state: &Rc, evidence: SummaryOperatorEvidence, ) { self.operations.insert(Rc::as_ptr(node), evidence); @@ -286,52 +286,57 @@ impl SummaryNodeEvidence { pub fn insert_retained_query( &mut self, - node: &Rc, + node: &Rc, evidence: RetainedSubDAGEvidence, ) { self.retained_queries.insert(Rc::as_ptr(node), evidence); } - pub(super) fn aggregation(&self, node: &SummaryNode) -> Option { + pub(super) fn aggregation(&self, node: &OperatorNode) -> Option { self.aggregations.get(&(node as *const _)).cloned() } } pub(super) fn summary_operation_evidence<'a>( - node: &SummaryNode, + node: &OperatorNode, evidence: &'a SummaryNodeEvidence, ) -> Result<&'a SummaryOperatorEvidence, AnalyticalCostError> { let operation = evidence .operations .get(&(node as *const _)) .ok_or(AnalyticalCostError::MissingOrStale("summary operation"))?; - let matches = matches!( - (&node.expr, operation), + // A binary operator or join over two inputs is `Binary` evidence; every + // other non-ASAP operator, and the accumulator/population boundaries, + // is a `ValueOperation`. + let matches = match (&node.operator, operation) { ( - SummaryExpr::BinaryOp { .. }, - SummaryOperatorEvidence::Binary(_) - ) | ( - SummaryExpr::ValueOperation { .. }, - SummaryOperatorEvidence::ValueOperation(_) - ) | ( - SummaryExpr::SummaryMerge { .. }, - SummaryOperatorEvidence::Merge(_) - ) | ( - SummaryExpr::SummarySubtract { .. }, - SummaryOperatorEvidence::Subtract(_) - ) | ( - SummaryExpr::SummaryDelete { .. }, - SummaryOperatorEvidence::Delete { .. } - ) | ( - SummaryExpr::SummaryEstimate { .. }, - SummaryOperatorEvidence::Readout(_) - ) - ); + Operator::NonASAP(NonASAPOp::BinaryOp { .. } | NonASAPOp::Join { .. }), + SummaryOperatorEvidence::Binary(_), + ) => true, + (Operator::NonASAP(NonASAPOp::BinaryOp { .. } | NonASAPOp::Join { .. }), _) => false, + ( + Operator::NonASAP(_) + | Operator::ASAP( + ASAPOp::FinalizeExactAccumulator { .. } + | ASAPOp::MaintainPopulation { .. } + | ASAPOp::EvaluatePopulation { .. }, + ), + SummaryOperatorEvidence::ValueOperation(_), + ) => true, + (Operator::ASAP(ASAPOp::SummaryMerge { .. }), SummaryOperatorEvidence::Merge(_)) + | (Operator::ASAP(ASAPOp::SummarySubtract { .. }), SummaryOperatorEvidence::Subtract(_)) + | (Operator::ASAP(ASAPOp::SummaryDelete { .. }), SummaryOperatorEvidence::Delete { .. }) + | ( + Operator::ASAP(ASAPOp::SummaryEstimate { .. }), + SummaryOperatorEvidence::Evaluation(_), + ) => true, + _ => false, + }; if matches { Ok(operation) } else { Err(AnalyticalCostError::InconsistentOperatorStatistics( - "summary operation evidence kind does not match SummaryExpr", + "summary operation evidence kind does not match the operator", )) } } diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs index bc1b7ddaf..ff4c69c1d 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs @@ -1,4 +1,4 @@ -//! Analytical resource cost for at-rest and incrementally maintained summary deployments. +//! Analytical resource cost for at-rest and at-rest and incrementally maintained summary deployments. //! //! The canonical workload and lifecycle types own deployment semantics. This //! module only adds physical evidence absent from those schemas: state size, @@ -7,14 +7,13 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, Predicate}; use asap_types::post_asap::{ BoundExpr, ErrorMetric, ExactKind, FieldDataType, GuaranteeSource, ProbabilityExpr, - ResultGuarantee, SketchAlgorithm, SummaryExpr, SummaryMaintenanceLifecycle, - SummaryMaintenanceLifecycleGuarantee, SummaryNode, SummaryWindowFramework, -}; -use asap_types::pre_asap::{ - agg_intent::AggIntent, CompareOpKind, InfoMatcher, Predicate, QueryExpr, Source, + ResultGuarantee, SketchAlgorithm, SummaryMaintenanceLifecycle, + SummaryMaintenanceLifecycleGuarantee, SummaryWindowFramework, }; +use asap_types::pre_asap::{agg_intent::AggIntent, CompareOpKind, InfoMatcher, Source}; use asap_types::types::AccuracyTarget; use asap_types::workload::{DataArrival, DataWorkload, QueryRecurrence, RepeatedDemand}; use serde::{Deserialize, Serialize}; diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs index 7859db9b8..a7f386a8b 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs @@ -7,7 +7,7 @@ pub struct SummaryMaintenanceCostModel { pub node_evidence: SummaryNodeEvidence, pub calibration: ResourceCalibration, pub capabilities: SummaryMaintenanceCapabilities, - target_comparisons: HashMap<*const QueryExpr, SummaryTargetComparison>, + target_comparisons: HashMap<*const OperatorNode, SummaryTargetComparison>, candidate_comparisons: HashMap, physical_plan_alternatives: HashMap>, @@ -15,17 +15,17 @@ pub struct SummaryMaintenanceCostModel { HashMap>, } -type CandidateComparisonKey = (*const QueryExpr, *const SummaryNode); +type CandidateComparisonKey = (*const OperatorNode, *const OperatorNode); #[derive(Debug, Clone)] struct BoundCandidateIdentity { - _target: Rc, - _root: Rc, + _target: Rc, + _root: Rc, } #[derive(Debug, Clone)] struct SummaryTargetComparison { - _target: Rc, + _target: Rc, scope: ComparisonScope, raw: RawInputEvidence, } @@ -59,73 +59,40 @@ fn info_source(selector: &[InfoMatcher]) -> Result }) } +/// Collect the source selections (scan sources with their predicates, and +/// info-metric selectors) of every leaf reachable from `node`, visiting a +/// shared node once. pub(super) fn query_source_selections( - query: &QueryExpr, + node: &OperatorNode, + seen: &mut HashSet<*const OperatorNode>, out: &mut Vec, ) -> Result<(), AnalyticalCostError> { - use QueryExpr::*; - match query { - Scan { + if !seen.insert(node as *const _) { + return Ok(()); + } + match &node.operator { + Operator::NonASAP(NonASAPOp::Scan { source, predicates, .. - } => out.push((source.clone(), predicates.clone(), vec![])), - PromqlVectorFromScalar(child) | PromqlScalarFromVector(child) => { - query_source_selections(child, out)? - } - PromqlInfoEnrich { selector, child } => { - query_source_selections(child, out)?; + }) => out.push((source.clone(), predicates.clone(), vec![])), + Operator::NonASAP(NonASAPOp::PromqlInfoEnrich { selector, child }) => { + query_source_selections(child, seen, out)?; out.push((info_source(selector)?, vec![], selector.clone())); } - PromqlRelabel { child, .. } - | Filter { child, .. } - | Project { child, .. } - | Aggregate { child, .. } - | Dedup { child, .. } - | PromqlSubquery { child, .. } - | TimeRange { child, .. } - | TimeShift { child, .. } - | SQLWindowFunc { child, .. } - | PromqlSeriesSample { child, .. } - | Sort { child, .. } - | Limit { child, .. } => query_source_selections(child, out)?, - Concat { children, .. } => { - for child in children { - query_source_selections(child, out)?; + _ => { + for child in node.children() { + query_source_selections(child, seen, out)?; } } - Join { left, right, .. } | SetOp { left, right, .. } => { - query_source_selections(left, out)?; - query_source_selections(right, out)?; - } - BinaryOp { lhs, rhs, .. } => { - query_source_selections(lhs, out)?; - query_source_selections(rhs, out)?; - } - PromqlScalarBridge(_) - | EvalTimestamp - | CurrentTimestamp - | Column(_) - | Literal(_) - | Compare { .. } - | BoolAnd(_) - | BoolOr(_) - | Not(_) - | IsNull(_) - | IsNotNull(_) - | Cast { .. } - | InList { .. } - | FunctionCall { .. } - | Arithmetic { .. } - | Case { .. } => {} } Ok(()) } fn validate_query_scope( - target: &QueryExpr, + target: &OperatorNode, scope: &ComparisonScope, ) -> Result<(), AnalyticalCostError> { let mut actual = Vec::new(); - query_source_selections(target, &mut actual)?; + query_source_selections(target, &mut HashSet::new(), &mut actual)?; let actual = deduplicate_source_selections(actual); let mut declared: Vec<_> = scope .sources @@ -428,8 +395,8 @@ impl SummaryMaintenanceCostModel { /// rejected rather than silently replacing the canonical context. pub fn bind_candidate_comparison( &mut self, - target: &Rc, - root: &Rc, + target: &Rc, + root: &Rc, scope: ComparisonScope, raw: RawInputEvidence, ) -> Result<(), AnalyticalCostError> { @@ -474,8 +441,8 @@ impl SummaryMaintenanceCostModel { /// candidate. Duplicate or empty provider identities are rejected. pub fn bind_physical_plan_alternative( &mut self, - target: &Rc, - root: &Rc, + target: &Rc, + root: &Rc, alternative: SummaryPhysicalPlanAlternative, ) -> Result<(), AnalyticalCostError> { let key = (Rc::as_ptr(target), Rc::as_ptr(root)); @@ -512,8 +479,8 @@ impl SummaryMaintenanceCostModel { /// evidence keep the implementations distinct during ranking. pub fn bind_window_framework_candidate( &mut self, - target: &Rc, - root: &Rc, + target: &Rc, + root: &Rc, candidate: SummaryWindowFrameworkCandidate, ) -> Result<(), AnalyticalCostError> { let key = (Rc::as_ptr(target), Rc::as_ptr(root)); @@ -565,8 +532,8 @@ impl SummaryMaintenanceCostModel { fn comparison_context( &self, - root: &SummaryNode, - target: Option<&QueryExpr>, + root: &OperatorNode, + target: Option<&OperatorNode>, horizon: Option, expected_reads: Option, ) -> Option<(CandidateComparisonKey, &SummaryTargetComparison)> { @@ -600,7 +567,7 @@ impl SummaryMaintenanceCostModel { fn complete_cost_with_evidence( &self, - root: &SummaryNode, + root: &OperatorNode, deployments: &[CostedSummaryDeployment<'_>], comparison: &SummaryTargetComparison, evidence: &SummaryNodeEvidence, @@ -619,7 +586,7 @@ impl SummaryMaintenanceCostModel { ) } - fn canonical_inputs(&self, summary: &SummaryNode) -> Option { + fn canonical_inputs(&self, summary: &OperatorNode) -> Option { let evidence = self.node_evidence.aggregation(summary)?; evidence.inputs.validate().ok()?; Some(evidence) @@ -631,7 +598,7 @@ impl SummaryMaintenanceCostModel { fn lifecycle_inputs( &self, - summary: &SummaryNode, + summary: &OperatorNode, horizon: Option, ) -> Option { let evidence = self.canonical_inputs(summary)?; @@ -657,8 +624,8 @@ impl SummaryMaintenanceCostModel { Some(SummaryMaintenanceLifecycleCostInputs { build_cost: Some(build), maintenance_cost_per_update: Some(maintenance), - // Readout is a separate physical operator in the complete DAG. - // A state-only candidate therefore does not fabricate readout + // Evaluation is a separate physical operator in the complete DAG. + // A state-only candidate therefore does not fabricate evaluation // evidence merely to keep a lifecycle alternative selectable. summary_read_cost: Some(Cost::ZERO), retention_cost_rate: Some(CostRate(retention_total.0 / horizon_seconds)), @@ -681,8 +648,7 @@ impl CostModel for SummaryMaintenanceCostModel { // Lifecycle selection supplies a complete override. If it cannot, // the candidate remains unavailable rather than receiving this // trait's structural fallback. - Replacement::Summary(_) => None, - Replacement::Rewrite(_) => None, + Replacement::SubDAG(_) => None, } } @@ -700,14 +666,14 @@ impl CostModel for SummaryMaintenanceCostModel { fn summary_maintenance_lifecycle_cost_inputs( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { SummaryMaintenanceLifecycleCostInputs::default() } fn summary_maintenance_lifecycle_cost_inputs_for_horizon( &self, - summary: &SummaryNode, + summary: &OperatorNode, horizon: Option, ) -> SummaryMaintenanceLifecycleCostInputs { self.lifecycle_inputs(summary, horizon).unwrap_or_default() @@ -715,15 +681,15 @@ impl CostModel for SummaryMaintenanceCostModel { fn summary_maintenance_capabilities( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { self.capabilities } fn complete_summary_candidate_cost( &self, - root: &SummaryNode, - target: Option<&QueryExpr>, + root: &OperatorNode, + target: Option<&OperatorNode>, deployments: &[CostedSummaryDeployment<'_>], horizon: Option, expected_reads: Option, @@ -742,8 +708,8 @@ impl CostModel for SummaryMaintenanceCostModel { fn complete_summary_candidate_estimate( &self, - root: &SummaryNode, - target: Option<&QueryExpr>, + root: &OperatorNode, + target: Option<&OperatorNode>, deployments: &[CostedSummaryDeployment<'_>], horizon: Option, expected_reads: Option, @@ -847,14 +813,14 @@ impl CostModel for SummaryMaintenanceCostModel { true } - fn raw_query_recompute_cost(&self, target: &QueryExpr) -> Option { + fn raw_query_recompute_cost(&self, target: &OperatorNode) -> Option { let _ = target; None } fn raw_query_recompute_total_cost( &self, - target: &QueryExpr, + target: &OperatorNode, expected_reads: f64, ) -> Option { let target_ptr = target as *const _; @@ -880,13 +846,14 @@ use super::estimator::*; mod tests { use std::rc::Rc; + use asap_types::ir::{ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode}; use asap_types::post_asap::{ EvaluationSchedule, ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, - OutputRepresentation, Schema, SummaryExpr, SummaryMaintenanceLifecycle, - SummaryMaintenanceLifecycleGuarantee, SummaryMaintenanceMode, + OutputRepresentation, Schema, SummaryMaintenanceLifecycle, + SummaryMaintenanceLifecycleGuarantee, SummaryMaintenanceMode, SummaryUpdate, }; use asap_types::pre_asap::{ - agg_intent::AggIntent, ColumnRef, DataType, QueryExpr, Reduction, Source, + agg_intent::AggIntent, ArithmeticOpKind, BinaryOpKind, DataType, Reduction, Source, }; use asap_types::workload::{ DataWorkload, Evidence, EvidenceSource, Predictability, Query, QueryLanguage, @@ -903,7 +870,7 @@ mod tests { }; fn estimate_test( - root: &SummaryNode, + root: &OperatorNode, guarantee: &SummaryMaintenanceLifecycleGuarantee, inputs: SummaryMaintenanceInputs, cpu: SummaryOperationCpuEvidence, @@ -912,7 +879,7 @@ mod tests { } fn estimate_join_test( - root: &SummaryNode, + root: &OperatorNode, guarantee: &SummaryMaintenanceLifecycleGuarantee, inputs: SummaryMaintenanceInputs, cpu: SummaryOperationCpuEvidence, @@ -1200,7 +1167,7 @@ mod tests { inputs, SummaryOperationCpuEvidence { insert_cpu_ops: Some(2.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }, ) @@ -1253,7 +1220,7 @@ mod tests { inputs, SummaryOperationCpuEvidence { insert_cpu_ops: Some(2.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }, ) @@ -1442,7 +1409,10 @@ mod tests { let mut model = streaming_model(); for group in space.target_subdag_candidates() { for candidate in &group.candidates { - if let Replacement::Summary(root) = &candidate.replacement { + if let Replacement::SubDAG(root) = &candidate.replacement { + if !root.contains_asap() { + continue; + } bind_aggregations( &mut model, &group.target, @@ -1713,10 +1683,13 @@ mod tests { op: CompareOpKind::Eq, value: "api".into(), }]; - let info_target = QueryExpr::PromqlInfoEnrich { - selector: selector.clone(), - child: target, - }; + let info_target = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( + NonASAPOp::PromqlInfoEnrich { + selector: selector.clone(), + child: target, + }, + )) + .unwrap(); let mut info_scope = streaming_scope(); info_scope .sources @@ -1739,8 +1712,9 @@ mod tests { } #[test] - fn delete_owner_must_be_the_unique_state_reachable_from_its_input() { - let workload = streaming_workload(); + fn summary_delete_dag_fails_closed_before_costing() { + // SummaryDelete is reserved: planning rejects the DAG even with full + // delete evidence, instead of costing (or owner-checking) the delete. let target = streaming_sum_query(); let root = summary_with_operations(false, false, true); let mut cpu = streaming_cpu(); @@ -1750,34 +1724,32 @@ mod tests { let mut model = streaming_model(); model.capabilities.delete = true; bind_aggregations(&mut model, &target, &root, streaming_inputs(), cpu); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - unreachable!(); - }; - let delete_ptr = Rc::as_ptr(summary_input); - let unrelated = summary_with_operations(false, false, false); - let unrelated_agg = evidence_nodes(&unrelated).0[0] as *const _; - model - .node_evidence - .operation_state_owners - .insert(delete_ptr, unrelated_agg); - let plan = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(plan.summary_total_cost, None); + // Under a evaluation, the reserved delete surfaces as an illegal child. + assert!(matches!( + streaming_planning_error(Rc::clone(&root), &model), + asap_types::post_asap::ExecutionDataStateError::IllegalChildDataState { + edge: "FinalizeExactAccumulator.child", + child: asap_types::post_asap::ExecutionDataState::QUERY_ROWS, + } + )); + // As the root, it is reported as the unimplemented operator itself. + assert!(matches!( + streaming_planning_error(evaluation_state(&root), &model), + asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { + operator: "SummaryDelete" + } + )); } #[test] fn summary_edge_and_io_evidence_fail_closed() { + // Over two independent summaries combined by a BinaryOp: a parent + // input edge that disagrees with its child's output, or missing I/O + // evidence on the root, leaves the whole-DAG cost unset. let workload = streaming_workload(); let target = streaming_sum_query(); - let root = summary_join(); + let root = add_independent_summary_results(); let mut model = streaming_model(); bind_aggregations( &mut model, @@ -1786,19 +1758,15 @@ mod tests { streaming_inputs(), streaming_cpu(), ); - let join = evidence_nodes(&root).1[0]; - model.node_evidence.joins.insert( - join as *const _, - SummaryJoinEvidence { - physical_id: "join-edge".into(), - inputs: vec![test_edge(), EdgeStatistics { rows: 2, bytes: 16 }], - output: test_edge(), - cpu_ops_per_execution: 1.0, - working_memory_bytes: 1, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, + model.node_evidence.insert_operation( + &root, + SummaryOperatorEvidence::Binary(test_resource( + "binary-edge", + vec![test_edge(), EdgeStatistics { rows: 2, bytes: 16 }], + 1.0, + 1, + 0, + )), ); let bad_edge = plan_summary_maintenance_lifecycles( Rc::clone(&root), @@ -1811,20 +1779,34 @@ mod tests { .unwrap(); assert_eq!(bad_edge.summary_total_cost, None); - model + let root_evidence = model .node_evidence - .joins - .get_mut(&(join as *const _)) + .operations + .get_mut(&Rc::as_ptr(&root)) .unwrap() - .inputs = vec![test_edge(), test_edge()]; + .resource_mut(); + root_evidence.inputs = vec![test_edge(), test_edge()]; + root_evidence.io_bytes_per_execution = None; + let missing_io = plan_summary_maintenance_lifecycles( + Rc::clone(&root), + WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), + 0, + Some(Horizon(5.0)), + SummaryMaintenanceLifecycleCapabilities::ALL, + &model, + ) + .unwrap(); + assert_eq!(missing_io.summary_total_cost, None); + + // Control: the same evidence with I/O restored is costable. model .node_evidence .operations .get_mut(&Rc::as_ptr(&root)) .unwrap() .resource_mut() - .io_bytes_per_execution = None; - let missing_io = plan_summary_maintenance_lifecycles( + .io_bytes_per_execution = Some(0); + let complete = plan_summary_maintenance_lifecycles( root, WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), 0, @@ -1833,14 +1815,17 @@ mod tests { &model, ) .unwrap(); - assert_eq!(missing_io.summary_total_cost, None); + assert!(complete.summary_total_cost.is_some()); } #[test] fn summary_edges_io_and_physical_identity_fail_closed() { + // Evidence bound to a structurally equal clone of the BinaryOp does not + // count for the real node; a bad input edge or missing I/O on the real + // node still fails closed. let workload = streaming_workload(); let target = streaming_sum_query(); - let root = summary_join(); + let root = add_independent_summary_results(); let mut model = streaming_model(); bind_aggregations( &mut model, @@ -1849,34 +1834,27 @@ mod tests { streaming_inputs(), streaming_cpu(), ); - let (_, joins) = evidence_nodes(&root); - model.node_evidence.insert_join( - &Rc::new(joins[0].clone()), - SummaryJoinEvidence { - physical_id: "unused".into(), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops_per_execution: 1.0, - working_memory_bytes: 1, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, + model.node_evidence.insert_operation( + &Rc::new((*root).clone()), + SummaryOperatorEvidence::Binary(test_resource( + "unused", + vec![test_edge(), test_edge()], + 1.0, + 1, + 0, + )), ); - // Bind the actual join, then make one parent input disagree with its - // child's output. - model.node_evidence.joins.insert( - joins[0] as *const _, - SummaryJoinEvidence { - physical_id: "join-edge".into(), - inputs: vec![test_edge(), EdgeStatistics { rows: 2, bytes: 16 }], - output: test_edge(), - cpu_ops_per_execution: 1.0, - working_memory_bytes: 1, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, + // Bind the actual BinaryOp, then make one parent input disagree with + // its child's output. + model.node_evidence.insert_operation( + &root, + SummaryOperatorEvidence::Binary(test_resource( + "binary-edge", + vec![test_edge(), EdgeStatistics { rows: 2, bytes: 16 }], + 1.0, + 1, + 0, + )), ); let bad_edge = plan_summary_maintenance_lifecycles( Rc::clone(&root), @@ -1889,19 +1867,14 @@ mod tests { .unwrap(); assert_eq!(bad_edge.summary_total_cost, None); - model - .node_evidence - .joins - .get_mut(&(joins[0] as *const _)) - .unwrap() - .inputs = vec![test_edge(), test_edge()]; - model + let root_evidence = model .node_evidence .operations .get_mut(&Rc::as_ptr(&root)) .unwrap() - .resource_mut() - .io_bytes_per_execution = None; + .resource_mut(); + root_evidence.inputs = vec![test_edge(), test_edge()]; + root_evidence.io_bytes_per_execution = None; let missing_io = plan_summary_maintenance_lifecycles( root, WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), @@ -1955,9 +1928,11 @@ mod tests { #[test] fn conflicting_evidence_cannot_alias_one_provider_physical_identity() { + // Two independent summary states (combined by a BinaryOp) that claim + // one physical id but carry different evidence leave the cost unset. let workload = streaming_workload(); let target = streaming_sum_query(); - let root = summary_join(); + let root = add_independent_summary_results(); let mut model = streaming_model(); bind_aggregations( &mut model, @@ -1967,6 +1942,7 @@ mod tests { streaming_cpu(), ); let aggregations = evidence_nodes(&root).0; + assert_eq!(aggregations.len(), 2); let first = aggregations[0] as *const _; let second = aggregations[1] as *const _; model @@ -1992,8 +1968,9 @@ mod tests { } #[test] - fn lifecycle_plan_does_not_fall_back_to_partial_agg_cost_for_a_join_root() { - let workload = streaming_workload(); + fn summary_join_root_fails_closed_even_with_join_evidence() { + // SummaryJoin is reserved: planning rejects the DAG whether or not + // join evidence is bound, so no partial or join cost is produced. let root = summary_join(); let target = streaming_sum_query(); let mut model = streaming_model(); @@ -2004,21 +1981,15 @@ mod tests { streaming_inputs(), streaming_cpu(), ); - let plan = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(plan.deployments.len(), 2); - assert_eq!(plan.summary_total_cost, None); + assert!(matches!( + streaming_planning_error(Rc::clone(&root), &model), + asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { + operator: "SummaryJoin" + } + )); - let mut costed = model; let join_node = evidence_nodes(&root).1[0]; - costed.node_evidence.joins.insert( + model.node_evidence.joins.insert( join_node as *const _, SummaryJoinEvidence { physical_id: "costed-join".into(), @@ -2031,28 +2002,28 @@ mod tests { io_bytes_per_execution: Some(0), }, ); - let costed_plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &costed, - ) - .unwrap(); - assert!(costed_plan.summary_total_cost.is_some()); + assert!(matches!( + streaming_planning_error(root, &model), + asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { + operator: "SummaryJoin" + } + )); } #[test] fn whole_dag_cost_requires_and_uses_each_rc_bound_state_evidence() { + // Over two independent summaries combined by a BinaryOp: the cost needs + // evidence for each Rc-bound state, charges peak transient memory, and + // de-duplicates bootstrap scans only on a shared provider read id. let workload = streaming_workload(); - let root = summary_join(); + let root = add_independent_summary_results(); + let (left, right) = binary_operands(&root); let target = streaming_sum_query(); - let (aggregations, joins) = evidence_nodes(&root); + let aggregations = [evaluation_state(&left), evaluation_state(&right)]; let mut model = streaming_model(); bind_comparison(&mut model, &target, &root); - model.node_evidence.aggregations.insert( - aggregations[0] as *const _, + model.node_evidence.insert_aggregation( + &aggregations[0], SummaryAggregateEvidence { physical_id: "left-state".into(), input: test_edge(), @@ -2078,8 +2049,8 @@ mod tests { second_inputs.state_bytes_per_summary = 250; let mut second_cpu = streaming_cpu(); second_cpu.insert_cpu_ops = Some(5.0); - model.node_evidence.aggregations.insert( - aggregations[1] as *const _, + model.node_evidence.insert_aggregation( + &aggregations[1], SummaryAggregateEvidence { physical_id: "right-state".into(), input: test_edge(), @@ -2090,31 +2061,37 @@ mod tests { insert_cpu_ops: second_cpu.insert_cpu_ops.unwrap(), }, ); - model.node_evidence.joins.insert( - joins[0] as *const _, - SummaryJoinEvidence { - physical_id: "join".into(), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops_per_execution: 6.0, - working_memory_bytes: 64, - output_buffer_bytes: 64, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, + // The left evaluation plays the old join's role: a 64-byte workspace and a + // 64-byte output that stays live until the root BinaryOp consumes it. + model.node_evidence.insert_operation( + &left, + SummaryOperatorEvidence::ValueOperation(test_resource( + "left-evaluation", + vec![test_edge()], + 6.0, + 64, + 64, + )), ); - model.node_evidence.operations.insert( - Rc::as_ptr(&root), - SummaryOperatorEvidence::Readout(SummaryOperatorResourceEvidence { - physical_id: "root-readout".into(), - inputs: vec![test_edge()], - output: test_edge(), - cpu_ops: 3.0, - working_memory_bytes: 0, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }), + model.node_evidence.insert_operation( + &right, + SummaryOperatorEvidence::ValueOperation(test_resource( + "right-evaluation", + vec![test_edge()], + 3.0, + 0, + 0, + )), + ); + model.node_evidence.insert_operation( + &root, + SummaryOperatorEvidence::Binary(test_resource( + "root-binary", + vec![test_edge(), test_edge()], + 3.0, + 0, + 0, + )), ); let complete = plan_summary_maintenance_lifecycles( Rc::clone(&root), @@ -2143,8 +2120,9 @@ mod tests { &model, ) .unwrap(); - // The join's 64-byte output remains live while the readout's workspace - // is active. The join's execution workspace is released first. + // The left evaluation's 64-byte output remains live while the binary's + // workspace is active (64 + 128 = 192); the evaluation's own workspace is + // released first, so the old peak was 64 + 64 = 128. assert_eq!( larger_workspace.summary_total_cost.unwrap().0 - complete.summary_total_cost.unwrap().0, 64.0 @@ -2184,7 +2162,10 @@ mod tests { }); let target = streaming_sum_query(); let root = summary_with_operations(false, false, false); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: summary_input, + }) = &root.operator + else { unreachable!(); }; let windowed_summary = Rc::clone(summary_input); @@ -2326,7 +2307,10 @@ mod tests { fn window_framework_candidates_require_unique_nonempty_planner_primitives() { let target = streaming_sum_query(); let root = summary_with_operations(false, false, false); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: summary_input, + }) = &root.operator + else { unreachable!(); }; let windowed_summary = Rc::clone(summary_input); @@ -2365,9 +2349,12 @@ mod tests { #[test] fn one_physical_identity_cannot_alias_different_window_frameworks() { + // Two independent summaries (combined by a BinaryOp) sharing one + // physical state id but assigned different window frameworks leave + // the cost unset. let workload = streaming_workload(); let target = streaming_sum_query(); - let root = summary_join(); + let root = add_independent_summary_results(); let mut model = streaming_model(); bind_aggregations( &mut model, @@ -2376,44 +2363,22 @@ mod tests { streaming_inputs(), streaming_cpu(), ); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - unreachable!(); - }; - let SummaryExpr::SummaryJoin { outer, inner, .. } = &summary_input.expr else { - unreachable!(); - }; - let aggregation_nodes = [Rc::clone(outer), Rc::clone(inner)]; - let (aggregations, joins) = evidence_nodes(&root); - model.node_evidence.joins.insert( - joins[0] as *const _, - SummaryJoinEvidence { - physical_id: "joined-readout".into(), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops_per_execution: 1.0, - working_memory_bytes: 8, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, - ); + let (left, right) = binary_operands(&root); + let aggregation_nodes = [evaluation_state(&left), evaluation_state(&right)]; let mut shared_aggregation = - model.node_evidence.aggregations[&(aggregations[0] as *const _)].clone(); + model.node_evidence.aggregations[&Rc::as_ptr(&aggregation_nodes[0])].clone(); shared_aggregation.physical_id = "shared-window-state".into(); - model - .node_evidence - .aggregations - .insert(aggregations[0] as *const _, shared_aggregation.clone()); - model - .node_evidence - .aggregations - .insert(aggregations[1] as *const _, shared_aggregation); + for aggregate in &aggregation_nodes { + model + .node_evidence + .insert_aggregation(aggregate, shared_aggregation.clone()); + } let retained_children: Vec<_> = aggregation_nodes .iter() - .map(|aggregate| match &aggregate.expr { - SummaryExpr::SummaryAgg { child, .. } => Rc::clone(child), + .map(|aggregate| match &aggregate.operator { + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => Rc::clone(child), _ => unreachable!(), }) .collect(); @@ -2428,7 +2393,7 @@ mod tests { } let candidate = SummaryWindowFrameworkCandidate { - physical_plan_id: "mixed-framework-join".into(), + physical_plan_id: "mixed-framework-binary".into(), assignments: vec![ SummaryWindowFrameworkAssignment { summary: Rc::clone(&aggregation_nodes[0]), @@ -2544,7 +2509,10 @@ mod tests { asap_types::workload::AccuracyRequirement::Explicit(AccuracyTarget::Epsilon(1.0)); let target = streaming_sum_query(); let root = summary_with_operations(false, false, false); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: summary_input, + }) = &root.operator + else { unreachable!(); }; let mut model = streaming_model(); @@ -2583,6 +2551,42 @@ mod tests { assert_eq!(plan.summary_total_cost, None); } + /// Bulk relational evidence cannot hide summary operators below an ordinary root. + #[test] + fn retained_subdag_evidence_cannot_hide_summary_work() { + let workload = streaming_workload(); + let target = streaming_sum_query(); + let root = add_shared_summary_result(); + let mut model = streaming_model(); + bind_aggregations( + &mut model, + &target, + &root, + streaming_inputs(), + streaming_cpu(), + ); + model.node_evidence.insert_retained_query( + &root, + RetainedSubDAGEvidence { + physical_id: "false-retained-root".into(), + output: test_edge(), + preprocessing_cpu_ops_over_horizon: 0.0, + working_memory_bytes: 0, + output_buffer_bytes: 0, + }, + ); + let plan = plan_summary_maintenance_lifecycles( + root, + WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), + 0, + Some(Horizon(5.0)), + SummaryMaintenanceLifecycleCapabilities::ALL, + &model, + ) + .unwrap(); + assert_eq!(plan.summary_total_cost, None); + } + #[test] fn whole_dag_fails_closed_for_missing_retained_work_or_false_source_lineage() { let workload = streaming_workload(); @@ -2631,23 +2635,22 @@ mod tests { } #[test] - fn aggregate_recurses_into_child_operations_and_state_only_needs_no_readout() { + fn state_only_needs_no_evaluation_and_summary_merge_child_fails_closed() { + // A state-only root is costable without evaluation evidence; a + // SummaryAgg over a reserved SummaryMerge is rejected at planning. let workload = streaming_workload(); let target = streaming_sum_query(); let estimated = summary_with_operations(false, false, false); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &estimated.expr else { - unreachable!(); - }; - let state_only = Rc::clone(summary_input); - let mut no_readout_cpu = streaming_cpu(); - no_readout_cpu.readout_cpu_ops = None; + let state_only = evaluation_state(&estimated); + let mut no_evaluation_cpu = streaming_cpu(); + no_evaluation_cpu.evaluation_cpu_ops = None; let mut state_model = streaming_model(); bind_aggregations( &mut state_model, &target, &state_only, streaming_inputs(), - no_readout_cpu, + no_evaluation_cpu, ); let state_plan = plan_summary_maintenance_lifecycles( state_only, @@ -2660,27 +2663,24 @@ mod tests { .unwrap(); assert!(state_plan.summary_total_cost.is_some()); - let child_readout = summary_with_operations(true, false, false); - let SummaryExpr::SummaryEstimate { - summary_input: child, - .. - } = &child_readout.expr - else { - unreachable!(); - }; - let child = Rc::clone(child); - let nested = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Wildcard), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::PerSubpopulationInstance, - filter: None, - }, - schema: estimated.schema.clone(), - guarantee: None, - }); + let nested = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: evaluation_state(&summary_with_operations(true, false, false)), + family: FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), + input: SummaryUpdate { + item: None, + weight: asap_types::post_asap::SummaryInputExpr::Constant(1.0), + weight_domain: Default::default(), + }, + reduction: Reduction::by(vec![]), + grouping: GroupingStrategy::PerSubpopulationInstance, + filter: None, + }), + count_state_schema(), + ) + .with_guarantee(None), + ); let mut nested_cpu = streaming_cpu(); nested_cpu.merge_cpu_ops = Some(1.0); let mut nested_model = streaming_model(); @@ -2691,23 +2691,12 @@ mod tests { streaming_inputs(), nested_cpu, ); - nested_model - .node_evidence - .operations - .retain(|_, operation| { - operation.resource().cpu_ops != 1.0 - || operation.resource().working_memory_bytes == 0 - }); - let nested_plan = plan_summary_maintenance_lifecycles( - nested, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &nested_model, - ) - .unwrap(); - assert_eq!(nested_plan.summary_total_cost, None); + assert!(matches!( + streaming_planning_error(nested, &nested_model), + asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { + operator: "SummaryMerge" + } + )); } #[test] @@ -2744,7 +2733,7 @@ mod tests { }, SummaryOperationCpuEvidence { insert_cpu_ops: Some(2.0), - readout_cpu_ops: Some(3.0), + evaluation_cpu_ops: Some(3.0), ..SummaryOperationCpuEvidence::default() }, ) @@ -2778,7 +2767,7 @@ mod tests { delete_cpu_ops: Some(5.0), delete_events_per_second: Some(4.0), delete_routing_fanout: Some(2), - readout_cpu_ops: Some(7.0), + evaluation_cpu_ops: Some(7.0), }, ) .unwrap(); @@ -2808,7 +2797,7 @@ mod tests { }, SummaryOperationCpuEvidence { insert_cpu_ops: Some(1.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }, ), @@ -2835,7 +2824,7 @@ mod tests { }, SummaryOperationCpuEvidence { insert_cpu_ops: Some(1.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }, ), @@ -2866,7 +2855,7 @@ mod tests { }, SummaryOperationCpuEvidence { insert_cpu_ops: Some(1.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }, ), @@ -2901,7 +2890,7 @@ mod tests { }, SummaryOperationCpuEvidence { insert_cpu_ops: Some(1.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }, ) @@ -2937,7 +2926,7 @@ mod tests { }, SummaryOperationCpuEvidence { insert_cpu_ops: Some(1.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }, ) @@ -2981,7 +2970,7 @@ mod tests { }; let cpu = SummaryOperationCpuEvidence { insert_cpu_ops: Some(1.0), - readout_cpu_ops: Some(1.0), + evaluation_cpu_ops: Some(1.0), ..SummaryOperationCpuEvidence::default() }; assert_eq!( @@ -3009,157 +2998,253 @@ mod tests { assert_eq!(estimate.peak_memory_bytes(), 64); // 4 persistent states + join memory. } - fn summary_with_operations(merge: bool, subtract: bool, delete: bool) -> Rc { + fn count_state_schema() -> Schema { + Schema::lifted( + vec![Field::new( + "count", + FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), + false, + )], + None, + ) + } + + fn count_evaluation_schema() -> Schema { + Schema::lifted(vec![Field::plain("count", DataType::Int64, false)], None) + } + + /// The retained relational input of every test summary: a bare scan of + /// the `metrics` series. + fn metrics_scan() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: "metrics".into(), + }, + predicates: vec![], + schema: Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + ], + 0, + vec![], + ), + })) + .unwrap() + } + + fn summary_with_operations(merge: bool, subtract: bool, delete: bool) -> Rc { let state_type = FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count); - let schema = Schema::lifted(vec![Field::new("count", state_type.clone(), false)], None); - let leaf = Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::new(QueryExpr::Scan { - source: Source::TimeSeries { - metric: "metrics".into(), - }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ], - 0, - vec![], - ), - })), - schema: schema.clone(), - guarantee: None, - }); - let agg = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: leaf, + let schema = count_state_schema(); + let child = metrics_scan(); + let agg = OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child, family: state_type, - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Wildcard), + input: SummaryUpdate { + item: None, + weight: asap_types::post_asap::SummaryInputExpr::Constant(1.0), + weight_domain: Default::default(), + }, reduction: Reduction::by(vec![]), grouping: GroupingStrategy::PerSubpopulationInstance, filter: None, - }, - schema: schema.clone(), - guarantee: None, - }); + }), + schema.clone(), + ) + .with_guarantee(None); + let agg = std::rc::Rc::new(agg); let mut root = Rc::clone(&agg); if merge { - root = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryMerge { - timing: asap_types::post_asap::ExecutionTiming::IngestionTime, - children: vec![Rc::clone(&agg), Rc::clone(&agg)], - }, - schema: schema.clone(), - guarantee: None, - }); + root = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryMerge { + children: vec![Rc::clone(&agg), Rc::clone(&agg)], + }), + schema.clone(), + ) + .with_guarantee(None), + ); } if subtract { - root = Rc::new(SummaryNode { - expr: SummaryExpr::SummarySubtract { - left: Rc::clone(&root), - right: Rc::clone(&agg), - }, - schema: schema.clone(), - guarantee: None, - }); + root = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummarySubtract { + left: Rc::clone(&root), + right: Rc::clone(&agg), + }), + schema.clone(), + ) + .with_guarantee(None), + ); } if delete { - root = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryDelete { - summary_input: root, - key: ColumnRef::Wildcard, - }, - schema: schema.clone(), - guarantee: None, - }); + root = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryDelete { + summary_input: root, + key: 0, + }), + schema.clone(), + ) + .with_guarantee(None), + ); } - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input: root, - query: asap_types::post_asap::SketchStatistic::PointCount { - key: ColumnRef::Wildcard, - value: None, - }, - }, - schema, - guarantee: Some(ResultGuarantee::exact("exact count readout")), - }) + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: root }), + count_evaluation_schema(), + ) + .with_guarantee(Some(ResultGuarantee::exact("exact count evaluation"))), + ) } - fn summary_join() -> Rc { + fn summary_join() -> Rc { let left = summary_with_operations(false, false, false); let right = summary_with_operations(false, false, false); - let SummaryExpr::SummaryEstimate { - summary_input: left, - .. - } = &left.expr + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: left }) = &left.operator else { unreachable!() }; - let SummaryExpr::SummaryEstimate { - summary_input: right, - .. - } = &right.expr + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: right }) = &right.operator else { unreachable!() }; - let schema = left.schema.clone(); - let join = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryJoin { - outer: Rc::clone(left), - inner: Rc::clone(right), - key: ColumnRef::Wildcard, - family: FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), - }, - schema: schema.clone(), - guarantee: None, - }); - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input: join, - query: asap_types::post_asap::SketchStatistic::PointCount { - key: ColumnRef::Wildcard, - value: None, - }, - }, - schema, - guarantee: None, - }) + let join = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryJoin { + outer: Rc::clone(left), + inner: Rc::clone(right), + key: 0, + family: FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), + }), + left.schema.clone(), + ) + .with_guarantee(None), + ); + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: join }), + count_evaluation_schema(), + ) + .with_guarantee(None), + ) } - fn summary_binary() -> Rc { + fn add_shared_summary_result() -> Rc { let operand = summary_with_operations(false, false, false); - Rc::new(SummaryNode { - expr: SummaryExpr::BinaryOp { - timing: asap_types::post_asap::ExecutionTiming::QueryTime, + Rc::new( + OperatorNode::new(Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + vector_match: None, + }, + return_bool: false, lhs: Rc::clone(&operand), rhs: operand, - operator: asap_types::post_asap::BinaryOperator { + })) + .unwrap() + .with_guarantee(Some(ResultGuarantee::exact("test binary"))), + ) + } + + /// Two independent summary states, each read out, combined by an ordinary + /// `BinaryOp`: the non-reserved replacement for a `SummaryJoin` fixture. + #[test] + fn independent_summary_results_form_a_valid_dag() { + add_independent_summary_results() + .validate_structure() + .unwrap(); + } + + fn add_independent_summary_results() -> Rc { + Rc::new( + OperatorNode::new(Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { checked_relative_division: false, checked_finite_division: false, - kind: asap_types::pre_asap::BinaryOpKind::Arithmetic( - asap_types::pre_asap::ArithmeticOpKind::Add, - ), + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), vector_match: None, }, - }, - schema: Schema::lifted( - vec![Field::new( - "value", - FieldDataType::Plain(DataType::Float64), - false, - )], - None, - ), - guarantee: Some(ResultGuarantee::exact("test binary")), - }) + return_bool: false, + lhs: summary_with_operations(false, false, false), + rhs: summary_with_operations(false, false, false), + })) + .unwrap() + .with_guarantee(Some(ResultGuarantee::exact("test binary"))), + ) + } + + /// The `(lhs, rhs)` evaluations of [`add_independent_summary_results`]. + fn binary_operands(root: &OperatorNode) -> (Rc, Rc) { + let Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) = &root.operator else { + unreachable!(); + }; + (Rc::clone(lhs), Rc::clone(rhs)) + } + + /// The `SummaryAgg` under one `SummaryEstimate` evaluation. + fn evaluation_state(evaluation: &OperatorNode) -> Rc { + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: summary_input, + }) = &evaluation.operator + else { + unreachable!(); + }; + Rc::clone(summary_input) + } + + /// The DAG-validation error that streaming lifecycle planning of `root` + /// fails closed with. + fn streaming_planning_error( + root: Rc, + model: &SummaryMaintenanceCostModel, + ) -> asap_types::post_asap::ExecutionDataStateError { + let workload = streaming_workload(); + match plan_summary_maintenance_lifecycles( + root, + WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), + 0, + Some(Horizon(5.0)), + SummaryMaintenanceLifecycleCapabilities::ALL, + model, + ) { + Err( + crate::summary_maintenance_lifecycle::SummaryMaintenanceLifecyclePlanError::InvalidPostAsapDAG( + error, + ), + ) => error, + Err(other) => panic!("unexpected planning error: {other}"), + Ok(_) => panic!("planning must fail closed"), + } + } + + fn test_resource( + physical_id: &str, + inputs: Vec, + cpu_ops: f64, + working_memory_bytes: u64, + output_buffer_bytes: u64, + ) -> SummaryOperatorResourceEvidence { + SummaryOperatorResourceEvidence { + physical_id: physical_id.into(), + inputs, + output: test_edge(), + cpu_ops, + working_memory_bytes, + output_buffer_bytes, + executions_per_evaluation: 1, + io_bytes_per_execution: Some(0), + } } #[test] fn exact_binary_is_costable_with_explicit_physical_evidence() { let workload = streaming_workload(); let target = streaming_sum_query(); - let root = summary_binary(); + let root = add_shared_summary_result(); let mut model = streaming_model(); bind_aggregations( &mut model, @@ -3186,29 +3271,16 @@ mod tests { assert!(plan.summary_total_cost.is_some()); } - fn streaming_sum_query() -> Rc { - let scan = Rc::new(QueryExpr::Scan { - source: Source::TimeSeries { - metric: "metrics".into(), - }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ], - 0, - vec![], - ), - }); - Rc::new(QueryExpr::Aggregate { + fn streaming_sum_query() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![]), measures: vec![AggIntent::Sum { col: None }], output_names: vec![], filters: vec![], having: None, - child: scan, - }) + child: metrics_scan(), + })) + .unwrap() } fn streaming_workload() -> QueryWorkload { @@ -3303,61 +3375,43 @@ mod tests { } } + /// A retained relational sub-DAG: a non-ASAP node with no summary below + /// it, costed as one unit through retained-query evidence. + fn is_retained(node: &OperatorNode) -> bool { + !node.contains_asap() + } + fn bind_comparison( model: &mut SummaryMaintenanceCostModel, - target: &Rc, - root: &Rc, + target: &Rc, + root: &Rc, ) { model .bind_candidate_comparison(target, root, streaming_scope(), streaming_raw()) .unwrap(); fn retained( model: &mut SummaryMaintenanceCostModel, - node: &Rc, - seen: &mut HashSet<*const SummaryNode>, + node: &Rc, + seen: &mut HashSet<*const OperatorNode>, ) { if !seen.insert(Rc::as_ptr(node)) { return; } - match &node.expr { - SummaryExpr::KeepPreAsap(_) => { - model.node_evidence.insert_retained_query( - node, - RetainedSubDAGEvidence { - physical_id: format!("retained-{node:p}"), - output: test_edge(), - preprocessing_cpu_ops_over_horizon: 1.0, - working_memory_bytes: 8, - output_buffer_bytes: 0, - }, - ); - } - SummaryExpr::SummaryAgg { child, .. } - | SummaryExpr::ValueOperation { child, .. } => retained(model, child, seen), - SummaryExpr::SummaryMerge { children, .. } => { - for child in children { - retained(model, child, seen); - } - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => { - retained(model, left, seen); - retained(model, right, seen); - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - retained(model, summary_input, seen) - } + if is_retained(node) { + model.node_evidence.insert_retained_query( + node, + RetainedSubDAGEvidence { + physical_id: format!("retained-{node:p}"), + output: test_edge(), + preprocessing_cpu_ops_over_horizon: 1.0, + working_memory_bytes: 8, + output_buffer_bytes: 0, + }, + ); + return; + } + for child in node.children() { + retained(model, child, seen); } } retained(model, root, &mut HashSet::new()); @@ -3384,24 +3438,23 @@ mod tests { fn streaming_cpu() -> SummaryOperationCpuEvidence { SummaryOperationCpuEvidence { insert_cpu_ops: Some(2.0), - readout_cpu_ops: Some(3.0), + evaluation_cpu_ops: Some(3.0), ..SummaryOperationCpuEvidence::default() } } fn bind_aggregations( model: &mut SummaryMaintenanceCostModel, - target: &Rc, - root: &Rc, + target: &Rc, + root: &Rc, inputs: SummaryMaintenanceInputs, cpu: SummaryOperationCpuEvidence, ) { bind_comparison(model, target, root); for node in evidence_nodes(root).0 { let source_root = matches!( - &node.expr, - SummaryExpr::SummaryAgg { child, .. } - if matches!(child.expr, SummaryExpr::KeepPreAsap(_)) + &node.operator, + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) if is_retained(child) ); let mut node_inputs = inputs; if !source_root { @@ -3426,147 +3479,131 @@ mod tests { }, ); } + fn resource( + physical_id: String, + inputs: Vec, + cpu_ops: f64, + working_memory_bytes: u64, + ) -> SummaryOperatorResourceEvidence { + SummaryOperatorResourceEvidence { + physical_id, + inputs, + output: test_edge(), + cpu_ops, + working_memory_bytes, + output_buffer_bytes: 0, + executions_per_evaluation: 1, + io_bytes_per_execution: Some(0), + } + } fn bind_ops( model: &mut SummaryMaintenanceCostModel, - node: &SummaryNode, - seen: &mut HashSet<*const SummaryNode>, + node: &OperatorNode, + seen: &mut HashSet<*const OperatorNode>, inputs: SummaryMaintenanceInputs, cpu: SummaryOperationCpuEvidence, ) { if !seen.insert(node as *const _) { return; } - let operation = match &node.expr { - SummaryExpr::BinaryOp { .. } => cpu.readout_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Binary(SummaryOperatorResourceEvidence { - physical_id: format!("binary-{node:p}"), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops, - working_memory_bytes: 0, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }) - }), - SummaryExpr::ValueOperation { .. } => cpu.readout_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::ValueOperation(SummaryOperatorResourceEvidence { - physical_id: format!("value-operation-{node:p}"), - inputs: vec![test_edge()], - output: test_edge(), - cpu_ops, - working_memory_bytes: 0, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), + if is_retained(node) { + return; + } + let operation = match &node.operator { + Operator::NonASAP(NonASAPOp::BinaryOp { .. }) => { + cpu.evaluation_cpu_ops.map(|cpu_ops| { + SummaryOperatorEvidence::Binary(resource( + format!("binary-{node:p}"), + vec![test_edge(), test_edge()], + cpu_ops, + 0, + )) }) - }), - SummaryExpr::SummaryMerge { .. } => cpu.merge_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Merge(SummaryOperatorResourceEvidence { - physical_id: format!("merge-{node:p}"), - inputs: match &node.expr { - SummaryExpr::SummaryMerge { children, .. } => { - vec![test_edge(); children.len()] - } - _ => unreachable!(), - }, - output: test_edge(), + } + Operator::NonASAP(NonASAPOp::Join { .. }) => None, + Operator::NonASAP(_) + | Operator::ASAP( + ASAPOp::FinalizeExactAccumulator { .. } + | ASAPOp::MaintainPopulation { .. } + | ASAPOp::EvaluatePopulation { .. }, + ) => cpu.evaluation_cpu_ops.map(|cpu_ops| { + SummaryOperatorEvidence::ValueOperation(resource( + format!("value-operation-{node:p}"), + vec![test_edge()], cpu_ops, - working_memory_bytes: inputs.state_bytes_per_summary, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }) + 0, + )) }), - SummaryExpr::SummarySubtract { .. } => cpu.subtract_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Subtract(SummaryOperatorResourceEvidence { - physical_id: format!("subtract-{node:p}"), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops, - working_memory_bytes: inputs.state_bytes_per_summary, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), + Operator::ASAP(ASAPOp::SummaryMerge { children }) => { + cpu.merge_cpu_ops.map(|cpu_ops| { + SummaryOperatorEvidence::Merge(resource( + format!("merge-{node:p}"), + vec![test_edge(); children.len()], + cpu_ops, + inputs.state_bytes_per_summary, + )) }) - }), - SummaryExpr::SummaryDelete { .. } => cpu.delete_cpu_ops.and_then(|cpu_ops| { - Some(SummaryOperatorEvidence::Delete { - resource: SummaryOperatorResourceEvidence { - physical_id: format!("delete-{node:p}"), - inputs: vec![test_edge()], - output: test_edge(), + } + Operator::ASAP(ASAPOp::SummarySubtract { .. }) => { + cpu.subtract_cpu_ops.map(|cpu_ops| { + SummaryOperatorEvidence::Subtract(resource( + format!("subtract-{node:p}"), + vec![test_edge(), test_edge()], cpu_ops, - working_memory_bytes: 0, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, - events_per_second: cpu.delete_events_per_second?, - routing_fanout: cpu.delete_routing_fanout?, + inputs.state_bytes_per_summary, + )) }) - }), - SummaryExpr::SummaryEstimate { .. } => cpu.readout_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Readout(SummaryOperatorResourceEvidence { - physical_id: format!("readout-{node:p}"), - inputs: vec![test_edge()], - output: test_edge(), - cpu_ops, - working_memory_bytes: 0, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), + } + Operator::ASAP(ASAPOp::SummaryDelete { .. }) => { + cpu.delete_cpu_ops.and_then(|cpu_ops| { + Some(SummaryOperatorEvidence::Delete { + resource: resource( + format!("delete-{node:p}"), + vec![test_edge()], + cpu_ops, + 0, + ), + events_per_second: cpu.delete_events_per_second?, + routing_fanout: cpu.delete_routing_fanout?, + }) }) - }), - _ => None, + } + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) => { + cpu.evaluation_cpu_ops.map(|cpu_ops| { + SummaryOperatorEvidence::Evaluation(resource( + format!("evaluation-{node:p}"), + vec![test_edge()], + cpu_ops, + 0, + )) + }) + } + Operator::ASAP( + ASAPOp::SummaryAgg { .. } + | ASAPOp::SummaryJoin { .. } + | ASAPOp::Extension { .. }, + ) => None, }; if let Some(operation) = operation { model .node_evidence .operations .insert(node as *const _, operation); - if let SummaryExpr::SummaryDelete { summary_input, .. } = &node.expr { + if let Operator::ASAP(ASAPOp::SummaryDelete { summary_input, .. }) = &node.operator + { fn owning_aggs( - node: &SummaryNode, - seen: &mut HashSet<*const SummaryNode>, - owners: &mut Vec<*const SummaryNode>, + node: &OperatorNode, + seen: &mut HashSet<*const OperatorNode>, + owners: &mut Vec<*const OperatorNode>, ) { if !seen.insert(node as *const _) { return; } - match &node.expr { - SummaryExpr::SummaryAgg { child, .. } => { - owners.push(node as *const _); - owning_aggs(child, seen, owners); - } - SummaryExpr::ValueOperation { child, .. } => { - owning_aggs(child, seen, owners) - } - SummaryExpr::SummaryMerge { children, .. } => { - for child in children { - owning_aggs(child, seen, owners); - } - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => { - owning_aggs(left, seen, owners); - owning_aggs(right, seen, owners); - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - owning_aggs(summary_input, seen, owners); - } - SummaryExpr::KeepPreAsap(_) => {} + if matches!(node.operator, Operator::ASAP(ASAPOp::SummaryAgg { .. })) { + owners.push(node as *const _); + } + for child in node.children() { + owning_aggs(child, seen, owners); } } let mut owners = Vec::new(); @@ -3581,36 +3618,8 @@ mod tests { } } } - match &node.expr { - SummaryExpr::SummaryAgg { child, .. } - | SummaryExpr::ValueOperation { child, .. } => { - bind_ops(model, child, seen, inputs, cpu) - } - SummaryExpr::SummaryMerge { children, .. } => { - for child in children { - bind_ops(model, child, seen, inputs, cpu); - } - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => { - bind_ops(model, left, seen, inputs, cpu); - bind_ops(model, right, seen, inputs, cpu); - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - bind_ops(model, summary_input, seen, inputs, cpu) - } - SummaryExpr::KeepPreAsap(_) => {} + for child in node.children() { + bind_ops(model, child, seen, inputs, cpu); } } bind_ops(model, root, &mut HashSet::new(), inputs, cpu); diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs index c3c12c20b..fb80d5ee0 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs @@ -3,7 +3,7 @@ use super::*; /// One per-state window choice within a complete Planner candidate. #[derive(Debug, Clone)] pub struct SummaryWindowFrameworkAssignment { - pub summary: Rc, + pub summary: Rc, /// `None` explicitly means that this state is not window-organized. pub framework: Option, } @@ -29,46 +29,20 @@ pub struct SummaryWindowFrameworkCandidate { pub node_evidence: SummaryNodeEvidence, } -pub(super) fn summary_aggregation_identities(root: &SummaryNode) -> HashSet<*const SummaryNode> { +pub(super) fn summary_aggregation_identities(root: &OperatorNode) -> HashSet<*const OperatorNode> { fn visit( - node: &SummaryNode, - seen: &mut HashSet<*const SummaryNode>, - out: &mut HashSet<*const SummaryNode>, + node: &OperatorNode, + seen: &mut HashSet<*const OperatorNode>, + out: &mut HashSet<*const OperatorNode>, ) { if !seen.insert(node as *const _) { return; } - match &node.expr { - SummaryExpr::KeepPreAsap(_) => {} - SummaryExpr::SummaryAgg { child, .. } => { - out.insert(node as *const _); - visit(child, seen, out); - } - SummaryExpr::ValueOperation { child, .. } => visit(child, seen, out), - SummaryExpr::SummaryMerge { children, .. } => { - for child in children { - visit(child, seen, out); - } - } - SummaryExpr::SummarySubtract { left, right } - | SummaryExpr::RelationalJoin { left, right, .. } - | SummaryExpr::BinaryOp { - lhs: left, - rhs: right, - .. - } - | SummaryExpr::SummaryJoin { - outer: left, - inner: right, - .. - } => { - visit(left, seen, out); - visit(right, seen, out); - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - visit(summary_input, seen, out); - } + if matches!(node.operator, Operator::ASAP(ASAPOp::SummaryAgg { .. })) { + out.insert(node as *const _); + } + for child in node.children() { + visit(child, seen, out); } } @@ -146,11 +120,11 @@ impl SummaryWindowAccuracyEvidence { eh_summaries.len() == 1 && eh_summaries.iter().all(|assignment| { matches!( - &assignment.summary.expr, - SummaryExpr::SummaryAgg { + &assignment.summary.operator, + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } if kind.algorithm() == &SketchAlgorithm::Kll + }) if kind.algorithm() == &SketchAlgorithm::Kll ) }) } @@ -160,14 +134,14 @@ impl SummaryWindowAccuracyEvidence { eh_summaries.len() == 1 && eh_summaries.iter().all(|assignment| { matches!( - &assignment.summary.expr, - SummaryExpr::SummaryAgg { + &assignment.summary.operator, + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate( ExactKind::Count | ExactKind::Sum, _ ), .. - } + }) ) }) } diff --git a/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs b/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs index 8e63a4ce1..a3a7bb8f8 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs @@ -8,12 +8,14 @@ use std::collections::HashMap; use std::rc::Rc; +use asap_types::ir::OperatorNode; use serde::Serialize; use asap_types::dag_export::{self, SummaryDAG}; +use asap_types::ir::physical_export::PhysicalASAPNodeId; use asap_types::post_asap::{ - PostAsapNodeId, ResultGuarantee, SummaryExpr, SummaryMaintenanceLifecycle, - SummaryMaintenanceLifecycleGuarantee, SummaryNode, SummaryWindowFramework, + ResultGuarantee, SummaryMaintenanceLifecycle, SummaryMaintenanceLifecycleGuarantee, + SummaryWindowFramework, }; use crate::summary_maintenance_lifecycle::{ @@ -42,7 +44,7 @@ pub struct SummaryMaintenanceDAGExport { #[derive(Debug, Clone, Serialize)] pub struct SummaryMaintenanceDeploymentExport { - pub post_asap_node_id: PostAsapNodeId, + pub post_asap_node_id: PhysicalASAPNodeId, #[serde(skip_serializing_if = "Option::is_none")] pub selected_window_framework: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -93,13 +95,7 @@ pub fn export_summary_maintenance_plan( .zip(&deployments) .map(|(deployment, export)| (Rc::as_ptr(&deployment.summary), export)) .collect(); - let mut next_node_id = 0; - annotate_lifecycle_deployments( - &plan.root, - &mut dag, - &deployment_by_summary, - &mut next_node_id, - ); + annotate_lifecycle_deployments(&mut dag, &deployment_by_summary); SummaryMaintenanceDAGExport { dag, @@ -116,47 +112,21 @@ pub fn export_summary_maintenance_plan( } } -/// Walk in the same post-order as `dag_export::export_summary` and attach a -/// deployment directly to every flattened occurrence of its state node. -/// This makes the decision visible to DAG consumers without asking them to -/// reconstruct pointer identity from DAG position. +/// Attach a deployment directly to the exported node of its `SummaryAgg`, +/// matched by the `Rc` identity every exported node carries. This makes the +/// decision visible to dag consumers without asking them to reconstruct +/// pointer identity from dag position. fn annotate_lifecycle_deployments( - node: &SummaryNode, dag: &mut SummaryDAG, - deployments: &HashMap<*const SummaryNode, &SummaryMaintenanceDeploymentExport>, - next_node_id: &mut usize, + deployments: &HashMap<*const OperatorNode, &SummaryMaintenanceDeploymentExport>, ) { - if !matches!(node.expr, SummaryExpr::KeepPreAsap(_)) { - for child in summary_children(&node.expr) { - annotate_lifecycle_deployments(child, dag, deployments, next_node_id); + for dag_node in &mut dag.nodes { + let Some(source) = &dag_node.source_node else { + continue; + }; + if let Some(deployment) = deployments.get(&Rc::as_ptr(source)) { + dag_node.detail["summary_maintenance"] = + serde_json::to_value(deployment).expect("lifecycle export is serializable"); } } - let dag_node = &mut dag.nodes[*next_node_id]; - if let Some(deployment) = deployments.get(&(node as *const SummaryNode)) { - dag_node.detail["summary_maintenance"] = - serde_json::to_value(deployment).expect("lifecycle export is serializable"); - } - *next_node_id += 1; -} - -fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { - match expr { - SummaryExpr::KeepPreAsap(_) => vec![], - SummaryExpr::BinaryOp { lhs, rhs, .. } => vec![lhs, rhs], - SummaryExpr::SummaryAgg { child, .. } => vec![child], - SummaryExpr::ValueOperation { child, .. } => vec![child], - SummaryExpr::SummaryJoin { outer, inner, .. } - | SummaryExpr::RelationalJoin { - left: outer, - right: inner, - .. - } - | SummaryExpr::SummarySubtract { - left: outer, - right: inner, - } => vec![outer, inner], - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => vec![summary_input], - SummaryExpr::SummaryMerge { children, .. } => children.iter().collect(), - } } diff --git a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs index cf41efeff..12f38694b 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs @@ -17,17 +17,21 @@ //! incrementally. Unknown evidence stays unknown and therefore cannot make a //! long-lived alternative win. +use asap_types::ir::cse::share_common_sub_dags; use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use asap_types::ir::physical_export::{ + compile_physical_asap_dag_with_node_ids, PhysicalASAPDAG, PhysicalASAPDAGValidationError, + PhysicalASAPNodeId, +}; +use asap_types::ir::timing::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; +use asap_types::ir::{ASAPOp, Operator, OperatorNode}; use asap_types::post_asap::{ - compile_post_asap_dag_with_node_ids, share_common_summary_sub_dags, EvaluationSchedule, - ExecutionDataStateError, ExecutionTiming, OutputRepresentation, PostAsapDAG, - PostAsapDAGValidationError, PostAsapNodeId, ResultGuarantee, SummaryExpr, - SummaryMaintenanceLifecycle, SummaryMaintenanceLifecycleGuarantee, SummaryMaintenanceMode, - SummaryNode, SummaryWindowFramework, ValueOperation, + EvaluationSchedule, ExecutionDataStateError, ExecutionTiming, OutputRepresentation, + ResultGuarantee, SummaryMaintenanceLifecycle, SummaryMaintenanceLifecycleGuarantee, + SummaryMaintenanceMode, SummaryWindowFramework, }; -use asap_types::pre_asap::QueryExpr; use asap_types::types::AccuracyTarget; use asap_types::workload::{ DataArrival, DataWorkload, Predictability, QueryRecurrence, QueryWorkload, RepeatedDemand, @@ -44,7 +48,7 @@ use crate::recurrence::{ }; use crate::replacement::{ CandidateCostOverrides, CandidateLogicalASAPDAGs, GlobalSelection, RealizationError, - Replacement, + Replacement, ReplacementProvenance, }; /// Summary-maintenance lifecycle shapes supported by the target runtime. @@ -167,11 +171,9 @@ pub struct SummaryMaintenanceDeployment { /// Identity of this summary in the exported post-ASAP semantic DAG. /// It is scoped to one plan version and is not a summary definition or /// summary instance identity. - pub post_asap_node_id: PostAsapNodeId, - /// The unique materialized `SummaryAgg`, or maintained population - /// (`MaintainPopulation`) not consumed by a `SummaryAgg`, represented by - /// this deployment. Cost-model lifecycle hooks receive this node. - pub summary: Rc, + pub post_asap_node_id: PhysicalASAPNodeId, + /// The unique materialized `SummaryAgg` represented by this deployment. + pub summary: Rc, /// Lifecycle, evaluation, and representation commitment selected for this /// state, or `None` when no alternative is selectable. pub summary_maintenance_lifecycle_guarantee: Option, @@ -187,10 +189,9 @@ pub struct SummaryMaintenanceDeployment { #[derive(Debug, Clone)] pub struct SummaryMaintenanceLifecyclePlan { /// Root of the materialized post-ASAP DAG being deployed. - pub root: Rc, - /// One entry per unique reachable `SummaryAgg`, then per unique - /// maintained population outside any `SummaryAgg`'s inputs; shared `Rc` - /// nodes appear only once. + pub root: Rc, + /// One entry per unique reachable `SummaryAgg`; shared `Rc` nodes appear + /// only once. pub deployments: Vec, /// Caller-supplied optimization horizon used to turn rates into total /// costs. `None` keeps horizon-dependent alternatives unselectable. @@ -223,14 +224,14 @@ pub enum SummaryMaintenanceTimingError { #[error(transparent)] InvalidPostAsapDAG(#[from] ExecutionDataStateError), #[error("summary {0:?} has no selected lifecycle")] - UnselectedLifecycle(PostAsapNodeId), + UnselectedLifecycle(PhysicalASAPNodeId), /// A maintained population outside any `SummaryAgg`'s inputs has no /// deployment, so its timing would be guessed. Enumeration always emits /// one; this arises only for a plan whose root or deployments were edited. #[error("node {0:?} maintains state that has no summary-maintenance lifecycle")] - UnplannedMaintainedState(PostAsapNodeId), + UnplannedMaintainedState(PhysicalASAPNodeId), #[error(transparent)] - InvalidPhases(#[from] PostAsapDAGValidationError), + InvalidPhases(#[from] PhysicalASAPDAGValidationError), } impl SummaryMaintenanceLifecyclePlan { @@ -239,19 +240,25 @@ impl SummaryMaintenanceLifecyclePlan { /// /// A retained (non-`Ephemeral`) state outlives one query, so it and every /// input it consumes run at ingestion time. Every other node runs at query - /// time: readouts and consumers of retained state, and each `Ephemeral` + /// time: evaluations and consumers of retained state, and each `Ephemeral` /// state not consumed by retained state together with its inputs, whose /// raw data the deployment must supply as a query source. This applies to /// maintained populations as to `SummaryAgg` states; a population feeding /// a `SummaryAgg` is one of its inputs. Timings already on the root are /// ignored. - pub fn execution_timed_dag(&self) -> Result { - let compiled = compile_post_asap_dag_with_node_ids(&self.root)?; + pub fn execution_timed_dag(&self) -> Result { + let mut memo = TimingMemo::new(); + let timed = apply_lifecycle_timings( + &self.root, + &LifecycleAssignment::default_maintained(), + &mut memo, + )?; + let compiled = compile_physical_asap_dag_with_node_ids(&timed)?; let dag = compiled.dag; for population in &standalone_populations(&self.root) { let id = compiled .node_ids - .node_id(population) + .node_id(memo.timed(population).expect("population was timed")) .expect("collected population belongs to the compiled DAG"); if !self .deployments @@ -390,23 +397,23 @@ pub struct SummaryMaintenanceLifecycleCandidates<'a> { arrival: DataArrival, required_accuracy: Vec, cost_model: &'a dyn CostModel, - comparison_target: Option<&'a QueryExpr>, + comparison_target: Option<&'a OperatorNode>, } /// Why an explicit per-state lifecycle choice cannot be bound. #[derive(Debug, thiserror::Error, PartialEq)] pub enum SummaryMaintenanceLifecycleChoiceError { #[error("summary {0:?} is not a deployment of this root")] - UnknownSummary(PostAsapNodeId), + UnknownSummary(PhysicalASAPNodeId), #[error("summary {0:?} is chosen more than once")] - DuplicateChoice(PostAsapNodeId), + DuplicateChoice(PhysicalASAPNodeId), #[error("summary {0:?} has no chosen lifecycle")] - MissingChoice(PostAsapNodeId), + MissingChoice(PhysicalASAPNodeId), #[error("chosen lifecycle is not an enumerated alternative of summary {0:?}")] - NotAnAlternative(PostAsapNodeId), + NotAnAlternative(PhysicalASAPNodeId), #[error("chosen lifecycle of summary {post_asap_node_id:?} is rejected: {rejection:?}")] Rejected { - post_asap_node_id: PostAsapNodeId, + post_asap_node_id: PhysicalASAPNodeId, rejection: Option, }, #[error("summary states on one maintenance path have different evaluation schedules")] @@ -480,7 +487,7 @@ impl SummaryMaintenanceLifecycleCandidates<'_> { /// cost are the model's and unknown cost is never replaced by zero. pub fn select( mut self, - choices: &[(PostAsapNodeId, SummaryMaintenanceLifecycle)], + choices: &[(PhysicalASAPNodeId, SummaryMaintenanceLifecycle)], ) -> Result { use SummaryMaintenanceLifecycleChoiceError as E; let deployments = &self.plan.deployments; @@ -580,7 +587,7 @@ struct SummaryMaintenanceWorkloadFacts { /// unique summary state, and select the cheapest legal alternative whose cost /// is fully known. pub fn plan_summary_maintenance_lifecycles( - root: Rc, + root: Rc, demand: WorkloadDemand<'_>, now_ms: u64, horizon: Option, @@ -603,7 +610,7 @@ pub fn plan_summary_maintenance_lifecycles( /// alternatives itself binds its choice with /// [`SummaryMaintenanceLifecycleCandidates::select`]. pub fn enumerate_summary_maintenance_lifecycles<'a>( - root: Rc, + root: Rc, demand: WorkloadDemand<'_>, now_ms: u64, horizon: Option, @@ -627,14 +634,14 @@ pub fn enumerate_summary_maintenance_lifecycles<'a>( /// DAG path multiplicity has been propagated by `CandidateLogicalASAPDAGs`. #[expect(clippy::too_many_arguments, reason = "internal bound planning context")] fn enumerate_with_profile<'a>( - root: Rc, + root: Rc, demand: WorkloadDemand<'_>, now_ms: u64, horizon: Option, capabilities: SummaryMaintenanceLifecycleCapabilities, cost_model: &'a dyn CostModel, profile: Option, - comparison_target: Option<&'a QueryExpr>, + comparison_target: Option<&'a OperatorNode>, ) -> Result, SummaryMaintenanceLifecyclePlanError> { demand.workload.validate()?; if let Some(data) = demand.data_workload { @@ -673,11 +680,33 @@ fn enumerate_with_profile<'a>( StateKind::SummaryAgg, ); summaries.extend(standalone_populations(&root)); - let node_ids = compile_post_asap_dag_with_node_ids(&root)?.node_ids; + let mut timing_memo = TimingMemo::new(); + let timed_root = apply_lifecycle_timings( + &root, + &LifecycleAssignment::default_maintained(), + &mut timing_memo, + )?; + let node_ids = compile_physical_asap_dag_with_node_ids(&timed_root)?.node_ids; let components = summary_state_components(&summaries); let deployments: Vec = summaries .into_iter() .map(|summary| { + let capabilities = if OperatorNode::reachable(&summary).iter().any(|node| { + matches!( + node.non_asap(), + Some(asap_types::ir::NonASAPOp::BinaryOp { .. }) + ) && asap_types::ir::timing::validate_default(node, ExecutionTiming::IngestionTime) + .is_err() + }) { + SummaryMaintenanceLifecycleCapabilities { + supports_ephemeral: capabilities.supports_ephemeral, + supports_prepared: false, + supports_shared: false, + supports_continuously_maintained: false, + } + } else { + capabilities + }; let alternatives = alternatives_for( &facts, horizon, @@ -686,8 +715,9 @@ fn enumerate_with_profile<'a>( cost_model.summary_maintenance_lifecycle_cost_inputs_for_horizon(&summary, horizon), ); SummaryMaintenanceDeployment { - post_asap_node_id: node_ids - .node_id(&summary) + post_asap_node_id: timing_memo + .timed(&summary) + .and_then(|timed| node_ids.node_id(timed)) .expect("collected summary belongs to the compiled DAG"), summary, summary_maintenance_lifecycle_guarantee: None, @@ -696,7 +726,7 @@ fn enumerate_with_profile<'a>( } }) .collect(); - let selected_raw_recompute = matches!(root.expr, SummaryExpr::KeepPreAsap(_)); + let selected_raw_recompute = !root.contains_asap(); Ok(SummaryMaintenanceLifecycleCandidates { plan: SummaryMaintenanceLifecyclePlan { root, @@ -761,9 +791,14 @@ pub fn global_selection_with_summary_maintenance_lifecycles<'a, Id>( continue; }; for candidate in &group.candidates { - let Replacement::Summary(summary) = &candidate.replacement else { + // Only summary realizations carry a maintenance lifecycle; a + // logical rewrite or CSE share/recompute candidate does not. + let Replacement::SubDAG(summary) = &candidate.replacement else { continue; }; + if candidate.provenance != ReplacementProvenance::SummaryRealization { + continue; + } costs.finalize_target(&group.target); let plan = enumerate_with_profile( Rc::clone(summary), @@ -800,14 +835,14 @@ pub fn global_selection_with_summary_maintenance_lifecycles<'a, Id>( // Intern every member once; members whose outermost state (the // `SummaryAgg` every other state of the candidate feeds) interns to the // same node share it. Classes are kept in first-member order. - let interned = share_common_summary_sub_dags( + let interned = share_common_sub_dags( members .iter() .enumerate() .map(|(index, (_, _, summary))| (index, Rc::clone(summary))) .collect(), ); - let mut classes: Vec<(Rc, Vec)> = Vec::new(); + let mut classes: Vec<(Rc, Vec)> = Vec::new(); for (index, root) in interned { let states = summary_states(&root); let Some(state) = states @@ -826,7 +861,7 @@ pub fn global_selection_with_summary_maintenance_lifecycles<'a, Id>( } let mut shared = Vec::new(); for (state, class) in classes { - let mut targets: Vec<&Rc> = Vec::new(); + let mut targets: Vec<&Rc> = Vec::new(); for &index in &class { let target = &members[index].0.target; if !targets.iter().any(|t| Rc::ptr_eq(t, target)) { @@ -902,7 +937,7 @@ pub fn global_selection_with_summary_maintenance_lifecycles<'a, Id>( /// `demand`, or `None` when no lifecycle alternative is selectable for it. /// No comparison target is supplied: the state serves several queries. pub(crate) fn shared_state_cost( - state: &Rc, + state: &Rc, demand: WorkloadDemand<'_>, now_ms: u64, horizon: Option, @@ -924,7 +959,7 @@ pub(crate) fn shared_state_cost( } /// Every unique `SummaryAgg` reachable from `root`. -pub(crate) fn summary_states(root: &Rc) -> Vec> { +pub(crate) fn summary_states(root: &Rc) -> Vec> { let mut states = Vec::new(); collect_states( root, @@ -939,7 +974,7 @@ pub(crate) fn summary_states(root: &Rc) -> Vec> { /// summary maintenance decisions. This does not create or maintain runtime state. pub fn assemble_selected_dag_with_summary_maintenance_lifecycles( selection: &GlobalSelection<'_>, - target: &Rc, + target: &Rc, demand: WorkloadDemand<'_>, now_ms: u64, horizon: Option, @@ -966,8 +1001,8 @@ pub fn assemble_selected_dag_with_summary_maintenance_lifecycles( /// [`assemble_selected_dag_with_summary_maintenance_lifecycles`], for a root /// the caller already assembled (and possibly interned across queries). pub(crate) fn plan_assembled_dag( - root: Rc, - target: &Rc, + root: Rc, + target: &Rc, demand: WorkloadDemand<'_>, now_ms: u64, horizon: Option, @@ -994,7 +1029,7 @@ pub(crate) fn plan_assembled_dag( .is_none_or(|summary| raw.0 <= summary.0) }) { - plan.root = crate::replacement::keep_pre_asap(target)?; + plan.root = crate::replacement::retain_exact(target)?; plan.deployments.clear(); plan.selected_raw_recompute = true; plan.selected_window_implementation_id = None; @@ -1445,66 +1480,35 @@ enum StateKind { /// Collect every unique node of `kind` reachable from `node`. fn collect_states( - node: &Rc, - seen: &mut HashSet<*const SummaryNode>, - output: &mut Vec>, + node: &Rc, + seen: &mut HashSet<*const OperatorNode>, + output: &mut Vec>, kind: StateKind, ) { if !seen.insert(Rc::as_ptr(node)) { return; } - match &node.expr { - SummaryExpr::SummaryAgg { child, .. } => { - if kind == StateKind::SummaryAgg { - output.push(Rc::clone(node)); - } - collect_states(child, seen, output, kind); - } - SummaryExpr::ValueOperation { - child, operation, .. - } => { - if kind == StateKind::Population - && matches!(operation, ValueOperation::MaintainPopulation { .. }) - { - output.push(Rc::clone(node)); - } - collect_states(child, seen, output, kind) - } - SummaryExpr::SummaryJoin { outer, inner, .. } - | SummaryExpr::RelationalJoin { - left: outer, - right: inner, - .. - } - | SummaryExpr::BinaryOp { - lhs: outer, - rhs: inner, - .. - } - | SummaryExpr::SummarySubtract { - left: outer, - right: inner, - } => { - collect_states(outer, seen, output, kind); - collect_states(inner, seen, output, kind); - } - SummaryExpr::SummaryDelete { summary_input, .. } - | SummaryExpr::SummaryEstimate { summary_input, .. } => { - collect_states(summary_input, seen, output, kind) - } - SummaryExpr::SummaryMerge { children, .. } => { - for child in children { - collect_states(child, seen, output, kind); - } - } - SummaryExpr::KeepPreAsap(_) => {} + if matches!( + (&node.operator, kind), + ( + Operator::ASAP(ASAPOp::SummaryAgg { .. }), + StateKind::SummaryAgg + ) | ( + Operator::ASAP(ASAPOp::MaintainPopulation { .. }), + StateKind::Population + ) + ) { + output.push(Rc::clone(node)); + } + for child in node.children() { + collect_states(child, seen, output, kind); } } /// Maintained populations that are not an input of any `SummaryAgg`. A /// population feeding summary state is on that state's maintenance path, so -/// that state's lifecycle times it, even when a readout also reads it directly. -fn standalone_populations(root: &Rc) -> Vec> { +/// that state's lifecycle times it, even when a evaluation also reads it directly. +fn standalone_populations(root: &Rc) -> Vec> { let mut summaries = Vec::new(); collect_states( root, @@ -1552,7 +1556,7 @@ pub(crate) fn evaluation_schedule( /// Summary states composed on one maintenance path must be produced on the /// same schedule. Return a component id for each collected state. -fn summary_state_components(summaries: &[Rc]) -> Vec { +fn summary_state_components(summaries: &[Rc]) -> Vec { let indices: HashMap<_, _> = summaries .iter() .enumerate() @@ -1568,16 +1572,18 @@ fn summary_state_components(summaries: &[Rc]) -> Vec { } for (parent_index, summary) in summaries.iter().enumerate() { - let SummaryExpr::SummaryAgg { child, .. } = &summary.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary.operator else { continue; }; if !matches!( - child.expr, - SummaryExpr::SummaryAgg { .. } - | SummaryExpr::SummaryJoin { .. } - | SummaryExpr::SummarySubtract { .. } - | SummaryExpr::SummaryDelete { .. } - | SummaryExpr::SummaryMerge { .. } + child.operator, + Operator::ASAP( + ASAPOp::SummaryAgg { .. } + | ASAPOp::SummaryJoin { .. } + | ASAPOp::SummarySubtract { .. } + | ASAPOp::SummaryDelete { .. } + | ASAPOp::SummaryMerge { .. } + ) ) { continue; } @@ -1603,10 +1609,10 @@ fn summary_state_components(summaries: &[Rc]) -> Vec { /// Inputs shared by every complete lifecycle-combination evaluation of one /// root, whether Planner searches combinations or a caller supplies one. struct CompleteCostContext<'a> { - root: &'a SummaryNode, + root: &'a OperatorNode, components: &'a [usize], cost_model: &'a dyn CostModel, - comparison_target: Option<&'a QueryExpr>, + comparison_target: Option<&'a OperatorNode>, horizon: Option, expected_reads: Option, required_accuracy: &'a [AccuracyTarget], @@ -1695,12 +1701,12 @@ fn apply_selection( #[expect(clippy::too_many_arguments, reason = "complete combination context")] fn select_complete_lifecycle_combination( - root: &SummaryNode, + root: &OperatorNode, deployments: &mut [SummaryMaintenanceDeployment], components: &[usize], arrival: DataArrival, cost_model: &dyn CostModel, - comparison_target: Option<&QueryExpr>, + comparison_target: Option<&OperatorNode>, horizon: Option, expected_reads: Option, required_accuracy: &[AccuracyTarget], @@ -1836,12 +1842,16 @@ mod tests { } } use super::*; + use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; + use asap_types::ir::{BinaryOperator, NonASAPOp}; use asap_types::post_asap::{ - ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, PostAsapOperatorPayload, - ResultGuarantee, Schema, SketchAlgorithm, + ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, ResultGuarantee, Schema, + SketchAlgorithm, }; use asap_types::pre_asap::AggIntent; - use asap_types::pre_asap::{ColumnRef, DataType, QueryExpr, Reduction, Source}; + use asap_types::pre_asap::{ + ArithmeticOpKind, BinaryOpKind, ColumnRef, DataType, Reduction, Source, + }; use asap_types::types::AccuracyTarget; use asap_types::workload::{ BatchEntry, DataWorkload, DurationMs, Evidence, EvidenceSource, Predictability, Query, @@ -1861,7 +1871,7 @@ mod tests { fn summary_maintenance_lifecycle_cost_inputs( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { SummaryMaintenanceLifecycleCostInputs { build_cost: Some(Cost(10.0)), @@ -1874,7 +1884,7 @@ mod tests { fn summary_maintenance_capabilities( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { SummaryMaintenanceCapabilities { incremental_update: true, @@ -1897,19 +1907,19 @@ mod tests { fn summary_maintenance_lifecycle_cost_inputs( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { UnitCosts.summary_maintenance_lifecycle_cost_inputs(summary) } fn summary_maintenance_capabilities( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { UnitCosts.summary_maintenance_capabilities(summary) } - fn raw_query_recompute_cost(&self, _target: &QueryExpr) -> Option { + fn raw_query_recompute_cost(&self, _target: &OperatorNode) -> Option { Some(Cost(1.0)) } } @@ -1927,14 +1937,14 @@ mod tests { fn summary_maintenance_lifecycle_cost_inputs( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { UnitCosts.summary_maintenance_lifecycle_cost_inputs(summary) } fn summary_maintenance_capabilities( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { SummaryMaintenanceCapabilities { incremental_update: true, @@ -1949,7 +1959,7 @@ mod tests { impl CostModel for SummaryMaintenancePrefersDdSketch { fn raw_query_recompute_total_cost( &self, - _target: &QueryExpr, + _target: &OperatorNode, _expected_reads: f64, ) -> Option { Some(Cost(1_000.0)) @@ -1967,7 +1977,7 @@ mod tests { fn summary_maintenance_lifecycle_cost_inputs( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { let build = match sketch_algorithm(summary) { Some(SketchAlgorithm::Kll) => 100.0, @@ -1999,8 +2009,8 @@ mod tests { fn complete_summary_candidate_cost( &self, - _root: &SummaryNode, - _target: Option<&QueryExpr>, + _root: &OperatorNode, + _target: Option<&OperatorNode>, deployments: &[CostedSummaryDeployment<'_>], _horizon: Option, _expected_reads: Option, @@ -2032,12 +2042,13 @@ mod tests { fn summary_maintenance_lifecycle_cost_inputs( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { + // A leaf summary is one built directly over kept pre-ASAP rows + // (its child is not an ASAP node); a nested one reads state. let is_leaf = matches!( - summary.expr, - SummaryExpr::SummaryAgg { ref child, .. } - if matches!(child.expr, SummaryExpr::KeepPreAsap(_)) + &summary.operator, + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) if !child.is_asap() ); SummaryMaintenanceLifecycleCostInputs { build_cost: Some(Cost(if is_leaf { 1.0 } else { 100.0 })), @@ -2050,7 +2061,7 @@ mod tests { fn summary_maintenance_capabilities( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { SummaryMaintenanceCapabilities { incremental_update: true, @@ -2060,23 +2071,27 @@ mod tests { } } - fn sketch_algorithm(node: &SummaryNode) -> Option { - match &node.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => sketch_algorithm(summary_input), - SummaryExpr::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - } => Some(kind.algorithm().clone()), - _ => None, + /// The sketch algorithm of the first `SummaryAgg` reachable from `node` + /// (through a evaluation or any relational operator kept above it). + fn sketch_algorithm(node: &OperatorNode) -> Option { + if let Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) = &node.operator + { + return Some(kind.algorithm().clone()); } + node.children() + .into_iter() + .find_map(|child| sketch_algorithm(child)) } - fn query_root() -> Rc { + fn query_root() -> Rc { query_root_for("m") } - fn query_root_for(metric: &str) -> Rc { - Rc::new(QueryExpr::Scan { + fn query_root_for(metric: &str) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::TimeSeries { metric: metric.into(), }, @@ -2089,22 +2104,24 @@ mod tests { 0, vec![], ), - }) + })) + .unwrap() } - fn sum_query() -> Rc { - Rc::new(QueryExpr::Aggregate { + fn sum_query() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![]), measures: vec![AggIntent::Sum { col: None }], output_names: vec![], filters: vec![], having: None, child: query_root(), - }) + })) + .unwrap() } - fn quantile_query() -> Rc { - Rc::new(QueryExpr::Aggregate { + fn quantile_query() -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![]), measures: vec![AggIntent::Quantile { col: None, @@ -2115,49 +2132,56 @@ mod tests { filters: vec![], having: None, child: query_root(), - }) + })) + .unwrap() } - fn summary() -> Rc { - let child = Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(query_root()), - schema: Schema::lifted(vec![], None), - guarantee: Some(ResultGuarantee::exact("raw")), - }); + /// An exact sum accumulator over the kept pre-ASAP scan. + fn summary() -> Rc { + let child = Rc::new( + query_root() + .as_ref() + .clone() + .with_guarantee(Some(ResultGuarantee::exact("raw"))), + ); let family = FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( - "value".into(), - )), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }, - schema: Schema::lifted(vec![Field::new("state", family, false)], None), - guarantee: Some(ResultGuarantee::exact("sum")), - }) + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child, + family: family.clone(), + input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( + "value".into(), + )), + reduction: Reduction::by(vec![]), + grouping: GroupingStrategy::default(), + filter: None, + }), + Schema::lifted(vec![Field::new("state", family, false)], None), + ) + .with_guarantee(Some(ResultGuarantee::exact("sum"))), + ) } - fn nested_summary() -> Rc { + fn nested_summary() -> Rc { let child = summary(); let family = FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( - "state".into(), - )), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }, - schema: Schema::lifted(vec![Field::new("state", family, false)], None), - guarantee: Some(ResultGuarantee::exact("nested sum")), - }) + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child, + family: family.clone(), + input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( + "state".into(), + )), + reduction: Reduction::by(vec![]), + grouping: GroupingStrategy::default(), + filter: None, + }), + Schema::lifted(vec![Field::new("state", family, false)], None), + ) + .with_guarantee(Some(ResultGuarantee::exact("nested sum"))), + ) } fn batch(predictability: Predictability) -> BatchEntry { @@ -2649,7 +2673,13 @@ mod tests { assert_eq!(plan.raw_recompute_total_cost, Some(Cost(1.0))); assert_eq!(plan.summary_total_cost, None); assert!(plan.deployments.is_empty()); - assert!(matches!(plan.root.expr, SummaryExpr::KeepPreAsap(_))); + // The logical query stays exact; deployment assigns execution timing. + assert!(!plan.root.contains_asap()); + assert!(matches!( + plan.root.non_asap(), + Some(NonASAPOp::Aggregate { .. }) + )); + assert!(plan.root.timing.is_none()); let exported = crate::summary_maintenance_dag_export::export_summary_maintenance_plan(&plan); @@ -2663,7 +2693,7 @@ mod tests { fn whole_candidate_cost_is_evaluated_before_selecting_a_lifecycle() { let root = summary(); let mut deployments = vec![SummaryMaintenanceDeployment { - post_asap_node_id: PostAsapNodeId(0), + post_asap_node_id: 0, summary: Rc::clone(&root), summary_maintenance_lifecycle_guarantee: None, selected_window_framework: None, @@ -2726,7 +2756,7 @@ mod tests { ]; let mut deployments: Vec<_> = (0..13) .map(|summary_index| SummaryMaintenanceDeployment { - post_asap_node_id: PostAsapNodeId(summary_index as u32), + post_asap_node_id: (summary_index as usize), summary: Rc::clone(&root), summary_maintenance_lifecycle_guarantee: None, selected_window_framework: None, @@ -2771,7 +2801,11 @@ mod tests { .unwrap(); assert!(plan.selected_raw_recompute); assert!(plan.raw_recompute_total_cost.is_none()); - assert!(matches!(plan.root.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!plan.root.contains_asap()); + assert!(matches!( + plan.root.non_asap(), + Some(NonASAPOp::Aggregate { .. }) + )); } #[test] @@ -2796,7 +2830,8 @@ mod tests { assert_eq!(plan.raw_recompute_total_cost, Some(Cost(1.0))); assert_eq!(plan.summary_total_cost, None); assert!(plan.deployments.is_empty()); - assert!(matches!(plan.root.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!plan.root.contains_asap()); + assert!(matches!(plan.root.non_asap(), Some(NonASAPOp::Scan { .. }))); } #[test] @@ -2827,15 +2862,30 @@ mod tests { #[test] fn lifecycle_cost_counts_one_shared_summary_node_once() { + // One shared exact accumulator read twice by the same root: a + // query-time `sum + sum` over one finalized state. (`SummaryMerge` + // is reserved in the unified IR, so the sharing is expressed through + // a relational consumer instead.) let shared = summary(); - let root = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryMerge { - timing: asap_types::post_asap::ExecutionTiming::IngestionTime, - children: vec![Rc::clone(&shared), Rc::clone(&shared)], - }, - schema: shared.schema.clone(), - guarantee: None, - }); + let finalized = Rc::new( + OperatorNode::new(Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: Rc::clone(&shared), + })) + .unwrap(), + ); + let root = + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + vector_match: None, + }, + return_bool: false, + lhs: Rc::clone(&finalized), + rhs: finalized, + })) + .unwrap(); let workload = workload( vec![batch(Predictability::AdHoc), batch(Predictability::AdHoc)], vec![], @@ -2873,7 +2923,7 @@ mod tests { fn summary_maintenance_lifecycle_cost_inputs( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { SummaryMaintenanceLifecycleCostInputs { build_cost: Some(Cost(10.0)), @@ -2886,18 +2936,18 @@ mod tests { fn summary_maintenance_capabilities( &self, - summary: &SummaryNode, + summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { UnitCosts.summary_maintenance_capabilities(summary) } fn raw_query_recompute_total_cost( &self, - target: &QueryExpr, + target: &OperatorNode, _expected_reads: f64, ) -> Option { - match target { - QueryExpr::Aggregate { measures, .. } => match measures[..] { + match target.non_asap() { + Some(NonASAPOp::Aggregate { measures, .. }) => match measures[..] { [AggIntent::Quantile { q: 0.5, .. }] => Some(Cost(1.0)), _ => Some(Cost(8.0)), }, @@ -2914,7 +2964,7 @@ mod tests { #[test] fn sharing_class_reverts_when_a_member_selects_elsewhere() { let quantile = |q| { - Rc::new(QueryExpr::Aggregate { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![]), measures: vec![AggIntent::Quantile { col: None, @@ -2926,7 +2976,8 @@ mod tests { filters: vec![], having: None, child: query_root(), - }) + })) + .unwrap() }; let workload = workload(vec![], vec![repeating(), repeating()], at_rest()); // Whether each root selected a summary rather than raw recompute. @@ -3024,7 +3075,7 @@ mod tests { fn choose( candidates: &SummaryMaintenanceLifecycleCandidates<'_>, lifecycle: SummaryMaintenanceLifecycle, - ) -> Vec<(PostAsapNodeId, SummaryMaintenanceLifecycle)> { + ) -> Vec<(PhysicalASAPNodeId, SummaryMaintenanceLifecycle)> { candidates .deployments() .iter() @@ -3134,7 +3185,7 @@ mod tests { use SummaryMaintenanceLifecycleChoiceError as E; let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); - let select = |model: &dyn CostModel, choice: &dyn Fn(PostAsapNodeId) -> Vec<_>| { + let select = |model: &dyn CostModel, choice: &dyn Fn(PhysicalASAPNodeId) -> Vec<_>| { let candidates = continuous_candidates(&workload, &data, model); let id = candidates.deployments()[0].post_asap_node_id; (id, candidates.select(&choice(id)).unwrap_err()) @@ -3176,10 +3227,8 @@ mod tests { vec![(id, continuous.clone()), (id, continuous.clone())] }); assert_eq!(error, E::DuplicateChoice(id)); - let (_, error) = select(&UnitCosts, &|_| { - vec![(PostAsapNodeId(u32::MAX), continuous.clone())] - }); - assert_eq!(error, E::UnknownSummary(PostAsapNodeId(u32::MAX))); + let (_, error) = select(&UnitCosts, &|_| vec![(usize::MAX, continuous.clone())]); + assert_eq!(error, E::UnknownSummary(usize::MAX)); } // Nested states on one maintenance path must share an evaluation schedule. @@ -3219,14 +3268,10 @@ mod tests { #[test] fn enumeration_lists_each_unique_summary_state_once() { let shared = summary(); - let root = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryMerge { - timing: asap_types::post_asap::ExecutionTiming::IngestionTime, - children: vec![Rc::clone(&shared), Rc::clone(&shared), summary()], - }, - schema: shared.schema.clone(), - guarantee: None, - }); + let root = test_binary( + test_binary(evaluation(&shared), evaluation(&shared)), + evaluation(&summary()), + ); let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); let candidates = enumerate_summary_maintenance_lifecycles( root, @@ -3250,26 +3295,41 @@ mod tests { .any(|deployment| Rc::ptr_eq(&deployment.summary, &shared))); } - fn readout(state: &Rc) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::ValueOperation { - child: Rc::clone(state), - operation: ValueOperation::FinalizeExactAccumulator, - timing: ExecutionTiming::QueryTime, - }, - schema: Schema { - closed: true, - unique_keys: vec![], - fields: vec![Field { - table: None, - name: "value".into(), - dtype: FieldDataType::Plain(DataType::Float64), - nullable: false, - }], - time_index: None, - }, - guarantee: Some(ResultGuarantee::exact("sum")), - }) + fn evaluation(state: &Rc) -> Rc { + std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: Rc::clone(state), + }), + Schema::lifted( + vec![Field::new( + "value", + FieldDataType::Plain(DataType::Float64), + false, + )], + None, + ), + ) + .with_guarantee(Some(ResultGuarantee::exact("sum"))), + ) + } + + fn test_binary(lhs: Rc, rhs: Rc) -> Rc { + let schema = lhs.schema.clone(); + Rc::new(OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::BinaryOp { + lhs, + rhs, + return_bool: false, + operator: BinaryOperator { + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + }), + schema, + )) } fn lifecycle_matching( @@ -3287,12 +3347,12 @@ mod tests { /// Bind the lifecycle `choose` picks for every state of `root`, then /// derive the timed DAG. fn timed_dag( - root: Rc, + root: Rc, workload: &QueryWorkload, data: &DataWorkload, horizon: Option, choose: impl Fn(&SummaryMaintenanceDeployment) -> SummaryMaintenanceLifecycle, - ) -> PostAsapDAG { + ) -> PhysicalASAPDAG { let candidates = enumerate_summary_maintenance_lifecycles( root, WorkloadDemand::new_with_data(workload, data, &[0]), @@ -3317,15 +3377,21 @@ mod tests { } /// Operator kinds in node-id order, each paired with its timing. - fn timings(dag: &PostAsapDAG) -> Vec<(&'static str, ExecutionTiming)> { + fn timings(dag: &PhysicalASAPDAG) -> Vec<(&'static str, ExecutionTiming)> { dag.nodes .iter() .map(|node| { let kind = match node.payload { - PostAsapOperatorPayload::Fallback { .. } => "raw", - PostAsapOperatorPayload::SummaryAgg { .. } => "state", - PostAsapOperatorPayload::Value { .. } => "readout", - PostAsapOperatorPayload::Binary { .. } => "binary", + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::BinaryOp { .. }) => "binary", + PhysicalASAPOperatorPayload::NonASAP(_) => "raw", + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) => "state", + PhysicalASAPOperatorPayload::ASAP(ASAPOp::FinalizeExactAccumulator { + .. + }) + | PhysicalASAPOperatorPayload::ASAP(ASAPOp::EvaluatePopulation { .. }) => { + "evaluation" + } + _ => "other", }; (kind, node.output_state.timing) @@ -3337,7 +3403,7 @@ mod tests { const QUERY: ExecutionTiming = ExecutionTiming::QueryTime; // Every retained lifecycle kind runs its state and inputs at ingestion - // time and its readout at query time. + // time and its evaluation at query time. #[test] fn retained_lifecycles_time_state_and_inputs_at_ingestion() { let mut scheduled = batch(Predictability::Predictable { @@ -3377,7 +3443,7 @@ mod tests { ]; for (workload, data, horizon, kind) in cases { let dag = timed_dag( - readout(&summary()), + evaluation(&summary()), &workload, &data, horizon, @@ -3385,21 +3451,21 @@ mod tests { ); assert_eq!( timings(&dag), - [("raw", INGEST), ("state", INGEST), ("readout", QUERY)] + [("raw", INGEST), ("state", INGEST), ("evaluation", QUERY)] ); } } - // An Ephemeral state, its raw input, and its readout all run at query time. + // An Ephemeral state, its raw input, and its evaluation all run at query time. #[test] fn ephemeral_lifecycle_times_state_and_downstream_at_query() { let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let dag = timed_dag(readout(&summary()), &workload, &at_rest(), None, |_| { + let dag = timed_dag(evaluation(&summary()), &workload, &at_rest(), None, |_| { SummaryMaintenanceLifecycle::Ephemeral }); assert_eq!( timings(&dag), - [("raw", QUERY), ("state", QUERY), ("readout", QUERY)] + [("raw", QUERY), ("state", QUERY), ("evaluation", QUERY)] ); } @@ -3408,25 +3474,9 @@ mod tests { #[test] fn shared_state_is_timed_once_for_all_consumers() { let state = summary(); - let lhs = readout(&state); + let lhs = evaluation(&state); let rhs = Rc::new(lhs.as_ref().clone()); - let root = Rc::new(SummaryNode { - expr: SummaryExpr::BinaryOp { - lhs, - rhs, - operator: asap_types::post_asap::BinaryOperator { - kind: asap_types::pre_asap::BinaryOpKind::Arithmetic( - asap_types::pre_asap::ArithmeticOpKind::Add, - ), - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, - }, - timing: QUERY, - }, - schema: readout(&state).schema.clone(), - guarantee: None, - }); + let root = test_binary(lhs, rhs); let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); let dag = timed_dag(root, &workload, &data, Some(Horizon(10.0)), |deployment| { @@ -3438,8 +3488,8 @@ mod tests { [ ("raw", INGEST), ("state", INGEST), - ("readout", QUERY), - ("readout", QUERY), + ("evaluation", QUERY), + ("evaluation", QUERY), ("binary", QUERY), ] ); @@ -3484,7 +3534,7 @@ mod tests { let data = at_rest(); let demand = WorkloadDemand::new_with_data(&workload, &data, &[0]); let plan = plan_summary_maintenance_lifecycles( - readout(&summary()), + evaluation(&summary()), demand, 1_000, None, @@ -3499,7 +3549,7 @@ mod tests { ) ); let raw = plan_summary_maintenance_lifecycles( - crate::replacement::keep_pre_asap(&sum_query()).unwrap(), + crate::replacement::retain_exact(&sum_query()).unwrap(), demand, 1_000, None, @@ -3509,16 +3559,13 @@ mod tests { .unwrap(); assert_eq!( timings(&raw.execution_timed_dag().unwrap()), - [("raw", QUERY)] + [("raw", QUERY), ("raw", QUERY)] ); } /// A strategy-built `sum(a)` over one maintained current-series population. - fn population_readout() -> Rc { - let target = Rc::new(crate::test_support::lower_promql( - "sum(a)", - AccuracyTarget::Exact, - )); + fn population_evaluation() -> Rc { + let target = crate::test_support::lower_promql("sum(a)", AccuracyTarget::Exact); crate::maintained_population::MaintainedPopulationStrategy::new(std::slice::from_ref( &target, )) @@ -3526,24 +3573,21 @@ mod tests { .unwrap() } - fn is_population(node: &SummaryNode) -> bool { + fn is_population(node: &OperatorNode) -> bool { matches!( - node.expr, - SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { .. }, - .. - } + node.operator, + Operator::ASAP(ASAPOp::MaintainPopulation { .. }) ) } - fn population_timings(dag: &PostAsapDAG) -> Vec<(&'static str, ExecutionTiming)> { + fn population_timings(dag: &PhysicalASAPDAG) -> Vec<(&'static str, ExecutionTiming)> { dag.nodes .iter() .zip(timings(dag)) .map(|(node, (kind, timing))| match node.payload { - PostAsapOperatorPayload::Value { - operation: ValueOperation::MaintainPopulation { .. }, - } => ("population", timing), + PhysicalASAPOperatorPayload::ASAP(ASAPOp::MaintainPopulation { .. }) => { + ("population", timing) + } _ => (kind, timing), }) .collect() @@ -3556,7 +3600,7 @@ mod tests { let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); let candidates = enumerate_summary_maintenance_lifecycles( - population_readout(), + population_evaluation(), WorkloadDemand::new_with_data(&workload, &data, &[0]), 1_000, Some(Horizon(10.0)), @@ -3594,7 +3638,7 @@ mod tests { let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); let plan = plan_summary_maintenance_lifecycles( - population_readout(), + population_evaluation(), WorkloadDemand::new_with_data(&workload, &data, &[0]), 1_000, Some(Horizon(10.0)), @@ -3624,7 +3668,7 @@ mod tests { let workload = workload(vec![], vec![repeating()], data.clone()); let timed = |lifecycle: SummaryMaintenanceLifecycle| { population_timings(&timed_dag( - population_readout(), + population_evaluation(), &workload, &data, Some(Horizon(10.0)), @@ -3633,11 +3677,21 @@ mod tests { }; assert_eq!( timed(SummaryMaintenanceLifecycle::ContinuouslyMaintained), - [("raw", INGEST), ("population", INGEST), ("readout", QUERY)] + [ + ("raw", INGEST), + ("raw", INGEST), + ("population", INGEST), + ("evaluation", QUERY) + ] ); assert_eq!( timed(SummaryMaintenanceLifecycle::Ephemeral), - [("raw", QUERY), ("population", QUERY), ("readout", QUERY)] + [ + ("raw", QUERY), + ("raw", QUERY), + ("population", QUERY), + ("evaluation", QUERY) + ] ); } @@ -3649,7 +3703,7 @@ mod tests { let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); let plan = plan_summary_maintenance_lifecycles( - population_readout(), + population_evaluation(), WorkloadDemand::new_with_data(&workload, &data, &[0]), 1_000, Some(Horizon(10.0)), @@ -3664,7 +3718,12 @@ mod tests { )); assert_eq!( population_timings(&plan.execution_timed_dag().unwrap()), - [("raw", INGEST), ("population", INGEST), ("readout", QUERY)] + [ + ("raw", INGEST), + ("raw", INGEST), + ("population", INGEST), + ("evaluation", QUERY) + ] ); } @@ -3672,39 +3731,38 @@ mod tests { // deployment: the state's lifecycle times it. #[test] fn population_feeding_summary_state_follows_that_state() { - let SummaryExpr::ValueOperation { + let Operator::ASAP(ASAPOp::EvaluatePopulation { child: population, .. - } = &population_readout().expr + }) = &population_evaluation().operator else { unreachable!() }; let state = summary(); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family, input, reduction, grouping, .. - } = &state.expr + }) = &state.operator else { unreachable!() }; - let state = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: Rc::clone(population), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: None, - }, - ..state.as_ref().clone() + let mut copy = state.as_ref().clone(); + copy.operator = Operator::ASAP(ASAPOp::SummaryAgg { + child: Rc::clone(population), + family: family.clone(), + input: input.clone(), + reduction: reduction.clone(), + grouping: grouping.clone(), + filter: None, }); + let state = Rc::new(copy); let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); let timed = |lifecycle: SummaryMaintenanceLifecycle| { population_timings(&timed_dag( - readout(&state), + evaluation(&state), &workload, &data, Some(Horizon(10.0)), @@ -3717,19 +3775,21 @@ mod tests { assert_eq!( timed(SummaryMaintenanceLifecycle::ContinuouslyMaintained), [ + ("raw", INGEST), ("raw", INGEST), ("population", INGEST), ("state", INGEST), - ("readout", QUERY) + ("evaluation", QUERY) ] ); assert_eq!( timed(SummaryMaintenanceLifecycle::Ephemeral), [ + ("raw", QUERY), ("raw", QUERY), ("population", QUERY), ("state", QUERY), - ("readout", QUERY) + ("evaluation", QUERY) ] ); } @@ -3739,59 +3799,40 @@ mod tests { // timed by the state's lifecycle. #[test] fn shared_population_follows_its_summary_consumer() { - let direct = population_readout(); - let SummaryExpr::ValueOperation { + let direct = population_evaluation(); + let Operator::ASAP(ASAPOp::EvaluatePopulation { child: population, .. - } = &direct.expr + }) = &direct.operator else { unreachable!() }; let state = summary(); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family, input, reduction, grouping, .. - } = &state.expr + }) = &state.operator else { unreachable!() }; - let state = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: Rc::clone(population), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: None, - }, - ..state.as_ref().clone() + let mut copy = state.as_ref().clone(); + copy.operator = Operator::ASAP(ASAPOp::SummaryAgg { + child: Rc::clone(population), + family: family.clone(), + input: input.clone(), + reduction: reduction.clone(), + grouping: grouping.clone(), + filter: None, }); - let binary = |lhs: Rc, rhs: Rc| { - Rc::new(SummaryNode { - schema: lhs.schema.clone(), - expr: SummaryExpr::BinaryOp { - lhs, - rhs, - operator: asap_types::post_asap::BinaryOperator { - kind: asap_types::pre_asap::BinaryOpKind::Arithmetic( - asap_types::pre_asap::ArithmeticOpKind::Add, - ), - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, - }, - timing: QUERY, - }, - guarantee: None, - }) - }; + let state = Rc::new(copy); + let binary = test_binary; let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); for root in [ - binary(Rc::clone(&direct), readout(&state)), - binary(readout(&state), Rc::clone(&direct)), + binary(Rc::clone(&direct), evaluation(&state)), + binary(evaluation(&state), Rc::clone(&direct)), ] { for lifecycle in [ SummaryMaintenanceLifecycle::ContinuouslyMaintained, @@ -3830,7 +3871,7 @@ mod tests { let data = continuous(1_000, 60_000); let workload = workload(vec![], vec![repeating()], data.clone()); let mut plan = plan_summary_maintenance_lifecycles( - population_readout(), + population_evaluation(), WorkloadDemand::new_with_data(&workload, &data, &[0]), 1_000, Some(Horizon(10.0)), diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index 612cf7c0d..d79206dd7 100644 --- a/crates/asap-aware-mapping/src/test_support.rs +++ b/crates/asap-aware-mapping/src/test_support.rs @@ -1,11 +1,16 @@ -use asap_types::pre_asap::QueryExpr; +// Shared fixture helpers; not every test module uses every helper. +#![allow(dead_code)] + +use std::rc::Rc; + +use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; use asap_types::workload::{ AccuracyRequirement, BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, TimeSelection, }; -pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> QueryExpr { +pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc { let workload = PlanningWorkload { query_workload: QueryWorkload { language: QueryLanguage::PromQL, @@ -30,8 +35,167 @@ pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> QueryExpr { ..Default::default() }), }; - asap_frontend_promql::lower_promql_workload(&workload, 0) + asap_frontend_promql::unified::lower_promql_workload(&workload, 0) .unwrap() .pop() .unwrap() } + +// ── 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_properties::{GroupKeys, Reduction, Source}; +use asap_types::ir::timing::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; +use asap_types::ir::{NonASAPOp, Predicate, ScalarExpr, TimeRangeKind}; +use asap_types::pre_asap::agg_intent::AggIntent; +use asap_types::pre_asap::schema::{ColumnId, DataType, Field, Schema}; + +/// A `TimeSeries("m")` scan over `[ts(0), value(1), labels...]`, time index 0, +/// no unique key. +pub(crate) 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(crate) 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(crate) fn scan(metric: &str, schema: Schema) -> Rc { + scan_from( + Source::TimeSeries { + metric: metric.into(), + }, + schema, + ) +} + +pub(crate) 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(crate) 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(crate) fn agg( + by: Vec, + intent: AggIntent, + child: Rc, +) -> Rc { + aggregate(Reduction::by(by), vec![intent], vec![], None, child) +} + +/// `intent without (excluded)`. +pub(crate) fn without_agg( + excluded: Vec, + intent: AggIntent, + child: Rc, +) -> Rc { + aggregate( + Reduction::Reduce(GroupKeys::without(excluded)), + vec![intent], + vec![], + None, + child, + ) +} + +/// A per-entity (per-series) single-measure aggregate. +pub(crate) fn agg_per_entity(intent: AggIntent, child: Rc) -> Rc { + aggregate(Reduction::PerEntity, vec![intent], vec![], None, child) +} + +pub(crate) fn filter(pred: ScalarExpr, child: Rc) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(pred), + child, + })) + .unwrap() +} + +pub(crate) 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(crate) fn time_range(range: Duration, child: Rc) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { + range, + kind: TimeRangeKind::Range, + child, + })) + .unwrap() +} + +/// `root` timed under the default (every summary maintained) lifecycle +/// assignment — the shape export and the post-ASAP validators consume. +pub(crate) fn timed(root: &Rc) -> Rc { + apply_lifecycle_timings( + root, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .expect("default lifecycle timings apply") +} + +/// Time `root` under the default lifecycle assignment (which runs every +/// data-state / population-contract check) and export it as a physical ASAP DAG. +pub(crate) fn time_and_export( + root: &Rc, +) -> Result< + asap_types::ir::physical_export::PhysicalASAPDAG, + asap_types::post_asap::execution_data_state::ExecutionDataStateError, +> { + let timed = apply_lifecycle_timings( + root, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + )?; + asap_types::ir::physical_export::compile_physical_asap_dag(&timed) +} diff --git a/crates/asap-aware-mapping/src/topk_reuse.rs b/crates/asap-aware-mapping/src/topk_reuse.rs index 8329569d1..aec95292a 100644 --- a/crates/asap-aware-mapping/src/topk_reuse.rs +++ b/crates/asap-aware-mapping/src/topk_reuse.rs @@ -7,7 +7,7 @@ use std::rc::Rc; -use asap_types::pre_asap::QueryExpr; +use asap_types::ir::{NonASAPOp, OperatorNode}; use crate::replacement::{ Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, @@ -15,22 +15,23 @@ use crate::replacement::{ /// Derives a smaller top-k result from a compatible larger top-k sibling. pub struct TopKLimitReuseStrategy { - limits: Vec>, + limits: Vec>, } impl TopKLimitReuseStrategy { - pub fn new(limits: &[Rc]) -> Self { + pub fn new(limits: &[Rc]) -> Self { Self { limits: limits.to_vec(), } } - fn larger_sources<'a>(&'a self, target: &TargetSubDAG<'_>) -> Vec<&'a Rc> { - let QueryExpr::Limit { - n: target_n, + fn larger_sources<'a>(&'a self, target: &TargetSubDAG<'_>) -> Vec<&'a Rc> { + let Some(NonASAPOp::Limit { + n: Some(target_n), offset: 0, child: target_child, - } = target.root.as_ref() + .. + }) = target.root.non_asap() else { return Vec::new(); }; @@ -42,11 +43,12 @@ impl TopKLimitReuseStrategy { if Rc::ptr_eq(candidate, target.root) { return false; } - let QueryExpr::Limit { - n, + let Some(NonASAPOp::Limit { + n: Some(n), offset: 0, child, - } = candidate.as_ref() + .. + }) = candidate.non_asap() else { return false; }; @@ -56,8 +58,8 @@ impl TopKLimitReuseStrategy { .collect(); // Prefer the smallest sufficient materialized top-k when several // larger siblings are available. - sources.sort_by_key(|source| match source.as_ref() { - QueryExpr::Limit { n, .. } => *n, + sources.sort_by_key(|source| match source.non_asap() { + Some(NonASAPOp::Limit { n: Some(n), .. }) => *n, _ => unreachable!(), }); sources @@ -70,34 +72,38 @@ impl ReplacementStrategy for TopKLimitReuseStrategy { } fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { - let QueryExpr::Limit { - n: target_n, + let Some(NonASAPOp::Limit { + n: Some(target_n), offset: 0, + partition_by, .. - } = target.root.as_ref() + }) = target.root.non_asap() else { return Vec::new(); }; self.larger_sources(target) .into_iter() - .map(|source| { - let source_n = match source.as_ref() { - QueryExpr::Limit { n, .. } => *n, + .filter_map(|source| { + let source_n = match source.non_asap() { + Some(NonASAPOp::Limit { n: Some(n), .. }) => *n, _ => unreachable!(), }; - ReplacementSubDAG { + let rewritten = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(*target_n), + offset: 0, + partition_by: partition_by.clone(), + child: Rc::clone(source), + })) + .ok()?; + Some(ReplacementSubDAG { strategy: "TopKLimitReuseStrategy", - replacement: Replacement::Rewrite(Rc::new(QueryExpr::Limit { - n: *target_n, - offset: 0, - child: Rc::clone(source), - })), + replacement: Replacement::SubDAG(rewritten), provenance: ReplacementProvenance::LogicalRewrite, rationale: format!( "derives top-{target_n} from the compatible shared top-{source_n} result; both rank the identical input with the same ordering" ), - } + }) }) .collect() } @@ -106,38 +112,39 @@ impl ReplacementStrategy for TopKLimitReuseStrategy { #[cfg(test)] mod tests { use super::*; - use asap_types::pre_asap::{Schema, Source}; + use crate::test_support::scan; + use asap_types::ir::operator_properties::GroupKeys; + use asap_types::pre_asap::Schema; - fn scan_named(metric: &str) -> Rc { - Rc::new(QueryExpr::Scan { - source: Source::TimeSeries { - metric: metric.into(), - }, - predicates: vec![], - schema: Schema::with_time_index(vec![], 0, vec![]), - }) + fn scan_named(metric: &str) -> Rc { + scan(metric, Schema::with_time_index(vec![], 0, vec![])) + } + + fn limit(n: usize, offset: usize, child: Rc) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(n), + offset, + partition_by: GroupKeys::none(), + child, + })) + .unwrap() } #[test] fn smaller_limit_reuses_larger_compatible_limit() { let child = scan_named("m"); - let small = Rc::new(QueryExpr::Limit { - n: 5, - offset: 0, - child: Rc::clone(&child), - }); - let large = Rc::new(QueryExpr::Limit { - n: 10, - offset: 0, - child, - }); + let small = limit(5, 0, Rc::clone(&child)); + let large = limit(10, 0, child); let strategy = TopKLimitReuseStrategy::new(&[Rc::clone(&small), Rc::clone(&large)]); let replacements = strategy.replacements(&TargetSubDAG::new(&small)); assert_eq!(replacements.len(), 1); - let Replacement::Rewrite(rewrite) = &replacements[0].replacement else { + let Replacement::SubDAG(rewrite) = &replacements[0].replacement else { panic!() }; - let QueryExpr::Limit { n: 5, child, .. } = rewrite.as_ref() else { + let Some(NonASAPOp::Limit { + n: Some(5), child, .. + }) = rewrite.non_asap() + else { panic!() }; assert!(Rc::ptr_eq(child, &large)); @@ -147,21 +154,9 @@ mod tests { fn offset_or_different_input_is_not_reused() { let a = scan_named("a"); let b = scan_named("b"); - let small = Rc::new(QueryExpr::Limit { - n: 5, - offset: 0, - child: a, - }); - let large = Rc::new(QueryExpr::Limit { - n: 10, - offset: 0, - child: b, - }); - let offset = Rc::new(QueryExpr::Limit { - n: 20, - offset: 1, - child: scan_named("a"), - }); + let small = limit(5, 0, a); + let large = limit(10, 0, b); + let offset = limit(20, 1, scan_named("a")); let strategy = TopKLimitReuseStrategy::new(&[Rc::clone(&small), large, offset]); assert!(!strategy.matches(&TargetSubDAG::new(&small))); } diff --git a/crates/asap-aware-mapping/tests/physical_handoff_cost.rs b/crates/asap-aware-mapping/tests/physical_handoff_cost.rs index a8a9ed42d..141750f61 100644 --- a/crates/asap-aware-mapping/tests/physical_handoff_cost.rs +++ b/crates/asap-aware-mapping/tests/physical_handoff_cost.rs @@ -5,7 +5,7 @@ use asap_aware_mapping::analytical_cost::{ use asap_aware_mapping::physical_operator_statistics::{ ComparisonScope, EdgeStatistics, OperatorStatistics, ScanSelection, UnaryEdgeStatistics, }; -use asap_types::pre_asap::query_expr::Source; +use asap_types::ir::operator_properties::Source; use asap_types::workload::{ DataArrival, DurationMs, QueryRecurrence, QueryTimeScope, TimeSelection, TimestampMs, }; diff --git a/crates/asap-aware-mapping/tests/storage_io.rs b/crates/asap-aware-mapping/tests/storage_io.rs index e9290d20a..811328f6b 100644 --- a/crates/asap-aware-mapping/tests/storage_io.rs +++ b/crates/asap-aware-mapping/tests/storage_io.rs @@ -5,7 +5,7 @@ use asap_aware_mapping::analytical_cost::{ use asap_aware_mapping::physical_operator_statistics::{ ComparisonScope, EdgeStatistics, OperatorStatistics, ScanSelection, UnaryEdgeStatistics, }; -use asap_types::pre_asap::query_expr::Source; +use asap_types::ir::operator_properties::Source; use asap_types::workload::{ DataArrival, DurationMs, QueryRecurrence, QueryTimeScope, TimeSelection, TimestampMs, }; diff --git a/crates/asap-physical-operators/src/capability.rs b/crates/asap-physical-operators/src/capability.rs index 5ed1c5501..8bdb1891e 100644 --- a/crates/asap-physical-operators/src/capability.rs +++ b/crates/asap-physical-operators/src/capability.rs @@ -2,22 +2,22 @@ //! //! `validate_summary_kernel` checks update kernels, including families without a //! native batch representation. `validate_native_family` and -//! `validate_sketch_readout` / `validate_exact_readout` check native state and readout support. -//! Keyed weighted-frequency readouts are checked by `Operator::keyed_readout`. +//! `validate_sketch_evaluation` / `validate_exact_evaluation` check native state and evaluation support. +//! Keyed weighted-frequency evaluations are checked by `Operator::keyed_evaluation`. //! A successful kernel check alone does not mean a physical DAG will bind. //! //! Stored-state encodings belong to deployments. Full plan acceptance is //! owned by `binding`, which also validates schemas, expressions and inputs. use crate::Error; use planner_types::post_asap::{ - ExactKind, ExactParams, FieldDataType, GroupingStrategy, SketchAlgorithm, SketchParams, - SketchStatistic, SummaryUpdate, + ExactKind, ExactParams, FieldDataType as SummaryFamilyType, GroupingStrategy, SketchAlgorithm, + SketchParams, SketchStatistic, SummaryUpdate, }; /// Check the same contract used by `create_planner_accumulator` before a plan /// is accepted. Execution timing is deliberately not a kernel property. pub fn validate_summary_kernel( - family: &FieldDataType, + family: &SummaryFamilyType, input: &SummaryUpdate, grouping: &GroupingStrategy, ) -> Result<(), String> { @@ -25,7 +25,7 @@ pub fn validate_summary_kernel( return Err("shared summary grouping has no registered kernel".into()); } let keyed = match family { - FieldDataType::ExactAggregate(kind, params) => { + SummaryFamilyType::ExactAggregate(kind, params) => { use ExactKind as K; use ExactParams as P; if !matches!( @@ -41,7 +41,7 @@ pub fn validate_summary_kernel( } input.item.is_some() } - FieldDataType::Sketch(kind, layout) => { + SummaryFamilyType::Sketch(kind, layout) => { if layout != grouping { return Err("Planner family and operator grouping disagree".into()); } @@ -138,9 +138,9 @@ pub(crate) fn is_unit_sample_frequency(update: &planner_types::post_asap::Summar ) } -pub fn validate_native_family(family: &FieldDataType) -> Result<(), Error> { +pub fn validate_native_family(family: &SummaryFamilyType) -> Result<(), Error> { use planner_types::post_asap::SketchAlgorithm as A; - if let FieldDataType::Sketch(kind, grouping) = family { + if let SummaryFamilyType::Sketch(kind, grouping) = family { // Plain Count-Min is native as stored state only: it merges and reads // its bare count, but the DAG does not build it from rows. if let (A::Cms, SketchParams::Cms { width, depth }) = (kind.algorithm(), kind.params()) { @@ -165,8 +165,8 @@ pub fn validate_native_family(family: &FieldDataType) -> Result<(), Error> { } } match family { - FieldDataType::ExactAggregate(..) => {} - FieldDataType::Sketch(kind, _) + SummaryFamilyType::ExactAggregate(..) => {} + SummaryFamilyType::Sketch(kind, _) if matches!(kind.algorithm(), A::Kll | A::DDSketch | A::Hll) => {} _ => { return Err(Error::Invalid( @@ -184,9 +184,9 @@ pub fn validate_native_family(family: &FieldDataType) -> Result<(), Error> { .map_err(Error::Invalid) } -/// A sketch readout is native only for the families Planner can read directly. -pub fn validate_sketch_readout( - family: &FieldDataType, +/// A sketch evaluation is native only for the families Planner can read directly. +pub fn validate_sketch_evaluation( + family: &SummaryFamilyType, query: &SketchStatistic, ) -> Result<(), Error> { validate_native_family(family)?; @@ -194,12 +194,12 @@ pub fn validate_sketch_readout( // A point count without an item value reads the total count. let bare_count = matches!(query, SketchStatistic::PointCount { value: None, .. }); let supported = match family { - FieldDataType::Sketch(kind, _) => match (kind.algorithm(), query) { + SummaryFamilyType::Sketch(kind, _) => match (kind.algorithm(), query) { (A::Kll, SketchStatistic::Quantile { q }) | (A::DDSketch, SketchStatistic::Quantile { q }) => { if !(0.0..=1.0).contains(q) { return Err(Error::Invalid( - "quantile readout requires quantile in [0,1]".into(), + "quantile evaluation requires quantile in [0,1]".into(), )); } true @@ -208,7 +208,7 @@ pub fn validate_sketch_readout( (A::Hll, SketchStatistic::Cardinality) => true, (A::Hll, _) => bare_count, // Only count intents read a Count-Min bare count, and their - // updates have unit weight; the readout is typed Int64 on that basis. + // updates have unit weight; the evaluation is typed Int64 on that basis. (A::Cms, _) => bare_count, _ => false, }, @@ -216,36 +216,39 @@ pub fn validate_sketch_readout( }; if !supported { return Err(Error::Invalid( - "readout is not implemented for this summary family".into(), + "evaluation is not implemented for this summary family".into(), )); } Ok(()) } -/// An exact readout must match the exact family it reads. -pub fn validate_exact_readout( - family: &FieldDataType, - readout: &crate::summary_kernels::exact::ExactReadout, +/// An exact evaluation must match the exact family it reads. +pub fn validate_exact_evaluation( + family: &SummaryFamilyType, + evaluation: &crate::summary_kernels::exact::ExactEvaluation, ) -> Result<(), Error> { validate_native_family(family)?; use crate::Statistic as S; use planner_types::post_asap::ExactKind as E; let supported = matches!( - (family, readout.statistic), - (FieldDataType::ExactAggregate(E::Sum, _), S::Sum) - | (FieldDataType::ExactAggregate(E::Count, _), S::Count) - | (FieldDataType::ExactAggregate(E::Min, _), S::Min) - | (FieldDataType::ExactAggregate(E::Max, _), S::Max) - | (FieldDataType::ExactAggregate(E::Rate, _), S::Rate) - | (FieldDataType::ExactAggregate(E::Increase, _), S::Increase) + (family, evaluation.statistic), + (SummaryFamilyType::ExactAggregate(E::Sum, _), S::Sum) + | (SummaryFamilyType::ExactAggregate(E::Count, _), S::Count) + | (SummaryFamilyType::ExactAggregate(E::Min, _), S::Min) + | (SummaryFamilyType::ExactAggregate(E::Max, _), S::Max) + | (SummaryFamilyType::ExactAggregate(E::Rate, _), S::Rate) + | ( + SummaryFamilyType::ExactAggregate(E::Increase, _), + S::Increase + ) ); if !supported { return Err(Error::Invalid( - "readout is not implemented for this summary family".into(), + "evaluation is not implemented for this summary family".into(), )); } - if readout.lookback_ms.is_some_and(|lookback| { - lookback <= 0 || !matches!(readout.statistic, S::Rate | S::Increase) + if evaluation.lookback_ms.is_some_and(|lookback| { + lookback <= 0 || !matches!(evaluation.statistic, S::Rate | S::Increase) }) { return Err(Error::Invalid("invalid exact counter lookback".into())); } diff --git a/crates/asap-physical-operators/src/dag/mod.rs b/crates/asap-physical-operators/src/dag/mod.rs index 74835cd98..1cf3ad1f4 100644 --- a/crates/asap-physical-operators/src/dag/mod.rs +++ b/crates/asap-physical-operators/src/dag/mod.rs @@ -5,4 +5,6 @@ pub use crate::runtime::{ Input, Limits, OutputStream, Reservation, RunContext, Scope, SharedValue, }; pub use crate::Error; -pub use crate::{expressions, operators, physical_planner as planner, sources as scan, values}; +pub use crate::{ + expressions, operators, unified_physical_planner as planner, unified_sources as scan, values, +}; diff --git a/crates/asap-physical-operators/src/readout.rs b/crates/asap-physical-operators/src/evaluation.rs similarity index 82% rename from crates/asap-physical-operators/src/readout.rs rename to crates/asap-physical-operators/src/evaluation.rs index e84d8c828..843145ce2 100644 --- a/crates/asap-physical-operators/src/readout.rs +++ b/crates/asap-physical-operators/src/evaluation.rs @@ -1,4 +1,4 @@ -//! Readouts over merged exact summary states. +//! Evaluations over merged exact summary states. use crate::summary_kernels::exact::ExactAccumulator; use crate::{AggregateCore, KeyByLabelValues, Statistic}; use std::sync::Arc; @@ -12,7 +12,7 @@ fn merge_exact_states( .as_any() .downcast_ref::() .cloned() - .ok_or_else(|| "readout requires Planner exact state".to_string()) + .ok_or_else(|| "evaluation requires Planner exact state".to_string()) }; let mut merged = exact(&states.next().ok_or("empty exact state input")?)?; for state in states { @@ -23,7 +23,7 @@ fn merge_exact_states( Ok(merged) } -/// PromQL counter readouts omit a series with fewer than two samples. Other +/// PromQL counter evaluations omit a series with fewer than two samples. Other /// state/type/range failures remain errors rather than empty results. pub fn insufficient_counter_samples(state: &dyn AggregateCore, statistic: Statistic) -> bool { matches!(statistic, Statistic::Rate | Statistic::Increase) @@ -36,7 +36,7 @@ pub fn insufficient_counter_samples(state: &dyn AggregateCore, statistic: Statis /// Merge already selected exact panes and read one population. `None` means /// the population is absent from the result: a counter with too few samples, /// or an empty MIN/MAX. -pub fn exact_readout( +pub fn exact_evaluation( states: impl IntoIterator>, statistic: Statistic, range_ms: Option<(i64, i64)>, @@ -47,17 +47,17 @@ pub fn exact_readout( return Ok(None); } merged - .readout(statistic, range_ms, key) + .evaluation(statistic, range_ms, key) .map_err(|error| error.to_string()) } #[cfg(test)] mod counter_tests { use super::*; - use planner_types::post_asap::{ExactKind, ExactParams, FieldDataType}; + use planner_types::post_asap::{ExactKind, ExactParams, FieldDataType as SummaryFamilyType}; fn counter(kind: ExactKind, params: ExactParams, keyed: bool) -> ExactAccumulator { - ExactAccumulator::new(FieldDataType::ExactAggregate(kind, params), keyed).unwrap() + ExactAccumulator::new(SummaryFamilyType::ExactAggregate(kind, params), keyed).unwrap() } // A counter population with a single sample is absent, keyed or not. @@ -76,7 +76,7 @@ mod counter_tests { let key = keyed.then(|| KeyByLabelValues::new_with_labels(vec!["checkout".into()])); state.update(key.as_ref(), 10., 10_000); assert_eq!( - exact_readout( + exact_evaluation( [Arc::new(state) as Arc], statistic, None, @@ -97,15 +97,15 @@ mod counter_tests { let rate = Statistic::Rate; let one = [Arc::new(state.clone()) as Arc]; assert_eq!( - exact_readout(one, rate, Some((0, 60_000)), None).unwrap(), + exact_evaluation(one, rate, Some((0, 60_000)), None).unwrap(), None ); state.update(None, 20., 20_000); let two = || [Arc::new(state.clone()) as Arc]; - assert!(exact_readout(two(), rate, Some((0, 60_000)), None) + assert!(exact_evaluation(two(), rate, Some((0, 60_000)), None) .unwrap() .is_some()); - assert!(exact_readout(two(), rate, Some((60_000, 0)), None).is_err()); - assert!(exact_readout([], rate, Some((0, 60_000)), None).is_err()); + assert!(exact_evaluation(two(), rate, Some((60_000, 0)), None).is_err()); + assert!(exact_evaluation([], rate, Some((0, 60_000)), None).is_err()); } } diff --git a/crates/asap-physical-operators/src/expressions/arithmetic.rs b/crates/asap-physical-operators/src/expressions/arithmetic.rs index e0766763d..64dbb8301 100644 --- a/crates/asap-physical-operators/src/expressions/arithmetic.rs +++ b/crates/asap-physical-operators/src/expressions/arithmetic.rs @@ -20,12 +20,13 @@ pub fn evaluate_float64_arithmetic( /// Execute the Planner binary contract after a deployment has resolved matching rows. pub fn evaluate_binary( - operator: &planner_types::post_asap::BinaryOperator, + operator: &crate::expressions::binary::BinaryOperator, left: f64, right: f64, ) -> Result { + use crate::expressions::binary::BinaryOpKind; use crate::{values::Value, Error}; - use planner_types::pre_asap::{ArithmeticOpKind, BinaryOpKind}; + use planner_types::pre_asap::ArithmeticOpKind; let invalid = || Error::Invalid("unsupported binary operation or invalid checked-division domain".into()); if operator.vector_match.is_some() { diff --git a/crates/asap-physical-operators/src/expressions/binary.rs b/crates/asap-physical-operators/src/expressions/binary.rs index c115fd908..81c1c2656 100644 --- a/crates/asap-physical-operators/src/expressions/binary.rs +++ b/crates/asap-physical-operators/src/expressions/binary.rs @@ -1,3 +1,35 @@ -//! Temporary kernel aliases during the unified compiler migration. -pub use planner_types::post_asap::BinaryOperator; -pub use planner_types::pre_asap::BinaryOpKind; +//! Execution configuration for a binary kernel, including comparison evaluation mode. +use planner_types::pre_asap::{ + ArithmeticOpKind, CompareOpKind, PromQLVectorSetOpKind, VectorMatch, +}; +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum BinaryOpKind { + Arithmetic(ArithmeticOpKind), + Compare(CompareOpKind), + CompareBool(CompareOpKind), + Set(PromQLVectorSetOpKind), +} +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct BinaryOperator { + pub kind: BinaryOpKind, + pub vector_match: Option, + pub checked_relative_division: bool, + pub checked_finite_division: bool, +} +impl BinaryOperator { + pub fn from_logical(operator: &planner_types::ir::BinaryOperator, return_bool: bool) -> Self { + use planner_types::pre_asap::BinaryOpKind as L; + Self { + kind: match &operator.kind { + L::Arithmetic(op) => BinaryOpKind::Arithmetic(op.clone()), + L::Compare(op) if return_bool => BinaryOpKind::CompareBool(op.clone()), + L::Compare(op) => BinaryOpKind::Compare(op.clone()), + L::CompareBool(op) => BinaryOpKind::CompareBool(op.clone()), + L::Set(op) => BinaryOpKind::Set(op.clone()), + }, + vector_match: operator.vector_match.clone(), + checked_relative_division: operator.checked_relative_division, + checked_finite_division: operator.checked_finite_division, + } + } +} diff --git a/crates/asap-physical-operators/src/expressions/mod.rs b/crates/asap-physical-operators/src/expressions/mod.rs index 5c1a29576..738b3eab2 100644 --- a/crates/asap-physical-operators/src/expressions/mod.rs +++ b/crates/asap-physical-operators/src/expressions/mod.rs @@ -6,17 +6,14 @@ use crate::{ use planner_types::pre_asap::{ArithmeticOpKind, DataType}; pub mod arithmetic; pub mod binary; -mod planner; pub mod unified_planner; -pub use planner::CompiledExpression; #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum Expression { Binary { - operator: planner_types::post_asap::BinaryOperator, + operator: crate::expressions::binary::BinaryOperator, left: Box, right: Box, }, - Planner(Box), UnifiedPlanner(Box), Column(usize), ExactFloat64(usize), @@ -59,9 +56,6 @@ impl Expression { pub fn unified_planner(expression: unified_planner::CompiledExpression) -> Self { Self::UnifiedPlanner(Box::new(expression)) } - pub fn planner(expression: crate::expressions::CompiledExpression) -> Self { - Self::Planner(Box::new(expression)) - } pub(crate) fn dtype(&self, input: &SchemaRef) -> Result<(DataType, bool), Error> { use Expression::*; match self { @@ -70,7 +64,8 @@ impl Expression { left, right, } => { - use planner_types::pre_asap::{BinaryOpKind, CompareOpKind}; + use crate::expressions::binary::BinaryOpKind; + use planner_types::pre_asap::CompareOpKind; let (a, n) = left.dtype(input)?; let (b, m) = right.dtype(input)?; if a != DataType::Float64 || b != a || operator.vector_match.is_some() { @@ -109,10 +104,6 @@ impl Expression { expression.validate_input(input)?; Ok(expression.dtype()) } - Planner(expression) => { - expression.validate_input(input)?; - Ok(expression.dtype()) - } FiniteFloat64(expression) => { if expression.dtype(input)? != (DataType::Float64, false) { return Err(invalid("finite update requires non-null Float64")); @@ -295,7 +286,6 @@ impl Expression { arithmetic::evaluate_binary(operator, a, b)? } } - Planner(expression) => expression.evaluate(row)?, UnifiedPlanner(expression) => expression.evaluate(row)?, Label { column, name } => { let Value::Map(entries) = &row[*column] else { @@ -329,7 +319,8 @@ impl Expression { } } Value::Utf8( - crate::physical_planner::promql_rows::encode_series_identity(&labels)?.into(), + crate::unified_physical_planner::promql_rows::encode_series_identity(&labels)? + .into(), ) } Column(i) => row[*i].clone(), diff --git a/crates/asap-physical-operators/src/expressions/planner.rs b/crates/asap-physical-operators/src/expressions/planner.rs deleted file mode 100644 index 2044d6e16..000000000 --- a/crates/asap-physical-operators/src/expressions/planner.rs +++ /dev/null @@ -1,545 +0,0 @@ -//! Planner scalar expressions evaluated over native typed rows. -use crate::{ - values::{SchemaRef, Value}, - Error, -}; -use planner_types::pre_asap::{ArithmeticOpKind, CompareOpKind, DataType, QueryExpr, ScalarValue}; -use std::{cmp::Ordering, sync::Arc}; - -pub(super) fn evaluate( - expr: &QueryExpr, - row: &[Value], - schema: &planner_types::pre_asap::Schema, -) -> Result { - match expr { - QueryExpr::Column(index) => row.get(*index).cloned().ok_or(Error::Invalid(format!( - "column {index} outside row width {}", - row.len() - ))), - QueryExpr::Literal(value) => Ok(match value { - ScalarValue::Interval { - months, - days, - nanos, - } => Value::Interval { - months: *months, - days: *days, - nanos: *nanos, - }, - ScalarValue::Int64(value) => Value::Int64(*value), - ScalarValue::Float64(value) => Value::Float64(*value), - ScalarValue::Utf8(value) => Value::Utf8(value.clone().into()), - ScalarValue::Boolean(value) => Value::Bool(*value), - ScalarValue::Null => Value::Null, - }), - QueryExpr::Compare { left, op, right } => { - let left = evaluate(left, row, schema)?; - let right = evaluate(right, row, schema)?; - compare(op, left, right) - } - QueryExpr::Arithmetic { op, left, right } => arithmetic( - op, - evaluate(left, row, schema)?, - evaluate(right, row, schema)?, - ), - QueryExpr::BoolAnd(parts) | QueryExpr::BoolOr(parts) => { - let and = matches!(expr, QueryExpr::BoolAnd(_)); - let mut null = false; - for part in parts { - match evaluate(part, row, schema)? { - Value::Bool(value) if value != and => return Ok(Value::Bool(value)), - Value::Bool(_) => {} - Value::Null => null = true, - _ => return Err(Error::Invalid("boolean predicate required".into())), - } - } - Ok(if null { Value::Null } else { Value::Bool(and) }) - } - QueryExpr::Not(value) => match evaluate(value, row, schema)? { - Value::Bool(value) => Ok(Value::Bool(!value)), - Value::Null => Ok(Value::Null), - _ => Err(Error::Invalid("boolean predicate required".into())), - }, - QueryExpr::IsNull(value) => Ok(Value::Bool(matches!( - evaluate(value, row, schema)?, - Value::Null - ))), - QueryExpr::IsNotNull(value) => Ok(Value::Bool(!matches!( - evaluate(value, row, schema)?, - Value::Null - ))), - QueryExpr::FunctionCall { name, args } => { - use planner_types::pre_asap::scalar_type_rules::MapScalarFunction; - if name.eq_ignore_ascii_case("asap_struct_field") { - expr.scalar_type(schema) - .map_err(|error| Error::Invalid(error.to_string()))?; - let DataType::Struct { fields } = args[0] - .scalar_type(schema) - .map_err(|error| Error::Invalid(error.to_string()))? - .0 - else { - unreachable!() - }; - let offset = match &args[1] { - QueryExpr::Literal(ScalarValue::Int64(index)) => { - usize::try_from(index - 1).ok() - } - QueryExpr::Literal(ScalarValue::Utf8(name)) => { - fields.iter().position(|field| &field.name == name) - } - _ => None, - } - .ok_or_else(|| Error::Invalid("struct field selector".into()))?; - let Value::Struct(values) = evaluate(&args[0], row, schema)? else { - return Err(Error::Invalid("struct field input".into())); - }; - return values - .get(offset) - .cloned() - .ok_or_else(|| Error::Invalid("struct field value".into())); - } - if name.eq_ignore_ascii_case("asap_element_access") { - let (output_type, _) = expr - .scalar_type(schema) - .map_err(|error| Error::Invalid(error.to_string()))?; - if let DataType::List { element } = args[0] - .scalar_type(schema) - .map_err(|error| Error::Invalid(error.to_string()))? - .0 - { - let Value::List(values) = evaluate(&args[0], row, schema)? else { - return Err(Error::Invalid("array access input".into())); - }; - let index = match evaluate(&args[1], row, schema)? { - Value::Null => return Ok(Value::Null), - Value::Int64(index) => index, - _ => return Err(Error::Invalid("array access index".into())), - }; - let offset = if index > 0 { - usize::try_from(index - 1).ok() - } else if index < 0 { - usize::try_from(index.unsigned_abs()) - .ok() - .and_then(|distance| values.len().checked_sub(distance)) - } else { - None - }; - return match offset.and_then(|offset| values.get(offset)) { - Some(value) => Ok(value.clone()), - None => default_collection_element(&output_type, element.nullable), - }; - } - } - let function = (if name.eq_ignore_ascii_case("asap_element_access") { - Some(MapScalarFunction::Access) - } else { - MapScalarFunction::from_name(name) - }) - .ok_or_else(|| Error::Invalid(format!("scalar function {name}")))?; - expr.scalar_type(schema) - .map_err(|error| Error::Invalid(error.to_string()))?; - let values = args - .iter() - .map(|arg| evaluate(arg, row, schema)) - .collect::, _>>()?; - match function { - MapScalarFunction::Construct => { - let mut values = values.into_iter(); - let mut entries = Vec::new(); - while let Some(key) = values.next() { - if !matches!(key, Value::Int64(_) | Value::Utf8(_) | Value::Bool(_)) { - return Err(Error::Invalid("map key value type".into())); - } - entries.push(( - key, - values - .next() - .ok_or_else(|| Error::Invalid("odd map argument count".into()))?, - )); - } - Ok(Value::Map(entries.into())) - } - MapScalarFunction::Concat => { - let mut entries = Vec::new(); - for value in values { - let Value::Map(next) = value else { - return Err(Error::Invalid("map concat argument".into())); - }; - entries.extend(next.iter().cloned()); - } - Ok(Value::Map(entries.into())) - } - MapScalarFunction::Access => { - let [Value::Map(entries), key] = values.as_slice() else { - return Err(Error::Invalid("map access arguments".into())); - }; - if matches!(key, Value::Null) { - return Ok(Value::Null); - } - if !matches!(key, Value::Int64(_) | Value::Utf8(_) | Value::Bool(_)) { - return Err(Error::Invalid("map lookup key type".into())); - } - if let Some((_, value)) = entries - .iter() - .find(|(candidate, _)| cell_cmp(candidate, key) == Some(Ordering::Equal)) - { - return Ok(value.clone()); - } - let ( - DataType::Map { - value, - value_nullable, - .. - }, - _, - ) = args[0] - .scalar_type(schema) - .map_err(|error| Error::Invalid(error.to_string()))? - else { - unreachable!() - }; - default_collection_element(&value, value_nullable) - } - } - } - other => Err(Error::Invalid(format!("scalar expression {other:?}"))), - } -} - -fn default_collection_element(dtype: &DataType, nullable: bool) -> Result { - if nullable { - return Ok(Value::Null); - } - Ok(match dtype { - DataType::Interval | DataType::Date => { - return Err(Error::Invalid("temporal value transport".into())) - } - DataType::Null => Value::Null, - DataType::Int64 => Value::Int64(0), - DataType::Float64 => Value::Float64(0.0), - DataType::Utf8 => Value::Utf8("".into()), - DataType::Bool => Value::Bool(false), - DataType::Map { .. } => Value::Map(Arc::from([])), - DataType::List { .. } => Value::List(Arc::from([])), - DataType::Struct { fields } => Value::Struct( - fields - .iter() - .map(|field| default_collection_element(&field.dtype, field.nullable)) - .collect::, _>>()? - .into(), - ), - _ => { - return Err(Error::Invalid( - "collection missing-element default type".into(), - )) - } - }) -} - -fn compare(op: &CompareOpKind, left: Value, right: Value) -> Result { - if matches!(left, Value::Null) || matches!(right, Value::Null) { - return Ok(Value::Null); - } - // NaN is unordered, not a type mismatch. Match the native scalar path. - if matches!(&left, Value::Float64(v) if v.is_nan()) - || matches!(&right, Value::Float64(v) if v.is_nan()) - { - return match op { - CompareOpKind::Ne => Ok(Value::Bool(true)), - CompareOpKind::Eq - | CompareOpKind::Lt - | CompareOpKind::Le - | CompareOpKind::Gt - | CompareOpKind::Ge => Ok(Value::Bool(false)), - _ => Err(Error::Invalid(format!("comparison {op:?}"))), - }; - } - let ordering = cell_cmp(&left, &right) - .ok_or_else(|| Error::Invalid("comparison of incompatible values".into()))?; - let value = match op { - CompareOpKind::Eq => ordering == Ordering::Equal, - CompareOpKind::Ne => ordering != Ordering::Equal, - CompareOpKind::Lt => ordering == Ordering::Less, - CompareOpKind::Le => ordering != Ordering::Greater, - CompareOpKind::Gt => ordering == Ordering::Greater, - CompareOpKind::Ge => ordering != Ordering::Less, - _ => return Err(Error::Invalid(format!("comparison {op:?}"))), - }; - Ok(Value::Bool(value)) -} - -fn arithmetic(op: &ArithmeticOpKind, left: Value, right: Value) -> Result { - let (left, right) = match (left, right) { - (Value::Int64(a), Value::Float64(b)) => (Value::Float64(a as f64), Value::Float64(b)), - (Value::Float64(a), Value::Int64(b)) => (Value::Float64(a), Value::Float64(b as f64)), - pair => pair, - }; - super::numeric(op, left, right) -} - -fn integer_float_cmp(integer: i64, float: f64) -> Option { - if float.is_nan() { - return None; - } - // These bounds are powers of two, exactly representable as Float64. - if float >= 9_223_372_036_854_775_808.0 { - return Some(Ordering::Less); - } - if float < -9_223_372_036_854_775_808.0 { - return Some(Ordering::Greater); - } - let integral = float as i64; - match integer.cmp(&integral) { - Ordering::Equal => 0.0_f64.partial_cmp(&float.fract()), - other => Some(other), - } -} - -fn cell_cmp(left: &Value, right: &Value) -> Option { - match (left, right) { - (Value::Int64(left), Value::Int64(right)) => Some(left.cmp(right)), - (Value::Float64(left), Value::Float64(right)) => left.partial_cmp(right), - (Value::Int64(left), Value::Float64(right)) => integer_float_cmp(*left, *right), - (Value::Float64(left), Value::Int64(right)) => { - integer_float_cmp(*right, *left).map(Ordering::reverse) - } - (Value::Utf8(left), Value::Utf8(right)) => Some(left.cmp(right)), - (Value::Bool(left), Value::Bool(right)) => Some(left.cmp(right)), - (Value::Timestamp(left), Value::Timestamp(right)) => Some(left.cmp(right)), - (Value::Map(left), Value::Map(right)) => { - for ((left_key, left_value), (right_key, right_value)) in left.iter().zip(right.iter()) - { - let order = cell_cmp(left_key, right_key)?; - if order != Ordering::Equal { - return Some(order); - } - let order = match (left_value, right_value) { - (Value::Null, Value::Null) => Ordering::Equal, - (Value::Null, _) => Ordering::Greater, - (_, Value::Null) => Ordering::Less, - _ => cell_cmp(left_value, right_value)?, - }; - if order != Ordering::Equal { - return Some(order); - } - } - Some(left.len().cmp(&right.len())) - } - _ => None, - } -} - -#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] -pub struct CompiledExpression { - expression: QueryExpr, - schema: planner_types::pre_asap::Schema, - output: (DataType, bool), -} -impl CompiledExpression { - pub fn compile(expression: &QueryExpr, input: &SchemaRef) -> Result { - let schema = input - .fields - .iter() - .map(|field| { - let planner_types::post_asap::FieldDataType::Plain(dtype) = &field.dtype else { - return Err(Error::Invalid( - "scalar expression cannot consume opaque summary state".into(), - )); - }; - Ok(planner_types::pre_asap::Field::plain( - field.name.clone(), - dtype.clone(), - field.nullable, - )) - }) - .collect::, Error>>()?; - let schema = planner_types::pre_asap::Schema::new(schema); - validate(expression, &schema)?; - let output = expression - .scalar_type(&schema) - .map_err(|e| Error::Invalid(e.to_string()))?; - Ok(Self { - expression: expression.clone(), - schema, - output, - }) - } - pub(crate) fn dtype(&self) -> (DataType, bool) { - self.output.clone() - } - pub(crate) fn validate_input(&self, input: &SchemaRef) -> Result<(), Error> { - let checked = Self::compile(&self.expression, input)?; - if checked.output != self.output { - return Err(Error::Invalid( - "persisted expression type differs from its semantics".into(), - )); - } - if input.fields.len() != self.schema.fields.len() - || input - .fields - .iter() - .zip(&self.schema.fields) - .any(|(field, column)| { - field.dtype != column.dtype.clone() || field.nullable != column.nullable - }) - { - return Err(Error::Invalid( - "expression input differs from its bound schema".into(), - )); - } - Ok(()) - } - /// Evaluate a row under the same typed schema used when binding the expression. - pub fn evaluate(&self, row: &[Value]) -> Result { - if row.len() != self.schema.fields.len() - || row.iter().zip(&self.schema.fields).any(|(value, column)| { - !column - .dtype - .plain() - .is_some_and(|dtype| value.matches(dtype, column.nullable)) - }) - { - return Err(Error::Invalid( - "expression input differs from its bound schema".into(), - )); - } - evaluate(&self.expression, row, &self.schema) - } -} -fn validate(expr: &QueryExpr, schema: &planner_types::pre_asap::Schema) -> Result<(), Error> { - let invalid = || Error::Invalid(format!("unsupported scalar expression: {expr:?}")); - expr.scalar_type(schema) - .map_err(|e| Error::Invalid(e.to_string()))?; - match expr { - QueryExpr::Column(_) | QueryExpr::Literal(_) => Ok(()), - QueryExpr::Arithmetic { left, right, .. } => { - for value in [left, right] { - validate(value, schema)?; - if !matches!( - value - .scalar_type(schema) - .map_err(|e| Error::Invalid(e.to_string()))? - .0, - DataType::Int64 | DataType::Float64 | DataType::Null - ) { - return Err(invalid()); - } - } - Ok(()) - } - QueryExpr::Compare { left, right, op } => { - if !matches!( - op, - CompareOpKind::Eq - | CompareOpKind::Ne - | CompareOpKind::Lt - | CompareOpKind::Le - | CompareOpKind::Gt - | CompareOpKind::Ge - ) { - return Err(invalid()); - } - validate(left, schema)?; - validate(right, schema)?; - let (a, _) = left - .scalar_type(schema) - .map_err(|e| Error::Invalid(e.to_string()))?; - let (b, _) = right - .scalar_type(schema) - .map_err(|e| Error::Invalid(e.to_string()))?; - fn comparable(dtype: &DataType) -> bool { - match dtype { - DataType::Null - | DataType::Int64 - | DataType::Float64 - | DataType::Utf8 - | DataType::Bool - | DataType::Timestamp => true, - DataType::Map { key, value, .. } => comparable(key) && comparable(value), - _ => false, - } - } - let numeric = |dtype: &DataType| matches!(dtype, DataType::Int64 | DataType::Float64); - if !comparable(&a) - || !comparable(&b) - || (a != b - && !matches!(a, DataType::Null) - && !matches!(b, DataType::Null) - && !(numeric(&a) && numeric(&b))) - { - return Err(invalid()); - } - Ok(()) - } - QueryExpr::FunctionCall { name, args } => { - if name != "asap_struct_field" - && name != "asap_element_access" - && planner_types::pre_asap::scalar_type_rules::MapScalarFunction::from_name(name) - .is_none() - { - return Err(invalid()); - } - for arg in args { - validate(arg, schema)?; - } - Ok(()) - } - QueryExpr::BoolAnd(parts) | QueryExpr::BoolOr(parts) => { - for part in parts { - validate(part, schema)?; - if !matches!( - part.scalar_type(schema) - .map_err(|e| Error::Invalid(e.to_string()))? - .0, - DataType::Bool | DataType::Null - ) { - return Err(invalid()); - } - } - Ok(()) - } - QueryExpr::Not(value) => { - validate(value, schema)?; - if !matches!( - value - .scalar_type(schema) - .map_err(|e| Error::Invalid(e.to_string()))? - .0, - DataType::Bool | DataType::Null - ) { - return Err(invalid()); - } - Ok(()) - } - QueryExpr::IsNull(value) | QueryExpr::IsNotNull(value) => validate(value, schema), - _ => Err(invalid()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn mixed_comparison_preserves_integer_precision_and_boundaries() { - assert_eq!( - integer_float_cmp(9_007_199_254_740_993, 9_007_199_254_740_992.0), - Some(Ordering::Greater) - ); - assert_eq!( - integer_float_cmp(i64::MAX, 9_223_372_036_854_775_808.0), - Some(Ordering::Less) - ); - assert_eq!( - integer_float_cmp(i64::MIN, -9_223_372_036_854_775_808.0), - Some(Ordering::Equal) - ); - assert_eq!(integer_float_cmp(-1, -1.5), Some(Ordering::Greater)); - assert_eq!(integer_float_cmp(1, 1.5), Some(Ordering::Less)); - assert_eq!(integer_float_cmp(0, f64::INFINITY), Some(Ordering::Less)); - assert_eq!( - integer_float_cmp(0, f64::NEG_INFINITY), - Some(Ordering::Greater) - ); - assert_eq!(integer_float_cmp(0, f64::NAN), None); - } -} diff --git a/crates/asap-physical-operators/src/expressions/unified_planner.rs b/crates/asap-physical-operators/src/expressions/unified_planner.rs index a23b3dab1..99f4c2b89 100644 --- a/crates/asap-physical-operators/src/expressions/unified_planner.rs +++ b/crates/asap-physical-operators/src/expressions/unified_planner.rs @@ -501,6 +501,10 @@ pub struct CompiledExpression { output: (DataType, bool), } impl CompiledExpression { + pub(crate) fn expression(&self) -> &ScalarExpr { + &self.expression + } + pub fn compile(expression: &ScalarExpr, input: &SchemaRef) -> Result { if !input.is_all_plain() { return Err(Error::Invalid( diff --git a/crates/asap-physical-operators/src/lib.rs b/crates/asap-physical-operators/src/lib.rs index 586da2508..a68af5a50 100644 --- a/crates/asap-physical-operators/src/lib.rs +++ b/crates/asap-physical-operators/src/lib.rs @@ -20,16 +20,14 @@ pub use planner_types as planner; pub mod dag; -pub mod readout; +pub mod evaluation; mod error; pub use error::Error; pub mod expressions; pub mod operators; -pub mod physical_planner; pub mod plan; pub mod runtime; -pub mod sources; pub mod values; pub mod unified_physical_planner; diff --git a/crates/asap-physical-operators/src/operators/aggregate/mod.rs b/crates/asap-physical-operators/src/operators/aggregate/mod.rs index 76eccd5ee..7b51b3c66 100644 --- a/crates/asap-physical-operators/src/operators/aggregate/mod.rs +++ b/crates/asap-physical-operators/src/operators/aggregate/mod.rs @@ -24,7 +24,8 @@ impl Operator { } else { t.clone() }, - false, + !input.has_promql_series_identity() + && (groups.is_empty() || plain(&input, *i)?.1), ) } Reduction::Quantile { column, q } => { @@ -183,7 +184,8 @@ async fn reduce( let mut work = Cooperative::new(context); let mut workspace = Workspace::new(context)?; let mut grouped = BTreeMap::>, Vec>>::new(); - if rows.is_empty() && groups.is_empty() { + // PromQL sums over an empty vector emit no sample. + if rows.is_empty() && groups.is_empty() && !input.has_promql_series_identity() { grouped.insert(vec![], vec![]); } for row in rows { @@ -318,6 +320,9 @@ async fn reduce_one( .ok_or_else(|| invalid("integer aggregate overflow"))?; count += 1; } + if count == 0 && !input.has_promql_series_identity() { + return Ok(Value::Null); + } return if matches!(measure, Reduction::Avg(_)) { Ok(Value::Float64(sum as f64 / count as f64)) } else { @@ -334,6 +339,9 @@ async fn reduce_one( }; floats.push(*v); } + if floats.is_empty() && !input.has_promql_series_identity() { + return Ok(Value::Null); + } Ok(Value::Float64(if matches!(measure, Reduction::Avg(_)) { promql_avg(&floats) } else { diff --git a/crates/asap-physical-operators/src/operators/aggregate/temporal.rs b/crates/asap-physical-operators/src/operators/aggregate/temporal.rs index e40a1fa56..a289c5284 100644 --- a/crates/asap-physical-operators/src/operators/aggregate/temporal.rs +++ b/crates/asap-physical-operators/src/operators/aggregate/temporal.rs @@ -308,7 +308,7 @@ mod tests { values::Batch, }; use planner_types::{ - post_asap::{Field, FieldDataType, Schema}, + post_asap::{Field as SummaryField, FieldDataType as SummaryFamilyType, Schema}, pre_asap::DataType, types::AccuracyTarget, }; @@ -318,23 +318,23 @@ mod tests { #[test] fn temporal_windows_execute_in_both_phases_and_count_is_integer() { let schema = Arc::new(Schema { - closed: true, - unique_keys: vec![], fields: vec![ - Field { - table: None, + SummaryField { name: "time".into(), - dtype: FieldDataType::Plain(DataType::Timestamp), + dtype: SummaryFamilyType::Plain(DataType::Timestamp), nullable: false, - }, - Field { table: None, + }, + SummaryField { name: "value".into(), - dtype: FieldDataType::Plain(DataType::Float64), + dtype: SummaryFamilyType::Plain(DataType::Float64), nullable: false, + table: None, }, ], time_index: Some(0), + unique_keys: vec![], + closed: false, }); for scope in [ Scope::Query { diff --git a/crates/asap-physical-operators/src/operators/aligned_binary.rs b/crates/asap-physical-operators/src/operators/aligned_binary.rs index 188d28d6f..909dcc369 100644 --- a/crates/asap-physical-operators/src/operators/aligned_binary.rs +++ b/crates/asap-physical-operators/src/operators/aligned_binary.rs @@ -1,6 +1,8 @@ //! Arithmetic on complete, aligned population/window rows used by precomputation. use super::*; -use planner_types::{post_asap::BinaryOperator, pre_asap::BinaryOpKind}; +use crate::expressions::binary::BinaryOpKind; +use crate::expressions::binary::BinaryOperator; + use std::collections::BTreeSet; impl Operator { @@ -22,11 +24,9 @@ impl Operator { )); } for (input, value) in [(&left, values.0), (&right, values.1)] { - if input - .fields - .get(value) - .is_none_or(|f| f.nullable || f.dtype != FieldDataType::Plain(DataType::Float64)) - { + if input.fields.get(value).is_none_or(|f| { + f.nullable || f.dtype != SummaryFamilyType::Plain(DataType::Float64) + }) { return Err(invalid( "aligned arithmetic requires non-null Float64 values", )); diff --git a/crates/asap-physical-operators/src/operators/common.rs b/crates/asap-physical-operators/src/operators/common.rs index 5ef5023cd..f67314bb1 100644 --- a/crates/asap-physical-operators/src/operators/common.rs +++ b/crates/asap-physical-operators/src/operators/common.rs @@ -2,20 +2,20 @@ use super::*; pub(super) fn invalid(message: &str) -> Error { Error::Invalid(message.into()) } -pub(super) fn schema(fields: Vec) -> SchemaRef { +pub(super) fn schema(fields: Vec) -> SchemaRef { Arc::new(Schema { - closed: true, - unique_keys: vec![], fields, + unique_keys: vec![], + closed: false, time_index: None, }) } -pub(super) fn result_field(name: &str, dtype: DataType, nullable: bool) -> Field { - Field { - table: None, +pub(super) fn result_field(name: &str, dtype: DataType, nullable: bool) -> SummaryField { + SummaryField { name: name.into(), - dtype: FieldDataType::Plain(dtype), + dtype: SummaryFamilyType::Plain(dtype), nullable, + table: None, } } @@ -76,4 +76,3 @@ pub(super) fn key_bytes(key: &[Vec]) -> usize { .map(|part| std::mem::size_of::>() + part.len()) .sum::() } -use planner_types::pre_asap::Schema; diff --git a/crates/asap-physical-operators/src/operators/joins/mod.rs b/crates/asap-physical-operators/src/operators/joins/mod.rs index 9dfd20dea..e494c3cdc 100644 --- a/crates/asap-physical-operators/src/operators/joins/mod.rs +++ b/crates/asap-physical-operators/src/operators/joins/mod.rs @@ -22,6 +22,15 @@ impl Operator { output: left, }) } + /// Require every candidate key to have an authoritative value at execution. + pub fn certified_semi_join( + left: SchemaRef, + right: SchemaRef, + keys: Vec<(usize, usize)>, + ) -> Result { + Ok(Self::semi_join(left, right, keys)?.require_complete_right()) + } + pub(crate) fn require_complete_right(mut self) -> Self { if let Kind::SemiJoin { require_complete_right, @@ -42,48 +51,20 @@ impl Operator { } } pub fn relational_join( - left: SchemaRef, - right: SchemaRef, - kind: planner_types::pre_asap::JoinKind, - predicate: &planner_types::pre_asap::Predicate, - output: SchemaRef, - ) -> Result { - let mut joined = left.fields.clone(); - joined.extend(right.fields.clone()); - let predicate = Expression::planner(crate::expressions::CompiledExpression::compile( - &predicate.0, - &schema(joined), - )?); - Self::bound_relational_join(left, right, kind, predicate, output) - } - pub fn unified_relational_join( left: SchemaRef, right: SchemaRef, kind: planner_types::pre_asap::JoinKind, predicate: &planner_types::ir::Predicate, output: SchemaRef, - ) -> Result { - let mut joined = left.fields.clone(); - joined.extend(right.fields.clone()); - let predicate = Expression::unified_planner( - crate::expressions::unified_planner::CompiledExpression::compile( - &predicate.0, - &schema(joined), - )?, - ); - Self::bound_relational_join(left, right, kind, predicate, output) - } - pub(crate) fn bound_relational_join( - left: SchemaRef, - right: SchemaRef, - kind: planner_types::pre_asap::JoinKind, - predicate: Expression, - output: SchemaRef, ) -> Result { use planner_types::pre_asap::JoinKind; let mut joined = left.fields.clone(); joined.extend(right.fields.clone()); - if predicate.dtype(&schema(joined.clone()))?.0 != DataType::Bool { + let predicate = crate::expressions::unified_planner::CompiledExpression::compile( + &predicate.0, + &schema(joined.clone()), + )?; + if predicate.dtype().0 != DataType::Bool { return Err(invalid("join predicate must be boolean")); } let fields = if matches!(kind, JoinKind::Semi | JoinKind::Anti) { diff --git a/crates/asap-physical-operators/src/operators/mod.rs b/crates/asap-physical-operators/src/operators/mod.rs index 75b313d75..4c2bb0723 100644 --- a/crates/asap-physical-operators/src/operators/mod.rs +++ b/crates/asap-physical-operators/src/operators/mod.rs @@ -8,7 +8,7 @@ use crate::{ }; use futures::StreamExt; use planner_types::{ - post_asap::{Field, FieldDataType, SummaryUpdate}, + post_asap::{Field as SummaryField, FieldDataType as SummaryFamilyType, Schema, SummaryUpdate}, pre_asap::{ColumnRef, DataType}, }; use std::{collections::BTreeMap, sync::Arc}; @@ -34,7 +34,7 @@ pub(crate) mod vector_window; pub use aggregate::Reduction; pub use series_window::SubquerySteps; pub use sort::SortKey; -pub use summary::ReadoutQuery; +pub use summary::SummaryEvaluation; #[derive(Clone, serde::Serialize, serde::Deserialize)] enum Kind { #[serde(skip)] @@ -58,13 +58,13 @@ enum Kind { column: usize, }, VectorBinary { - operator: planner_types::post_asap::BinaryOperator, + operator: crate::expressions::binary::BinaryOperator, return_bool: bool, }, AlignedBinary { keys: Vec<(usize, usize)>, values: (usize, usize), - operator: planner_types::post_asap::BinaryOperator, + operator: crate::expressions::binary::BinaryOperator, }, RangeWindow { intent: Box>, @@ -89,7 +89,7 @@ enum Kind { unique: bool, }, SeriesBinary { - operator: planner_types::post_asap::BinaryOperator, + operator: crate::expressions::binary::BinaryOperator, scalars: [bool; 2], }, SeriesRelabel { @@ -130,21 +130,21 @@ enum Kind { }, Join { kind: planner_types::pre_asap::JoinKind, - predicate: Box, + predicate: Box, }, SummaryBuild { - family: FieldDataType, + family: SummaryFamilyType, value: usize, time: Option, groups: Vec, }, KeyedSummaryBuild { - family: FieldDataType, + family: SummaryFamilyType, value: usize, items: Vec, groups: Vec, }, - KeyedReadout { + KeyedEvaluation { state: usize, k: usize, }, @@ -152,9 +152,9 @@ enum Kind { state: usize, groups: Vec, }, - Readout { + Evaluation { state: usize, - query: ReadoutQuery, + query: SummaryEvaluation, }, } /// A bound operation has a fully checked input/output contract before execution. @@ -175,11 +175,11 @@ impl Operator { } } - pub(crate) fn is_counter_readout(&self) -> bool { + pub(crate) fn is_counter_evaluation(&self) -> bool { matches!( self.kind, - Kind::Readout { - query: ReadoutQuery::Exact(crate::summary_kernels::exact::ExactReadout { + Kind::Evaluation { + query: SummaryEvaluation::Exact(crate::summary_kernels::exact::ExactEvaluation { statistic: crate::Statistic::Rate | crate::Statistic::Increase, .. }), @@ -192,21 +192,24 @@ impl Operator { if lookback <= 0 { return Err(invalid("counter lookback must be positive")); } - if let Kind::Readout { - query: ReadoutQuery::Exact(readout), + if let Kind::Evaluation { + query: SummaryEvaluation::Exact(evaluation), .. } = &mut self.kind { - readout.lookback_ms = Some(lookback); + evaluation.lookback_ms = Some(lookback); } Ok(self) } - /// Resolve a counter readout's logical lookback to this run's evaluation range. - pub(super) fn readout_range(&self, context: &RunContext) -> Result, Error> { - let Kind::Readout { + /// Resolve a counter evaluation's logical lookback to this run's evaluation range. + pub(super) fn evaluation_range( + &self, + context: &RunContext, + ) -> Result, Error> { + let Kind::Evaluation { query: - ReadoutQuery::Exact(crate::summary_kernels::exact::ExactReadout { + SummaryEvaluation::Exact(crate::summary_kernels::exact::ExactEvaluation { lookback_ms: Some(lookback), .. }), @@ -252,7 +255,7 @@ impl Operator { } if output.time_index.is_some_and(|i| { i >= output.fields.len() - || output.fields[i].dtype != FieldDataType::Plain(DataType::Timestamp) + || output.fields[i].dtype != SummaryFamilyType::Plain(DataType::Timestamp) }) { return Err(invalid("invalid output time column")); } @@ -335,15 +338,15 @@ impl PhysicalOperator for Operator { Kind::SemiJoin { .. } => "SemiJoin", Kind::Join { .. } => "RelationalJoin", Kind::SummaryBuild { .. } | Kind::KeyedSummaryBuild { .. } => "SummaryAgg", - Kind::KeyedReadout { .. } => "SummaryEstimate", + Kind::KeyedEvaluation { .. } => "SummaryEstimate", Kind::SummaryMerge { .. } => "SummaryMerge", - Kind::Readout { .. } => "SummaryReadout", + Kind::Evaluation { .. } => "SummaryEvaluation", } } fn validate_context(&self, context: &RunContext) -> Result<(), Error> { current_series::validate_context(self, context)?; series_window::validate_context(self, context)?; - self.readout_range(context).map(|_| ()) + self.evaluation_range(context).map(|_| ()) } fn input_schemas(&self) -> Vec { self.inputs.clone() @@ -387,9 +390,9 @@ impl PhysicalOperator for Operator { Kind::Join { .. } | Kind::SemiJoin { .. } => joins::execute(self, inputs, context), Kind::SummaryMerge { .. } => summary::execute_merge(self, inputs, context), Kind::SummaryBuild { .. } - | Kind::Readout { .. } + | Kind::Evaluation { .. } | Kind::KeyedSummaryBuild { .. } - | Kind::KeyedReadout { .. } => summary::execute(self, inputs, context), + | Kind::KeyedEvaluation { .. } => summary::execute(self, inputs, context), } } } diff --git a/crates/asap-physical-operators/src/operators/scope_timestamp.rs b/crates/asap-physical-operators/src/operators/scope_timestamp.rs index bb7a4e855..33fbdd1ec 100644 --- a/crates/asap-physical-operators/src/operators/scope_timestamp.rs +++ b/crates/asap-physical-operators/src/operators/scope_timestamp.rs @@ -26,7 +26,7 @@ impl Operator { candidate.dtype == field.dtype && candidate.nullable == field.nullable && (candidate.name == field.name - || !matches!(field.dtype, FieldDataType::Plain(_))) + || !matches!(field.dtype, SummaryFamilyType::Plain(_))) }) .map(|(index, _)| index) .collect(); diff --git a/crates/asap-physical-operators/src/operators/series_labels.rs b/crates/asap-physical-operators/src/operators/series_labels.rs index 2a8c5d1ee..3987c5142 100644 --- a/crates/asap-physical-operators/src/operators/series_labels.rs +++ b/crates/asap-physical-operators/src/operators/series_labels.rs @@ -1,10 +1,9 @@ //! PromQL label-set rewriting and binary operators over rows that carry a //! series identity or plain label columns. use super::*; -use planner_types::{ - post_asap::BinaryOperator, - pre_asap::{schema::PROMQL_SERIES_IDENTITY, BinaryOpKind, VectorMatchKind}, -}; +use crate::expressions::binary::BinaryOpKind; +use crate::expressions::binary::BinaryOperator; +use planner_types::pre_asap::{schema::PROMQL_SERIES_IDENTITY, VectorMatchKind}; type Labels = BTreeMap; diff --git a/crates/asap-physical-operators/src/operators/summary/mod.rs b/crates/asap-physical-operators/src/operators/summary/mod.rs index 90cc2c724..5dab8109f 100644 --- a/crates/asap-physical-operators/src/operators/summary/mod.rs +++ b/crates/asap-physical-operators/src/operators/summary/mod.rs @@ -1,22 +1,22 @@ use super::*; -/// A summary readout: a sketch query, or an exact readout with typed parameters. +/// A summary evaluation: a sketch query, or an exact evaluation with typed parameters. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum ReadoutQuery { +pub enum SummaryEvaluation { Sketch(planner_types::post_asap::SketchStatistic), - Exact(crate::summary_kernels::exact::ExactReadout), + Exact(crate::summary_kernels::exact::ExactEvaluation), } impl Operator { pub fn keyed_summary_build( input: SchemaRef, - family: FieldDataType, + family: SummaryFamilyType, value: usize, items: Vec, groups: Vec, ) -> Result { use crate::summary_kernels::weighted_frequency::WeightedFrequency; crate::values::validate_family(&family)?; - let FieldDataType::Sketch(kind, _) = &family else { + let SummaryFamilyType::Sketch(kind, _) = &family else { return Err(invalid("keyed sketch required")); }; WeightedFrequency::configuration(kind)?; @@ -43,11 +43,11 @@ impl Operator { .iter() .map(|&i| input.fields[i].clone()) .collect::>(); - fields.push(Field { - table: None, + fields.push(SummaryField { name: "state".into(), dtype: family.clone(), nullable: false, + table: None, }); Ok(Self { kind: Kind::KeyedSummaryBuild { @@ -60,7 +60,7 @@ impl Operator { output: schema(fields), }) } - pub fn keyed_readout( + pub fn keyed_evaluation( input: SchemaRef, state: usize, k: usize, @@ -68,31 +68,31 @@ impl Operator { ) -> Result { use crate::summary_kernels::weighted_frequency::WeightedFrequency; crate::values::validate_family(&field(&input, state)?.dtype)?; - let FieldDataType::Sketch(kind, _) = &field(&input, state)?.dtype else { - return Err(invalid("keyed readout requires summary state")); + let SummaryFamilyType::Sketch(kind, _) = &field(&input, state)?.dtype else { + return Err(invalid("keyed evaluation requires summary state")); }; let (_, _, _, capacity) = WeightedFrequency::configuration(kind)?; if k > capacity || output.fields.len() <= input.fields.len() { - return Err(invalid("invalid keyed readout shape or capacity")); + return Err(invalid("invalid keyed evaluation shape or capacity")); } if state + 1 != input.fields.len() || output.fields[..state] != input.fields[..state] - || output.fields.last().unwrap().dtype != FieldDataType::Plain(DataType::Float64) + || output.fields.last().unwrap().dtype != SummaryFamilyType::Plain(DataType::Float64) { return Err(invalid( - "keyed readout must preserve partitions and return a Float64 score", + "keyed evaluation must preserve partitions and return a Float64 score", )); } crate::values::validate_schema(&output)?; Ok(Self { - kind: Kind::KeyedReadout { state, k }, + kind: Kind::KeyedEvaluation { state, k }, inputs: vec![input], output, }) } pub fn summary_build( input: SchemaRef, - family: FieldDataType, + family: SummaryFamilyType, value: usize, time: Option, groups: Vec, @@ -110,7 +110,7 @@ impl Operator { if time.is_none() && matches!( family, - FieldDataType::ExactAggregate( + SummaryFamilyType::ExactAggregate( planner_types::post_asap::ExactKind::Rate | planner_types::post_asap::ExactKind::Increase, _ @@ -129,11 +129,11 @@ impl Operator { .iter() .map(|&i| input.fields[i].clone()) .collect::>(); - fields.push(Field { - table: None, + fields.push(SummaryField { name: "state".into(), dtype: family.clone(), nullable: false, + table: None, }); Ok(Self { kind: Kind::SummaryBuild { @@ -153,7 +153,7 @@ impl Operator { ) -> Result { validate_groups(&input, &groups)?; crate::values::validate_family(&field(&input, state)?.dtype)?; - if matches!(field(&input, state)?.dtype, FieldDataType::Plain(_)) { + if matches!(field(&input, state)?.dtype, SummaryFamilyType::Plain(_)) { return Err(invalid("summary state required")); } let mut fields = groups @@ -167,21 +167,25 @@ impl Operator { output: schema(fields), }) } - pub fn readout(input: SchemaRef, state: usize, query: ReadoutQuery) -> Result { + pub fn evaluation( + input: SchemaRef, + state: usize, + query: SummaryEvaluation, + ) -> Result { let family = &field(&input, state)?.dtype; crate::values::validate_family(family)?; match &query { - ReadoutQuery::Sketch(query) => { - crate::capability::validate_sketch_readout(family, query)? + SummaryEvaluation::Sketch(query) => { + crate::capability::validate_sketch_evaluation(family, query)? } - ReadoutQuery::Exact(readout) => { - crate::capability::validate_exact_readout(family, readout)? + SummaryEvaluation::Exact(evaluation) => { + crate::capability::validate_exact_evaluation(family, evaluation)? } } let mut fields = input.fields.clone(); let result_type = if matches!( fields[state].dtype, - FieldDataType::ExactAggregate(planner_types::post_asap::ExactKind::Count, _) + SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Count, _) ) || integral_count(family, &query) { DataType::Int64 @@ -193,7 +197,7 @@ impl Operator { let nullable = fields.len() == 1 && matches!( fields[state].dtype, - FieldDataType::ExactAggregate( + SummaryFamilyType::ExactAggregate( planner_types::post_asap::ExactKind::Min | planner_types::post_asap::ExactKind::Max, _ @@ -201,7 +205,7 @@ impl Operator { ); fields[state] = result_field("value", result_type, nullable); Ok(Self { - kind: Kind::Readout { state, query }, + kind: Kind::Evaluation { state, query }, inputs: vec![input], output: schema(fields), }) @@ -210,12 +214,12 @@ impl Operator { /// The Planner reads a Count-Min bare count only for count intents, whose /// output is Int64 and whose updates have unit weight; execution rejects a /// non-integral total rather than rounding it. -fn integral_count(family: &FieldDataType, query: &ReadoutQuery) -> bool { - matches!(family, FieldDataType::Sketch(kind, _) +fn integral_count(family: &SummaryFamilyType, query: &SummaryEvaluation) -> bool { + matches!(family, SummaryFamilyType::Sketch(kind, _) if kind.algorithm() == &planner_types::post_asap::SketchAlgorithm::Cms) && matches!( query, - ReadoutQuery::Sketch(planner_types::post_asap::SketchStatistic::PointCount { + SummaryEvaluation::Sketch(planner_types::post_asap::SketchStatistic::PointCount { value: None, .. }) @@ -226,7 +230,7 @@ pub(super) fn execute<'a>( mut inputs: Vec>, context: RunContext, ) -> Result, Error> { - let range_ms = operator.readout_range(&context)?; + let range_ms = operator.evaluation_range(&context)?; let output = operator.output.clone(); let input = inputs.pop().ok_or_else(|| invalid("input missing"))?; match &operator.kind { @@ -238,7 +242,7 @@ pub(super) fn execute<'a>( } => Ok(futures::stream::once(async move { Batch::try_new( output, - build_summary(input, family, *value, *time, groups, &context).await?, + build_summary(input, family, *value, *time, groups, !operator.inputs[0].has_promql_series_identity(), &context).await?, ) }) .boxed_local()), @@ -254,7 +258,7 @@ pub(super) fn execute<'a>( ) }) .boxed_local()), - Kind::KeyedReadout { state, k } => Ok(input + Kind::KeyedEvaluation { state, k } => Ok(input .map(move |batch| { let batch = batch?; let mut rows = Vec::new(); @@ -272,7 +276,7 @@ pub(super) fn execute<'a>( // The typed output schema restores epoch-millisecond // timestamp keys from the kernel's Int64 representation. for (value, field) in values.iter_mut().zip(&output.fields) { - if field.dtype == FieldDataType::Plain(DataType::Timestamp) { + if field.dtype == SummaryFamilyType::Plain(DataType::Timestamp) { if let Value::Int64(time) = value { *value = Value::Timestamp(*time); } @@ -284,25 +288,25 @@ pub(super) fn execute<'a>( Batch::try_new(output.clone(), rows) }) .boxed_local()), - Kind::Readout { state, query } => Ok(input + Kind::Evaluation { state, query } => Ok(input .map(move |batch| { let batch = batch?; let mut rows = batch.rows().to_vec(); - if let ReadoutQuery::Exact(readout) = query { + if let SummaryEvaluation::Exact(evaluation) = query { rows.retain(|row| !matches!(&row[*state], Value::Summary { state: summary, .. } - if crate::readout::insufficient_counter_samples(summary.as_ref(), readout.statistic))); + if crate::evaluation::insufficient_counter_samples(summary.as_ref(), evaluation.statistic))); } for row in &mut rows { let Value::Summary { state: summary, .. } = &row[*state] else { return Err(invalid("summary value required")); }; row[*state] = match query { - ReadoutQuery::Sketch(query) => { + SummaryEvaluation::Sketch(query) => { let value = summary .estimate(query) .map_err(|e| Error::Operator(e.to_string()))?; if output.fields[*state].dtype - == FieldDataType::Plain(DataType::Int64) + == SummaryFamilyType::Plain(DataType::Int64) { // Below 2^53 an f64 sum of unit updates is exact. if value.fract() != 0.0 || !(0.0..9.007_199_254_740_992e15).contains(&value) { @@ -315,12 +319,13 @@ pub(super) fn execute<'a>( Value::Float64(value) } } - ReadoutQuery::Exact(readout) => { + SummaryEvaluation::Exact(evaluation) => { let exact = summary .as_any() .downcast_ref::() - .ok_or_else(|| invalid("exact readout requires exact state"))?; - if output.fields[*state].dtype == FieldDataType::Plain(DataType::Int64) { + .ok_or_else(|| invalid("exact evaluation requires exact state"))?; + if output.fields[*state].nullable && exact.is_empty_sum() { Value::Null } + else if output.fields[*state].dtype == SummaryFamilyType::Plain(DataType::Int64) { let count = exact.count().ok_or_else(|| { Error::Operator("exact count state lacks an integer count".into()) })?; @@ -329,7 +334,7 @@ pub(super) fn execute<'a>( })?) } else { match exact - .readout(readout.statistic, range_ms, None) + .evaluation(evaluation.statistic, range_ms, None) .map_err(|e| Error::Operator(e.to_string()))? { Some(value) => Value::Float64(value), @@ -372,10 +377,11 @@ pub(super) fn execute_merge<'a>( async fn build_summary( mut input: Input<'_, Batch>, - family: &FieldDataType, + family: &SummaryFamilyType, value: usize, time: Option, groups: &[usize], + emit_empty_global: bool, context: &RunContext, ) -> Result>, Error> { type State = ( @@ -398,12 +404,13 @@ async fn build_summary( }; let mut work = Cooperative::new(context); let mut states = BTreeMap::>, State>::new(); - if groups.is_empty() { + // PromQL aggregation of an empty vector produces no sample. + if groups.is_empty() && emit_empty_global { states.insert(vec![], create(vec![], 0)?); } let ordered_time = matches!( family, - FieldDataType::ExactAggregate( + SummaryFamilyType::ExactAggregate( planner_types::post_asap::ExactKind::Rate | planner_types::post_asap::ExactKind::Increase, _ @@ -472,7 +479,7 @@ async fn merge_summary( groups: &[usize], context: &RunContext, ) -> Result>, Error> { - type GroupState = (Vec, FieldDataType, Arc); + type GroupState = (Vec, SummaryFamilyType, Arc); let mut states: BTreeMap>, GroupState> = BTreeMap::new(); let mut work = Cooperative::new(context); let mut memory = context.reserve(0)?; @@ -531,14 +538,14 @@ async fn merge_summary( async fn build_keyed_summary( mut input: Input<'_, Batch>, - family: &FieldDataType, + family: &SummaryFamilyType, value: usize, items: &[usize], groups: &[usize], context: &RunContext, ) -> Result>, Error> { use crate::{summary_kernels::weighted_frequency::WeightedFrequency, AggregateCore}; - let FieldDataType::Sketch(kind, _) = family else { + let SummaryFamilyType::Sketch(kind, _) = family else { unreachable!() }; let (algorithm, width, depth, capacity) = WeightedFrequency::configuration(kind)?; diff --git a/crates/asap-physical-operators/src/operators/unchecked.rs b/crates/asap-physical-operators/src/operators/unchecked.rs index a7d3bd683..1b5dd7c4f 100644 --- a/crates/asap-physical-operators/src/operators/unchecked.rs +++ b/crates/asap-physical-operators/src/operators/unchecked.rs @@ -134,11 +134,11 @@ impl TryFrom for Operator { operator } } - Kind::Join { kind, predicate } => Operator::bound_relational_join( + Kind::Join { kind, predicate } => Operator::relational_join( input(0)?, input(1)?, kind, - *predicate, + &planner_types::ir::Predicate(predicate.expression().clone()), output.clone(), )?, Kind::SummaryBuild { @@ -153,13 +153,13 @@ impl TryFrom for Operator { items, groups, } => Operator::keyed_summary_build(input(0)?, family, value, items, groups)?, - Kind::KeyedReadout { state, k } => { - Operator::keyed_readout(input(0)?, state, k, output.clone())? + Kind::KeyedEvaluation { state, k } => { + Operator::keyed_evaluation(input(0)?, state, k, output.clone())? } Kind::SummaryMerge { state, groups } => { Operator::summary_merge(input(0)?, state, groups)? } - Kind::Readout { state, query } => Operator::readout(input(0)?, state, query)?, + Kind::Evaluation { state, query } => Operator::evaluation(input(0)?, state, query)?, } .with_output_schema(output)?; if serde_json::to_value(&op.kind).map_err(|error| invalid(&error.to_string()))? diff --git a/crates/asap-physical-operators/src/operators/vector_binary.rs b/crates/asap-physical-operators/src/operators/vector_binary.rs index 7027fb9be..0f4af7cf4 100644 --- a/crates/asap-physical-operators/src/operators/vector_binary.rs +++ b/crates/asap-physical-operators/src/operators/vector_binary.rs @@ -1,6 +1,7 @@ //! Label matching and scalar broadcasting are physical computation, not source binding. use super::*; -use planner_types::{post_asap::BinaryOperator, pre_asap::BinaryOpKind}; +use crate::expressions::binary::BinaryOpKind; +use crate::expressions::binary::BinaryOperator; pub(crate) fn value_schema(scalar: bool) -> SchemaRef { let mut fields = Vec::new(); diff --git a/crates/asap-physical-operators/src/operators/vector_window.rs b/crates/asap-physical-operators/src/operators/vector_window.rs index 6b4d58347..6b21ff6a4 100644 --- a/crates/asap-physical-operators/src/operators/vector_window.rs +++ b/crates/asap-physical-operators/src/operators/vector_window.rs @@ -1,7 +1,6 @@ //! Window bounds are typed input data; aggregation and histogram semantics stay native. use super::*; use planner_types::pre_asap::AggIntent; -use planner_types::pre_asap::Schema; pub(crate) fn matrix_schema() -> SchemaRef { let mut fields = vector_binary::value_schema(false).fields.clone(); @@ -9,9 +8,9 @@ pub(crate) fn matrix_schema() -> SchemaRef { fields.push(result_field("window_start", DataType::Timestamp, false)); fields.push(result_field("window_end", DataType::Timestamp, false)); Arc::new(Schema { - closed: true, - unique_keys: vec![], fields, + unique_keys: vec![], + closed: false, time_index: Some(1), }) } diff --git a/crates/asap-physical-operators/src/physical_planner/candidates.rs b/crates/asap-physical-operators/src/physical_planner/candidates.rs deleted file mode 100644 index 0e6ee86ba..000000000 --- a/crates/asap-physical-operators/src/physical_planner/candidates.rs +++ /dev/null @@ -1,483 +0,0 @@ -//! Compile maintenance-selected frontiers without deployment-specific DAG rewrites. -use super::*; - -/// One computation realization; lifecycle/window/revision requirements accompany -/// it during optimization and deployment. Stored outputs have no storage identity. -/// Deserialization validates the producer/reader boundary. -#[derive(Clone, serde::Serialize, serde::Deserialize)] -#[serde(try_from = "UncheckedPhysicalASAPDAG")] -pub struct PhysicalASAPDAG { - pub precompute: Option, - pub query: CompiledPhysicalDAG, - pub materialized_outputs: BTreeMap, -} - -/// Compile an explicit materialization frontier selected by Planner maintenance -/// search. Operators upstream of that frontier run in precompute, including -/// readouts/reductions; query execution receives their typed output values. -/// Empty frontiers retain the full computation in the query DAG. -/// -/// Repeated windows must be instantiated with the same evaluation/population -/// contract used to build each output. This API never treats a result from a -/// different window or revision as interchangeable merely because types match. -pub fn compile_candidate( - dag: &PostAsapDAG, - inputs: BTreeMap, - roots: &[NodeId], - frontier: &[NodeId], -) -> Result { - cut_candidate(&compile(dag, inputs, roots)?, frontier) -} - -/// Derive one frontier's candidate from a complete [`compile`] result by -/// partitioning its operators; nothing is lowered again. A deployment compiles -/// each query DAG once and derives every placement choice from that result. -/// The candidate is identical to [`compile_candidate`] for the same frontier. -pub fn cut_candidate( - compiled: &CompiledPhysicalDAG, - frontier: &[NodeId], -) -> Result { - if frontier.is_empty() { - return Ok(PhysicalASAPDAG { - precompute: None, - query: compiled.clone(), - materialized_outputs: BTreeMap::new(), - }); - } - let frontier_set: BTreeSet<_> = frontier.iter().copied().collect(); - // `compile` retains only reachable nodes and numbers its helper operators - // above the u32 Planner ID range; only Planner outputs are boundaries. - if frontier_set.len() != frontier.len() - || frontier - .iter() - .any(|&id| !compiled.is_operator(id) || u32::try_from(id).is_err()) - { - return Err(invalid("frontier must contain distinct computed outputs")); - } - let inputs: BTreeMap<_, _> = compiled - .input_contracts() - .map(|(id, contract)| (id, contract.clone())) - .collect(); - let precompute = compiled.cut(&inputs, frontier)?; - let mut materialized_outputs = BTreeMap::new(); - for &id in frontier { - let mut output = precompute.output_contract(id)?; - if output.properties.boundedness != Boundedness::Bounded { - return Err(invalid("materialized output requires bounded execution")); - } - // A stored reader may stream batches even when the producer blocked. - // Its timing is independent; the retained result still must be finite. - output.properties.emission = Emission::Unknown; - materialized_outputs.insert(id, output); - } - let mut query_inputs = inputs; - query_inputs.extend(materialized_outputs.clone()); - let query = compiled.cut(&query_inputs, compiled.roots())?; - let used: BTreeSet<_> = query.input_contracts().map(|(id, _)| id).collect(); - if !frontier.iter().all(|id| used.contains(id)) { - return Err(invalid( - "frontier contains an output shadowed by another boundary", - )); - } - Ok(PhysicalASAPDAG { - precompute: Some(precompute), - query, - materialized_outputs, - }) -} - -/// Materialization frontier implied by lifecycle-assigned timing: ingestion-time -/// nodes read by a query-time node, plus the root when it is ingestion-timed. -/// `cut_candidate` of one [`compile`] result with this frontier realizes the -/// assignment, so different assignments are different cuts of one lowering. -/// That holds while timing-dependent lowering (an ingestion-time `Binary` -/// aligns by value column) has the same timing at compile time as here. -/// A query-time node feeding an ingestion-time node has no valid placement. -pub fn frontier_from_timing(dag: &PostAsapDAG) -> Result, Error> { - use planner_types::post_asap::ExecutionTiming::IngestionTime; - let timing = dag - .nodes - .iter() - .map(|node| (node.id, node.output_state.timing)) - .collect::>(); - let mut frontier = BTreeSet::new(); - if timing.get(&dag.root) == Some(&IngestionTime) { - frontier.insert(u64::from(dag.root.0)); - } - for edge in &dag.edges { - let (Some(&producer), Some(&consumer)) = - (timing.get(&edge.producer), timing.get(&edge.consumer)) - else { - return Err(invalid("timed DAG edge names an unknown node")); - }; - match (producer == IngestionTime, consumer == IngestionTime) { - (true, false) => { - frontier.insert(u64::from(edge.producer.0)); - } - (false, true) => return Err(invalid("query-time node feeds an ingestion-time node")), - _ => {} - } - } - Ok(frontier.into_iter().collect()) -} - -/// Enumerate bounded, reachable materialization frontiers above explicit inputs. -/// Each frontier is an antichain: storing an output and its ancestor together -/// would leave the ancestor unused by query execution. Lifecycle eligibility -/// and deployment feasibility are evaluated separately before cost selection. -/// Exceeding the search budget returns an error, never a partial inventory. -pub fn enumerate_frontiers( - dag: &PostAsapDAG, - inputs: &BTreeMap, - roots: &[NodeId], - max_candidates: usize, -) -> Result>, Error> { - enumerate_compiled_frontiers(&compile(dag, inputs.clone(), roots)?, max_candidates) -} - -fn enumerate_compiled_frontiers( - compiled: &CompiledPhysicalDAG, - max_candidates: usize, -) -> Result>, Error> { - if max_candidates == 0 { - return Err(invalid( - "frontier search requires a positive candidate budget", - )); - } - let mut ancestors = BTreeMap::>::new(); - let mut eligible = Vec::new(); - for (id, properties) in compiled.output_properties()? { - if !compiled.is_operator(id) - || u32::try_from(id).is_err() - || properties.boundedness != Boundedness::Bounded - { - continue; - } - let mut seen = BTreeSet::new(); - let mut pending = vec![id]; - while let Some(current) = pending.pop() { - if seen.insert(current) { - pending.extend(compiled.dependencies(current)); - } - } - ancestors.insert(id, seen); - eligible.push(id); - } - let mut frontiers = vec![vec![]]; - for id in eligible { - let additions = frontiers - .iter() - .filter(|frontier| { - frontier.iter().all(|previous| { - !ancestors[&id].contains(previous) && !ancestors[previous].contains(&id) - }) - }) - .map(|frontier| { - let mut next = frontier.clone(); - next.push(id); - next - }) - .collect::>(); - if additions.len() > max_candidates.saturating_sub(frontiers.len()) { - return Err(invalid( - "materialization frontier search exceeds candidate budget", - )); - } - frontiers.extend(additions); - } - Ok(frontiers) -} - -/// Lower every maintenance candidate before feasibility/cost evaluation. Keep -/// individual failures visible; do not substitute another computation on error. -/// The DAG is lowered once; each frontier is a [`cut_candidate`] of it. -pub fn compile_candidates( - dag: &PostAsapDAG, - inputs: BTreeMap, - roots: &[NodeId], - frontiers: &[Vec], -) -> Vec> { - match compile(dag, inputs, roots) { - Ok(compiled) => frontiers - .iter() - .map(|frontier| cut_candidate(&compiled, frontier)) - .collect(), - Err(error) => frontiers.iter().map(|_| Err(error.clone())).collect(), - } -} - -/// Complete workload cost supplied by scoped optimizer/deployment evidence. -/// The evaluator includes build/update work, retained state, shared producers -/// and recurrent reads over the same horizon; these are not per-query timings. -#[derive(Clone, Debug)] -pub struct CandidateCost { - pub workload_scope: String, - pub horizon_seconds: f64, - pub total_cost: f64, -} - -pub struct CandidateSelection { - pub candidate: T, - pub candidate_index: usize, - pub cost: CandidateCost, -} - -/// Select only compiled and deployment-feasible physical candidates. `None` -/// rejects an unbindable candidate before pricing. Comparable scoped costs are -/// required; deployment never rewrites the selected frontier after this step. -/// The payload is generic so deployments can retain binding/diagnostic metadata -/// alongside each compiled computation without duplicating winner selection. -pub fn select_candidate( - candidates: Vec>, - mut evaluate: impl FnMut(&T) -> Result, Error>, -) -> Result, Error> { - let mut scope: Option<(String, f64)> = None; - let mut selected: Option> = None; - for (candidate_index, candidate) in candidates.into_iter().enumerate() { - let Ok(candidate) = candidate else { continue }; - let Some(cost) = evaluate(&candidate)? else { - continue; - }; - if cost.workload_scope.is_empty() - || !cost.horizon_seconds.is_finite() - || cost.horizon_seconds <= 0. - || !cost.total_cost.is_finite() - || cost.total_cost < 0. - { - return Err(invalid( - "candidate cost lacks a valid workload scope/horizon", - )); - } - let current_scope = (cost.workload_scope.clone(), cost.horizon_seconds); - if scope.as_ref().is_some_and(|scope| scope != ¤t_scope) { - return Err(invalid( - "candidate costs describe different workloads or horizons", - )); - } - scope = Some(current_scope); - if selected - .as_ref() - .is_none_or(|selected| cost.total_cost < selected.cost.total_cost) - { - selected = Some(CandidateSelection { - candidate, - candidate_index, - cost, - }); - } - } - selected.ok_or_else(|| invalid("no feasible priced physical candidate")) -} - -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct UncheckedPhysicalASAPDAG { - precompute: Option, - query: CompiledPhysicalDAG, - materialized_outputs: BTreeMap, -} -impl TryFrom for PhysicalASAPDAG { - type Error = Error; - fn try_from(candidate: UncheckedPhysicalASAPDAG) -> Result { - let result = Self { - precompute: candidate.precompute, - query: candidate.query, - materialized_outputs: candidate.materialized_outputs, - }; - result.validate()?; - Ok(result) - } -} - -impl PhysicalASAPDAG { - /// Validate the physical handoff, including the producer/reader boundary. - pub fn validate(&self) -> Result<(), Error> { - self.query.validate()?; - let Some(precompute) = &self.precompute else { - return if self.materialized_outputs.is_empty() { - Ok(()) - } else { - Err(invalid("materialized outputs have no producer DAG")) - }; - }; - precompute.validate()?; - let outputs: BTreeSet<_> = self.materialized_outputs.keys().copied().collect(); - if outputs.is_empty() || outputs != precompute.roots().iter().copied().collect() { - return Err(invalid("physical frontier differs from precompute outputs")); - } - let readers: BTreeMap<_, _> = self.query.input_contracts().collect(); - for (&id, contract) in &self.materialized_outputs { - let produced = precompute.output_contract(id)?; - // Direct frontiers retain their node IDs. Temporal candidates can - // read several window instances through distinct input slots; - // their deployment bindings must validate those slots separately. - let reader = readers.get(&id); - if contract.schema != produced.schema - || reader.is_some_and(|reader| contract.schema != reader.schema) - || produced.properties.boundedness != Boundedness::Bounded - || contract.properties.boundedness != Boundedness::Bounded - || reader - .is_some_and(|reader| reader.properties.boundedness != Boundedness::Bounded) - { - return Err(invalid("physical frontier schema or boundedness mismatch")); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use planner_types::workload::*; - - fn grouped_rate() -> (PostAsapDAG, BTreeMap, NodeId) { - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: Query("sum by(job)(rate(m[1m]))".into()), - requirements: QueryRequirements { - accuracy: AccuracyRequirement::Explicit( - planner_types::types::AccuracyTarget::Exact, - ), - ..Default::default() - }, - predictability: Predictability::Unknown, - invocations: 1, - execute_at: None, - time_selection: TimeSelection::default(), - }]), - repeating_queries: None, - }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1000)), - ..Default::default() - }, - ..Default::default() - }), - }; - let root = asap_frontend_promql::lower_promql_workload(&workload, 0) - .unwrap() - .remove(0); - let root = std::rc::Rc::new(promql_rows::with_series_identity(&root).unwrap()); - let space = asap_aware_mapping::search_workload(vec![("q", root)]); - let selected = space - .global_selection(&asap_aware_mapping::cost_model::DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) - .unwrap() - .unwrap(); - let dag = planner_types::post_asap::compile_post_asap_dag(&selected).unwrap(); - let state = dag - .nodes - .iter() - .find(|node| matches!(node.payload, Payload::SummaryAgg { .. })) - .unwrap(); - let inputs = BTreeMap::from([( - u64::from(state.id.0), - InputContract::bounded(Arc::new(state.output_schema.clone())), - )]); - (dag.clone(), inputs, u64::from(dag.root.0)) - } - - /// Enumerating and cutting every frontier lowers each Planner node once. - #[test] - fn candidates_for_all_frontiers_share_one_lowering() { - let (dag, inputs, root) = grouped_rate(); - let lowered = || crate::physical_planner::LOWERED_NODES.with(|count| count.get()); - let before = lowered(); - let compiled = compile(&dag, inputs, &[root]).unwrap(); - let once = lowered() - before; - let frontiers = enumerate_compiled_frontiers(&compiled, 4096).unwrap(); - assert!(frontiers.len() >= 3, "{frontiers:?}"); - for frontier in &frontiers { - cut_candidate(&compiled, frontier).unwrap(); - } - assert!(once > 0); - assert_eq!(lowered() - before, once); - } - - fn with_timing( - dag: &PostAsapDAG, - timing: impl Fn(&PostAsapDAGNode) -> planner_types::post_asap::ExecutionTiming, - ) -> PostAsapDAG { - let mut timed = dag.clone(); - for node in &mut timed.nodes { - node.output_state.timing = timing(node); - } - for edge in &mut timed.edges { - let producer = timed.nodes.iter().find(|node| node.id == edge.producer); - edge.data_state = producer.unwrap().output_state; - } - timed - } - - fn raw_input(dag: &PostAsapDAG) -> BTreeMap { - let raw = dag - .nodes - .iter() - .find(|node| matches!(node.payload, Payload::Fallback { .. })) - .unwrap(); - BTreeMap::from([( - u64::from(raw.id.0), - InputContract::bounded(Arc::new(raw.output_schema.clone())), - )]) - } - - /// Cutting one compilation by a retained-state timing and by the all - /// query-time timing (what ContinuouslyMaintained and Ephemeral assign) - /// lowers each Planner node once and matches `compile_candidate`. - #[test] - fn timing_cuts_share_one_lowering() { - use planner_types::post_asap::ExecutionTiming::QueryTime; - let (retained, _, root) = grouped_rate(); - let ephemeral = with_timing(&retained, |_| QueryTime); - let inputs = raw_input(&retained); - let lowered = || crate::physical_planner::LOWERED_NODES.with(|count| count.get()); - let before = lowered(); - let compiled = compile(&ephemeral, inputs.clone(), &[root]).unwrap(); - let once = lowered() - before; - let cuts = [&retained, &ephemeral].map(|timed| { - let frontier = frontier_from_timing(timed).unwrap(); - let cut = cut_candidate(&compiled, &frontier).unwrap(); - (timed, frontier, cut) - }); - assert!(once > 0); - assert_eq!(lowered() - before, once); - assert_eq!(cuts[0].1.len(), 1); - assert!(cuts[1].1.is_empty()); - for (timed, frontier, cut) in cuts { - let expected = compile_candidate(timed, inputs.clone(), &[root], &frontier).unwrap(); - assert_eq!( - serde_json::to_vec(&cut).unwrap(), - serde_json::to_vec(&expected).unwrap() - ); - } - } - - /// The frontier is the ingestion-time nodes read at query time; an - /// ingestion-time root is itself the frontier. - #[test] - fn frontier_from_timing_includes_ingestion_root() { - use planner_types::post_asap::ExecutionTiming::IngestionTime; - let (dag, _, root) = grouped_rate(); - let timed = with_timing(&dag, |_| IngestionTime); - assert_eq!(frontier_from_timing(&timed).unwrap(), [root]); - } - - /// A query-time node feeding an ingestion-time node is rejected. - #[test] - fn frontier_from_timing_rejects_query_time_input_to_ingestion() { - use planner_types::post_asap::ExecutionTiming::{IngestionTime, QueryTime}; - let (dag, _, _) = grouped_rate(); - let timed = with_timing(&dag, |node| { - if node.id == dag.root { - IngestionTime - } else { - QueryTime - } - }); - assert!(frontier_from_timing(&timed).is_err()); - } -} diff --git a/crates/asap-physical-operators/src/physical_planner/compiled.rs b/crates/asap-physical-operators/src/physical_planner/compiled.rs deleted file mode 100644 index f5f092354..000000000 --- a/crates/asap-physical-operators/src/physical_planner/compiled.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Reader-independent physical computation and checked deployment instantiation. -use super::*; - -/// A typed execution boundary, without storage identity or a live reader. -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct InputContract { - pub schema: SchemaRef, - pub properties: PlanProperties, -} -impl InputContract { - pub fn bounded(schema: SchemaRef) -> Self { - Self { - schema, - properties: PlanProperties { - boundedness: Boundedness::Bounded, - emission: Emission::Unknown, - }, - } - } - pub fn from_source(source: &dyn PhysicalOperator) -> Self { - Self { - schema: source.output_schema(), - properties: source.properties(&[]), - } - } -} -#[derive(Clone, serde::Serialize, serde::Deserialize)] -enum Node { - Input(InputContract), - Operator { - inputs: Vec, - operator: Operator, - }, -} - -/// Selected native operators and input slots. Rebinding never repeats lowering. -/// Serde is format-agnostic; deployments choose the encoding and its versioning. -/// Deserialization validates the DAG before it is usable. -#[derive(Clone, serde::Serialize, serde::Deserialize)] -#[serde(try_from = "UncheckedDAG")] -pub struct CompiledPhysicalDAG { - nodes: BTreeMap, - roots: Vec, -} -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct UncheckedDAG { - nodes: BTreeMap, - roots: Vec, -} -impl TryFrom for CompiledPhysicalDAG { - type Error = Error; - fn try_from(dag: UncheckedDAG) -> Result { - let result = Self { - nodes: dag.nodes, - roots: dag.roots, - }; - result.validate()?; - Ok(result) - } -} - -impl CompiledPhysicalDAG { - /// Link already-selected physical fragments without lowering operators again. - /// Fragment keys and source keys share a namespace; repeated dependency IDs - /// therefore remain one producer in the composed DAG. - pub fn compose( - sources: BTreeMap, - fragments: BTreeMap, Self)>, - roots: Vec, - ) -> Result { - if sources.keys().any(|id| fragments.contains_key(id)) { - return Err(invalid("physical source and fragment IDs overlap")); - } - let mut contracts = sources.clone(); - for (&id, (_, fragment)) in &fragments { - fragment.validate()?; - let [root] = fragment.roots() else { - return Err(invalid("composed fragment requires one root")); - }; - if fragment.input_contracts().any(|(id, _)| id == *root) { - return Err(invalid("fragment root must be a computed output")); - } - contracts.insert(id, fragment.output_contract(*root)?); - } - let mut next = contracts - .keys() - .next_back() - .copied() - .unwrap_or(0) - .checked_add(1) - .ok_or_else(|| invalid("physical node ID overflow"))?; - let mut result = Self::new(roots); - for (id, contract) in sources { - result.add_input(id, contract)?; - } - for (id, (inputs, fragment)) in fragments { - if inputs.len() != fragment.input_contracts().count() { - return Err(invalid("physical fragment input arity mismatch")); - } - let mut mapping = BTreeMap::new(); - for ((local, expected), global) in fragment.input_contracts().zip(inputs) { - let actual = contracts - .get(&global) - .ok_or_else(|| invalid("missing physical fragment dependency"))?; - if expected.schema != actual.schema - || (expected.properties.boundedness == Boundedness::Bounded - && actual.properties.boundedness != Boundedness::Bounded) - { - return Err(invalid("physical fragment dependency contract mismatch")); - } - mapping.insert(local, global); - } - mapping.insert(fragment.roots[0], id); - for local in fragment.nodes.keys() { - if !mapping.contains_key(local) { - mapping.insert(*local, next); - next = next - .checked_add(1) - .ok_or_else(|| invalid("physical node ID overflow"))?; - } - } - for (local, node) in fragment.nodes { - if let Node::Operator { inputs, operator } = node { - result.add( - mapping[&local], - inputs.into_iter().map(|input| mapping[&input]).collect(), - operator, - )?; - } - } - } - result.validate()?; - Ok(result) - } - - /// Assemble already-lowered operators and typed external inputs. This is - /// useful for engines that compose multiple compiled computation fragments. - pub fn from_operators( - inputs: BTreeMap, - operators: BTreeMap, Operator)>, - roots: Vec, - ) -> Result { - let mut result = Self::new(roots); - for (id, contract) in inputs { - result.add_input(id, contract)?; - } - for (id, (inputs, operator)) in operators { - result.add(id, inputs, operator)?; - } - result.validate()?; - Ok(result) - } - pub(super) fn new(roots: Vec) -> Self { - Self { - nodes: BTreeMap::new(), - roots, - } - } - pub(super) fn add_input(&mut self, id: NodeId, contract: InputContract) -> Result<(), Error> { - self.insert(id, Node::Input(contract)) - } - pub(super) fn add( - &mut self, - id: NodeId, - inputs: Vec, - operator: Operator, - ) -> Result<(), Error> { - self.insert(id, Node::Operator { inputs, operator }) - } - fn insert(&mut self, id: NodeId, node: Node) -> Result<(), Error> { - if self.nodes.insert(id, node).is_some() { - return Err(invalid(format!("duplicate physical node {id}"))); - } - Ok(()) - } - /// Identify the external input whose rows survive unchanged at this output. - /// Protocol adapters can retain labels that are outside a closed physical schema. - pub fn row_source(&self, id: NodeId) -> Option { - match self.nodes.get(&id)? { - Node::Input(_) => Some(id), - Node::Operator { inputs, operator } => { - let index = operator.row_preserving_input()?; - self.row_source(*inputs.get(index)?) - } - } - } - - /// Selected operator name, for plan inspection without decoding its wire format. - /// Certified candidate pruning checks authoritative-key coverage inside this operator. - pub fn certified_pruning_keys(&self, id: NodeId) -> Option<&[(usize, usize)]> { - match self.nodes.get(&id)? { - Node::Operator { operator, .. } => operator.certified_pruning_keys(), - Node::Input(_) => None, - } - } - pub fn operator_name(&self, id: NodeId) -> Option<&str> { - match self.nodes.get(&id)? { - Node::Input(_) => Some("Input"), - Node::Operator { operator, .. } => Some(operator.name()), - } - } - - pub fn roots(&self) -> &[NodeId] { - &self.roots - } - pub fn input_contracts(&self) -> impl Iterator { - self.nodes.iter().filter_map(|(&id, node)| match node { - Node::Input(contract) => Some((id, contract)), - Node::Operator { .. } => None, - }) - } - /// Derive a reachable output contract without opening deployment readers. - pub fn output_contract(&self, id: NodeId) -> Result { - let properties = *self - .output_properties()? - .get(&id) - .ok_or_else(|| invalid("output is not reachable"))?; - let schema = match self - .nodes - .get(&id) - .ok_or_else(|| invalid("missing output"))? - { - Node::Input(contract) => contract.schema.clone(), - Node::Operator { operator, .. } => operator.output_schema(), - }; - Ok(InputContract { schema, properties }) - } - /// Properties of every reachable node, derived in one contract-only pass. - pub(super) fn output_properties(&self) -> Result, Error> { - let sources = self - .input_contracts() - .map(|(id, contract)| (id, Box::new(contract.clone()) as Source<'_>)) - .collect(); - self.instantiate(sources)?.properties(&self.roots) - } - /// Direct physical dependencies; empty for inputs and unknown IDs. - pub(super) fn dependencies(&self, id: NodeId) -> &[NodeId] { - match self.nodes.get(&id) { - Some(Node::Operator { inputs, .. }) => inputs, - _ => &[], - } - } - pub(super) fn is_operator(&self, id: NodeId) -> bool { - matches!(self.nodes.get(&id), Some(Node::Operator { .. })) - } - /// Keep the already-lowered operators reachable from `roots`, replacing - /// each node in `boundaries` by a typed input. Nothing is lowered again. - pub(super) fn cut( - &self, - boundaries: &BTreeMap, - roots: &[NodeId], - ) -> Result { - let mut result = Self::new(roots.to_vec()); - let mut pending = roots.to_vec(); - while let Some(id) = pending.pop() { - if result.nodes.contains_key(&id) { - continue; - } - let node = match boundaries.get(&id) { - Some(contract) => Node::Input(contract.clone()), - None => self - .nodes - .get(&id) - .cloned() - .ok_or_else(|| invalid(format!("missing physical node {id}")))?, - }; - if let Node::Operator { inputs, .. } = &node { - pending.extend(inputs); - } - result.nodes.insert(id, node); - } - result.validate()?; - Ok(result) - } - /// Validate using contract-only sources. No deployment reader is available. - pub fn validate(&self) -> Result<(), Error> { - let sources = self - .input_contracts() - .map(|(id, c)| (id, Box::new(c.clone()) as Source<'_>)) - .collect(); - self.instantiate(sources).map(|_| ()) - } - /// Resolve exactly the declared inputs and validate before any source starts. - pub fn instantiate<'a>( - &self, - mut sources: BTreeMap>, - ) -> Result, Error> { - let mut dag = PhysicalDAG::default(); - for (&id, node) in &self.nodes { - match node { - Node::Input(contract) => { - let source = sources - .remove(&id) - .ok_or_else(|| invalid(format!("missing physical input {id}")))?; - let actual = source.properties(&[]); - if !source.input_schemas().is_empty() - || source.output_schema() != contract.schema - || (contract.properties.boundedness != Boundedness::Unknown - && actual.boundedness != contract.properties.boundedness) - || (contract.properties.emission != Emission::Unknown - && actual.emission != contract.properties.emission) - { - return Err(invalid(format!( - "physical input {id} violates its compiled contract" - ))); - } - dag.add_boxed( - id, - vec![], - Box::new(CheckedSource { - source, - output: contract.schema.clone(), - }), - )?; - } - Node::Operator { inputs, operator } => { - dag.add(id, inputs.clone(), operator.clone())?; - } - } - } - if !sources.is_empty() { - return Err(invalid("unexpected physical input binding")); - } - dag.validate(&self.roots)?; - Ok(dag) - } -} -impl PhysicalOperator for InputContract { - fn name(&self) -> &str { - "UnresolvedInput" - } - fn input_schemas(&self) -> Vec { - vec![] - } - fn output_schema(&self) -> SchemaRef { - self.schema.clone() - } - fn properties(&self, _: &[PlanProperties]) -> PlanProperties { - self.properties - } - fn output_bytes(&self, batch: &Batch) -> usize { - batch.bytes() - } - fn start<'a>( - &'a self, - _: Vec>, - _: crate::runtime::RunContext, - ) -> Result, Error> { - Err(invalid("physical input must be resolved before execution")) - } -} diff --git a/crates/asap-physical-operators/src/physical_planner/mod.rs b/crates/asap-physical-operators/src/physical_planner/mod.rs deleted file mode 100644 index e1490c03c..000000000 --- a/crates/asap-physical-operators/src/physical_planner/mod.rs +++ /dev/null @@ -1,1156 +0,0 @@ -//! Compile logical computation to native operators with typed external inputs. -//! Compilation needs no readers; deployment resolves inputs after selection. -use crate::operators::ReadoutQuery; -use crate::summary_kernels::exact::ExactReadout; -use crate::{ - operators::{Expression, Operator, Reduction, SortKey}, - plan::{Boundedness, Emission, NodeId, PhysicalDAG, PhysicalOperator, PlanProperties}, - values::{Batch, SchemaRef}, - Error, -}; -use planner_types::{ - post_asap::{ - ExactOperation, FieldDataType, PostAsapDAG, PostAsapDAGNode, - PostAsapOperatorPayload as Payload, SketchStatistic, SummaryInputExpr, ValueOperation, - }, - pre_asap::{ - AggIntent, ColumnRef, CompareOpKind, DataType, GroupKeys, QueryExpr, - Reduction as PlannerReduction, - }, -}; -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, -}; -fn invalid(message: impl Into) -> Error { - Error::Invalid(message.into()) -} - -/// Source nodes cut the DAG at an installed storage/ingestion frontier. The -/// binding must have exactly the declared schema and no upstream dependencies. -/// A deployment must authorize these frontiers before calling this function. -pub type Source<'a> = Box + 'a>; - -pub mod precompute; -pub mod promql_fallback; -pub mod promql_rows; -pub mod promql_values; - -mod candidates; -pub use candidates::{ - compile_candidate, compile_candidates, cut_candidate, enumerate_frontiers, - frontier_from_timing, select_candidate, CandidateCost, CandidateSelection, PhysicalASAPDAG, -}; - -mod compiled; -pub use compiled::{CompiledPhysicalDAG, InputContract}; - -mod row_values; - -/// Compile computation without opening or retaining deployment readers. -/// Input contracts identify explicit boundaries selected by maintenance planning. -pub fn compile( - dag: &PostAsapDAG, - inputs: BTreeMap, - roots: &[NodeId], -) -> Result { - compile_internal(dag, inputs, roots) -} - -/// Convenience for callers that already resolved inputs. Lowering still uses -/// only their contracts, and instantiation checks those contracts again. -pub fn bind<'a>( - dag: &PostAsapDAG, - sources: BTreeMap>, - roots: &[NodeId], -) -> Result, Error> { - let inputs = sources - .iter() - .map(|(&id, source)| (id, InputContract::from_source(source.as_ref()))) - .collect(); - compile(dag, inputs, roots)?.instantiate(sources) -} - -/// Resolve raw scan connectors before invoking the reader-independent compiler. -pub fn bind_with_data_sources<'a>( - dag: &PostAsapDAG, - mut sources: BTreeMap>, - roots: &[NodeId], - data_sources: &crate::sources::DataSources, -) -> Result, Error> { - // Only resolve scans reachable below the selected input boundaries. - let mut pending = roots.to_vec(); - let mut seen = BTreeSet::new(); - while let Some(id) = pending.pop() { - if !seen.insert(id) || sources.contains_key(&id) { - continue; - } - let node = dag - .nodes - .iter() - .find(|n| u64::from(n.id.0) == id) - .ok_or_else(|| invalid(format!("missing node {id}")))?; - if let Payload::Fallback { - expression: expression @ QueryExpr::Scan { .. }, - } = &node.payload - { - sources.insert(id, Box::new(data_sources.bind(expression)?)); - } else { - pending.extend( - dag.edges - .iter() - .filter(|e| u64::from(e.consumer.0) == id) - .map(|e| u64::from(e.producer.0)), - ); - } - } - bind(dag, sources, roots) -} - -#[cfg(test)] -thread_local! { - /// Planner nodes lowered by this thread, for compile-once tests. - static LOWERED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Helper operators are numbered from their Planner node alone, above the u32 -/// Planner ID range, so every boundary choice yields a sub-DAG of the same -/// lowering and candidate cuts need not renumber operators. A node lowering to -/// several helpers takes consecutive indices below its base. -fn helper_id(node: NodeId, index: u64) -> NodeId { - debug_assert!(node <= u64::from(u32::MAX) && index < 1 << 16); - u64::MAX - (node << 16) - index -} - -fn compile_internal( - dag: &PostAsapDAG, - mut sources: BTreeMap, - roots: &[NodeId], -) -> Result { - preflight_depth(dag)?; - dag.validate().map_err(|e| invalid(e.to_string()))?; - let nodes = dag - .nodes - .iter() - .map(|node| (u64::from(node.id.0), node)) - .collect::>(); - let mut dependencies = BTreeMap::>::new(); - // Binary input order is semantic; serialized edge order is not. - let mut edges = dag.edges.iter().collect::>(); - edges.sort_by_key(|edge| { - ( - edge.consumer.0, - match edge.role { - planner_types::post_asap::EdgeRole::Left => 0, - planner_types::post_asap::EdgeRole::Input => 1, - planner_types::post_asap::EdgeRole::Right => 2, - }, - ) - }); - // Scalar literal operands of query-time arithmetic are folded into the consumer. - let mut literals = BTreeMap::::new(); - for edge in edges { - let consumer = u64::from(edge.consumer.0); - if let ( - Payload::Fallback { expression }, - Some(PostAsapDAGNode { - payload: Payload::Binary { .. }, - .. - }), - ) = ( - &nodes[&u64::from(edge.producer.0)].payload, - nodes.get(&consumer), - ) { - if let Some(value) = row_values::scalar_literal(expression) { - let left = edge.role == planner_types::post_asap::EdgeRole::Left; - if literals.insert(consumer, (value, left)).is_some() { - return Err(invalid("binary with two scalar literals is not folded")); - } - continue; - } - } - dependencies - .entry(u64::from(edge.consumer.0)) - .or_default() - .push(u64::from(edge.producer.0)); - } - let known = |id: &NodeId| { - nodes.contains_key(id) - || promql_fallback::raw_series_owner(*id).is_some_and(|owner| { - matches!( - nodes.get(&owner), - Some(PostAsapDAGNode { - payload: Payload::Fallback { .. }, - .. - }) - ) - }) - }; - if !sources.keys().all(known) { - return Err(invalid("source binding names an unknown node")); - } - let mut ordered = Vec::new(); - let mut seen = BTreeSet::new(); - let mut pending = roots.iter().map(|&id| (id, false)).collect::>(); - while let Some((id, expanded)) = pending.pop() { - if expanded { - ordered.push(id); - continue; - } - if !seen.insert(id) { - continue; - } - if !nodes.contains_key(&id) { - return Err(invalid(format!("missing root {id}"))); - } - pending.push((id, true)); - if !sources.contains_key(&id) { - for &input in dependencies.get(&id).into_iter().flatten() { - pending.push((input, false)); - } - } - } - let mut physical_dag = CompiledPhysicalDAG::new(roots.to_vec()); - for id in ordered { - let node = nodes[&id]; - let mut auxiliary = helper_id(id, 0); - let output = Arc::new(node.output_schema.clone()); - crate::values::validate_schema(&output)?; - if let Some(source) = sources.remove(&id) { - if source.schema != output { - return Err(invalid("frontier does not have the declared schema")); - } - physical_dag.add_input(id, source)?; - } else { - #[cfg(test)] - LOWERED_NODES.with(|count| count.set(count.get() + 1)); - let mut inputs = dependencies.get(&id).cloned().unwrap_or_default(); - let mut schemas = inputs - .iter() - .map(|id| Arc::new(nodes[id].output_schema.clone())) - .collect::>(); - if matches!(node.payload, Payload::SummaryMerge) && inputs.len() > 1 { - if schemas.iter().any(|s| s != &schemas[0]) { - return Err(invalid("summary merge inputs have different schemas")); - } - physical_dag.add( - auxiliary, - inputs, - Operator::union(schemas[0].clone(), schemas.len())?, - )?; - inputs = vec![auxiliary]; - schemas.truncate(1); - } - // A consumed bare selector supplies raw range rows (e.g. to a - // per-entity summary), not an instant vector, so only its consumer computes. - let raw_rows = matches!( - &node.payload, - Payload::Fallback { - expression: QueryExpr::TimeRange { .. } - } - ) && dag.edges.iter().any(|e| u64::from(e.producer.0) == id); - if let (Payload::Fallback { expression }, false) = (&node.payload, raw_rows) { - let promql_fallback::Lowering { - selectors, - mut steps, - } = promql_fallback::lower(expression) - .map_err(|error| invalid(format!("node {id}: {error}")))?; - let mut slots = Vec::new(); - for (i, (_, schema)) in selectors.iter().enumerate() { - let slot = promql_fallback::raw_series_input(id, i); - match sources.remove(&slot) { - Some(contract) if &contract.schema == schema => { - physical_dag.add_input(slot, contract)? - } - Some(_) => { - return Err(invalid(format!( - "node {id}: raw series input {slot} differs from the selector schema" - ))) - } - None => { - return Err(invalid(format!( - "node {id}: PromQL fallback requires raw series input {slot}" - ))) - } - } - slots.push(slot); - } - let (last, last_inputs) = steps - .pop() - .ok_or_else(|| invalid("empty PromQL lowering"))?; - let mut ids = Vec::new(); - let resolve = |inputs: Vec, ids: &[NodeId]| { - inputs - .into_iter() - .map(|input| match input { - promql_fallback::Input::Raw(i) => slots[i], - promql_fallback::Input::Step(i) => ids[i], - }) - .collect::>() - }; - for (operator, inputs) in steps { - physical_dag.add(auxiliary, resolve(inputs, &ids), operator)?; - ids.push(auxiliary); - auxiliary -= 1; - } - physical_dag.add( - id, - resolve(last_inputs, &ids), - last.with_output_schema(output)?, - )?; - continue; - } - if let Payload::Value { - operation: ValueOperation::MaintainPopulation { population }, - } = &node.payload - { - use planner_types::post_asap::maintained_population::PopulationInput; - let PopulationInput::CurrentSeries(spec) = &population.input else { - return Err(invalid( - "native maintained population requires a current-series input", - )); - }; - let [input] = schemas.as_slice() else { - return Err(invalid("current-series population requires one input")); - }; - if spec.without { - return Err(invalid( - "dynamic without grouping requires label-set projection", - )); - } - let identity = named_column( - input, - &ColumnRef::Named(promql_rows::SERIES_IDENTITY_COLUMN.into()), - )?; - let coordinate = input - .time_index - .ok_or_else(|| invalid("current-series input lacks timestamp"))?; - let value = named_column(input, &ColumnRef::SampleValue)?; - let lookback = i64::try_from(spec.lookback_ms) - .map_err(|_| invalid("current-series lookback overflows"))?; - physical_dag.add( - id, - inputs, - Operator::current_series(input.clone(), identity, coordinate, value, lookback)? - .with_output_schema(output)?, - )?; - continue; - } - if let Payload::Value { - operation: ValueOperation::ReadPopulation { readout }, - } = &node.payload - { - use planner_types::post_asap::maintained_population::{ - PopulationInput, PopulationStatistic, - }; - let [producer] = inputs.as_slice() else { - return Err(invalid("population readout requires one input")); - }; - let Payload::Value { - operation: ValueOperation::MaintainPopulation { population }, - } = &nodes[producer].payload - else { - return Err(invalid( - "population readout requires its declared population", - )); - }; - let PopulationInput::CurrentSeries(spec) = &population.input else { - return Err(invalid("current-series population required")); - }; - if spec.without { - return Err(invalid( - "dynamic without ranking requires label-set projection", - )); - } - let input = schemas[0].clone(); - let PopulationStatistic::TopK { k } = readout else { - let mut chain = - row_values::population_aggregate(&input, &spec.grouping, readout)?; - let last = chain.pop().expect("nonempty chain"); - let mut inputs = inputs; - for operator in chain { - physical_dag.add(auxiliary, inputs, operator)?; - inputs = vec![auxiliary]; - auxiliary -= 1; - } - physical_dag.add(id, inputs, last.with_output_schema(output)?)?; - continue; - }; - let groups = spec - .grouping - .iter() - .map(|name| named_column(&input, &ColumnRef::Named(name.clone()))) - .collect::, _>>()?; - let value = named_column(&input, &ColumnRef::SampleValue)?; - physical_dag.add( - auxiliary, - inputs, - Operator::sort( - input.clone(), - vec![SortKey { - column: value, - descending: true, - nulls_first: false, - }], - groups.clone(), - )?, - )?; - physical_dag.add( - id, - vec![auxiliary], - Operator::limit(input, *k as u64, 0, groups)?.with_output_schema(output)?, - )?; - continue; - } - // A closed row must include either all source labels or the explicit - // complete-label identity. Projected labels alone are insufficient. - if let Payload::SummaryAgg { - family, - input: update, - reduction: PlannerReduction::PerEntity, - grouping, - filter: None, - } = &node.payload - { - let [input_id] = inputs.as_slice() else { - return Err(invalid("per-entity summary requires one input")); - }; - let Payload::Fallback { - expression: QueryExpr::TimeRange { child, .. }, - } = &nodes[input_id].payload - else { - return Err(invalid( - "per-entity summary requires a resolved raw time range", - )); - }; - let QueryExpr::Scan { schema, .. } = child.as_ref() else { - return Err(invalid("per-entity summary requires a resolved source")); - }; - if !schema.closed || update.item.is_some() { - return Err(invalid( - "per-entity summary requires complete source identity", - )); - } - crate::capability::validate_summary_kernel(family, update, grouping) - .map_err(Error::Invalid)?; - let SummaryInputExpr::Column(value) = &update.weight else { - return Err(invalid( - "per-entity update requires a projected value column", - )); - }; - let input = schemas[0].clone(); - let value = named_column(&input, value)?; - let coordinate = input - .time_index - .ok_or_else(|| invalid("temporal input lacks time"))?; - let groups = (0..input.fields.len()) - .filter(|&column| column != value && column != coordinate) - .collect(); - let build = Operator::summary_build( - input, - family.clone(), - value, - Some(coordinate), - groups, - )?; - let compact = build.schema(); - physical_dag.add(auxiliary, inputs, build)?; - physical_dag.add( - id, - vec![auxiliary], - Operator::scope_timestamp(compact, output)?, - )?; - continue; - } - if let Payload::Binary { operator } = &node.payload { - let query_time = node.output_state.timing - == planner_types::post_asap::ExecutionTiming::QueryTime; - if let Some(&(value, left)) = literals.get(&id) { - let [input] = schemas.as_slice() else { - return Err(invalid("scalar binary requires one row input")); - }; - if !query_time { - return Err(invalid("scalar literal binary must run at query time")); - } - let scalar = - Operator::scalar(crate::values::Value::Float64(value), DataType::Float64)?; - let (sides, scalars, operands) = if left { - ( - [scalar.schema(), input.clone()], - [true, false], - vec![auxiliary, inputs[0]], - ) - } else { - ( - [input.clone(), scalar.schema()], - [false, true], - vec![inputs[0], auxiliary], - ) - }; - let [l, r] = sides; - let binary = Operator::series_binary(l, r, operator.clone(), scalars) - .map_err(|error| invalid(format!("node {id}: {error}")))?; - physical_dag.add(auxiliary, vec![], scalar)?; - physical_dag.add(id, operands, binary.with_output_schema(output)?)?; - auxiliary -= 1; - continue; - } - let label_map = |schema: &SchemaRef| { - schema - .fields - .iter() - .any(|f| matches!(f.dtype, FieldDataType::Plain(DataType::Map { .. }))) - }; - // Grouped rows carry their labels as columns; per-series rows - // carry the series identity. - if let (true, [left, right]) = (query_time, schemas.as_slice()) { - if !label_map(left) && !label_map(right) { - // A scalar-valued Fallback operand, such as `scalar(x)`, has no labels. - let scalar = |input: &NodeId| { - matches!( - nodes.get(input).map(|node| &node.payload), - Some(Payload::Fallback { expression }) - if promql_fallback::scalar(expression) - ) - }; - let binary = Operator::series_binary( - left.clone(), - right.clone(), - operator.clone(), - [scalar(&inputs[0]), scalar(&inputs[1])], - ) - .map_err(|error| invalid(format!("node {id}: {error}")))?; - physical_dag.add(id, inputs, binary.with_output_schema(output)?)?; - continue; - } - } - } - if let Payload::Value { - operation: ValueOperation::FinalizeExactAccumulator, - } = &node.payload - { - // Exact counts read out as Int64; PromQL declares a Float64 sample. - let readout = bind_operation(node, &schemas) - .map_err(|error| invalid(format!("node {id}: {error}")))?; - let actual = readout.schema(); - let converted = actual.fields.iter().zip(&output.fields).position(|(a, d)| { - a.dtype == FieldDataType::Plain(DataType::Int64) - && d.dtype == FieldDataType::Plain(DataType::Float64) - }); - if let Some(column) = converted { - let columns = actual - .fields - .iter() - .enumerate() - .map(|(i, field)| { - ( - field.name.clone(), - if i == column { - Expression::ExactFloat64(i) - } else { - Expression::Column(i) - }, - ) - }) - .collect(); - let project = - Operator::project(actual, columns)?.with_output_schema(output.clone())?; - physical_dag.add(auxiliary, inputs, readout)?; - if temporal_readout_drops_name(node) { - physical_dag.add(auxiliary - 1, vec![auxiliary], project)?; - physical_dag.add( - id, - vec![auxiliary - 1], - Operator::series_without_name(output)?, - )?; - } else { - physical_dag.add(id, vec![auxiliary], project)?; - } - auxiliary -= 1; - continue; - } - } - let mut operator = compile_node(node, &schemas) - .map_err(|error| invalid(format!("node {id}: {error}")))?; - if operator.is_counter_readout() { - let mut pending = vec![id]; - let mut visited = BTreeSet::new(); - let mut ranges = BTreeSet::new(); - while let Some(ancestor) = pending.pop() { - if !visited.insert(ancestor) { - continue; - } - if let Payload::Fallback { - expression: QueryExpr::TimeRange { range, .. }, - } = &nodes[&ancestor].payload - { - ranges.insert( - i64::try_from(range.as_millis()) - .map_err(|_| invalid("counter lookback exceeds Int64"))?, - ); - continue; - } - pending.extend(dependencies.get(&ancestor).into_iter().flatten().copied()); - } - if ranges.len() > 1 { - return Err(invalid("counter readout has ambiguous logical windows")); - } - if let Some(lookback) = ranges.into_iter().next() { - operator = operator.with_counter_lookback(lookback)?; - } - } - if temporal_readout_drops_name(node) { - physical_dag.add(auxiliary, inputs, operator)?; - physical_dag.add(id, vec![auxiliary], Operator::series_without_name(output)?)?; - } else { - physical_dag.add(id, inputs, operator)?; - } - } - } - physical_dag.validate()?; - Ok(physical_dag) -} - -// Temporal summary readouts produce PromQL vectors, whose range functions drop -// the metric name before matching/filtering. Stored state retains its full identity. -fn temporal_readout_drops_name(node: &PostAsapDAGNode) -> bool { - node.output_schema - .fields - .iter() - .any(|field| field.name == promql_rows::SERIES_IDENTITY_COLUMN) - && matches!( - &node.payload, - Payload::Value { - operation: ValueOperation::FinalizeExactAccumulator - } | Payload::SummaryEstimate { - query: SketchStatistic::Quantile { .. } - | SketchStatistic::Cardinality - | SketchStatistic::PointCount { .. } - | SketchStatistic::FrequencyL2 - | SketchStatistic::FrequencyEntropy - } - ) -} - -/// Bind a Planner node against the schemas supplied by its deployment edges. -/// This is the same checked path used by complete DAG binding. -pub fn compile_node(node: &PostAsapDAGNode, inputs: &[SchemaRef]) -> Result { - for schema in inputs { - crate::values::validate_schema(schema)?; - } - bind_operation(node, inputs)?.with_output_schema(Arc::new(node.output_schema.clone())) -} - -fn bind_operation(node: &PostAsapDAGNode, inputs: &[SchemaRef]) -> Result { - if let Payload::Binary { operator } = &node.payload { - let [left, right] = inputs else { - return Err(invalid("binary requires two inputs")); - }; - if node.output_state.timing == planner_types::post_asap::ExecutionTiming::IngestionTime { - let value = |schema: &SchemaRef| -> Result { - let columns = schema - .fields - .iter() - .enumerate() - .filter(|(_, field)| { - field.dtype - == FieldDataType::Plain(planner_types::pre_asap::DataType::Float64) - }) - .map(|(i, _)| i) - .collect::>(); - match columns.as_slice() { - [value] => Ok(*value), - _ => Err(invalid("aligned binary requires one value column")), - } - }; - let (l, r) = (value(left)?, value(right)?); - let keys = left - .fields - .iter() - .enumerate() - .filter(|(i, _)| *i != l) - .map(|(i, field)| { - right - .fields - .iter() - .position(|other| other.name == field.name && other.dtype == field.dtype) - .map(|j| (i, j)) - .ok_or_else(|| invalid("aligned input identities differ")) - }) - .collect::, _>>()?; - return Operator::aligned_binary( - left.clone(), - right.clone(), - keys, - (l, r), - operator.clone(), - ); - } - return Operator::vector_binary(left.clone(), right.clone(), operator.clone(), false); - } - if let Payload::RelationalJoin { - join_kind, - pred, - pruning, - } = &node.payload - { - use planner_types::{post_asap::CandidateCompleteness, pre_asap::JoinKind}; - if pruning.is_some() && *join_kind != JoinKind::Semi { - return Err(invalid("pruning certificate requires a semi-join")); - } - if matches!(pruning,Some(CandidateCompleteness::Certified { guarantee }) if guarantee.has_unknown() || guarantee.metric != planner_types::post_asap::ErrorMetric::TopKMembership) - { - return Err(invalid("invalid pruning certificate")); - } - let [left, right] = inputs else { - return Err(invalid("join requires two inputs")); - }; - if *join_kind == JoinKind::Semi { - if let Ok(keys) = equijoin_keys(pred, left, right) { - let operator = Operator::semi_join(left.clone(), right.clone(), keys)?; - return Ok( - if matches!(pruning, Some(CandidateCompleteness::Certified { .. })) { - operator.require_complete_right() - } else { - operator - }, - ); - } - } - if matches!(pruning, Some(CandidateCompleteness::Certified { .. })) { - return Err(invalid("certified pruning requires explicit equijoin keys")); - } - return Operator::relational_join( - left.clone(), - right.clone(), - join_kind.clone(), - pred, - Arc::new(node.output_schema.clone()), - ); - } - let [input] = inputs else { - return Err(invalid( - "native Planner binding currently requires a unary operation or an explicit source", - )); - }; - match &node.payload { - Payload::Value { operation, .. } => match operation { - ValueOperation::Project { cols, .. } => Operator::project( - input.clone(), - cols.iter() - .enumerate() - .map(|(i, col)| { - Ok(( - node.output_schema - .fields - .get(i) - .ok_or_else(|| invalid("projection width mismatch"))? - .name - .clone(), - match &col.expr { - QueryExpr::Column(index) => Expression::Column(*index), - expr => expression(expr, input)?, - }, - )) - }) - .collect::>()?, - ), - ValueOperation::Filter { pred } => { - Operator::filter(input.clone(), expression(&pred.0, input)?) - } - ValueOperation::Sort { keys, partition_by } => Operator::sort( - input.clone(), - keys.iter() - .map(|key| { - let QueryExpr::Column(column) = key.expr else { - return Err(invalid( - "sort expression must be projected before sorting", - )); - }; - Ok(SortKey { - column, - descending: !key.ascending, - nulls_first: key.nulls_first, - }) - }) - .collect::>()?, - groups(input, partition_by)?, - ), - ValueOperation::Limit { - n, - offset, - partition_by, - } => Operator::limit( - input.clone(), - *n as u64, - *offset as u64, - groups(input, partition_by)?, - ), - ValueOperation::Exact(ExactOperation::Aggregate { - reduction, - measures, - output_names, - filters, - having: None, - }) => { - if filters.iter().any(Option::is_some) { - return Err(invalid("filtered aggregate has no native implementation")); - } - if measures.len() != output_names.len() { - return Err(invalid("aggregate output names differ from measures")); - } - let PlannerReduction::Reduce(keys) = reduction else { - return Err(invalid( - "per-entity aggregate requires an explicit entity binding", - )); - }; - let measures = measures - .iter() - .zip(output_names) - .map(|(m, name)| { - let column = |col: Option| { - col.map(Ok) - .unwrap_or_else(|| named_column(input, &ColumnRef::SampleValue)) - }; - let m = match m { - AggIntent::Count { .. } => Reduction::Count, - AggIntent::Sum { col } => Reduction::Sum(column(*col)?), - AggIntent::Avg { col } => Reduction::Avg(column(*col)?), - AggIntent::Min { col } => Reduction::Min(column(*col)?), - AggIntent::Max { col } => Reduction::Max(column(*col)?), - _ => { - return Err(invalid( - "aggregate intent has no native implementation", - )) - } - }; - Ok((name.clone(), m)) - }) - .collect::>()?; - Operator::aggregate(input.clone(), groups(input, keys)?, measures) - } - ValueOperation::FinalizeExactAccumulator => { - let state = summary_column(input)?; - use crate::Statistic as S; - use planner_types::post_asap::ExactKind as E; - let statistic = match &input.fields[state].dtype { - FieldDataType::ExactAggregate(kind, _) => match kind { - E::Sum => S::Sum, - E::Count => S::Count, - E::Min => S::Min, - E::Max => S::Max, - E::Rate => S::Rate, - E::Increase => S::Increase, - _ => return Err(invalid("exact family readout is unsupported")), - }, - _ => return Err(invalid("exact finalization requires exact state")), - }; - Operator::readout( - input.clone(), - state, - ReadoutQuery::Exact(ExactReadout { - statistic, - lookback_ms: None, - }), - ) - } - _ => Err(invalid("value operation has no native implementation")), - }, - Payload::SummaryAgg { - family, - input: update, - reduction, - grouping, - filter, - } => { - if filter.is_some() { - return Err(invalid( - "filtered summary update has no native implementation", - )); - } - if let Some(item) = &update.item { - let PlannerReduction::Reduce(keys) = reduction else { - return Err(invalid("keyed summary requires explicit partitions")); - }; - let SummaryInputExpr::Column(weight) = &update.weight else { - return Err(invalid( - "keyed summary weight must be a finalized value column", - )); - }; - if matches!(family, FieldDataType::Sketch(kind, _) if kind.algorithm() == &planner_types::post_asap::SketchAlgorithm::CmsWithHeap) - && !matches!( - update.weight_domain, - planner_types::post_asap::WeightDomain::NonNegative { .. } - ) - { - return Err(invalid("CMS requires a nonnegative weight contract")); - } - fn columns( - expr: &SummaryInputExpr, - input: &SchemaRef, - result: &mut Vec, - ) -> Result<(), Error> { - match expr { - SummaryInputExpr::Column(column) => { - result.push(named_column(input, column)?) - } - SummaryInputExpr::Tuple(items) => { - for item in items { - columns(item, input, result)?; - } - } - _ => return Err(invalid("keyed summary needs explicit item columns")), - } - Ok(()) - } - let mut items = Vec::new(); - columns(item, input, &mut items)?; - return Operator::keyed_summary_build( - input.clone(), - family.clone(), - named_column(input, weight)?, - items, - groups(input, keys)?, - ); - } - crate::capability::validate_summary_kernel(family, update, grouping) - .map_err(Error::Invalid)?; - let SummaryInputExpr::Column(column) = &update.weight else { - return Err(invalid( - "summary update expression must be projected to a column", - )); - }; - let PlannerReduction::Reduce(keys) = reduction else { - return Err(invalid( - "summary construction requires explicit grouping columns", - )); - }; - Operator::summary_build( - input.clone(), - family.clone(), - named_column(input, column)?, - input.time_index, - groups(input, keys)?, - ) - } - Payload::SummaryMerge => { - let state = summary_column(input)?; - Operator::summary_merge( - input.clone(), - state, - (0..input.fields.len()) - .filter(|&i| i != state && Some(i) != input.time_index) - .collect(), - ) - } - Payload::SummaryEstimate { query } => { - if let SketchStatistic::TopK { k } = query { - return Operator::keyed_readout( - input.clone(), - summary_column(input)?, - *k, - Arc::new(node.output_schema.clone()), - ); - } - Operator::readout( - input.clone(), - summary_column(input)?, - ReadoutQuery::Sketch(query.clone()), - ) - } - _ => Err(invalid( - "physical operation has no native binding; no fallback is installed", - )), - } -} -fn summary_column(input: &SchemaRef) -> Result { - let columns = input - .fields - .iter() - .enumerate() - .filter(|(_, f)| !matches!(f.dtype, FieldDataType::Plain(_))) - .map(|(i, _)| i) - .collect::>(); - match columns.as_slice() { - [column] => Ok(*column), - _ => Err(invalid("one summary state column required")), - } -} -fn named_column(input: &SchemaRef, column: &ColumnRef) -> Result { - let name = match column { - // This summary-update lookup matches column names without qualifiers. - // Reject ambiguous names rather than guessing a join side. - ColumnRef::Named(name) | ColumnRef::Qualified { name, .. } => name.as_str(), - ColumnRef::SampleValue => "value", - _ => { - return Err(invalid( - "summary update requires an unambiguous bound column", - )) - } - }; - let matches = input - .fields - .iter() - .enumerate() - .filter(|(_, field)| field.name == name) - .map(|(i, _)| i) - .collect::>(); - match matches.as_slice() { - [column] => Ok(*column), - _ => Err(invalid("summary update column missing or ambiguous")), - } -} -fn groups(input: &SchemaRef, groups: &GroupKeys) -> Result, Error> { - if groups.is_without() { - return Err(invalid("grouping without requires resolved label columns")); - } - if groups.keys().iter().any(|&i| i >= input.fields.len()) { - return Err(invalid("grouping column out of range")); - } - Ok(groups.keys().to_vec()) -} -fn expression(expr: &QueryExpr, input: &SchemaRef) -> Result { - Ok(Expression::planner( - crate::expressions::CompiledExpression::compile(expr, input)?, - )) -} - -struct CheckedSource<'a> { - source: Source<'a>, - output: SchemaRef, -} -impl PhysicalOperator for CheckedSource<'_> { - fn properties(&self, inputs: &[crate::plan::PlanProperties]) -> crate::plan::PlanProperties { - self.source.properties(inputs) - } - - fn name(&self) -> &str { - self.source.name() - } - fn input_schemas(&self) -> Vec { - vec![] - } - fn output_schema(&self) -> SchemaRef { - self.output.clone() - } - fn output_bytes(&self, batch: &Batch) -> usize { - self.source.output_bytes(batch) - } - fn start<'a>( - &'a self, - inputs: Vec>, - context: crate::runtime::RunContext, - ) -> Result, Error> { - use futures::StreamExt; - Ok(self - .source - .start(inputs, context)? - .map(|batch| { - let batch = batch?; - if batch.schema() != &self.output { - return Err(invalid("source batch differs from its bound schema")); - } - Ok(batch) - }) - .boxed_local()) - } -} - -// Bound recursion before invoking the upstream recursive provenance validator. -fn preflight_depth(dag: &PostAsapDAG) -> Result<(), Error> { - let mut remaining = dag - .nodes - .iter() - .map(|node| (node.id, 0usize)) - .collect::>(); - if remaining.len() != dag.nodes.len() { - return Err(invalid("duplicate Planner node")); - } - let mut consumers = BTreeMap::<_, Vec<_>>::new(); - for edge in &dag.edges { - if !remaining.contains_key(&edge.producer) { - return Err(invalid("missing Planner edge producer")); - } - *remaining - .get_mut(&edge.consumer) - .ok_or_else(|| invalid("missing Planner edge consumer"))? += 1; - consumers - .entry(edge.producer) - .or_default() - .push(edge.consumer); - } - let mut ready = remaining - .iter() - .filter(|(_, n)| **n == 0) - .map(|(id, _)| *id) - .collect::>(); - let mut depths = BTreeMap::new(); - let mut visited = 0; - while let Some(id) = ready.pop_front() { - visited += 1; - let depth = *depths.get(&id).unwrap_or(&1usize); - if depth > 128 { - return Err(invalid("DAG exceeds the supported execution depth of 128")); - } - for &consumer in consumers.get(&id).into_iter().flatten() { - let next = depths.entry(consumer).or_insert(1); - *next = (*next).max(depth + 1); - let count = remaining.get_mut(&consumer).expect("validated endpoint"); - *count -= 1; - if *count == 0 { - ready.push_back(consumer); - } - } - } - if visited != dag.nodes.len() { - return Err(invalid("Planner DAG contains a cycle")); - } - Ok(()) -} - -/// Join predicates address the concatenated left/right schema. -fn semi_join_keys( - expr: &QueryExpr, - left: usize, - right: usize, - keys: &mut Vec<(usize, usize)>, -) -> Result<(), Error> { - match expr { - QueryExpr::BoolAnd(parts) => { - for part in parts { - semi_join_keys(part, left, right, keys)?; - } - } - QueryExpr::Compare { - left: a, - op: CompareOpKind::Eq, - right: b, - } => { - let (QueryExpr::Column(a), QueryExpr::Column(b)) = (a.as_ref(), b.as_ref()) else { - return Err(invalid("semi-join requires column equality keys")); - }; - let (a, b) = if a < b { (*a, *b) } else { (*b, *a) }; - if a >= left || b < left || b >= left + right { - return Err(invalid("semi-join key must match left to right")); - } - keys.push((a, b - left)); - } - _ => return Err(invalid("unsupported semi-join predicate")), - } - Ok(()) -} - -/// Resolve equality keys against the Planner join's concatenated input schema. -/// Deployments may use these positions to bind their source columns. -pub fn equijoin_keys( - pred: &planner_types::pre_asap::Predicate, - left: &planner_types::post_asap::Schema, - right: &planner_types::post_asap::Schema, -) -> Result, Error> { - let mut keys = Vec::new(); - semi_join_keys(&pred.0, left.fields.len(), right.fields.len(), &mut keys)?; - if keys.is_empty() { - return Err(invalid("semi-join requires explicit matching keys")); - } - Ok(keys) -} diff --git a/crates/asap-physical-operators/src/physical_planner/precompute.rs b/crates/asap-physical-operators/src/physical_planner/precompute.rs deleted file mode 100644 index 10dffdc0c..000000000 --- a/crates/asap-physical-operators/src/physical_planner/precompute.rs +++ /dev/null @@ -1,645 +0,0 @@ -//! Compile immutable summary-input computation with explicit population and pane identity. -use super::promql_rows::SERIES_IDENTITY_COLUMN as SERIES_IDENTITY; -use super::*; -use planner_types::{ - post_asap::{ExecutionTiming, GroupingStrategy, Schema}, - pre_asap::DataType, -}; - -/// Physical rows carry the population and pane coordinate alongside the logical value. -/// These fields preserve identities which are implicit in a stored summary instance. -pub fn population_schema(family: FieldDataType) -> SchemaRef { - Arc::new(Schema { - closed: true, - unique_keys: vec![], - fields: vec![ - planner_types::post_asap::Field { - table: None, - name: "$population".into(), - dtype: FieldDataType::Plain(DataType::Map { - key: Box::new(DataType::Utf8), - value: Box::new(DataType::Utf8), - value_nullable: false, - }), - nullable: false, - }, - planner_types::post_asap::Field { - table: None, - name: "$window_end".into(), - dtype: FieldDataType::Plain(DataType::Timestamp), - nullable: false, - }, - planner_types::post_asap::Field { - table: None, - name: "value".into(), - dtype: family, - nullable: false, - }, - ], - time_index: Some(1), - }) -} - -/// Raw sample rows at a precompute boundary. `$population` holds the series' -/// complete label set, so it is the complete source identity of per-series -/// summaries; `$timestamp` is the sample time and `value` a finite sample -/// (stale markers are not samples). Rows are what the boundary's source scan -/// selected; the deployment decides which rows and panes they are. Label sets -/// must be canonical (sorted, unique, no empty values), since they are the -/// population identity: build rows with [`raw_sample_row`]. -pub fn raw_sample_schema() -> SchemaRef { - let mut schema = (*population_schema(FieldDataType::Plain(DataType::Float64))).clone(); - schema.fields[1].name = "$timestamp".into(); - Arc::new(schema) -} - -/// A raw sample row whose label set is sorted, unique and omits empty values, -/// so one series always has one population identity. -pub fn raw_sample_row( - labels: &BTreeMap, - timestamp_ms: i64, - value: f64, -) -> Vec { - use crate::values::Value; - vec![ - Value::Map( - labels - .iter() - .filter(|(_, v)| !v.is_empty()) - .map(|(k, v)| { - ( - Value::Utf8(k.as_str().into()), - Value::Utf8(v.as_str().into()), - ) - }) - .collect::>() - .into(), - ), - Value::Timestamp(timestamp_ms), - Value::Float64(value), - ] -} - -/// Input contract of a precompute boundary: raw sample rows for a raw time -/// series scan, otherwise the stored population of its summary state. -pub fn boundary_schema(node: &PostAsapDAGNode) -> Result { - let Payload::Fallback { expression } = &node.payload else { - return source_schema(&node.output_schema); - }; - let scan = match expression { - planner_types::pre_asap::QueryExpr::TimeRange { child, .. } => child.as_ref(), - expression => expression, - }; - if !matches!( - scan, - planner_types::pre_asap::QueryExpr::Scan { - source: planner_types::pre_asap::Source::TimeSeries { .. }, - .. - } - ) { - return source_schema(&node.output_schema); - } - let logical = &node.output_schema; - // Labels may be absent from a series; its label map then omits them. - let valid = logical - .fields - .iter() - .enumerate() - .all(|(i, field)| match &field.dtype { - FieldDataType::Plain(DataType::Timestamp) => { - Some(i) == logical.time_index && !field.nullable - } - FieldDataType::Plain(DataType::Float64) => field.name == "value" && !field.nullable, - FieldDataType::Plain(DataType::Utf8) => true, - _ => false, - }) - && !logical - .fields - .iter() - .any(|f| f.name.starts_with('$') && f.name != SERIES_IDENTITY) - && logical.time_index.is_some() - && logical.fields.iter().filter(|f| f.name == "value").count() == 1; - if !valid { - return Err(invalid( - "raw sample boundary requires labels, a timestamp and one Float64 value", - )); - } - Ok(raw_sample_schema()) -} - -/// Validate the adapter layout during installed-plan recovery without lowering operators. -/// Borrows shared schema metadata and returns an Arc-owned schema for the -/// population adapter layout. -pub fn source_schema(logical: &Schema) -> Result { - let states = logical - .fields - .iter() - .filter(|f| !matches!(f.dtype, FieldDataType::Plain(_))) - .collect::>(); - let [state] = states.as_slice() else { - return Err(invalid( - "stored population requires one typed summary state", - )); - }; - if logical.fields.iter().enumerate().any(|(i, field)| matches!(&field.dtype, FieldDataType::Plain(dtype) - if field.nullable || !matches!(dtype, DataType::Utf8) && !(Some(i) == logical.time_index && *dtype == DataType::Timestamp))) { - return Err(invalid("stored population metadata cannot reconstruct extra value columns")); - } - if state.nullable { - return Err(invalid("stored population state cannot be null")); - } - Ok(population_schema(state.dtype.clone())) -} - -pub fn is_population_schema(schema: &SchemaRef) -> bool { - schema - .fields - .get(2) - .is_some_and(|field| *schema == population_schema(field.dtype.clone())) -} - -/// Compile a complete selected precompute sub-DAG. Inputs are already-computed -/// state boundaries; the deployment supplies groups, panes and states, never operations. -pub fn compile( - dag: &PostAsapDAG, - frontiers: &[NodeId], - roots: &[NodeId], -) -> Result { - preflight_depth(dag)?; - dag.validate().map_err(|e| invalid(e.to_string()))?; - let nodes = dag - .nodes - .iter() - .map(|n| (u64::from(n.id.0), n)) - .collect::>(); - let frontier = frontiers.iter().copied().collect::>(); - if frontier.len() != frontiers.len() || roots.iter().any(|r| frontier.contains(r)) { - return Err(invalid( - "precompute boundaries must be distinct from outputs", - )); - } - let mut dependencies = BTreeMap::>::new(); - let mut edges = dag.edges.iter().collect::>(); - edges.sort_by_key(|edge| { - ( - edge.consumer.0, - match edge.role { - planner_types::post_asap::EdgeRole::Left => 0, - planner_types::post_asap::EdgeRole::Input => 1, - planner_types::post_asap::EdgeRole::Right => 2, - }, - ) - }); - for edge in edges { - dependencies - .entry(u64::from(edge.consumer.0)) - .or_default() - .push(u64::from(edge.producer.0)); - } - let mut ordered = Vec::new(); - let mut seen = BTreeSet::new(); - let mut pending = roots.iter().map(|&id| (id, false)).collect::>(); - while let Some((id, expanded)) = pending.pop() { - if expanded { - ordered.push(id); - continue; - } - if !seen.insert(id) { - continue; - } - if !nodes.contains_key(&id) { - return Err(invalid("missing precompute node")); - } - pending.push((id, true)); - if !frontier.contains(&id) { - pending.extend( - dependencies - .get(&id) - .into_iter() - .flatten() - .map(|id| (*id, false)), - ); - } - } - let mut sources = BTreeMap::new(); - let mut fragments = BTreeMap::new(); - let mut outputs = BTreeMap::::new(); - for id in ordered { - let node = nodes[&id]; - if frontier.contains(&id) { - let schema = boundary_schema(node)?; - sources.insert(id, InputContract::bounded(schema.clone())); - outputs.insert(id, schema); - continue; - } - if node.output_state.timing != ExecutionTiming::IngestionTime { - return Err(invalid("precompute DAG contains a query-time operation")); - } - let inputs = dependencies.get(&id).cloned().unwrap_or_default(); - let schemas = inputs - .iter() - .map(|id| { - outputs - .get(id) - .cloned() - .ok_or_else(|| invalid("missing precompute input")) - }) - .collect::, _>>()?; - let physical_dag = fragment( - node, - &schemas, - &inputs.iter().map(|id| nodes[id]).collect::>(), - )?; - outputs.insert( - id, - physical_dag - .output_contract(physical_dag.roots()[0])? - .schema, - ); - fragments.insert(id, (inputs, physical_dag)); - } - CompiledPhysicalDAG::compose(sources, fragments, roots.to_vec()) -} - -fn validate_value_output(node: &PostAsapDAGNode) -> Result<(), Error> { - let schema = &node.output_schema; - // Physical population rows already carry the complete identity in `$population`. - // Typed logical plans may expose its opaque series-identity column as metadata. - let identity = planner_types::pre_asap::schema::PROMQL_SERIES_IDENTITY; - let identities = schema - .fields - .iter() - .filter(|field| field.name == identity) - .collect::>(); - if identities.len() > 1 - || identities - .iter() - .any(|field| field.nullable || field.dtype != FieldDataType::Plain(DataType::Utf8)) - { - return Err(invalid( - "precompute series identity requires one non-null Utf8 column", - )); - } - let values = schema - .fields - .iter() - .enumerate() - .filter(|(i, field)| Some(*i) != schema.time_index && field.name != identity) - .collect::>(); - if !matches!(values.as_slice(), [(_, field)] if !field.nullable && field.dtype == FieldDataType::Plain(DataType::Float64)) - || schema.time_index.is_some_and(|i| { - schema - .fields - .get(i) - .is_none_or(|f| f.nullable || f.dtype != FieldDataType::Plain(DataType::Timestamp)) - }) - { - return Err(invalid( - "precompute value schema requires Float64 and an optional declared timestamp", - )); - } - Ok(()) -} - -fn fragment( - node: &PostAsapDAGNode, - schemas: &[SchemaRef], - parents: &[&PostAsapDAGNode], -) -> Result { - let sources = schemas - .iter() - .enumerate() - .map(|(id, schema)| (id as u64, InputContract::bounded(schema.clone()))) - .collect(); - let mut operators = BTreeMap::new(); - let mut next = schemas.len() as u64; - let mut add = |inputs: Vec, op: Operator| -> Result { - let id = next; - next += 1; - operators.insert(id, (inputs, op)); - Ok(id) - }; - let root = match &node.payload { - Payload::Binary { operator } => { - validate_value_output(node)?; - if node.output_schema.time_index.is_none() - || parents.iter().any(|p| p.output_schema.time_index.is_none()) - { - return Err(invalid( - "precompute binary requires declared window timestamps", - )); - } - let [left, right] = schemas else { - return Err(invalid("precompute binary requires two inputs")); - }; - add( - vec![0, 1], - Operator::aligned_binary( - left.clone(), - right.clone(), - vec![(0, 0), (1, 1)], - (2, 2), - operator.clone(), - )?, - )? - } - Payload::Value { - operation: ValueOperation::FinalizeExactAccumulator, - } => { - let [input] = schemas else { - return Err(invalid("finalize requires one state input")); - }; - validate_value_output(node)?; - let statistic = match &input.fields[2].dtype { - FieldDataType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _) => { - crate::Statistic::Sum - } - FieldDataType::ExactAggregate(planner_types::post_asap::ExactKind::Count, _) => { - crate::Statistic::Count - } - _ => { - return Err(invalid( - "precompute finalization requires explicit Sum or Count semantics", - )) - } - }; - let read = Operator::readout( - input.clone(), - 2, - ReadoutQuery::Exact(ExactReadout { - statistic, - lookback_ms: None, - }), - )?; - let output = read.schema(); - let read = add(vec![0], read)?; - let project = Operator::project( - output, - vec![ - ("$population".into(), Expression::Column(0)), - ("$window_end".into(), Expression::Column(1)), - ( - "value".into(), - Expression::FiniteFloat64(Box::new(Expression::ExactFloat64(2))), - ), - ], - )? - .with_output_schema(population_schema(FieldDataType::Plain(DataType::Float64)))?; - add(vec![read], project)? - } - Payload::SummaryAgg { - family, - input: update, - reduction, - grouping, - filter, - } => { - if filter.is_some() { - return Err(invalid( - "filtered summary update has no native implementation", - )); - } - let [input] = schemas else { - return Err(invalid("summary update requires one input")); - }; - // Item identities resolve against the complete label set of raw - // samples; finalized readouts carry no such identity. - let raw = *input == raw_sample_schema(); - // A unit-frequency summary (HLL) observes each raw sample value. - let unit_frequency = raw - && crate::capability::is_unit_sample_frequency(update) - && matches!(family, FieldDataType::Sketch(kind, _) if !matches!( - kind.algorithm(), - planner_types::post_asap::SketchAlgorithm::Cms - | planner_types::post_asap::SketchAlgorithm::CountSketch - | planner_types::post_asap::SketchAlgorithm::CmsWithHeap - | planner_types::post_asap::SketchAlgorithm::CountSketchWithHeap - )); - let keyed = update.item.is_some() && !unit_frequency; - if (keyed && !raw) || !matches!(grouping, GroupingStrategy::PerSubpopulationInstance) { - return Err(invalid( - "precompute keyed/shared update needs its dedicated physical candidate", - )); - } - crate::capability::validate_summary_kernel(family, update, grouping) - .map_err(Error::Invalid)?; - if raw - && matches!( - update.weight_domain, - planner_types::post_asap::WeightDomain::NonNegative { - proof: planner_types::post_asap::NonNegativeWeightProof::ResetAwareCounterDerivative - } - ) - { - return Err(invalid( - "a counter-derivative weight cannot be read from raw cumulative samples", - )); - } - if keyed - && matches!(family, FieldDataType::Sketch(kind, _) if kind.algorithm() == &planner_types::post_asap::SketchAlgorithm::CmsWithHeap) - && !matches!( - update.weight_domain, - planner_types::post_asap::WeightDomain::NonNegative { .. } - ) - { - return Err(invalid("CMS requires a nonnegative weight contract")); - } - let labels = match reduction { - PlannerReduction::PerEntity => Expression::Column(0), - PlannerReduction::Reduce(keys) => Expression::LabelSet { - column: 0, - labels: keys - .keys() - .iter() - .map(|key| { - parents[0] - .output_schema - .fields - .get(*key) - // A raw label map omits absent labels; the - // series identity is not one of its labels. - .filter(|field| { - (raw || !field.nullable) - && field.name != SERIES_IDENTITY - && field.dtype == FieldDataType::Plain(DataType::Utf8) - }) - .map(|f| f.name.clone()) - .ok_or_else(|| { - invalid("summary grouping must identify population labels") - }) - }) - .collect::, _>>()?, - without: keys.is_without(), - }, - }; - let weight = match &update.weight { - _ if unit_frequency => Expression::Column(2), - SummaryInputExpr::Constant(value) => Expression::Literal { - value: crate::values::Value::Float64(*value), - dtype: DataType::Float64, - }, - SummaryInputExpr::Column(ColumnRef::SampleValue) => Expression::Column(2), - SummaryInputExpr::Column(ColumnRef::Named(name)) - if parents[0].output_schema.fields.iter().any(|f| { - f.name == *name && f.dtype == FieldDataType::Plain(DataType::Float64) - }) => - { - Expression::Column(2) - } - _ => { - return Err(invalid( - "summary weight does not resolve to the input value", - )) - } - }; - let mut columns = vec![ - ("$population".into(), labels), - ("$window_end".into(), Expression::Column(1)), - ("value".into(), Expression::FiniteFloat64(Box::new(weight))), - ]; - let mut fields = population_schema(FieldDataType::Plain(DataType::Float64)) - .fields - .clone(); - if keyed { - let mut items = Vec::new(); - raw_items( - update.item.as_ref().expect("keyed item"), - &parents[0].output_schema, - &mut items, - )?; - for (index, (expression, dtype)) in items.into_iter().enumerate() { - let name = format!("$item{index}"); - fields.push(planner_types::post_asap::Field { - table: None, - name: name.clone(), - dtype: FieldDataType::Plain(dtype), - nullable: false, - }); - columns.push((name, expression)); - } - } - let item_columns = (3..fields.len()).collect::>(); - let project = Operator::project(input.clone(), columns)?.with_output_schema( - Arc::new(Schema { - closed: true, - unique_keys: vec![], - fields, - time_index: Some(1), - }), - )?; - let projected = project.schema(); - let project = add(vec![0], project)?; - let build = if keyed { - Operator::keyed_summary_build(projected, family.clone(), 2, item_columns, vec![0])? - } else { - Operator::summary_build(projected, family.clone(), 2, Some(1), vec![0])? - }; - let built = build.schema(); - let build = add(vec![project], build)?; - add( - vec![build], - Operator::scope_timestamp(built, population_schema(family.clone()))?, - )? - } - Payload::SummaryMerge => { - let Some(input) = schemas.first() else { - return Err(invalid("summary merge requires inputs")); - }; - if schemas.iter().any(|s| s != input) { - return Err(invalid("summary merge inputs differ")); - } - let union = add( - (0..schemas.len() as u64).collect(), - Operator::union(input.clone(), schemas.len())?, - )?; - let merge = Operator::summary_merge(input.clone(), 2, vec![0])?; - let merged = merge.schema(); - let merge = add(vec![union], merge)?; - add( - vec![merge], - Operator::scope_timestamp(merged, input.clone())?, - )? - } - _ => { - return Err(invalid( - "precompute operation has no native population implementation", - )) - } - }; - CompiledPhysicalDAG::from_operators(sources, operators, vec![root]) -} - -/// Resolve keyed item identities over raw sample rows: labels (absent labels -/// read as empty, as in PromQL), the sample value, or the canonical encoding -/// of the label set less excluded labels. -fn raw_items( - expr: &SummaryInputExpr, - scan: &Schema, - items: &mut Vec<(Expression, DataType)>, -) -> Result<(), Error> { - // Open PromQL scans need not list every label, so any name that is not - // another scan column (value, time, series identity) reads as a label. - let label = |column: &ColumnRef| match column { - ColumnRef::Named(name) | ColumnRef::Qualified { name, .. } - if !name.starts_with('$') - && scan.fields.iter().all(|f| { - &f.name != name || f.dtype == FieldDataType::Plain(DataType::Utf8) - }) => - { - Some(name.clone()) - } - _ => None, - }; - let identity = |excluding: Vec| { - ( - Expression::LabelIdentity { - column: 0, - excluding, - }, - DataType::Utf8, - ) - }; - match expr { - SummaryInputExpr::Column(ColumnRef::SampleValue) => { - items.push((Expression::Column(2), DataType::Float64)) - } - SummaryInputExpr::Column(ColumnRef::Named(name) | ColumnRef::Qualified { name, .. }) - if name == "value" => - { - items.push((Expression::Column(2), DataType::Float64)) - } - SummaryInputExpr::Column(ColumnRef::Named(name) | ColumnRef::Qualified { name, .. }) - if name == SERIES_IDENTITY => - { - items.push(identity(vec![])) - } - SummaryInputExpr::Column(column) if label(column).is_some() => items.push(( - Expression::Label { - column: 0, - name: label(column).expect("resolved label"), - }, - DataType::Utf8, - )), - SummaryInputExpr::EntityIdentity( - planner_types::post_asap::EntityIdentity::PromqlLabelSet { excluding }, - ) => items.push(identity( - excluding - .iter() - .map(|column| { - label(column).ok_or_else(|| invalid("excluded identity label is not a label")) - }) - .collect::>()?, - )), - SummaryInputExpr::Tuple(parts) if !parts.is_empty() => { - for part in parts { - raw_items(part, scan, items)?; - } - } - _ => { - return Err(invalid( - "keyed summary item does not resolve over raw samples", - )) - } - } - Ok(()) -} diff --git a/crates/asap-physical-operators/src/physical_planner/promql_fallback.rs b/crates/asap-physical-operators/src/physical_planner/promql_fallback.rs deleted file mode 100644 index 237504c98..000000000 --- a/crates/asap-physical-operators/src/physical_planner/promql_fallback.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! Compile a retained PromQL sub-DAG (`Fallback`) from its typed expression. -//! The deployment supplies the raw series of each selector; the Planner -//! computes selection, range functions, subqueries, matching and aggregation. -use super::*; -use crate::operators::SubquerySteps; -use planner_types::post_asap::execution_data_state::lift_plain; -use planner_types::pre_asap::{AtModifier, VectorMatchKind}; - -/// Input slot for the raw series read by the `selector`th selector (in -/// [`raw_series`] order) of Fallback node `node`. The node's own ID names its -/// computed output, so the raw rows need another. -pub fn raw_series_input(node: NodeId, selector: usize) -> NodeId { - node | ((selector as u64 + 1) << 32) -} - -/// The Fallback node that owns a raw-series input slot. -pub(super) fn raw_series_owner(slot: NodeId) -> Option { - (slot >> 32 != 0).then_some(slot & u64::from(u32::MAX)) -} - -/// A selector expression and its raw-series row schema. -pub type Selector = (QueryExpr, SchemaRef); - -/// The selectors a Fallback expression reads, left to right, and the row -/// schema of the raw series the deployment supplies for each at -/// [`raw_series_input`]. The rows must cover the selector's window at every -/// evaluation instant `T`, or at its `@` time: `(T - offset - range, T - offset]`; -/// under a subquery `[R:S] offset O` that is `(T - O - R - offset - range, T - O - offset]`. -pub fn raw_series(expression: &QueryExpr) -> Result, Error> { - Ok(lower(expression)?.selectors) -} - -/// An operator input: a selector's raw rows or an earlier step. -pub(super) enum Input { - Raw(usize), - Step(usize), -} - -/// Operators computing an expression; the last step is its result. -#[derive(Default)] -pub(super) struct Lowering { - pub selectors: Vec, - pub steps: Vec<(Operator, Vec)>, -} - -pub(super) fn lower(expression: &QueryExpr) -> Result { - let mut lowering = Lowering::default(); - lowering.value(expression)?; - Ok(lowering) -} - -fn declared(expression: &QueryExpr) -> Result { - let schema = expression - .output_schema() - .map_err(|error| invalid(error.to_string()))?; - Ok(Arc::new(lift_plain(&schema))) -} - -fn millis(duration: &std::time::Duration) -> Result { - i64::try_from(duration.as_millis()).map_err(|_| invalid("PromQL duration exceeds Int64")) -} - -/// A fixed `@` time. `start()`/`end()` depend on the deployment's range query. -fn at(shift: &planner_types::pre_asap::TimeShift) -> Result, Error> { - match shift.at { - None => Ok(None), - Some(AtModifier::Timestamp(at)) => Ok(Some(at)), - Some(AtModifier::Start | AtModifier::End) => Ok(None), - } -} - -fn range_anchor(expression: &QueryExpr) -> Option { - match expression { - QueryExpr::TimeRange { child, .. } => range_anchor(child), - QueryExpr::TimeShift { shift, .. } => shift - .at - .filter(|at| matches!(at, AtModifier::Start | AtModifier::End)), - _ => None, - } -} - -/// `TimeRange { range, [TimeShift { offset, @ }], Scan }`: range, offset, `@`. -fn selector(expression: &QueryExpr) -> Result<(i64, i64, Option), Error> { - let QueryExpr::TimeRange { range, child } = expression else { - return Err(invalid("PromQL operand must be a series selector")); - }; - let (offset, at, scan) = match child.as_ref() { - QueryExpr::TimeShift { shift, child } => (shift.offset_ms, at(shift)?, child.as_ref()), - scan => (0, None, scan), - }; - if !matches!(scan, QueryExpr::Scan { .. }) { - return Err(invalid("PromQL selector must read one scan")); - } - Ok((millis(range)?, offset, at)) -} - -/// PromQL scalar-valued expressions have no labels to match. A binary -/// operator is scalar-valued when both operands are. -pub(super) fn scalar(expression: &QueryExpr) -> bool { - match expression { - QueryExpr::PromqlScalarBridge(_) - | QueryExpr::PromqlScalarFromVector(_) - | QueryExpr::EvalTimestamp => true, - QueryExpr::BinaryOp { lhs, rhs, .. } => scalar(lhs) && scalar(rhs), - _ => false, - } -} - -impl Lowering { - fn schema(&self, input: &Input) -> SchemaRef { - match input { - Input::Raw(i) => self.selectors[*i].1.clone(), - Input::Step(i) => self.steps[*i].0.schema(), - } - } - - fn add(&mut self, operator: Operator, inputs: Vec) -> Input { - self.steps.push((operator, inputs)); - Input::Step(self.steps.len() - 1) - } - - /// Conform `operator` to the logical schema of the expression it computes. - fn push( - &mut self, - operator: Operator, - inputs: Vec, - logical: &QueryExpr, - ) -> Result { - Ok(self.add(operator.with_output_schema(declared(logical)?)?, inputs)) - } - - fn read(&mut self, selector: &QueryExpr) -> Result { - let schema = declared(selector)?; - if !schema - .fields - .iter() - .any(|f| f.name == promql_rows::SERIES_IDENTITY_COLUMN) - { - return Err(invalid( - "PromQL fallback requires the complete series identity", - )); - } - self.selectors.push((selector.clone(), schema)); - Ok(Input::Raw(self.selectors.len() - 1)) - } - - /// An instant vector, or a scalar for scalar-valued expressions. - fn value(&mut self, expression: &QueryExpr) -> Result { - match expression { - QueryExpr::Concat { children, .. } => { - if !children.iter().all(|branch| matches!(branch, - QueryExpr::PromqlRelabel { child, .. } if matches!(child.as_ref(), - QueryExpr::Aggregate { measures, .. } if matches!(measures.as_slice(), [AggIntent::HistogramQuantile { .. }])))) { - return Err(invalid("PromQL concatenation requires classic histogram quantile branches")); - } - let inputs = children - .iter() - .map(|child| self.value(child)) - .collect::, _>>()?; - let output = declared(expression)?; - if inputs.iter().any(|input| self.schema(input) != output) { - return Err(invalid( - "concatenated PromQL branches require equal schemas", - )); - } - let union = self.add(Operator::union(output.clone(), inputs.len())?, inputs); - // Multi-quantile branches drop the metric name and form one vector. - self.push( - Operator::series_without_name(output)?, - vec![union], - expression, - ) - } - QueryExpr::PromqlRelabel { dst, value, child } => { - let step = self.value(child)?; - let input = self.schema(&step); - let (replacement, source_regex) = match value.as_ref() { - QueryExpr::Literal(planner_types::pre_asap::ScalarValue::Utf8(value)) => { - (value.clone(), None) - } - QueryExpr::FunctionCall { name, args } if name == "label_replace" => { - let [QueryExpr::Column(source), QueryExpr::Literal(planner_types::pre_asap::ScalarValue::Utf8(pattern)), QueryExpr::Literal(planner_types::pre_asap::ScalarValue::Utf8( - replacement, - ))] = args.as_slice() - else { - return Err(invalid("invalid label_replace arguments")); - }; - let source = input - .fields - .get(*source) - .ok_or_else(|| invalid("label_replace source missing"))? - .name - .clone(); - (replacement.clone(), Some((source, pattern.clone()))) - } - _ => return Err(invalid("unsupported PromQL label rewrite")), - }; - let operator = Operator::series_relabel( - input, - declared(expression)?, - dst.clone(), - replacement, - source_regex, - )?; - self.push(operator, vec![step], expression) - } - QueryExpr::TimeRange { .. } => { - let (range, offset, at) = selector(expression)?; - let input = self.read(expression)?; - let schema = self.schema(&input); - self.push( - Operator::series_window(schema, None, range, offset, at, None)? - .with_series_range_bounds(range_anchor(expression), None)?, - vec![input], - expression, - ) - } - QueryExpr::Aggregate { - reduction: planner_types::pre_asap::Reduction::PerEntity, - measures, - having: None, - child, - filters, - .. - } if filters.iter().all(Option::is_none) => { - let [function] = measures.as_slice() else { - return Err(invalid("range function requires one measure")); - }; - let step = self.range_function(function, child, expression)?; - if matches!(function, AggIntent::LastOverTime) { - return Ok(step); - } - // Other range functions drop the name; equal label sets then error. - let input = self.schema(&step); - Ok(self.add(Operator::series_without_name(input)?, vec![step])) - } - QueryExpr::Aggregate { - reduction: planner_types::pre_asap::Reduction::Reduce(keys), - measures, - having: None, - child, - filters, - .. - } if filters.iter().all(Option::is_none) => { - let [measure] = measures.as_slice() else { - return Err(invalid("vector aggregation requires one measure")); - }; - let input = self.value(child)?; - self.aggregate(input, measure, keys, expression) - } - QueryExpr::Sort { - keys, - partition_by, - child, - } => { - let step = self.value(child)?; - let input = self.schema(&step); - let keys = keys - .iter() - .map(|key| match key.expr { - QueryExpr::Column(column) => Ok(SortKey { - column, - descending: !key.ascending, - nulls_first: key.nulls_first, - }), - _ => Err(invalid("sort key must be a column")), - }) - .collect::>()?; - let groups = groups(&input, partition_by)?; - self.push(Operator::sort(input, keys, groups)?, vec![step], expression) - } - QueryExpr::Limit { n, offset, child } => { - let step = self.value(child)?; - let input = self.schema(&step); - // `topk by (...)` partitions through the Sort it limits. - let groups = match child.as_ref() { - QueryExpr::Sort { partition_by, .. } => groups(&input, partition_by)?, - _ => vec![], - }; - self.push( - Operator::limit(input, *n as u64, *offset as u64, groups)?, - vec![step], - expression, - ) - } - QueryExpr::BinaryOp { - op, - lhs, - rhs, - vector_match, - } => { - let sides = vec![self.value(lhs)?, self.value(rhs)?]; - let operator = planner_types::post_asap::BinaryOperator { - kind: op.clone(), - vector_match: vector_match.clone(), - checked_relative_division: false, - checked_finite_division: false, - }; - let binary = Operator::series_binary( - self.schema(&sides[0]), - self.schema(&sides[1]), - operator, - [scalar(lhs), scalar(rhs)], - )?; - self.push(binary, sides, expression) - } - QueryExpr::PromqlScalarFromVector(child) => { - let step = self.value(child)?; - let input = self.schema(&step); - let value = named_column(&input, &ColumnRef::SampleValue)?; - self.push( - Operator::vector_to_scalar(input, value)?, - vec![step], - expression, - ) - } - QueryExpr::PromqlVectorFromScalar(child) => { - let step = self.value(child)?; - let input = self.schema(&step); - Ok(self.add( - Operator::scope_timestamp(input, declared(expression)?)?, - vec![step], - )) - } - QueryExpr::EvalTimestamp => self.push(Operator::evaluation_time(), vec![], expression), - QueryExpr::PromqlScalarBridge(_) => { - let value = row_values::scalar_literal(expression) - .ok_or_else(|| invalid("PromQL scalar must be a literal"))?; - self.push( - Operator::scalar(crate::values::Value::Float64(value), DataType::Float64)?, - vec![], - expression, - ) - } - _ => Err(invalid("PromQL expression has no native fallback lowering")), - } - } - - /// `function(matrix)`, where the matrix is a range selector or a subquery. - fn range_function( - &mut self, - function: &AggIntent, - matrix: &QueryExpr, - logical: &QueryExpr, - ) -> Result { - let function = unbound(function)?; - let (subquery, offset, at_ms) = match matrix { - QueryExpr::TimeShift { shift, child } => (child.as_ref(), shift.offset_ms, at(shift)?), - other => (other, 0, None), - }; - let QueryExpr::PromqlSubquery { - range: outer, - resolution, - child, - } = subquery - else { - let (range, offset, at) = selector(matrix)?; - let input = self.read(matrix)?; - let schema = self.schema(&input); - return self.push( - Operator::series_window(schema, Some(function), range, offset, at, None)? - .with_series_range_bounds(range_anchor(matrix), None)?, - vec![input], - logical, - ); - }; - let step = resolution.as_ref().ok_or_else(|| { - invalid("subquery resolution defaults to the deployment evaluation interval") - })?; - let steps = SubquerySteps { - range_ms: millis(outer)?, - step_ms: millis(step)?, - offset_ms: offset, - at_ms, - }; - // Each step evaluates a per-series selection or range function. - let (inner, selected) = match child.as_ref() { - QueryExpr::Aggregate { - reduction: planner_types::pre_asap::Reduction::PerEntity, - measures, - having: None, - child: selected, - .. - } => match measures.as_slice() { - [inner] => (Some(unbound(inner)?), selected.as_ref()), - _ => return Err(invalid("range function requires one measure")), - }, - selected => (None, selected), - }; - let (range, inner_offset, inner_at) = selector(selected)?; - let raw = self.read(selected)?; - let schema = self.schema(&raw); - let mut step = self.push( - Operator::series_window( - schema, - inner.clone(), - range, - inner_offset, - inner_at, - Some(steps), - )? - .with_series_range_bounds(range_anchor(selected), range_anchor(matrix))?, - vec![raw], - child, - )?; - // Name removal must validate each subquery evaluation step. - if inner.is_some() && !matches!(inner, Some(AggIntent::LastOverTime)) { - let input = self.schema(&step); - let relabel = Operator::series_without_name(input)?; - step = self.add(relabel, vec![step]); - } - let input = self.schema(&step); - self.push( - Operator::series_window(input, Some(function), steps.range_ms, offset, at_ms, None)? - .with_series_range_bounds(range_anchor(matrix), None)?, - vec![step], - logical, - ) - } - - /// Cross-series aggregation. A global aggregate groups by one constant so - /// that no input series yields an empty vector, not one row. - fn aggregate( - &mut self, - mut step: Input, - measure: &AggIntent, - keys: &GroupKeys, - logical: &QueryExpr, - ) -> Result { - let mut input = self.schema(&step); - if let AggIntent::HistogramQuantile { q, le } = measure { - if !keys.is_without() || keys.keys() != [*le] { - return Err(invalid("histogram_quantile must group without (le)")); - } - let operator = Operator::series_histogram_quantile(input, *q, *le)?; - return self.push(operator, vec![step], logical); - } - let value = input - .fields - .iter() - .enumerate() - .filter(|(_, f)| f.dtype == FieldDataType::Plain(DataType::Float64)) - .map(|(i, _)| i) - .collect::>(); - let [value] = value.as_slice() else { - return Err(invalid("PromQL aggregation requires one Float64 value")); - }; - let value = *value; - let reduction = match measure { - AggIntent::Sum { col: None } => Reduction::Sum(value), - AggIntent::Avg { col: None } => Reduction::Avg(value), - AggIntent::Min { col: None } => Reduction::Min(value), - AggIntent::Max { col: None } => Reduction::Max(value), - AggIntent::Count { .. } => Reduction::Count, - _ => return Err(invalid("vector aggregate has no native lowering")), - }; - let mut groups = if keys.is_without() { - // Group by every remaining label, including the rewritten identity. - let excluded = keys.keys(); - if excluded.iter().any(|&i| i >= input.fields.len()) { - return Err(invalid("grouping column out of range")); - } - let names = excluded.iter().map(|&i| input.fields[i].name.clone()); - let relabel = - Operator::series_labels(input.clone(), VectorMatchKind::Ignoring, names.collect())?; - step = self.add(relabel, vec![step]); - (0..input.fields.len()) - .filter(|&i| Some(i) != input.time_index && i != value && !excluded.contains(&i)) - .collect() - } else { - groups(&input, keys)? - }; - let global = groups.is_empty(); - if global { - let mut columns = (0..input.fields.len()) - .map(|i| (input.fields[i].name.clone(), Expression::Column(i))) - .collect::>(); - columns.push(( - "$promql_global_group".into(), - Expression::Literal { - value: crate::values::Value::Utf8("".into()), - dtype: DataType::Utf8, - }, - )); - let project = Operator::project(input, columns)?; - input = project.schema(); - groups = vec![input.fields.len() - 1]; - step = self.add(project, vec![step]); - } - let output = declared(logical)?; - let name = output - .fields - .last() - .ok_or_else(|| invalid("aggregate output lacks a value"))? - .name - .clone(); - let aggregate = Operator::aggregate(input, groups, vec![(name, reduction)])?; - let actual = aggregate.schema(); - let step = self.add(aggregate, vec![step]); - // Drop the constant group; convert counts where PromQL declares Float64. - let skip = usize::from(global); - let columns = actual.fields[skip..] - .iter() - .zip(&output.fields) - .enumerate() - .map(|(i, (field, declared))| { - let column = i + skip; - let expression = if field.dtype != declared.dtype { - Expression::ExactFloat64(column) - } else { - Expression::Column(column) - }; - (field.name.clone(), expression) - }) - .collect(); - self.push(Operator::project(actual, columns)?, vec![step], logical) - } -} - -fn unbound(intent: &AggIntent) -> Result, Error> { - Ok(match intent { - AggIntent::Rate => AggIntent::Rate, - AggIntent::Deriv => AggIntent::Deriv, - AggIntent::PredictLinear { seconds } => AggIntent::PredictLinear { seconds: *seconds }, - AggIntent::Increase => AggIntent::Increase, - AggIntent::Delta => AggIntent::Delta, - AggIntent::Count { accuracy } => AggIntent::Count { - accuracy: accuracy.clone(), - }, - AggIntent::Sum { col: None } => AggIntent::Sum { col: None }, - AggIntent::Avg { col: None } => AggIntent::Avg { col: None }, - AggIntent::Min { col: None } => AggIntent::Min { col: None }, - AggIntent::Max { col: None } => AggIntent::Max { col: None }, - AggIntent::IRate => AggIntent::IRate, - AggIntent::IDelta => AggIntent::IDelta, - AggIntent::Changes => AggIntent::Changes, - AggIntent::Resets => AggIntent::Resets, - AggIntent::LastOverTime => AggIntent::LastOverTime, - AggIntent::Quantile { - col: None, - q, - accuracy, - } => AggIntent::Quantile { - col: None, - q: *q, - accuracy: accuracy.clone(), - }, - _ => return Err(invalid("unsupported PromQL range function")), - }) -} diff --git a/crates/asap-physical-operators/src/physical_planner/promql_rows.rs b/crates/asap-physical-operators/src/physical_planner/promql_rows.rs deleted file mode 100644 index f3082927b..000000000 --- a/crates/asap-physical-operators/src/physical_planner/promql_rows.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! A bounded PromQL source row carries the entire label set, not just labels -//! mentioned by the query. The source adapter owns this lossless encoding. -use super::*; -use planner_types::pre_asap::DataType; -use std::rc::Rc; - -/// Not a legal PromQL label name, so it cannot shadow a user label. -pub use planner_types::pre_asap::schema::PROMQL_SERIES_IDENTITY as SERIES_IDENTITY_COLUMN; - -/// Canonical, reversible identity. JSON object encoding preserves label names, -/// empty values and escaping; sorting makes ingestion order irrelevant. -pub fn encode_series_identity(labels: &BTreeMap) -> Result { - serde_json::to_string(labels).map_err(|error| invalid(error.to_string())) -} - -pub fn decode_series_identity(encoded: &str) -> Result, Error> { - let labels: BTreeMap = - serde_json::from_str(encoded).map_err(|error| invalid(error.to_string()))?; - if encode_series_identity(&labels)? != encoded { - return Err(invalid("series identity is not canonically encoded")); - } - Ok(labels) -} - -/// Resolve the row representation before candidate search; see -/// [`planner_types::pre_asap::schema::with_promql_series_identity`]. -pub fn with_series_identity(root: &QueryExpr) -> Result { - planner_types::pre_asap::schema::with_promql_series_identity(root).map_err(invalid) -} - -/// Construct source rows only from full identities. The named label columns -/// are projections of that same identity and cannot independently redefine it. -pub fn series_row( - schema: &SchemaRef, - labels: &BTreeMap, - timestamp: i64, - value: f64, -) -> Result, Error> { - use crate::values::Value; - let identity = encode_series_identity(labels)?; - let mut found = false; - let row = schema - .fields - .iter() - .enumerate() - .map(|(index, field)| { - if field.name == SERIES_IDENTITY_COLUMN { - if field.dtype != FieldDataType::Plain(DataType::Utf8) || field.nullable || found { - return Err(invalid("invalid series identity column")); - } - found = true; - Ok(Value::Utf8(identity.clone().into())) - } else if Some(index) == schema.time_index { - Ok(Value::Timestamp(timestamp)) - } else if field.name == "value" - && field.dtype == FieldDataType::Plain(DataType::Float64) - { - Ok(Value::Float64(value)) - } else if field.dtype == FieldDataType::Plain(DataType::Utf8) { - Ok(labels.get(&field.name).map_or_else( - || Value::Utf8("".into()), - |value| Value::Utf8(value.clone().into()), - )) - } else { - Err(invalid("unsupported PromQL source column")) - } - }) - .collect::, _>>()?; - if !found { - return Err(invalid("source lacks its full series identity")); - } - Ok(row) -} - -/// Compile the selected TopK computation above an existing maintained-population -/// source. The boundary supplies the complete eligible vector, not a truncated -/// TopK result; ranking remains a native physical operator. -pub fn compile_current_series_readout( - selected: &Rc, -) -> Result { - use planner_types::post_asap::{ - compile_post_asap_dag, maintained_population::PopulationStatistic, Field, - }; - let mut dag = compile_post_asap_dag(selected).map_err(|error| invalid(error.to_string()))?; - // Typed snapshot candidates already carry full identity throughout the DAG. - // Cut at the population output, preserving all selected heap/readout nodes. - let populations = dag.nodes.iter().filter(|node| matches!(&node.payload, - Payload::Value { operation: ValueOperation::MaintainPopulation { population } } - if matches!(population.input, planner_types::post_asap::maintained_population::PopulationInput::CurrentSeries(_)) - )).collect::>(); - if let [population] = populations.as_slice() { - if population - .output_schema - .fields - .iter() - .any(|field| field.name == SERIES_IDENTITY_COLUMN) - { - return compile( - &dag, - BTreeMap::from([( - u64::from(population.id.0), - InputContract::bounded(Arc::new(population.output_schema.clone())), - )]), - &[u64::from(dag.root.0)], - ); - } - } - if dag.nodes.len() != 3 - || !dag.nodes.iter().any(|node| { - node.id == dag.root - && matches!( - node.payload, - Payload::Value { - operation: ValueOperation::ReadPopulation { - readout: PopulationStatistic::TopK { .. } - } - } - ) - }) - { - return Err(invalid( - "expected one selected current-series TopK computation", - )); - } - let mut frontier = None; - for node in &mut dag.nodes { - match &mut node.payload { - Payload::Fallback { expression } => { - *expression = with_series_identity(expression)?; - } - Payload::Value { - operation: ValueOperation::MaintainPopulation { .. }, - } => { - frontier = Some(u64::from(node.id.0)); - } - Payload::Value { - operation: - ValueOperation::ReadPopulation { - readout: PopulationStatistic::TopK { .. }, - }, - } => {} - _ => return Err(invalid("unsupported current-series readout dependency")), - } - if node - .output_schema - .fields - .iter() - .any(|field| field.name == SERIES_IDENTITY_COLUMN) - { - return Err(invalid( - "current-series input already has a physical identity column", - )); - } - node.output_schema.fields.push(Field { - table: None, - name: SERIES_IDENTITY_COLUMN.into(), - dtype: FieldDataType::Plain(DataType::Utf8), - nullable: false, - }); - } - for edge in &mut dag.edges { - edge.intermediate_schema = dag - .nodes - .iter() - .find(|node| node.id == edge.producer) - .unwrap() - .output_schema - .clone(); - } - let frontier = frontier.ok_or_else(|| invalid("missing current-series population"))?; - let schema = Arc::new( - dag.nodes - .iter() - .find(|node| u64::from(node.id.0) == frontier) - .unwrap() - .output_schema - .clone(), - ); - compile( - &dag, - BTreeMap::from([(frontier, InputContract::bounded(schema))]), - &[u64::from(dag.root.0)], - ) -} - -/// Compile selected ranking or aggregation above an exact per-series Rate -/// readout. Deployments bind complete window readouts at this boundary; -/// the heap is rebuilt independently for each evaluation. This does not move -/// that frontier to ingestion time or authorize combining finalized rates. -pub fn compile_rate_ranking( - selected: &Rc, -) -> Result< - ( - Rc, - CompiledPhysicalDAG, - ), - Error, -> { - use planner_types::post_asap::{ - compile_post_asap_dag_with_node_ids, ExactKind, SummaryExpr, SummaryNode, - }; - fn frontier(node: &Rc) -> Option> { - match &node.expr { - SummaryExpr::ValueOperation { - child, - operation: ValueOperation::FinalizeExactAccumulator, - timing: planner_types::post_asap::ExecutionTiming::QueryTime, - } if matches!(&child.expr, SummaryExpr::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Rate, _), - reduction: planner_types::pre_asap::Reduction::PerEntity, - child: raw, .. - } if matches!(&raw.expr, SummaryExpr::KeepPreAsap(expr) if matches!(expr.as_ref(), QueryExpr::TimeRange { .. }))) => - { - Some(Rc::clone(node)) - } - SummaryExpr::ValueOperation { child, .. } | SummaryExpr::SummaryAgg { child, .. } => { - frontier(child) - } - SummaryExpr::SummaryEstimate { summary_input, .. } => frontier(summary_input), - _ => None, - } - } - let source = frontier(selected) - .ok_or_else(|| invalid("ranking requires one exact per-series Rate frontier"))?; - if !source - .schema - .fields - .iter() - .any(|field| field.name == SERIES_IDENTITY_COLUMN) - { - return Err(invalid("Rate ranking requires complete series identity")); - } - let compiled = compile_post_asap_dag_with_node_ids(selected) - .map_err(|error| invalid(error.to_string()))?; - let id = u64::from( - compiled - .node_ids - .node_id(&source) - .ok_or_else(|| invalid("missing Rate frontier"))? - .0, - ); - let program = compile( - &compiled.dag, - BTreeMap::from([(id, InputContract::bounded(Arc::new(source.schema.clone())))]), - &[u64::from(compiled.dag.root.0)], - )?; - Ok((source, program)) -} - -/// Compile a lifecycle-timed DAG whose heap or grouped Sum over per-series -/// Rate readouts runs at ingestion time: fresh aggregate state per closed -/// window. The input is the complete collection of per-series counter states. -pub fn compile_fixed_window_rate_aggregation( - dag: &planner_types::post_asap::PostAsapDAG, -) -> Result { - use planner_types::post_asap::{ExactKind, ExecutionTiming, SketchAlgorithm}; - let sources = dag - .nodes - .iter() - .filter(|n| { - matches!( - &n.payload, - Payload::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Rate, _), - reduction: planner_types::pre_asap::Reduction::PerEntity, - .. - } - ) - }) - .collect::>(); - let heaps = dag - .nodes - .iter() - .filter(|n| { - n.output_state.timing == ExecutionTiming::IngestionTime - && match &n.payload { - Payload::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - } => matches!( - kind.algorithm(), - SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap - ), - Payload::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Sum, _), - .. - } => true, - _ => false, - } - }) - .collect::>(); - let ([source], [heap]) = (sources.as_slice(), heaps.as_slice()) else { - return Err(invalid( - "expected one selected fixed-window Rate aggregation", - )); - }; - if !source - .output_schema - .fields - .iter() - .any(|f| f.name == SERIES_IDENTITY_COLUMN) - { - return Err(invalid( - "fixed-window Rate aggregation requires complete series identity", - )); - } - compile_candidate( - dag, - BTreeMap::from([( - u64::from(source.id.0), - InputContract::bounded(Arc::new(source.output_schema.clone())), - )]), - &[u64::from(dag.root.0)], - &[u64::from(heap.id.0)], - ) -} diff --git a/crates/asap-physical-operators/src/physical_planner/promql_values.rs b/crates/asap-physical-operators/src/physical_planner/promql_values.rs deleted file mode 100644 index 880916ed2..000000000 --- a/crates/asap-physical-operators/src/physical_planner/promql_values.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Physical scalar/vector contracts preserve complete label sets across native computation. -use super::*; - -pub fn scalar_schema() -> SchemaRef { - crate::operators::vector_binary::value_schema(true) -} -pub fn vector_schema() -> SchemaRef { - crate::operators::vector_binary::value_schema(false) -} - -pub fn matrix_schema() -> SchemaRef { - crate::operators::vector_window::matrix_schema() -} - -pub fn compile_scalar(value: f64) -> Result { - let operator = Operator::scalar( - crate::values::Value::Float64(value), - planner_types::pre_asap::DataType::Float64, - )? - .with_output_schema(scalar_schema())?; - CompiledPhysicalDAG::from_operators( - BTreeMap::new(), - BTreeMap::from([(0, (vec![], operator))]), - vec![0], - ) -} - -pub fn compile_temporal( - intent: &AggIntent, - preserve_metric_name: bool, -) -> Result { - let operator = Operator::range_window(intent.clone())?; - let mut operators = vec![operator]; - if !preserve_metric_name { - operators.push(Operator::project( - vector_schema(), - vec![ - ( - "labels".into(), - Expression::LabelSet { - column: 0, - labels: vec![], - without: true, - }, - ), - ("value".into(), Expression::Column(1)), - ], - )?); - } - unary(operators, matrix_schema()) -} - -pub fn compile_histogram_quantile() -> Result { - CompiledPhysicalDAG::from_operators( - BTreeMap::from([ - (0, InputContract::bounded(scalar_schema())), - (1, InputContract::bounded(vector_schema())), - ]), - BTreeMap::from([(2, (vec![0, 1], Operator::histogram_quantile()))]), - vec![2], - ) -} - -/// Compile before deployment chooses readers. Input slots 0 and 1 retain operand order. -pub fn compile_binary( - operator: &planner_types::post_asap::BinaryOperator, - return_bool: bool, - left_scalar: bool, - right_scalar: bool, -) -> Result { - let left = crate::operators::vector_binary::value_schema(left_scalar); - let right = crate::operators::vector_binary::value_schema(right_scalar); - let op = Operator::vector_binary(left.clone(), right.clone(), operator.clone(), return_bool)?; - CompiledPhysicalDAG::from_operators( - BTreeMap::from([ - (0, InputContract::bounded(left)), - (1, InputContract::bounded(right)), - ]), - BTreeMap::from([(2, (vec![0, 1], op))]), - vec![2], - ) -} - -fn unary(operators: Vec, input: SchemaRef) -> Result { - let root = operators.len() as u64; - CompiledPhysicalDAG::from_operators( - BTreeMap::from([(0, InputContract::bounded(input))]), - operators - .into_iter() - .enumerate() - .map(|(i, op)| ((i + 1) as u64, (vec![i as u64], op))) - .collect(), - vec![root], - ) -} - -fn grouped(grouping: &GroupKeys) -> Result { - let labels = grouping - .keys() - .iter() - .map(|key| match key { - ColumnRef::Named(label) => Ok(label.clone()), - _ => Err(invalid("vector grouping requires label names")), - }) - .collect::, _>>()?; - Operator::project( - vector_schema(), - vec![ - ("labels".into(), Expression::Column(0)), - ("value".into(), Expression::Column(1)), - ( - "group".into(), - Expression::LabelSet { - column: 0, - labels, - without: grouping.is_without(), - }, - ), - ], - ) -} - -fn vector_output(input: SchemaRef, labels: usize, value: usize) -> Result { - let value = Expression::ExactFloat64(value); - Operator::project( - input, - vec![ - ("labels".into(), Expression::Column(labels)), - ("value".into(), value), - ], - ) -} - -pub fn compile_aggregate( - intent: &AggIntent, - grouping: &GroupKeys, -) -> Result { - let project = grouped(grouping)?; - let reduction = match intent { - AggIntent::Sum { .. } => Reduction::Sum(1), - AggIntent::Avg { .. } => Reduction::Avg(1), - AggIntent::Count { .. } => Reduction::Count, - AggIntent::Min { .. } => Reduction::Min(1), - AggIntent::Max { .. } => Reduction::Max(1), - _ => return Err(invalid("unsupported vector aggregate")), - }; - let aggregate = - Operator::aggregate(project.schema(), vec![2], vec![("value".into(), reduction)])?; - let output = vector_output(aggregate.schema(), 0, 1)?; - unary(vec![project, aggregate, output], vector_schema()) -} - -pub fn compile_sort( - descending: bool, - grouping: &GroupKeys, -) -> Result { - let project = grouped(grouping)?; - let sort = Operator::sort( - project.schema(), - vec![SortKey { - column: 1, - descending, - nulls_first: false, - }], - vec![2], - )?; - let output = vector_output(sort.schema(), 0, 1)?; - unary(vec![project, sort, output], vector_schema()) -} - -pub fn compile_limit( - n: u64, - offset: u64, - grouping: &GroupKeys, -) -> Result { - let project = grouped(grouping)?; - let limit = Operator::limit(project.schema(), n, offset, vec![2])?; - let output = vector_output(limit.schema(), 0, 1)?; - unary(vec![project, limit, output], vector_schema()) -} - -pub fn compile_negate(scalar: bool) -> Result { - let input = if scalar { - scalar_schema() - } else { - vector_schema() - }; - let mut columns = Vec::new(); - if !scalar { - columns.push(("labels".into(), Expression::Column(0))); - } - columns.push(( - if scalar { - "$promql_scalar".into() - } else { - "value".into() - }, - Expression::Negate(Box::new(Expression::Column(if scalar { 0 } else { 1 }))), - )); - unary(vec![Operator::project(input.clone(), columns)?], input) -} - -pub fn compile_vector_to_scalar() -> Result { - unary( - vec![Operator::vector_to_scalar(vector_schema(), 1)?.with_output_schema(scalar_schema())?], - vector_schema(), - ) -} - -/// A stored exact-state input retains the complete population identity. The -/// deployment supplies eligible panes; merging and finalization are computation. -pub fn exact_state_schema(family: FieldDataType) -> Result { - if !matches!(family, FieldDataType::ExactAggregate(..)) { - return Err(invalid("exact-state input requires an exact family")); - } - crate::values::validate_family(&family)?; - let mut schema = (*vector_schema()).clone(); - schema.fields[1].dtype = family; - Ok(Arc::new(schema)) -} - -/// Retain exact readout semantics before any deployment state is opened. -pub fn compile_exact_readout( - family: FieldDataType, - lookback_ms: u64, - preserve_metric_name: bool, -) -> Result { - use planner_types::post_asap::ExactKind; - let statistic = match &family { - FieldDataType::ExactAggregate(kind, _) => match kind { - ExactKind::Sum => crate::Statistic::Sum, - ExactKind::Count => crate::Statistic::Count, - ExactKind::Min => crate::Statistic::Min, - ExactKind::Max => crate::Statistic::Max, - ExactKind::Rate => crate::Statistic::Rate, - ExactKind::Increase => crate::Statistic::Increase, - ExactKind::IRate => return Err(invalid("instant-rate state readout is not supported")), - }, - _ => return Err(invalid("exact readout requires an exact family")), - }; - let input = exact_state_schema(family)?; - let merge = Operator::summary_merge(input.clone(), 1, vec![0])?; - let mut readout = Operator::readout( - merge.schema(), - 1, - ReadoutQuery::Exact(ExactReadout { - statistic, - lookback_ms: None, - }), - )?; - if matches!( - statistic, - crate::Statistic::Rate | crate::Statistic::Increase - ) { - readout = readout.with_counter_lookback( - i64::try_from(lookback_ms).map_err(|_| invalid("counter lookback exceeds Int64"))?, - )?; - } - let project = Operator::project( - readout.schema(), - vec![ - ( - "labels".into(), - if preserve_metric_name { - Expression::Column(0) - } else { - Expression::LabelSet { - column: 0, - labels: vec![], - without: true, - } - }, - ), - ("value".into(), Expression::ExactFloat64(1)), - ], - )?; - unary(vec![merge, readout, project], input) -} diff --git a/crates/asap-physical-operators/src/physical_planner/row_values.rs b/crates/asap-physical-operators/src/physical_planner/row_values.rs deleted file mode 100644 index 3396df685..000000000 --- a/crates/asap-physical-operators/src/physical_planner/row_values.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Query-time PromQL value computation over logical row schemas. -use super::*; -use planner_types::post_asap::maintained_population::PopulationStatistic; -use planner_types::pre_asap::{DataType, ScalarValue}; - -/// A PromQL number literal has no row schema; its consumer folds it in. -pub(super) fn scalar_literal(expression: &QueryExpr) -> Option { - match expression { - QueryExpr::PromqlScalarBridge(child) => scalar_literal(child), - QueryExpr::Literal(ScalarValue::Float64(value)) => Some(*value), - _ => None, - } -} - -/// Aggregate readouts of a maintained current-series population, as a chain. -pub(super) fn population_aggregate( - input: &SchemaRef, - grouping: &[String], - readout: &PopulationStatistic, -) -> Result, Error> { - let groups = grouping - .iter() - .map(|name| named_column(input, &ColumnRef::Named(name.clone()))) - .collect::, _>>()?; - let value = named_column(input, &ColumnRef::SampleValue)?; - let reduction = match readout { - PopulationStatistic::Sum => Reduction::Sum(value), - PopulationStatistic::Count => Reduction::Count, - PopulationStatistic::Average => Reduction::Avg(value), - PopulationStatistic::Quantile { q } => Reduction::Quantile { - column: value, - q: *q, - }, - PopulationStatistic::TopK { .. } => { - return Err(invalid( - "TopK population readout ranks; it does not aggregate", - )) - } - }; - if !groups.is_empty() { - return Ok(vec![Operator::aggregate( - input.clone(), - groups, - vec![("value".into(), reduction)], - )?]); - } - // A global aggregate over no members is an empty PromQL vector, not one row. - let aggregate = Operator::aggregate( - input.clone(), - vec![], - vec![ - ("value".into(), reduction), - ("members".into(), Reduction::Count), - ], - )?; - let zero = Expression::Literal { - value: crate::values::Value::Int64(0), - dtype: DataType::Int64, - }; - let filter = Operator::filter( - aggregate.schema(), - Expression::Less(Box::new(zero), Box::new(Expression::Column(1))), - )?; - let project = Operator::project( - filter.schema(), - vec![("value".into(), Expression::Column(0))], - )?; - Ok(vec![aggregate, filter, project]) -} diff --git a/crates/asap-physical-operators/src/runtime/batch_execution.rs b/crates/asap-physical-operators/src/runtime/batch_execution.rs index ca59877cb..733741022 100644 --- a/crates/asap-physical-operators/src/runtime/batch_execution.rs +++ b/crates/asap-physical-operators/src/runtime/batch_execution.rs @@ -88,7 +88,7 @@ mod tests { values::Value, }; use planner_types::{ - post_asap::{Field, FieldDataType, Schema}, + post_asap::{Field as SummaryField, FieldDataType as SummaryFamilyType, Schema}, pre_asap::DataType, }; use std::sync::Arc; @@ -97,15 +97,15 @@ mod tests { #[test] fn same_native_chain_inside_query_and_ingestion_execution() { let schema = Arc::new(Schema { - closed: true, - unique_keys: vec![], - fields: vec![Field { - table: None, + fields: vec![SummaryField { name: "value".into(), - dtype: FieldDataType::Plain(DataType::Float64), + dtype: SummaryFamilyType::Plain(DataType::Float64), nullable: false, + table: None, }], time_index: None, + unique_keys: vec![], + closed: false, }); for scope in [ Scope::Query { @@ -143,10 +143,10 @@ mod tests { #[test] fn in_memory_source_drives_cooperative_yields() { let schema = Arc::new(Schema { - closed: true, - unique_keys: vec![], fields: vec![], time_index: None, + unique_keys: vec![], + closed: false, }); let batch = Batch::try_new(schema.clone(), vec![vec![]]).unwrap(); let source = Operator::source(schema, vec![batch; 65]).unwrap(); @@ -165,10 +165,10 @@ mod tests { #[test] fn returned_batches_keep_their_resource_reservation() { let schema = Arc::new(Schema { - closed: true, - unique_keys: vec![], fields: vec![], time_index: None, + unique_keys: vec![], + closed: false, }); let batch = Batch::try_new(schema.clone(), vec![vec![]]).unwrap(); let bytes = batch.bytes(); @@ -196,10 +196,10 @@ mod tests { #[test] fn cancellation_is_not_bypassed_by_in_memory_execution() { let schema = Arc::new(Schema { - closed: true, - unique_keys: vec![], fields: vec![], time_index: None, + unique_keys: vec![], + closed: false, }); let batch = Batch::try_new(schema, vec![vec![]]).unwrap(); let context = RunContext::new( diff --git a/crates/asap-physical-operators/src/sources/memory.rs b/crates/asap-physical-operators/src/sources/memory.rs deleted file mode 100644 index 1055888de..000000000 --- a/crates/asap-physical-operators/src/sources/memory.rs +++ /dev/null @@ -1,44 +0,0 @@ -use super::*; -/// Immutable in-memory raw data. The connector owns the resident input; each -/// cursor clones only the next requested batch, not the entire data set. -pub struct MemorySource { - schema: SchemaRef, - batches: Vec, -} -impl MemorySource { - pub fn new(schema: SchemaRef, batches: Vec) -> Result { - crate::values::validate_schema(&schema)?; - if schema - .fields - .iter() - .any(|f| !matches!(f.dtype, FieldDataType::Plain(_))) - { - return Err(Error::Invalid( - "raw source cannot contain summary states".into(), - )); - } - if batches.iter().any(|batch| batch.schema() != &schema) { - return Err(Error::Invalid("memory source batch schema mismatch".into())); - } - Ok(Self { schema, batches }) - } -} -impl RawSource for MemorySource { - fn boundedness(&self) -> crate::plan::Boundedness { - crate::plan::Boundedness::Bounded - } - fn schema(&self) -> SchemaRef { - self.schema.clone() - } - fn scan(&self, context: RunContext) -> Result, Error> { - Ok(stream::iter(self.batches.iter()) - .map(move |batch| { - if context.is_cancelled() { - return Err(Error::Cancelled); - } - let _allocation = context.reserve(batch.bytes())?; - Ok(batch.clone()) - }) - .boxed_local()) - } -} diff --git a/crates/asap-physical-operators/src/sources/mod.rs b/crates/asap-physical-operators/src/sources/mod.rs deleted file mode 100644 index 4b401d666..000000000 --- a/crates/asap-physical-operators/src/sources/mod.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Raw data access. Connectors provide rows; Scan owns Planner predicate semantics. -use crate::{ - expressions::CompiledExpression, - plan::PhysicalOperator, - runtime::{Input, OutputStream, RunContext}, - values::{Batch, SchemaRef, Value}, - Error, -}; -use futures::{stream, StreamExt}; -use planner_types::{ - post_asap::{FieldDataType, Schema}, - pre_asap::{DataType, QueryExpr, Source}, -}; -use std::sync::Arc; - -/// A bound data source. Metadata must be stable for the lifetime of the binding. -/// Each scan opens an independent cursor. Connectors return raw, unfiltered rows -/// and must honor cancellation and bound their own I/O buffers. Dropping a cursor -/// must release its resources. A connector error is never an empty successful scan. -pub trait RawSource { - fn schema(&self) -> SchemaRef; - /// Declare a finite snapshot/window explicitly; execution scope alone does not bound a cursor. - fn boundedness(&self) -> crate::plan::Boundedness { - crate::plan::Boundedness::Unknown - } - fn scan(&self, context: RunContext) -> Result, Error>; -} - -/// Explicit source identities; no implicit network discovery or fallback. -#[derive(Default)] -pub struct DataSources { - sources: Vec<(Source, Arc)>, -} -impl DataSources { - pub fn register(&mut self, identity: Source, source: Arc) -> Result<(), Error> { - if self.sources.iter().any(|(key, _)| key == &identity) { - return Err(Error::Invalid("duplicate data source".into())); - } - crate::values::validate_schema(&source.schema())?; - self.sources.push((identity, source)); - Ok(()) - } - pub fn bind(&self, expression: &QueryExpr) -> Result { - let QueryExpr::Scan { - source, - predicates, - schema, - } = expression - else { - return Err(Error::Invalid( - "raw Scan requires a Planner Scan leaf".into(), - )); - }; - let output = Arc::new(Schema::lifted(schema.fields.clone(), schema.time_index)); - crate::values::validate_schema(&output)?; - let reader = self - .sources - .iter() - .find(|(key, _)| key == source) - .map(|(_, reader)| reader.clone()) - .ok_or_else(|| Error::Invalid(format!("unbound raw source: {source:?}")))?; - if reader.schema() != output { - return Err(Error::Invalid( - "raw source differs from Planner Scan schema".into(), - )); - } - let predicates = predicates - .iter() - .map(|predicate| { - let predicate = CompiledExpression::compile(&predicate.0, &output)?; - if predicate.dtype().0 != DataType::Bool { - return Err(Error::Invalid("Scan predicate must be boolean".into())); - } - Ok(predicate) - }) - .collect::, Error>>()?; - Ok(Scan { - reader, - output, - predicates, - }) - } -} - -pub struct Scan { - reader: Arc, - output: SchemaRef, - predicates: Vec, -} -impl PhysicalOperator for Scan { - fn properties(&self, _: &[crate::plan::PlanProperties]) -> crate::plan::PlanProperties { - crate::plan::PlanProperties { - boundedness: self.reader.boundedness(), - emission: crate::plan::Emission::Incremental, - } - } - - fn name(&self) -> &str { - "Scan" - } - fn input_schemas(&self) -> Vec { - vec![] - } - fn output_schema(&self) -> SchemaRef { - self.output.clone() - } - fn output_bytes(&self, batch: &Batch) -> usize { - batch.bytes() - } - fn start<'a>( - &'a self, - inputs: Vec>, - context: RunContext, - ) -> Result, Error> { - if !inputs.is_empty() { - return Err(Error::Invalid("Scan cannot have inputs".into())); - } - if context.is_cancelled() { - return Err(Error::Cancelled); - } - // Opening is lazy: validation and construction of a run perform no I/O. - let opening = context.clone(); - let stream = stream::once(async move { - if opening.is_cancelled() { - return Err(Error::Cancelled); - } - self.reader.scan(opening) - }); - use futures::TryStreamExt; - Ok(stream - .try_flatten() - .map(move |batch| { - if context.is_cancelled() { - return Err(Error::Cancelled); - } - let batch = batch?; - if batch.schema() != &self.output { - return Err(Error::Invalid( - "connector returned a different Scan schema".into(), - )); - } - if self.predicates.is_empty() { - return Ok(batch); - } - let _workspace = - context.reserve(batch.bytes().checked_mul(2).ok_or(Error::MemoryLimit)?)?; - let mut rows = Vec::new(); - for row in batch.rows() { - if context.is_cancelled() { - return Err(Error::Cancelled); - } - let mut keep = true; - for predicate in &self.predicates { - match predicate.evaluate(row)? { - Value::Bool(true) => {} - Value::Bool(false) | Value::Null => { - keep = false; - break; - } - _ => { - return Err(Error::Invalid("Scan predicate is not boolean".into())) - } - } - } - if keep { - rows.push(row.clone()); - } - } - Batch::try_new(self.output.clone(), rows) - }) - .boxed_local()) - } -} - -mod memory; -pub use memory::MemorySource; diff --git a/crates/asap-physical-operators/src/summary_kernels/exact.rs b/crates/asap-physical-operators/src/summary_kernels/exact.rs index d4754686e..d5375f9bf 100644 --- a/crates/asap-physical-operators/src/summary_kernels/exact.rs +++ b/crates/asap-physical-operators/src/summary_kernels/exact.rs @@ -2,7 +2,7 @@ use super::increase::IncreaseAccumulator; use crate::Statistic; use crate::{AggregateCore, KeyByLabelValues, Measurement}; -use planner_types::post_asap::{ExactKind, ExactParams, FieldDataType}; +use planner_types::post_asap::{ExactKind, ExactParams, FieldDataType as SummaryFamilyType}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -10,7 +10,11 @@ type Error = Box; #[derive(Debug, Clone, Serialize, Deserialize)] enum ScalarState { - Sum { sum: f64, compensation: f64 }, + Sum { + sum: f64, + compensation: f64, + seen: bool, + }, Count(u64), Min(Option), Max(Option), @@ -18,7 +22,7 @@ enum ScalarState { } /// Both the family and population layout survive persistence. Sharing counter -/// arithmetic never authorizes a Rate state to answer an Increase readout. +/// arithmetic never authorizes a Rate state to answer an Increase evaluation. /// /// Deserialization validates the payload against its declared family, so /// deployments can persist this state with any serde format without mirroring @@ -26,14 +30,14 @@ enum ScalarState { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(try_from = "ExactPayload")] pub struct ExactAccumulator { - family: FieldDataType, + family: SummaryFamilyType, scalar: ScalarState, keyed: Option>, } #[derive(Deserialize)] struct ExactPayload { - family: FieldDataType, + family: SummaryFamilyType, scalar: ScalarState, keyed: Option>, } @@ -63,10 +67,10 @@ impl TryFrom for ExactAccumulator { } } -/// Planned readout of an exact summary. `lookback_ms` is the logical PromQL +/// Planned evaluation of an exact summary. `lookback_ms` is the logical PromQL /// counter window; the evaluation range is resolved from it at run time. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct ExactReadout { +pub struct ExactEvaluation { pub statistic: Statistic, #[serde(default, skip_serializing_if = "Option::is_none")] pub lookback_ms: Option, @@ -75,22 +79,24 @@ pub struct ExactReadout { impl ExactAccumulator { /// Read one population. An empty MIN/MAX population reads as `None`. /// `range_ms` extrapolates a counter Rate/Increase to that evaluation range. - pub fn readout( + pub fn evaluation( &self, statistic: Statistic, range_ms: Option<(i64, i64)>, key: Option<&KeyByLabelValues>, ) -> Result, Error> { if statistic != self.statistic() { - return Err("readout differs from Planner exact family".into()); + return Err("evaluation differs from Planner exact family".into()); } let state = match (&self.keyed, key) { (Some(states), Some(key)) => states.get(key).ok_or("unknown exact population")?, (None, None) => &self.scalar, - _ => return Err("readout population differs from installed layout".into()), + _ => return Err("evaluation population differs from installed layout".into()), }; match state { - ScalarState::Sum { sum, compensation } => Ok(Some(sum + compensation)), + ScalarState::Sum { + sum, compensation, .. + } => Ok(Some(sum + compensation)), ScalarState::Count(count) => Ok(Some(*count as f64)), ScalarState::Min(value) | ScalarState::Max(value) => Ok(*value), ScalarState::Counter(Some(counter)) => counter @@ -100,6 +106,11 @@ impl ExactAccumulator { } } + /// SQL SUM distinguishes an empty/all-NULL input from an observed zero. + pub(crate) fn is_empty_sum(&self) -> bool { + self.keyed.is_none() && matches!(self.scalar, ScalarState::Sum { seen: false, .. }) + } + /// Exact integer count of an unkeyed Count state. pub fn count(&self) -> Option { match (&self.keyed, &self.scalar) { @@ -128,19 +139,22 @@ impl ExactAccumulator { Ok(()) } - pub fn new(family: FieldDataType, keyed: bool) -> Result { + pub fn new(family: SummaryFamilyType, keyed: bool) -> Result { use ExactKind as K; use ExactParams as P; let scalar = match &family { - FieldDataType::ExactAggregate(K::Sum, P::Sum) => ScalarState::Sum { + SummaryFamilyType::ExactAggregate(K::Sum, P::Sum) => ScalarState::Sum { sum: 0.0, compensation: 0.0, + seen: false, }, - FieldDataType::ExactAggregate(K::Count, P::Count) => ScalarState::Count(0), - FieldDataType::ExactAggregate(K::Min, P::Min) => ScalarState::Min(None), - FieldDataType::ExactAggregate(K::Max, P::Max) => ScalarState::Max(None), - FieldDataType::ExactAggregate(K::Rate, P::Rate) - | FieldDataType::ExactAggregate(K::Increase, P::Increase) => ScalarState::Counter(None), + SummaryFamilyType::ExactAggregate(K::Count, P::Count) => ScalarState::Count(0), + SummaryFamilyType::ExactAggregate(K::Min, P::Min) => ScalarState::Min(None), + SummaryFamilyType::ExactAggregate(K::Max, P::Max) => ScalarState::Max(None), + SummaryFamilyType::ExactAggregate(K::Rate, P::Rate) + | SummaryFamilyType::ExactAggregate(K::Increase, P::Increase) => { + ScalarState::Counter(None) + } _ => return Err(format!("unsupported exact Planner family: {family:?}")), }; Ok(Self { @@ -150,7 +164,7 @@ impl ExactAccumulator { }) } - pub fn family(&self) -> &FieldDataType { + pub fn family(&self) -> &SummaryFamilyType { &self.family } pub(crate) fn insufficient_counter_samples( @@ -188,7 +202,14 @@ impl ExactAccumulator { _ => panic!("exact update population layout differs from installed DAG"), }; match state { - ScalarState::Sum { sum, compensation } => compensated_add(sum, compensation, value), + ScalarState::Sum { + sum, + compensation, + seen, + } => { + compensated_add(sum, compensation, value); + *seen = true; + } ScalarState::Count(count) => { *count = count.checked_add(1).expect("exact count overflow") } @@ -214,12 +235,12 @@ impl ExactAccumulator { fn statistic(&self) -> Statistic { match self.family { - FieldDataType::ExactAggregate(ExactKind::Sum, _) => Statistic::Sum, - FieldDataType::ExactAggregate(ExactKind::Count, _) => Statistic::Count, - FieldDataType::ExactAggregate(ExactKind::Min, _) => Statistic::Min, - FieldDataType::ExactAggregate(ExactKind::Max, _) => Statistic::Max, - FieldDataType::ExactAggregate(ExactKind::Rate, _) => Statistic::Rate, - FieldDataType::ExactAggregate(ExactKind::Increase, _) => Statistic::Increase, + SummaryFamilyType::ExactAggregate(ExactKind::Sum, _) => Statistic::Sum, + SummaryFamilyType::ExactAggregate(ExactKind::Count, _) => Statistic::Count, + SummaryFamilyType::ExactAggregate(ExactKind::Min, _) => Statistic::Min, + SummaryFamilyType::ExactAggregate(ExactKind::Max, _) => Statistic::Max, + SummaryFamilyType::ExactAggregate(ExactKind::Rate, _) => Statistic::Rate, + SummaryFamilyType::ExactAggregate(ExactKind::Increase, _) => Statistic::Increase, _ => unreachable!("validated exact family"), } } @@ -244,16 +265,22 @@ fn merge_scalar(left: &ScalarState, right: &ScalarState) -> Result { let (mut sum, mut compensation) = (*a, *ac); compensated_add(&mut sum, &mut compensation, *b); compensated_add(&mut sum, &mut compensation, *bc); - ScalarState::Sum { sum, compensation } + ScalarState::Sum { + sum, + compensation, + seen: *a_seen || *b_seen, + } } (ScalarState::Count(a), ScalarState::Count(b)) => { ScalarState::Count(a.checked_add(*b).ok_or("exact count overflow")?) @@ -311,7 +338,7 @@ mod tests { #[derive(Serialize)] struct Payload { - family: FieldDataType, + family: SummaryFamilyType, scalar: ScalarState, keyed: Option>, } @@ -320,8 +347,8 @@ mod tests { rmp_serde::from_slice(&rmp_serde::to_vec_named(payload).unwrap()) } - fn sum() -> FieldDataType { - FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) + fn sum() -> SummaryFamilyType { + SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) } // Stored Sum preserves low-order increments across updates, persistence and pane merge. @@ -337,7 +364,7 @@ mod tests { negative.update(None, -1e16, 1); restored.merge_from(&negative).unwrap(); assert_eq!( - restored.readout(Statistic::Sum, None, None).unwrap(), + restored.evaluation(Statistic::Sum, None, None).unwrap(), Some(1.0) ); } @@ -349,18 +376,18 @@ mod tests { state.update(None, f64::INFINITY, 0); state.update(None, 1.0, 0); assert_eq!( - state.readout(Statistic::Sum, None, None).unwrap(), + state.evaluation(Statistic::Sum, None, None).unwrap(), Some(f64::INFINITY) ); state.update(None, f64::NEG_INFINITY, 0); assert!(state - .readout(Statistic::Sum, None, None) + .evaluation(Statistic::Sum, None, None) .unwrap() .unwrap() .is_nan()); } - // A persisted exact state decodes back to the same family, layout and readout. + // A persisted exact state decodes back to the same family, layout and evaluation. #[test] fn serialized_state_round_trips() { let mut state = ExactAccumulator::new(sum(), true).unwrap(); @@ -370,7 +397,9 @@ mod tests { let restored: ExactAccumulator = rmp_serde::from_slice(&bytes).unwrap(); assert_eq!(restored.family(), &sum()); assert_eq!( - restored.readout(Statistic::Sum, None, Some(&key)).unwrap(), + restored + .evaluation(Statistic::Sum, None, Some(&key)) + .unwrap(), Some(2.5) ); } @@ -390,6 +419,7 @@ mod tests { scalar: ScalarState::Sum { sum: 0.0, compensation: 0.0, + seen: false, }, keyed: Some(HashMap::from([(key, ScalarState::Max(Some(1.0)))])), }; @@ -400,10 +430,11 @@ mod tests { #[test] fn decode_rejects_unsupported_family() { let payload = Payload { - family: FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Count), + family: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Count), scalar: ScalarState::Sum { sum: 0.0, compensation: 0.0, + seen: false, }, keyed: None, }; diff --git a/crates/asap-physical-operators/src/summary_kernels/factory.rs b/crates/asap-physical-operators/src/summary_kernels/factory.rs index 4be126d6e..be819f8a7 100644 --- a/crates/asap-physical-operators/src/summary_kernels/factory.rs +++ b/crates/asap-physical-operators/src/summary_kernels/factory.rs @@ -6,7 +6,7 @@ use crate::summary_kernels::{ HydraKllSketchAccumulator, }; use crate::{AggregateCore, KeyByLabelValues}; -use planner_types::post_asap::{FieldDataType, SketchAlgorithm, SketchParams}; +use planner_types::post_asap::{FieldDataType as SummaryFamilyType, SketchAlgorithm, SketchParams}; /// Generate the clone-based `AccumulatorUpdater` methods for updaters whose /// inner `acc` field implements `Clone + AggregateCore`. @@ -527,7 +527,7 @@ fn cms_heap_dims(params: &SketchParams) -> (usize, usize, usize) { /// Construct the kernel declared by a Planner SummaryAgg. No deployment config /// tags participate in this dispatch and unsupported payloads are errors. pub fn create_planner_accumulator( - family: &FieldDataType, + family: &SummaryFamilyType, input: &planner_types::post_asap::SummaryUpdate, grouping: &planner_types::post_asap::GroupingStrategy, ) -> Result, String> { @@ -548,7 +548,7 @@ pub fn create_planner_accumulator( if grouping != &GroupingStrategy::PerSubpopulationInstance { return Err("shared summary grouping requires a supported Planner Hydra kernel".into()); } - if matches!(family, FieldDataType::ExactAggregate(..)) { + if matches!(family, SummaryFamilyType::ExactAggregate(..)) { return Ok(Box::new(PlannerExactUpdater { acc: crate::summary_kernels::exact::ExactAccumulator::new( family.clone(), @@ -556,7 +556,7 @@ pub fn create_planner_accumulator( )?, })); } - let FieldDataType::Sketch(kind, family_grouping) = family else { + let SummaryFamilyType::Sketch(kind, family_grouping) = family else { return Err(format!("unsupported Planner summary family {family:?}")); }; if family_grouping != grouping { @@ -748,7 +748,7 @@ mod planner_parameter_regression { }, ), ] { - let family = FieldDataType::Sketch( + let family = SummaryFamilyType::Sketch( SketchKind::new(algorithm.clone(), params), Default::default(), ); diff --git a/crates/asap-physical-operators/src/summary_kernels/traits.rs b/crates/asap-physical-operators/src/summary_kernels/traits.rs index 9c5d028fb..46e2c7f86 100644 --- a/crates/asap-physical-operators/src/summary_kernels/traits.rs +++ b/crates/asap-physical-operators/src/summary_kernels/traits.rs @@ -5,7 +5,7 @@ pub type KernelError = Box; /// In-memory state of one population's summary. /// /// Kernels adapt `asap_sketchlib` structures (or exact Planner state) to the -/// operations physical operators need: merge, typed readout and memory +/// operations physical operators need: merge, typed evaluation and memory /// accounting. Grouping belongs to operators; byte encodings belong to /// `asap_sketchlib` and deployments. pub trait AggregateCore: Send + Sync { @@ -20,8 +20,8 @@ pub trait AggregateCore: Send + Sync { /// Merge with a state of the same family and shape, leaving both inputs unchanged. fn merge_with(&self, other: &dyn AggregateCore) -> Result, KernelError>; - /// Answer a sketch readout. Exact states are read through - /// [`ExactAccumulator::readout`](super::exact::ExactAccumulator::readout). + /// Answer a sketch evaluation. Exact states are read through + /// [`ExactAccumulator::evaluation`](super::exact::ExactAccumulator::evaluation). fn estimate(&self, query: &SketchStatistic) -> Result { Err(format!("{query:?} is not supported by this summary").into()) } diff --git a/crates/asap-physical-operators/src/summary_kernels/univmon.rs b/crates/asap-physical-operators/src/summary_kernels/univmon.rs index 9340b1c0e..23114ab1b 100644 --- a/crates/asap-physical-operators/src/summary_kernels/univmon.rs +++ b/crates/asap-physical-operators/src/summary_kernels/univmon.rs @@ -1,4 +1,4 @@ -//! One frequency state shared by count, distinct, L2 and entropy readouts. +//! One frequency state shared by count, distinct, L2 and entropy evaluations. use crate::AggregateCore; use asap_sketchlib::{DataInput, UnivMon}; @@ -169,10 +169,10 @@ mod tests { } } - // Count, distinct, L2 and entropy readouts count each non-NaN sample once; + // Count, distinct, L2 and entropy evaluations count each non-NaN sample once; // signed zero is one identity. #[test] - fn frequency_readouts() { + fn frequency_evaluations() { let mut state = UnivMonAccumulator::new(32, 5, 1024, 4).unwrap(); for value in [0.0, -0.0, 2.0, 2.0, f64::NAN] { state.insert_sample(value).unwrap(); @@ -187,9 +187,9 @@ mod tests { .is_err()); } - // A sketch taken out and adopted back answers the same readouts. + // A sketch taken out and adopted back answers the same evaluations. #[test] - fn adopted_sketch_keeps_readouts() { + fn adopted_sketch_keeps_evaluations() { let mut state = UnivMonAccumulator::new(32, 5, 1024, 4).unwrap(); for value in [1.0, 2.0, 2.0] { state.insert_sample(value).unwrap(); diff --git a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs index d617a835a..f90b1416a 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs @@ -1,4 +1,4 @@ -//! Compile maintenance-selected frontiers without deployment-specific dag rewrites. +//! Compile maintenance-selected frontiers without deployment-specific DAG rewrites. use super::*; /// One computation realization; lifecycle/window/revision requirements accompany @@ -327,3 +327,170 @@ impl CompiledPhysicalPlan { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use planner_types::workload::*; + + fn grouped_rate() -> (PhysicalASAPDAG, BTreeMap, NodeId) { + let workload = PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(vec![BatchEntry { + query: Query("sum by(job)(rate(m[1m]))".into()), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit( + planner_types::types::AccuracyTarget::Exact, + ), + ..Default::default() + }, + predictability: Predictability::Unknown, + invocations: 1, + execute_at: None, + time_selection: TimeSelection::default(), + }]), + repeating_queries: None, + }, + data_workload: Some(DataWorkload { + data_ingestion_interval: Evidence { + value: Some(DurationMs(1000)), + ..Default::default() + }, + ..Default::default() + }), + }; + let root = asap_frontend_promql::unified::lower_promql_workload(&workload, 0) + .unwrap() + .remove(0); + let root = promql_rows::with_series_identity(&root).unwrap(); + let space = asap_aware_mapping::search_workload(vec![("q", root)]); + let selected = space + .global_selection(&asap_aware_mapping::cost_model::DefaultCostModel) + .assemble_selected_dag(&space.roots[0].1) + .unwrap() + .unwrap(); + let selected = planner_types::ir::apply_lifecycle_timings( + &selected, + &Default::default(), + &mut Default::default(), + ) + .unwrap(); + let dag = planner_types::ir::physical_export::compile_physical_asap_dag(&selected).unwrap(); + let state = dag + .nodes + .iter() + .find(|node| matches!(node.payload, Payload::ASAP(ASAPOp::SummaryAgg { .. }))) + .unwrap(); + let inputs = BTreeMap::from([( + state.id as u64, + InputContract::bounded(Arc::new(state.output_schema.clone())), + )]); + (dag.clone(), inputs, dag.roots[0] as u64) + } + + /// Enumerating and cutting every frontier lowers each Planner node once. + #[test] + fn candidates_for_all_frontiers_share_one_lowering() { + let (dag, inputs, root) = grouped_rate(); + let lowered = || crate::unified_physical_planner::LOWERED_NODES.with(|count| count.get()); + let before = lowered(); + let compiled = compile(&dag, inputs, &[root]).unwrap(); + let once = lowered() - before; + let frontiers = enumerate_compiled_frontiers(&compiled, 4096).unwrap(); + assert!(frontiers.len() >= 3, "{frontiers:?}"); + for frontier in &frontiers { + cut_candidate(&compiled, frontier).unwrap(); + } + assert!(once > 0); + assert_eq!(lowered() - before, once); + } + + fn with_timing( + dag: &PhysicalASAPDAG, + timing: impl Fn(&PhysicalASAPDAGNode) -> planner_types::post_asap::ExecutionTiming, + ) -> PhysicalASAPDAG { + let mut timed = dag.clone(); + for node in &mut timed.nodes { + node.output_state.timing = timing(node); + } + for edge in &mut timed.edges { + let producer = timed.nodes.iter().find(|node| node.id == edge.producer); + edge.data_state = producer.unwrap().output_state; + } + timed + } + + fn raw_input(dag: &PhysicalASAPDAG) -> BTreeMap { + let raw = dag + .nodes + .iter() + .find(|node| { + matches!( + node.payload, + Payload::NonASAP(planner_types::ir::NonASAPOp::TimeRange { .. }) + ) + }) + .unwrap(); + BTreeMap::from([( + raw.id as u64, + InputContract::bounded(Arc::new(raw.output_schema.clone())), + )]) + } + + /// Cutting one compilation by a retained-state timing and by the all + /// query-time timing (what ContinuouslyMaintained and Ephemeral assign) + /// lowers each Planner node once and matches `compile_candidate`. + #[test] + fn timing_cuts_share_one_lowering() { + use planner_types::post_asap::ExecutionTiming::QueryTime; + let (retained, _, root) = grouped_rate(); + let ephemeral = with_timing(&retained, |_| QueryTime); + let inputs = raw_input(&retained); + let lowered = || crate::unified_physical_planner::LOWERED_NODES.with(|count| count.get()); + let before = lowered(); + let compiled = compile(&ephemeral, inputs.clone(), &[root]).unwrap(); + let once = lowered() - before; + let cuts = [&retained, &ephemeral].map(|timed| { + let frontier = frontier_from_timing(timed).unwrap(); + let cut = cut_candidate(&compiled, &frontier).unwrap(); + (timed, frontier, cut) + }); + assert!(once > 0); + assert_eq!(lowered() - before, once); + assert_eq!(cuts[0].1.len(), 1); + assert!(cuts[1].1.is_empty()); + for (timed, frontier, cut) in cuts { + let expected = compile_candidate(timed, inputs.clone(), &[root], &frontier).unwrap(); + assert_eq!( + serde_json::to_vec(&cut).unwrap(), + serde_json::to_vec(&expected).unwrap() + ); + } + } + + /// The frontier is the ingestion-time nodes read at query time; an + /// ingestion-time root is itself the frontier. + #[test] + fn frontier_from_timing_includes_ingestion_root() { + use planner_types::post_asap::ExecutionTiming::IngestionTime; + let (dag, _, root) = grouped_rate(); + let timed = with_timing(&dag, |_| IngestionTime); + assert_eq!(frontier_from_timing(&timed).unwrap(), [root]); + } + + /// A query-time node feeding an ingestion-time node is rejected. + #[test] + fn frontier_from_timing_rejects_query_time_input_to_ingestion() { + use planner_types::post_asap::ExecutionTiming::{IngestionTime, QueryTime}; + let (dag, _, _) = grouped_rate(); + let timed = with_timing(&dag, |node| { + if node.id == dag.roots[0] { + IngestionTime + } else { + QueryTime + } + }); + assert!(frontier_from_timing(&timed).is_err()); + } +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/compiled.rs b/crates/asap-physical-operators/src/unified_physical_planner/compiled.rs index 2b9244806..f5f092354 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/compiled.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/compiled.rs @@ -35,7 +35,7 @@ enum Node { /// Selected native operators and input slots. Rebinding never repeats lowering. /// Serde is format-agnostic; deployments choose the encoding and its versioning. -/// Deserialization validates the dag before it is usable. +/// Deserialization validates the DAG before it is usable. #[derive(Clone, serde::Serialize, serde::Deserialize)] #[serde(try_from = "UncheckedDAG")] pub struct CompiledPhysicalDAG { @@ -63,7 +63,7 @@ impl TryFrom for CompiledPhysicalDAG { impl CompiledPhysicalDAG { /// Link already-selected physical fragments without lowering operators again. /// Fragment keys and source keys share a namespace; repeated dependency IDs - /// therefore remain one producer in the composed dag. + /// therefore remain one producer in the composed DAG. pub fn compose( sources: BTreeMap, fragments: BTreeMap, Self)>, diff --git a/crates/asap-physical-operators/src/unified_physical_planner/mod.rs b/crates/asap-physical-operators/src/unified_physical_planner/mod.rs index 04d2dbcc9..4763eb24a 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/mod.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/mod.rs @@ -2,8 +2,8 @@ //! Compilation needs no readers; deployment resolves inputs after selection. //! A node without a native lowering fails compilation; the deployment runtime //! must execute it. -use crate::operators::ReadoutQuery; -use crate::summary_kernels::exact::ExactReadout; +use crate::operators::SummaryEvaluation; +use crate::summary_kernels::exact::ExactEvaluation; use crate::{ operators::{Expression, Operator, Reduction, SortKey}, plan::{Boundedness, Emission, NodeId, PhysicalDAG, PhysicalOperator, PlanProperties}, @@ -116,7 +116,7 @@ thread_local! { } /// Helper operators are numbered from their Planner node alone, above the u32 -/// Planner ID range, so every boundary choice yields a subgraph of the same +/// Planner ID range, so every boundary choice yields a sub-DAG of the same /// lowering and candidate cuts need not renumber operators. A node lowering to /// several helpers takes consecutive indices below its base. fn helper_id(node: NodeId, index: u64) -> Result { @@ -187,7 +187,7 @@ fn compile_internal( } } } - let mut dag = CompiledPhysicalDAG::new(roots.to_vec()); + let mut physical_dag = CompiledPhysicalDAG::new(roots.to_vec()); for id in ordered { let node = nodes[&id]; let auxiliary = helper_id(id, 0)?; @@ -197,7 +197,7 @@ fn compile_internal( if source.schema != output { return Err(invalid("frontier does not have the declared schema")); } - dag.add_input(id, source)?; + physical_dag.add_input(id, source)?; } else { #[cfg(test)] LOWERED_NODES.with(|count| count.set(count.get() + 1)); @@ -212,7 +212,7 @@ fn compile_internal( if schemas.iter().any(|s| s != &schemas[0]) { return Err(invalid("summary merge inputs have different schemas")); } - dag.add( + physical_dag.add( auxiliary, inputs, Operator::union(schemas[0].clone(), schemas.len())?, @@ -245,7 +245,7 @@ fn compile_internal( let value = named_column(input, &ColumnRef::SampleValue)?; let lookback = i64::try_from(spec.lookback_ms) .map_err(|_| invalid("current-series lookback overflows"))?; - dag.add( + physical_dag.add( id, inputs, Operator::current_series(input.clone(), identity, coordinate, value, lookback)? @@ -283,10 +283,10 @@ fn compile_internal( let mut inputs = inputs; for (index, operator) in chain.into_iter().enumerate() { let helper = helper_id(id, index as u64)?; - dag.add(helper, inputs, operator)?; + physical_dag.add(helper, inputs, operator)?; inputs = vec![helper]; } - dag.add(id, inputs, last.with_output_schema(output)?)?; + physical_dag.add(id, inputs, last.with_output_schema(output)?)?; continue; }; let groups = spec @@ -295,7 +295,7 @@ fn compile_internal( .map(|name| named_column(&input, &ColumnRef::Named(name.clone()))) .collect::, _>>()?; let value = named_column(&input, &ColumnRef::SampleValue)?; - dag.add( + physical_dag.add( auxiliary, inputs, Operator::sort( @@ -308,7 +308,7 @@ fn compile_internal( groups.clone(), )?, )?; - dag.add( + physical_dag.add( id, vec![auxiliary], Operator::limit(input, *k as u64, 0, groups)?.with_output_schema(output)?, @@ -365,8 +365,8 @@ fn compile_internal( groups, )?; let compact = build.schema(); - dag.add(auxiliary, inputs, build)?; - dag.add( + physical_dag.add(auxiliary, inputs, build)?; + physical_dag.add( id, vec![auxiliary], Operator::scope_timestamp(compact, output)?, @@ -402,7 +402,7 @@ fn compile_internal( [false, false], ) .map_err(|error| invalid(format!("node {id}: {error}")))?; - dag.add(id, inputs, binary.with_output_schema(output)?)?; + physical_dag.add(id, inputs, binary.with_output_schema(output)?)?; continue; } } @@ -434,23 +434,23 @@ fn compile_internal( .collect(); let project = Operator::project(actual, columns)?.with_output_schema(output.clone())?; - dag.add(auxiliary, inputs, evaluation)?; + physical_dag.add(auxiliary, inputs, evaluation)?; if temporal_evaluation_drops_name(node) { - dag.add(auxiliary - 1, vec![auxiliary], project)?; - dag.add( + physical_dag.add(auxiliary - 1, vec![auxiliary], project)?; + physical_dag.add( id, vec![auxiliary - 1], Operator::series_without_name(output)?, )?; } else { - dag.add(id, vec![auxiliary], project)?; + physical_dag.add(id, vec![auxiliary], project)?; } continue; } } let mut operator = compile_node(node, &schemas) .map_err(|error| invalid(format!("node {id}: {error}")))?; - if operator.is_counter_readout() { + if operator.is_counter_evaluation() { let mut pending = vec![id]; let mut visited = BTreeSet::new(); let mut ranges = BTreeSet::new(); @@ -477,15 +477,15 @@ fn compile_internal( } } if temporal_evaluation_drops_name(node) { - dag.add(auxiliary, inputs, operator)?; - dag.add(id, vec![auxiliary], Operator::series_without_name(output)?)?; + physical_dag.add(auxiliary, inputs, operator)?; + physical_dag.add(id, vec![auxiliary], Operator::series_without_name(output)?)?; } else { - dag.add(id, inputs, operator)?; + physical_dag.add(id, inputs, operator)?; } } } - dag.validate()?; - Ok(dag) + physical_dag.validate()?; + Ok(physical_dag) } // Temporal summary evaluations produce PromQL vectors, whose range functions drop @@ -587,7 +587,7 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result Result return Err(invalid("exact finalization requires exact state")), }; - Operator::readout( + Operator::evaluation( input.clone(), state, - ReadoutQuery::Exact(ExactReadout { + SummaryEvaluation::Exact(ExactEvaluation { statistic, lookback_ms: None, }), @@ -837,17 +837,17 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result { if let SketchStatistic::TopK { k } = query { - return Operator::keyed_readout( + return Operator::keyed_evaluation( input.clone(), summary_column(input)?, *k, Arc::new(node.output_schema.clone()), ); } - Operator::readout( + Operator::evaluation( input.clone(), summary_column(input)?, - ReadoutQuery::Sketch(query.clone()), + SummaryEvaluation::Sketch(query.clone()), ) } _ => Err(invalid( @@ -870,9 +870,8 @@ fn summary_column(input: &SchemaRef) -> Result { } fn named_column(input: &SchemaRef, column: &ColumnRef) -> Result { let name = match column { - // Executable SchemaRef retains column names, not table qualifiers. - // Frontend binding has resolved the qualifier; still reject ambiguous - // names here rather than guessing a join side. + // This summary-update lookup matches column names without qualifiers. + // Reject ambiguous names rather than guessing a join side. ColumnRef::Named(name) | ColumnRef::Qualified { name, .. } => name.as_str(), ColumnRef::SampleValue => "value", _ => { diff --git a/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs b/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs index e5e9cff83..ef2d00350 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs @@ -1,6 +1,8 @@ //! Compile immutable summary-input computation with explicit population and pane identity. use super::promql_rows::SERIES_IDENTITY_COLUMN as SERIES_IDENTITY; use super::*; +use planner_types::ir::ASAPOp; +use planner_types::ir::NonASAPOp; use planner_types::post_asap::FieldDataType as SummaryFamilyType; use planner_types::{ post_asap::{ExecutionTiming, GroupingStrategy, Schema}, @@ -137,6 +139,8 @@ pub fn boundary_schema( } /// Validate the adapter layout during installed-plan recovery without lowering operators. +/// Borrows shared schema metadata and returns an Arc-owned schema for the +/// population adapter layout. pub fn source_schema(logical: &Schema) -> Result { let states = logical .fields @@ -241,7 +245,7 @@ pub fn compile( continue; } if node.output_state.timing != ExecutionTiming::IngestionTime { - return Err(invalid("precompute dag contains a query-time operation")); + return Err(invalid("precompute DAG contains a query-time operation")); } let inputs = dependencies.get(&id).cloned().unwrap_or_default(); let schemas = inputs @@ -253,13 +257,18 @@ pub fn compile( .ok_or_else(|| invalid("missing precompute input")) }) .collect::, _>>()?; - let dag = fragment( + let physical_dag = fragment( node, &schemas, &inputs.iter().map(|id| nodes[id]).collect::>(), )?; - outputs.insert(id, dag.output_contract(dag.roots()[0])?.schema); - fragments.insert(id, (inputs, dag)); + outputs.insert( + id, + physical_dag + .output_contract(physical_dag.roots()[0])? + .schema, + ); + fragments.insert(id, (inputs, physical_dag)); } CompiledPhysicalDAG::compose(sources, fragments, roots.to_vec()) } @@ -370,10 +379,10 @@ fn fragment( )) } }; - let read = Operator::readout( + let read = Operator::evaluation( input.clone(), 2, - ReadoutQuery::Exact(ExactReadout { + SummaryEvaluation::Exact(ExactEvaluation { statistic, lookback_ms: None, }), diff --git a/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs index 15d7dd689..81b790b62 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs @@ -4,6 +4,8 @@ use super::*; use planner_types::ir::physical_export::{ compile_physical_asap_dag, compile_physical_asap_dag_with_node_ids, }; +use planner_types::ir::ASAPOp; +use planner_types::ir::NonASAPOp; use planner_types::post_asap::FieldDataType as SummaryFamilyType; use planner_types::pre_asap::DataType; use std::rc::Rc; diff --git a/crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs b/crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs index 223d7f15c..a8a091249 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs @@ -243,10 +243,10 @@ pub fn compile_exact_evaluation( }; let input = exact_state_schema(family)?; let merge = Operator::summary_merge(input.clone(), 1, vec![0])?; - let mut evaluation = Operator::readout( + let mut evaluation = Operator::evaluation( merge.schema(), 1, - ReadoutQuery::Exact(ExactReadout { + SummaryEvaluation::Exact(ExactEvaluation { statistic, lookback_ms: None, }), diff --git a/crates/asap-physical-operators/src/values.rs b/crates/asap-physical-operators/src/values.rs index b1186c171..41640b7b6 100644 --- a/crates/asap-physical-operators/src/values.rs +++ b/crates/asap-physical-operators/src/values.rs @@ -2,7 +2,7 @@ use crate::AggregateCore; use crate::Error; use planner_types::{ - post_asap::{Field, FieldDataType, Schema}, + post_asap::{Field as SummaryField, FieldDataType as SummaryFamilyType, Schema}, pre_asap::DataType, }; use std::{cmp::Ordering, sync::Arc}; @@ -28,7 +28,7 @@ pub enum Value { Map(Arc<[(Value, Value)]>), #[serde(skip)] Summary { - family: FieldDataType, + family: SummaryFamilyType, state: Arc, }, } @@ -214,7 +214,9 @@ impl Batch { } for (value, field) in row.iter().zip(&schema.fields) { let matches = match (&field.dtype, value) { - (FieldDataType::Plain(dtype), value) => value.matches(dtype, field.nullable), + (SummaryFamilyType::Plain(dtype), value) => { + value.matches(dtype, field.nullable) + } (expected, Value::Summary { family, state }) => { expected == family && validate_state(family, state.as_ref()).is_ok() } @@ -260,7 +262,7 @@ pub(crate) fn group_key(row: &[Value], columns: &[usize]) -> Result> pub(crate) use crate::capability::validate_native_family as validate_family; -fn validate_state(family: &FieldDataType, state: &dyn AggregateCore) -> Result<(), Error> { +fn validate_state(family: &SummaryFamilyType, state: &dyn AggregateCore) -> Result<(), Error> { use crate::summary_kernels::{ count_min_sketch::CountMinSketchAccumulator, datasketches_kll::DatasketchesKLLAccumulator, dd_sketch::DDSketchAccumulator, exact::ExactAccumulator, hll_sketch::HllSketchAccumulator, @@ -268,7 +270,7 @@ fn validate_state(family: &FieldDataType, state: &dyn AggregateCore) -> Result<( use planner_types::post_asap::SketchParams; validate_family(family)?; let valid = match family { - FieldDataType::Sketch(kind, _) + SummaryFamilyType::Sketch(kind, _) if matches!( kind.params(), SketchParams::CmsWithHeap { .. } | SketchParams::CountSketchWithHeap { .. } @@ -284,11 +286,11 @@ fn validate_state(family: &FieldDataType, state: &dyn AggregateCore) -> Result<( }) } - FieldDataType::ExactAggregate(..) => state + SummaryFamilyType::ExactAggregate(..) => state .as_any() .downcast_ref::() .is_some_and(|s| s.family() == family && !s.is_keyed()), - FieldDataType::Sketch(kind, _) => match kind.params() { + SummaryFamilyType::Sketch(kind, _) => match kind.params() { SketchParams::Kll { k } => state .as_any() .downcast_ref::() @@ -325,14 +327,14 @@ pub(crate) fn validate_schema(schema: &SchemaRef) -> Result<(), Error> { schema .fields .get(index) - .is_none_or(|field| field.dtype != FieldDataType::Plain(DataType::Timestamp)) + .is_none_or(|field| field.dtype != SummaryFamilyType::Plain(DataType::Timestamp)) }) { return Err(Error::Invalid( "time index must name a Timestamp column".into(), )); } for field in &schema.fields { - if !matches!(field.dtype, FieldDataType::Plain(_)) { + if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { validate_family(&field.dtype)?; if field.nullable { return Err(Error::Invalid( @@ -344,7 +346,7 @@ pub(crate) fn validate_schema(schema: &SchemaRef) -> Result<(), Error> { Ok(()) } -pub(crate) fn field(schema: &SchemaRef, column: usize) -> Result<&Field, Error> { +pub(crate) fn field(schema: &SchemaRef, column: usize) -> Result<&SummaryField, Error> { schema .fields .get(column) @@ -352,7 +354,7 @@ pub(crate) fn field(schema: &SchemaRef, column: usize) -> Result<&Field, Error> } pub(crate) fn plain(schema: &SchemaRef, column: usize) -> Result<(&DataType, bool), Error> { let f = field(schema, column)?; - let FieldDataType::Plain(dtype) = &f.dtype else { + let SummaryFamilyType::Plain(dtype) = &f.dtype else { return Err(Error::Invalid("plain value required".into())); }; Ok((dtype, f.nullable)) @@ -367,7 +369,7 @@ mod weighted_state_tests { // A state cannot acquire a different family or shape merely by relabeling its batch. #[test] fn weighted_state_family_and_shape_must_match() { - let cms = FieldDataType::Sketch( + let cms = SummaryFamilyType::Sketch( SketchKind::new( SketchAlgorithm::CmsWithHeap, SketchParams::CmsWithHeap { @@ -378,7 +380,7 @@ mod weighted_state_tests { ), Default::default(), ); - let cs = FieldDataType::Sketch( + let cs = SummaryFamilyType::Sketch( SketchKind::new( SketchAlgorithm::CountSketchWithHeap, SketchParams::CountSketchWithHeap { @@ -395,7 +397,7 @@ mod weighted_state_tests { let wrong_shape = WeightedFrequency::new(FrequencyAlgorithm::CountSketch, 64, 5, 8).unwrap(); assert!(validate_state(&cs, &wrong_shape).is_err()); - let even_depth = FieldDataType::Sketch( + let even_depth = SummaryFamilyType::Sketch( SketchKind::new( SketchAlgorithm::CountSketchWithHeap, SketchParams::CountSketchWithHeap { diff --git a/crates/asap-physical-operators/tests/blocking_resources.rs b/crates/asap-physical-operators/tests/blocking_resources.rs index 37f780812..66702afdb 100644 --- a/crates/asap-physical-operators/tests/blocking_resources.rs +++ b/crates/asap-physical-operators/tests/blocking_resources.rs @@ -7,16 +7,18 @@ use asap_physical_operators::{ Error, }; use futures::{executor::block_on, FutureExt, StreamExt}; +use planner_types::ir::Predicate; +use planner_types::ir::ScalarExpr as QueryExpr; use planner_types::{ - post_asap::{Field, FieldDataType, Schema}, - pre_asap::{DataType, JoinKind, Predicate, QueryExpr, ScalarValue}, + post_asap::{Field, FieldDataType}, + pre_asap::{DataType, JoinKind, ScalarValue}, }; use std::sync::Arc; fn schema(width: usize) -> SchemaRef { - Arc::new(Schema { - closed: true, + Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: (0..width) .map(|i| Field { table: None, @@ -60,9 +62,7 @@ fn cross_join() -> Operator { schema(1), schema(1), JoinKind::Cross, - &Predicate(std::rc::Rc::new(QueryExpr::Literal(ScalarValue::Boolean( - true, - )))), + &Predicate(QueryExpr::Literal(ScalarValue::Boolean(true))), schema(2), ) .unwrap() @@ -189,9 +189,10 @@ fn cooperative_sort_preserves_ties_across_chunks() { #[test] fn weighted_summary_build_yields_within_a_batch() { use planner_types::post_asap::{SketchAlgorithm, SketchKind, SketchParams}; - let input = Arc::new(Schema { - closed: true, + + let input = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![ Field { table: None, diff --git a/crates/asap-physical-operators/tests/current_series_heap.rs b/crates/asap-physical-operators/tests/current_series_heap.rs index 079c2bd69..e879d7aeb 100644 --- a/crates/asap-physical-operators/tests/current_series_heap.rs +++ b/crates/asap-physical-operators/tests/current_series_heap.rs @@ -1,22 +1,25 @@ //! Spatial heap weights come from a fresh instant vector, never sample history. +#[path = "unified_common/mod.rs"] +mod common; use asap_physical_operators::{ operators::Operator, - physical_planner::{ + runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{ promql_rows::{decode_series_identity, series_row, SERIES_IDENTITY_COLUMN}, CompiledPhysicalDAG, InputContract, Source, }, - runtime::{Limits, RunContext, Scope}, values::{Batch, Value}, }; +use common::compile_physical_asap_dag; use futures::{executor::block_on, StreamExt}; -use planner_types::pre_asap::Schema; +use planner_types::ir::physical_export::PhysicalASAPOperatorPayload; use planner_types::{post_asap::*, pre_asap::DataType}; use std::{collections::BTreeMap, sync::Arc}; fn schema() -> Arc { - Arc::new(Schema { - closed: true, + Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: [ ("ts", DataType::Timestamp), ("value", DataType::Float64), @@ -169,9 +172,9 @@ fn spatial_heap_ranks_latest_values_in_independent_runs() { }; let family = FieldDataType::Sketch(SketchKind::new(algorithm, params), Default::default()); let build = Operator::keyed_summary_build(schema(), family, 1, vec![3], vec![2]).unwrap(); - let output = Arc::new(Schema { - closed: true, + let output = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![ schema().fields[2].clone(), schema().fields[3].clone(), @@ -179,7 +182,7 @@ fn spatial_heap_ranks_latest_values_in_independent_runs() { ], time_index: None, }); - let read = Operator::keyed_readout(build.schema(), 1, 1, output).unwrap(); + let read = Operator::keyed_evaluation(build.schema(), 1, 1, output).unwrap(); let plan = CompiledPhysicalDAG::from_operators( BTreeMap::from([(0, InputContract::bounded(schema()))]), BTreeMap::from([ @@ -270,7 +273,7 @@ fn current_series_observes_resource_limits() { #[test] fn identity_encoding_is_lossless_and_rejects_noncanonical_inputs() { - use asap_physical_operators::physical_planner::promql_rows::encode_series_identity; + use asap_physical_operators::unified_physical_planner::promql_rows::encode_series_identity; let labels = BTreeMap::from([ ("a".into(), "quote\"slash\\".into()), ("other".into(), "".into()), @@ -293,7 +296,9 @@ fn identity_encoding_is_lossless_and_rejects_noncanonical_inputs() { // test does not manually assemble the computation or its dependency edges. #[test] fn planner_current_series_candidate_compiles_with_dynamic_identity() { - use asap_physical_operators::physical_planner::{compile, promql_rows::with_series_identity}; + use asap_physical_operators::unified_physical_planner::{ + compile, promql_rows::with_series_identity, + }; use planner_types::{types::AccuracyTarget, workload::*}; use std::rc::Rc; let workload = PlanningWorkload { @@ -320,7 +325,7 @@ fn planner_current_series_candidate_compiles_with_dynamic_identity() { ..Default::default() }), }; - let original = asap_frontend_promql::lower_promql_workload(&workload, 0) + let original = asap_frontend_promql::unified::lower_promql_workload(&workload, 0) .unwrap() .remove(0); let open_root = Rc::new(original.clone()); @@ -331,7 +336,7 @@ fn planner_current_series_candidate_compiles_with_dynamic_identity() { .candidate(&open_root) .unwrap(); let snapshot_program = - asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout( + asap_physical_operators::unified_physical_planner::promql_rows::compile_current_series_evaluation( &open_selected, ) .unwrap(); @@ -348,20 +353,24 @@ fn planner_current_series_candidate_compiles_with_dynamic_identity() { ) .candidate(&root) .unwrap(); - let logical = compile_post_asap_dag(&selected).unwrap(); + let logical = compile_physical_asap_dag(&selected).unwrap(); let raw = logical .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::Fallback { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP( + planner_types::ir::NonASAPOp::TimeRange { .. } + ) + ) + }) .unwrap(); let raw_schema = Arc::new(raw.output_schema.clone()); let physical = compile( &logical, - BTreeMap::from([( - u64::from(raw.id.0), - InputContract::bounded(raw_schema.clone()), - )]), - &[u64::from(logical.root.0)], + BTreeMap::from([(raw.id as u64, InputContract::bounded(raw_schema.clone()))]), + &[logical.roots[0] as u64], ) .unwrap(); let bytes = String::from_utf8(serde_json::to_vec(&physical).unwrap()).unwrap(); diff --git a/crates/asap-physical-operators/tests/deployment.rs b/crates/asap-physical-operators/tests/deployment.rs index 2857a81cb..72e0503f1 100644 --- a/crates/asap-physical-operators/tests/deployment.rs +++ b/crates/asap-physical-operators/tests/deployment.rs @@ -35,7 +35,7 @@ fn read(state: &dyn AggregateCore) -> f64 { } // The same kernels work when every build is query-time, when only a prefix -// was precomputed, and when all state was precomputed before the readout. +// was precomputed, and when all state was precomputed before the evaluation. #[test] fn raw_partial_and_fully_precomputed_use_the_same_kernels() { let raw: Vec = (0..128).map(f64::from).collect(); diff --git a/crates/asap-physical-operators/tests/deployment_computation.rs b/crates/asap-physical-operators/tests/deployment_computation.rs index e20fc27ee..ff049f4fa 100644 --- a/crates/asap-physical-operators/tests/deployment_computation.rs +++ b/crates/asap-physical-operators/tests/deployment_computation.rs @@ -1,21 +1,25 @@ //! Planner-selected PromQL computation compiles from the timed DAG alone; //! the deployment supplies only raw rows at the ingestion frontier. +#[path = "unified_common/mod.rs"] +mod common; use asap_physical_operators::{ operators::Operator, - physical_planner::{compile, promql_rows, CompiledPhysicalDAG, InputContract, Source}, runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{compile, promql_rows, CompiledPhysicalDAG, InputContract, Source}, values::{Batch, Value}, }; +use common::compile_physical_asap_dag; use futures::{executor::block_on, StreamExt}; -use planner_types::pre_asap::Schema; -use planner_types::{post_asap::*, pre_asap::QueryExpr, types::AccuracyTarget, workload::*}; +use planner_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPOperatorPayload}; +use planner_types::ir::ASAPOp; +use planner_types::{post_asap::*, types::AccuracyTarget, workload::*}; use std::{collections::BTreeMap, rc::Rc, sync::Arc}; -fn lower(query: &str) -> QueryExpr { +fn lower(query: &str) -> Rc { lower_with(query, AccuracyTarget::Exact) } -fn lower_with(query: &str, accuracy: AccuracyTarget) -> QueryExpr { +fn lower_with(query: &str, accuracy: AccuracyTarget) -> Rc { let workload = PlanningWorkload { query_workload: QueryWorkload { language: QueryLanguage::PromQL, @@ -40,60 +44,61 @@ fn lower_with(query: &str, accuracy: AccuracyTarget) -> QueryExpr { ..Default::default() }), }; - asap_frontend_promql::lower_promql_workload(&workload, 0) + asap_frontend_promql::unified::lower_promql_workload(&workload, 0) .unwrap() .remove(0) } /// The first exact summary candidate, as Planner selection would hand it over. -fn exact_dag(query: &str) -> PostAsapDAG { - use asap_aware_mapping::{Replacement, ReplacementStrategy, TargetSubDAG}; +fn exact_dag(query: &str) -> PhysicalASAPDAG { let expression = lower(query); - let root = Rc::new(promql_rows::with_series_identity(&expression).unwrap_or(expression)); - asap_aware_mapping::SketchAlgorithmStrategy::new(&asap_aware_mapping::DefaultCostModel) - .replacements(&TargetSubDAG::new(&root)) - .into_iter() - .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) => { - let dag = compile_post_asap_dag(&node).ok()?; - dag.nodes - .iter() - .all(|n| !matches!(&n.payload, PostAsapOperatorPayload::SummaryAgg { family, .. } if !matches!(family, FieldDataType::ExactAggregate(..)))) - .then_some(dag) - } - _ => None, - }) + let root = promql_rows::with_series_identity(&expression).unwrap_or(expression); + let space = asap_aware_mapping::search_workload(vec![("q", root)]); + let selected = space + .global_selection(&asap_aware_mapping::DefaultCostModel) + .assemble_selected_dag(&space.roots[0].1) .unwrap() + .unwrap(); + compile_physical_asap_dag(&selected).unwrap() } -fn population_dag(query: &str) -> PostAsapDAG { - let root = Rc::new(promql_rows::with_series_identity(&lower(query)).unwrap()); +fn population_dag(query: &str) -> PhysicalASAPDAG { + let root = promql_rows::with_series_identity(&lower(query)).unwrap(); let selected = asap_aware_mapping::maintained_population::MaintainedPopulationStrategy::new( std::slice::from_ref(&root), ) .candidate(&root) .unwrap(); - compile_post_asap_dag(&selected).unwrap() + compile_physical_asap_dag(&selected).unwrap() } /// Raw scan nodes are the frontier; everything above them is compiled. -fn raw_inputs(dag: &PostAsapDAG) -> Vec<(u64, Arc, String)> { +fn raw_inputs(dag: &PhysicalASAPDAG) -> Vec<(u64, Arc, String)> { dag.nodes .iter() .filter_map(|node| match &node.payload { - PostAsapOperatorPayload::Fallback { - expression: QueryExpr::TimeRange { child, .. }, - } => match child.as_ref() { - QueryExpr::Scan { - source: planner_types::pre_asap::Source::TimeSeries { metric }, - .. - } => Some(( - u64::from(node.id.0), - Arc::new(node.output_schema.clone()), - metric.clone(), - )), - _ => None, - }, + PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::TimeRange { + .. + }) => { + let mut id = node.id; + loop { + let n = dag.nodes.iter().find(|n| n.id == id)?; + if let PhysicalASAPOperatorPayload::NonASAP( + planner_types::ir::NonASAPOp::Scan { + source: planner_types::pre_asap::Source::TimeSeries { metric }, + .. + }, + ) = &n.payload + { + return Some(( + node.id as u64, + Arc::new(node.output_schema.clone()), + metric.clone(), + )); + } + id = dag.edges.iter().find(|e| e.consumer == id)?.producer; + } + } _ => None, }) .collect() @@ -104,7 +109,7 @@ type Sample = (&'static str, &'static str, &'static str, i64, f64); /// Compile, round-trip, bind raw `(metric, job, instance, ts, value)` samples, /// and return the root's batches. fn execute( - dag: &PostAsapDAG, + dag: &PhysicalASAPDAG, samples: &[Sample], end: i64, ) -> Result>, String> { @@ -114,7 +119,7 @@ fn execute( /// [`execute`], supplying samples of each instance in `relabel` under its /// `(__name__, instance)` instead. fn execute_relabeled( - dag: &PostAsapDAG, + dag: &PhysicalASAPDAG, samples: &[Sample], end: i64, relabel: &BTreeMap<&str, (&str, &str)>, @@ -126,7 +131,7 @@ fn execute_relabeled( .iter() .map(|(id, schema, _)| (*id, InputContract::bounded(schema.clone()))) .collect(), - &[u64::from(dag.root.0)], + &[dag.roots[0] as u64], ) .map_err(|e| e.to_string())?; let program: CompiledPhysicalDAG = @@ -195,7 +200,11 @@ fn execute_relabeled( } /// [`execute`], returning `(job, value)` rows of the root. -fn run(dag: &PostAsapDAG, samples: &[Sample], end: i64) -> Result, String> { +fn run( + dag: &PhysicalASAPDAG, + samples: &[Sample], + end: i64, +) -> Result, String> { let mut rows = BTreeMap::new(); for batch in execute(dag, samples, end)? { let job = batch.schema().fields.iter().position(|f| f.name == "job"); @@ -250,7 +259,7 @@ fn population_aggregates_match_current_series_reference() { } } -// A global readout of an empty population is an empty vector, as in PromQL. +// A global evaluation of an empty population is an empty vector, as in PromQL. #[test] fn global_population_aggregate_of_no_members_is_empty() { // Latest values are [1, 2, 5, 7] at 60s; every member has expired by 1000s. @@ -313,40 +322,7 @@ fn grouped_vector_arithmetic_matches_labels() { // then roll up per job: api has 2 + 1 + 1 samples in 5m, db has 1. #[test] fn exact_count_finalizes_to_declared_float_value() { - let mut dag = exact_dag("sum by (job) (count_over_time(m[5m]))"); - let finalize = dag - .nodes - .iter() - .find(|node| { - matches!( - node.payload, - PostAsapOperatorPayload::Value { - operation: ValueOperation::FinalizeExactAccumulator - } - ) - }) - .unwrap() - .clone(); - let root = dag.nodes.iter().find(|n| n.id == dag.root).unwrap().clone(); - let mut edge = dag - .edges - .iter() - .find(|e| e.producer == finalize.id) - .unwrap() - .clone(); - // Read the rolled-up exact state the same way the query path does. - let mut read = finalize.clone(); - read.id = PostAsapNodeId(root.id.0 + 1); - read.output_schema = root.output_schema.clone(); - read.output_schema.fields.last_mut().unwrap().dtype = - FieldDataType::Plain(planner_types::pre_asap::DataType::Float64); - edge.producer = root.id; - edge.consumer = read.id; - edge.intermediate_schema = root.output_schema.clone(); - edge.data_state = root.output_state; - dag.root = read.id; - dag.nodes.push(read); - dag.edges.push(edge); + let dag = exact_dag("sum by (job) (count_over_time(m[5m]))"); assert_eq!( run(&dag, SAMPLES, 60_000).unwrap(), reference(&[("api", 4.), ("db", 1.)]) @@ -354,10 +330,20 @@ fn exact_count_finalizes_to_declared_float_value() { } /// `dag` with its Binary operator replaced by `kind`. -fn with_kind(mut dag: PostAsapDAG, kind: planner_types::pre_asap::BinaryOpKind) -> PostAsapDAG { +fn with_kind( + mut dag: PhysicalASAPDAG, + kind: planner_types::pre_asap::BinaryOpKind, + bool_result: bool, +) -> PhysicalASAPDAG { for node in &mut dag.nodes { - if let PostAsapOperatorPayload::Binary { operator } = &mut node.payload { + if let PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::BinaryOp { + operator, + return_bool, + .. + }) = &mut node.payload + { operator.kind = kind.clone(); + *return_bool = bool_result; } } dag @@ -367,30 +353,30 @@ fn with_kind(mut dag: PostAsapDAG, kind: planner_types::pre_asap::BinaryOpKind) // holds, with their value, on either side of the literal; `bool` yields 1 or 0. #[test] fn grouped_comparisons_filter_or_return_bool() { - use planner_types::pre_asap::{BinaryOpKind::*, CompareOpKind::Gt}; - // sum_over_time over 5m per job: api = 14, db = 5. - let right = exact_dag("sum by (job) (sum_over_time(m[5m])) * 10"); - let left = exact_dag("10 - sum by (job) (sum_over_time(m[5m]))"); - for (dag, expected) in [ + for (query, expected) in [ ( - with_kind(right.clone(), Compare(Gt)), + "sum by(job)(sum_over_time(m[5m])) > 10", reference(&[("api", 14.)]), ), ( - with_kind(right, CompareBool(Gt)), + "sum by(job)(sum_over_time(m[5m])) > bool 10", reference(&[("api", 1.), ("db", 0.)]), ), - (with_kind(left, Compare(Gt)), reference(&[("db", 5.)])), + ( + "10 > sum by(job)(sum_over_time(m[5m]))", + reference(&[("db", 5.)]), + ), ] { - assert_eq!(run(&dag, SAMPLES, 60_000).unwrap(), expected); + assert_eq!(run(&exact_dag(query), SAMPLES, 60_000).unwrap(), expected); } } -// A `bool` comparison Binary over per-series readouts matches one-to-one and +// A `bool` comparison Binary over per-series evaluations matches one-to-one and // drops the metric name; a filter keeps the surviving left value. #[test] fn per_series_comparisons_filter_or_return_bool() { use planner_types::pre_asap::{BinaryOpKind::*, CompareOpKind::*}; + let samples = counter("a", "api", 10., 10.) .chain(counter("a", "db", 10., 10.)) .chain(counter("b", "api", 5., 5.)) @@ -399,18 +385,23 @@ fn per_series_comparisons_filter_or_return_bool() { // rate: a{api} = a{db} = 50/300, b{api} = 25/300, b{db} = 100/300. let dag = exact_dag("rate(a[5m]) / rate(b[5m])"); assert_eq!( - run_series(&with_kind(dag.clone(), Compare(Gt)), &samples, 300_000).unwrap(), + run_series( + &with_kind(dag.clone(), Compare(Gt), false), + &samples, + 300_000 + ) + .unwrap(), series(&[("api", "x", 50. / 300.)]) ); assert_eq!( - run_series(&with_kind(dag, CompareBool(Lt)), &samples, 300_000).unwrap(), + run_series(&with_kind(dag, Compare(Lt), true), &samples, 300_000).unwrap(), series(&[("api", "x", 0.), ("db", "x", 1.)]) ); } /// [`execute`], returning per-series `(identity, value)` rows of the root, /// with NaN-aware formatting for comparison. -fn run_series(dag: &PostAsapDAG, samples: &[Sample], end: i64) -> Result { +fn run_series(dag: &PhysicalASAPDAG, samples: &[Sample], end: i64) -> Result { let mut rows = BTreeMap::new(); for batch in execute(dag, samples, end)? { let schema = batch.schema(); @@ -509,13 +500,13 @@ fn per_series_rate_ratio_matches_prometheus() { // A literal operand applies to every stored per-series value, on either side, // and drops the metric name. #[test] -fn per_series_scalar_arithmetic_applies_to_stored_readouts() { +fn per_series_scalar_arithmetic_applies_to_stored_evaluations() { let samples = counter("m", "api", 10., 10.).collect::>(); // rate = 40 * 1.25 / 300 = 1/6. for (query, expected) in [ ("rate(m[5m]) * 2", 50. / 300. * 2.), ("1 - rate(m[5m])", 1. - 50. / 300.), - // The stored sum readout keeps `__name__`; the arithmetic drops it. + // The stored sum evaluation keeps `__name__`; the arithmetic drops it. ("sum_over_time(m[5m]) * 2", 150. * 2.), ] { assert_eq!( @@ -545,12 +536,16 @@ fn per_series_scalar_arithmetic_rejects_label_sets_equal_without_the_name() { } fn with_vector_match( - mut dag: PostAsapDAG, + mut dag: PhysicalASAPDAG, kind: planner_types::pre_asap::VectorMatchKind, labels: &[&str], -) -> PostAsapDAG { +) -> PhysicalASAPDAG { for node in &mut dag.nodes { - if let PostAsapOperatorPayload::Binary { operator } = &mut node.payload { + if let PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::BinaryOp { + operator, + .. + }) = &mut node.payload + { operator.vector_match = Some(planner_types::pre_asap::VectorMatch { kind: kind.clone(), labels: labels.iter().map(|l| l.to_string()).collect(), @@ -621,47 +616,52 @@ fn population_sums_and_averages_are_compensated() { } } -// A bare count over stored Count-Min state compiles to a Planner readout that +// A bare count over stored Count-Min state compiles to a Planner evaluation that // returns the sketch's total update weight, including colliding items. #[test] -fn stored_count_min_bare_count_compiles_to_a_readout() { +fn stored_count_min_bare_count_compiles_to_a_evaluation() { use asap_aware_mapping::{Replacement, ReplacementStrategy, TargetSubDAG}; use asap_physical_operators::summary_kernels::CountMinSketchAccumulator; - let root = Rc::new(lower_with("count(up)", AccuracyTarget::Epsilon(0.02))); - let dag = - asap_aware_mapping::SketchAlgorithmStrategy::new(&asap_aware_mapping::DefaultCostModel) - .replacements(&TargetSubDAG::new(&root)) - .into_iter() - .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) => { - let dag = compile_post_asap_dag(&node).ok()?; - let bare_count = dag.nodes.iter().any(|n| { - matches!( - &n.payload, - PostAsapOperatorPayload::SummaryEstimate { - query: SketchStatistic::PointCount { value: None, .. } - } - ) - }); - let count_min = dag.nodes.iter().any(|n| { - matches!(&n.payload, PostAsapOperatorPayload::SummaryAgg { + let root = lower_with("count(up)", AccuracyTarget::Epsilon(0.02)); + let dag = asap_aware_mapping::ASAPStrategies::new(&asap_aware_mapping::DefaultCostModel) + .replacements(&TargetSubDAG::new(&root)) + .into_iter() + .find_map(|candidate| match candidate.replacement { + Replacement::SubDAG(node) => { + let dag = compile_physical_asap_dag(&node).ok()?; + let bare_count = dag.nodes.iter().any(|n| { + matches!( + &n.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryEstimate { + query: SketchStatistic::PointCount { value: None, .. }, + .. + }) + ) + }); + let count_min = dag.nodes.iter().any(|n| { + matches!(&n.payload, PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } if kind.algorithm() == &SketchAlgorithm::Cms) - }); - (bare_count && count_min).then_some(dag) - } - _ => None, - }) - .expect("Planner lists a Count-Min candidate for count(up)"); + }) if kind.algorithm() == &SketchAlgorithm::Cms) + }); + (bare_count && count_min).then_some(dag) + } + _ => None, + }) + .expect("Planner lists a Count-Min candidate for count(up)"); let state = dag .nodes .iter() - .find(|n| matches!(n.payload, PostAsapOperatorPayload::SummaryAgg { .. })) + .find(|n| { + matches!( + n.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) + ) + }) .unwrap(); - let PostAsapOperatorPayload::SummaryAgg { + let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } = &state.payload + }) = &state.payload else { unreachable!() }; @@ -671,11 +671,8 @@ fn stored_count_min_bare_count_compiles_to_a_readout() { let schema = Arc::new(state.output_schema.clone()); let program = compile( &dag, - BTreeMap::from([( - u64::from(state.id.0), - InputContract::bounded(schema.clone()), - )]), - &[u64::from(dag.root.0)], + BTreeMap::from([(state.id as u64, InputContract::bounded(schema.clone()))]), + &[dag.roots[0] as u64], ) .unwrap(); let program: CompiledPhysicalDAG = @@ -697,7 +694,7 @@ fn stored_count_min_bare_count_compiles_to_a_readout() { let batch = Batch::try_new(schema.clone(), vec![row]).unwrap(); let physical_dag = program .instantiate(BTreeMap::from([( - u64::from(state.id.0), + state.id as u64, Box::new(Operator::source(schema, vec![batch]).unwrap()) as Source<'_>, )])) .unwrap(); diff --git a/crates/asap-physical-operators/tests/physical_dag.rs b/crates/asap-physical-operators/tests/physical_dag.rs index e2ad735d6..ff63a6f6e 100644 --- a/crates/asap-physical-operators/tests/physical_dag.rs +++ b/crates/asap-physical-operators/tests/physical_dag.rs @@ -8,15 +8,23 @@ use asap_physical_operators::{ Statistic, }; use futures::{executor::block_on, StreamExt}; +use planner_types::ir::physical_export::{ + EdgeRole, GroupingEdgeCompatibility, PhysicalASAPDAG, PhysicalASAPDAGEdge, PhysicalASAPDAGNode, + PhysicalASAPOperatorPayload, WindowEdgeCompatibility, +}; +use planner_types::ir::ASAPOp; +use planner_types::ir::BinaryOperator; +use planner_types::ir::NonASAPOp; +use planner_types::ir::ScalarExpr as QueryExpr; use planner_types::{ - post_asap::{ExactKind, ExactParams, Field, FieldDataType, Schema}, + post_asap::{ExactKind, ExactParams, Field, FieldDataType}, pre_asap::DataType, }; use std::sync::Arc; fn schema(fields: &[(&str, DataType, bool)]) -> SchemaRef { - Arc::new(Schema { - closed: true, + Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: fields .iter() .map(|(name, dtype, nullable)| Field { @@ -117,7 +125,7 @@ fn grouped_sort_limit_across_batches() { // The same computation runs in either engine scope with fresh per-run state. #[test] -fn summary_construction_merge_and_readout_at_both_phases() { +fn summary_construction_merge_and_evaluation_at_both_phases() { let schema = schema(&[("v", DataType::Float64, false)]); let batches = (1..=20) .map(|v| Batch::try_new(schema.clone(), vec![vec![Value::Float64(v as f64)]]).unwrap()) @@ -140,11 +148,11 @@ fn summary_construction_merge_and_readout_at_both_phases() { dag.add( 4, vec![3], - Operator::readout( + Operator::evaluation( state, 0, - asap_physical_operators::operators::ReadoutQuery::Exact( - asap_physical_operators::summary_kernels::exact::ExactReadout { + asap_physical_operators::operators::SummaryEvaluation::Exact( + asap_physical_operators::summary_kernels::exact::ExactEvaluation { statistic: Statistic::Sum, lookback_ms: None, }, @@ -295,10 +303,10 @@ fn binding_rejects_unsupported_operations() { vec![], ) .unwrap(); - assert!(Operator::readout( + assert!(Operator::evaluation( sum.schema(), 0, - asap_physical_operators::operators::ReadoutQuery::Sketch( + asap_physical_operators::operators::SummaryEvaluation::Sketch( planner_types::post_asap::SketchStatistic::Quantile { q: 0.5 } ) ) @@ -310,6 +318,7 @@ fn binding_rejects_unsupported_operations() { #[test] fn kll_raw_partial_and_precomputed_are_native_dags() { use planner_types::post_asap::{GroupingStrategy, SketchAlgorithm, SketchKind, SketchParams}; + let input = schema(&[("value", DataType::Float64, false)]); let family = FieldDataType::Sketch( SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 512 }), @@ -396,10 +405,10 @@ fn kll_raw_partial_and_precomputed_are_native_dags() { dag.add( 5, vec![4], - Operator::readout( + Operator::evaluation( state.clone(), 0, - asap_physical_operators::operators::ReadoutQuery::Sketch( + asap_physical_operators::operators::SummaryEvaluation::Sketch( planner_types::post_asap::SketchStatistic::Quantile { q: 0.5 }, ), ) @@ -423,9 +432,9 @@ fn exact_state_and_family_validation() { let family = FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); let mut acc = ExactAccumulator::new(family.clone(), false).unwrap(); acc.update(None, 7., 0); - let schema = Arc::new(Schema { - closed: true, + let schema = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![Field { table: None, name: "state".into(), @@ -452,11 +461,11 @@ fn exact_state_and_family_validation() { dag.add( 1, vec![0], - Operator::readout( + Operator::evaluation( schema.clone(), 0, - asap_physical_operators::operators::ReadoutQuery::Exact( - asap_physical_operators::summary_kernels::exact::ExactReadout { + asap_physical_operators::operators::SummaryEvaluation::Exact( + asap_physical_operators::summary_kernels::exact::ExactEvaluation { statistic: Statistic::Sum, lookback_ms: None, }, @@ -486,57 +495,58 @@ fn exact_state_and_family_validation() { fn bind_post_asap_before_execution() { use asap_physical_operators::dag::planner::bind; use planner_types::{ - post_asap::{ - EdgeRole, ExecutionDataState, GroupingEdgeCompatibility, PostAsapDAG, PostAsapDAGEdge, - PostAsapDAGNode, PostAsapNodeId, PostAsapOperatorPayload, ValueOperation, - WindowEdgeCompatibility, - }, - pre_asap::{ArithmeticOpKind, ProjectItem, QueryExpr, ScalarValue}, + post_asap::ExecutionDataState, + pre_asap::{ArithmeticOpKind, ScalarValue}, }; - use std::{collections::BTreeMap, rc::Rc}; + use std::collections::BTreeMap; let schema = schema(&[("value", DataType::Float64, false)]); - let node = |id, payload| PostAsapDAGNode { - id: PostAsapNodeId(id), + let node = |id, payload| PhysicalASAPDAGNode { + id, payload, output_state: ExecutionDataState::QUERY_ROWS, output_schema: (*schema).clone(), guarantee: None, }; - let mut dag = PostAsapDAG { + let mut dag = PhysicalASAPDAG { nodes: vec![ node( 0, - PostAsapOperatorPayload::Fallback { - expression: QueryExpr::promql_scalar(1.), - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Values { + rows: vec![vec![planner_types::ir::ScalarExpr::Literal( + planner_types::pre_asap::ScalarValue::Float64(1.), + )]], + schema: (*schema).clone(), + }), ), node( 1, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Project { - cols: vec![ProjectItem { - alias: None, - expr: QueryExpr::Arithmetic { - op: ArithmeticOpKind::Add, - left: Rc::new(QueryExpr::Column(0)), - right: Rc::new(QueryExpr::Literal(ScalarValue::Float64(2.))), - }, - }], - qualifier: None, - }, - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Project { + cols: vec![planner_types::ir::ProjectItem { + alias: None, + expr: planner_types::ir::ScalarExpr::Arithmetic { + semantics: planner_types::ir::ExprSemantics::Sql, + op: ArithmeticOpKind::Add, + left: Box::new(planner_types::ir::ScalarExpr::Column(0)), + right: Box::new(planner_types::ir::ScalarExpr::Literal( + ScalarValue::Float64(2.), + )), + }, + }], + qualifier: None, + child: 0, + }), ), ], - edges: vec![PostAsapDAGEdge { - producer: PostAsapNodeId(0), - consumer: PostAsapNodeId(1), + edges: vec![PhysicalASAPDAGEdge { + producer: 0, + consumer: 1, role: EdgeRole::Input, intermediate_schema: (*schema).clone(), data_state: ExecutionDataState::QUERY_ROWS, grouping: GroupingEdgeCompatibility::NotApplicable, window: WindowEdgeCompatibility::NotApplicable, }], - root: PostAsapNodeId(1), + roots: vec![1], }; let sources = || -> BTreeMap> { BTreeMap::from([( @@ -555,17 +565,16 @@ fn bind_post_asap_before_execution() { // A literal Fallback needs no deployment input. let literal = bind(&dag, BTreeMap::new(), &[1]).unwrap(); assert_eq!(floats(&run(&literal, 1, query()), 0), vec![3.]); - dag.nodes[1].payload = PostAsapOperatorPayload::Value { - operation: ValueOperation::Extension { - name: "unknown".into(), - }, - }; + dag.nodes[1].payload = PhysicalASAPOperatorPayload::ASAP(ASAPOp::Extension { + child: 0, + name: "unsupported".into(), + }); assert!(bind(&dag, sources(), &[1]).is_err()); } // A completed empty population has an exact zero count, with integer output. #[test] -fn empty_exact_count_is_an_integer_state_readout() { +fn empty_exact_count_is_an_integer_state_evaluation() { let input = schema(&[("value", DataType::Float64, false)]); let build = Operator::summary_build( input.clone(), @@ -575,11 +584,11 @@ fn empty_exact_count_is_an_integer_state_readout() { vec![], ) .unwrap(); - let read = Operator::readout( + let read = Operator::evaluation( build.schema(), 0, - asap_physical_operators::operators::ReadoutQuery::Exact( - asap_physical_operators::summary_kernels::exact::ExactReadout { + asap_physical_operators::operators::SummaryEvaluation::Exact( + asap_physical_operators::summary_kernels::exact::ExactEvaluation { statistic: Statistic::Count, lookback_ms: None, }, @@ -598,13 +607,7 @@ fn empty_exact_count_is_an_integer_state_readout() { #[test] fn source_batches_must_match_the_bound_schema() { use asap_physical_operators::dag::{self, PhysicalOperator}; - use planner_types::{ - post_asap::{ - ExecutionDataState, PostAsapDAG, PostAsapDAGNode, PostAsapNodeId, - PostAsapOperatorPayload, - }, - pre_asap::QueryExpr, - }; + use planner_types::post_asap::ExecutionDataState; use std::{cell::Cell, collections::BTreeMap, rc::Rc}; struct WrongSource { schema: SchemaRef, @@ -637,18 +640,21 @@ fn source_batches_must_match_the_bound_schema() { } let expected = schema(&[("value", DataType::Float64, false)]); let starts = Rc::new(Cell::new(0)); - let plan = PostAsapDAG { - nodes: vec![PostAsapDAGNode { - id: PostAsapNodeId(0), - payload: PostAsapOperatorPayload::Fallback { - expression: QueryExpr::promql_scalar(1.), - }, + let plan = PhysicalASAPDAG { + nodes: vec![PhysicalASAPDAGNode { + id: 0, + payload: PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Values { + rows: vec![vec![planner_types::ir::ScalarExpr::Literal( + planner_types::pre_asap::ScalarValue::Float64(1.), + )]], + schema: (*expected).clone(), + }), output_state: ExecutionDataState::QUERY_ROWS, output_schema: (*expected).clone(), guarantee: None, }], edges: vec![], - root: PostAsapNodeId(0), + roots: vec![0], }; let source = Box::new(WrongSource { schema: expected, @@ -709,26 +715,27 @@ fn planner_semijoin_sort_limit_contract_at_both_phases() { use asap_physical_operators::dag::planner::{bind, Source}; use planner_types::{ post_asap::*, - pre_asap::{CompareOpKind, GroupKeys, JoinKind, Predicate, QueryExpr, SortKey}, + pre_asap::{CompareOpKind, GroupKeys, JoinKind}, }; - use std::{collections::BTreeMap, rc::Rc}; + use std::collections::BTreeMap; let rows_schema = schema(&[ ("group", DataType::Utf8, false), ("key", DataType::Utf8, false), ("score", DataType::Float64, false), ]); let keys_schema = schema(&[("key", DataType::Utf8, false)]); - let node = |id, payload, schema: &asap_physical_operators::values::SchemaRef| PostAsapDAGNode { - id: PostAsapNodeId(id), - payload, - output_schema: (**schema).clone(), - output_state: ExecutionDataState::QUERY_ROWS, - guarantee: None, - }; + let node = + |id, payload, schema: &asap_physical_operators::values::SchemaRef| PhysicalASAPDAGNode { + id, + payload, + output_schema: (**schema).clone(), + output_state: ExecutionDataState::QUERY_ROWS, + guarantee: None, + }; let edge = |producer, consumer, role, schema: &asap_physical_operators::values::SchemaRef| { - PostAsapDAGEdge { - producer: PostAsapNodeId(producer), - consumer: PostAsapNodeId(consumer), + PhysicalASAPDAGEdge { + producer, + consumer, role, intermediate_schema: (**schema).clone(), data_state: ExecutionDataState::QUERY_ROWS, @@ -737,58 +744,64 @@ fn planner_semijoin_sort_limit_contract_at_both_phases() { } }; let groups = GroupKeys::by(vec![0]); - let dag = PostAsapDAG { + let dag = PhysicalASAPDAG { nodes: vec![ node( 0, - PostAsapOperatorPayload::Fallback { - expression: QueryExpr::promql_scalar(0.), - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Values { + rows: vec![vec![planner_types::ir::ScalarExpr::Literal( + planner_types::pre_asap::ScalarValue::Float64(0.), + )]], + schema: (*rows_schema).clone(), + }), &rows_schema, ), node( 1, - PostAsapOperatorPayload::Fallback { - expression: QueryExpr::promql_scalar(0.), - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Values { + rows: vec![vec![planner_types::ir::ScalarExpr::Literal( + planner_types::pre_asap::ScalarValue::Float64(0.), + )]], + schema: (*keys_schema).clone(), + }), &keys_schema, ), node( 2, - PostAsapOperatorPayload::RelationalJoin { - join_kind: JoinKind::Semi, - pruning: None, - pred: Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(1)), + PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::Join { + kind: JoinKind::Semi, + pred: planner_types::ir::Predicate(planner_types::ir::ScalarExpr::Compare { + left: Box::new(planner_types::ir::ScalarExpr::Column(1)), op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(3)), - })), - }, + right: Box::new(planner_types::ir::ScalarExpr::Column(3)), + semantics: planner_types::ir::ExprSemantics::Sql, + }), + left: 0, + right: 1, + }), &rows_schema, ), node( 3, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Sort { - keys: vec![SortKey { - expr: QueryExpr::Column(2), - ascending: false, - nulls_first: false, - }], - partition_by: groups.clone(), - }, - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Sort { + keys: vec![planner_types::ir::SortKey { + expr: planner_types::ir::ScalarExpr::Column(2), + ascending: false, + nulls_first: false, + }], + partition_by: groups.clone(), + child: 2, + }), &rows_schema, ), node( 4, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Limit { - n: 1, - offset: 0, - partition_by: groups, - }, - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Limit { + n: Some(1), + offset: 0, + partition_by: groups, + child: 3, + }), &rows_schema, ), ], @@ -799,7 +812,7 @@ fn planner_semijoin_sort_limit_contract_at_both_phases() { edge(2, 3, EdgeRole::Input, &rows_schema), edge(3, 4, EdgeRole::Input, &rows_schema), ], - root: PostAsapNodeId(4), + roots: vec![4], }; let text = |v: &str| Value::Utf8(v.into()); for (phase, scope) in [ @@ -861,9 +874,9 @@ fn planner_semijoin_sort_limit_contract_at_both_phases() { // Planner scalar signatures, collection access and null predicates share native execution. #[test] fn planner_expressions_preserve_collection_and_nullable_types() { - use asap_physical_operators::dag::expressions::CompiledExpression; - use planner_types::pre_asap::{CompareOpKind, QueryExpr, ScalarValue}; - use std::rc::Rc; + use asap_physical_operators::dag::expressions::unified_planner::CompiledExpression; + use planner_types::pre_asap::{CompareOpKind, ScalarValue}; + let input_schema = schema(&[( "items", DataType::Map { @@ -884,7 +897,9 @@ fn planner_expressions_preserve_collection_and_nullable_types() { input_schema.clone(), vec![( "count".into(), - Expression::planner(CompiledExpression::compile(&access, &input_schema).unwrap()), + Expression::unified_planner( + CompiledExpression::compile(&access, &input_schema).unwrap(), + ), )], ) .unwrap(); @@ -911,16 +926,19 @@ fn planner_expressions_preserve_collection_and_nullable_types() { let projected = project.schema(); dag.add(1, vec![0], project).unwrap(); let predicate = QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + semantics: planner_types::ir::ExprSemantics::Sql, + left: Box::new(QueryExpr::Column(0)), op: CompareOpKind::Ge, - right: Rc::new(QueryExpr::Literal(ScalarValue::Int64(1))), + right: Box::new(QueryExpr::Literal(ScalarValue::Int64(1))), }; dag.add( 2, vec![1], Operator::filter( projected.clone(), - Expression::planner(CompiledExpression::compile(&predicate, &projected).unwrap()), + Expression::unified_planner( + CompiledExpression::compile(&predicate, &projected).unwrap(), + ), ) .unwrap(), ) @@ -937,14 +955,16 @@ fn planner_expressions_preserve_collection_and_nullable_types() { // Outer, semi and anti joins share Planner predicates and preserve SQL null behavior. #[test] fn native_relational_join_kinds_preserve_unmatched_rows() { - use planner_types::pre_asap::{CompareOpKind, JoinKind, Predicate, QueryExpr}; - use std::rc::Rc; + use planner_types::ir::Predicate; + use planner_types::pre_asap::{CompareOpKind, JoinKind}; + let input = schema(&[("key", DataType::Int64, true)]); - let predicate = Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + let predicate = Predicate(QueryExpr::Compare { + semantics: planner_types::ir::ExprSemantics::Sql, + left: Box::new(QueryExpr::Column(0)), op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(1)), - })); + right: Box::new(QueryExpr::Column(1)), + }); for (kind, count) in [ (JoinKind::Inner, 1), (JoinKind::Left, 3), @@ -1018,6 +1038,7 @@ fn weighted_rate_topk_preserves_partitions_fractional_scores_and_evaluation_scop } fn assert_weighted_rate_topk(count_sketch: bool) { use planner_types::post_asap::{SketchAlgorithm, SketchKind, SketchParams}; + let raw = schema(&[ ("service", DataType::Utf8, false), ("job", DataType::Utf8, false), @@ -1084,7 +1105,7 @@ fn assert_weighted_rate_topk(count_sketch: bool) { ("service", DataType::Utf8, false), ("score", DataType::Float64, false), ]); - let readout = Operator::keyed_readout(build.schema(), 1, 8, output.clone()).unwrap(); + let evaluation = Operator::keyed_evaluation(build.schema(), 1, 8, output.clone()).unwrap(); let mut dag = PhysicalDAG::default(); dag.add( 0, @@ -1094,7 +1115,7 @@ fn assert_weighted_rate_topk(count_sketch: bool) { .unwrap(); dag.add(1, vec![0], rates).unwrap(); dag.add(2, vec![1], build).unwrap(); - dag.add(3, vec![2], readout).unwrap(); + dag.add(3, vec![2], evaluation).unwrap(); dag.add( 4, vec![3], @@ -1138,20 +1159,19 @@ fn assert_weighted_rate_topk(count_sketch: bool) { // The grouped temporal reducer's sample schema must survive physical Sort/Limit binding. #[test] fn grouped_temporal_schema_compiles_and_executes_topk() { - use asap_physical_operators::physical_planner::{ + use asap_physical_operators::unified_physical_planner::{ compile_node, CompiledPhysicalDAG, InputContract, Source, }; - use planner_types::post_asap::{ - ExecutionDataState, PostAsapDAGNode, PostAsapNodeId, PostAsapOperatorPayload, - ValueOperation, - }; + use planner_types::ir::physical_export::{PhysicalASAPDAGNode, PhysicalASAPOperatorPayload}; + use planner_types::post_asap::ExecutionDataState; + use planner_types::pre_asap::{ - aggregate_output_schema, AggIntent, Field, GroupKeys, QueryExpr, Reduction as IrReduction, - Schema as IrSchema, + aggregate_output_schema, AggIntent, GroupKeys, Reduction as IrReduction, Schema as IrSchema, }; + let grouped = IrSchema::new(vec![ - Field::plain("job", DataType::Utf8, false), - Field::plain("sum", DataType::Float64, false), + planner_types::pre_asap::Field::plain("job", DataType::Utf8, false), + planner_types::pre_asap::Field::plain("sum", DataType::Float64, false), ]); let output = aggregate_output_schema( &grouped, @@ -1167,15 +1187,15 @@ fn grouped_temporal_schema_compiles_and_executes_topk() { .map(|c| { ( c.name.as_str(), - c.dtype.plain().unwrap().clone(), + c.plain_dtype().unwrap().clone(), c.nullable, ) }) .collect::>(), ); - let node = |id, operation| PostAsapDAGNode { - id: PostAsapNodeId(id), - payload: PostAsapOperatorPayload::Value { operation }, + let node = |id, operation| PhysicalASAPDAGNode { + id, + payload: PhysicalASAPOperatorPayload::NonASAP(operation), output_state: ExecutionDataState::QUERY_ROWS, output_schema: (*input).clone(), guarantee: None, @@ -1183,13 +1203,14 @@ fn grouped_temporal_schema_compiles_and_executes_topk() { let sort = compile_node( &node( 1, - ValueOperation::Sort { - keys: vec![planner_types::pre_asap::SortKey { - expr: QueryExpr::Column(1), + NonASAPOp::Sort { + keys: vec![planner_types::ir::SortKey { + expr: planner_types::ir::ScalarExpr::Column(1), ascending: false, nulls_first: false, }], partition_by: GroupKeys::none(), + child: 0, }, ), std::slice::from_ref(&input), @@ -1198,10 +1219,11 @@ fn grouped_temporal_schema_compiles_and_executes_topk() { let limit = compile_node( &node( 2, - ValueOperation::Limit { - n: 1, + NonASAPOp::Limit { + n: Some(1), offset: 0, partition_by: GroupKeys::none(), + child: 1, }, ), std::slice::from_ref(&input), @@ -1248,37 +1270,32 @@ fn grouped_temporal_schema_compiles_and_executes_topk() { // A certified candidate set must have authoritative values for every key, including after recovery. #[test] fn certified_pruning_rejects_missing_authoritative_values_after_recovery() { - use asap_physical_operators::physical_planner::{ + use asap_physical_operators::unified_physical_planner::{ compile_node, CompiledPhysicalDAG, InputContract, Source, }; use planner_types::{ post_asap::*, - pre_asap::{CompareOpKind, JoinKind, Predicate, QueryExpr}, + pre_asap::{CompareOpKind, JoinKind}, }; - use std::{collections::BTreeMap, rc::Rc}; + use std::collections::BTreeMap; let schema = schema(&[("key", DataType::Utf8, false)]); for certified in [false, true] { - let node = PostAsapDAGNode { - id: PostAsapNodeId(2), + let node = PhysicalASAPDAGNode { + id: 2, output_schema: (*schema).clone(), output_state: ExecutionDataState::QUERY_ROWS, guarantee: None, - payload: PostAsapOperatorPayload::RelationalJoin { - join_kind: JoinKind::Semi, - pred: Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + payload: PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::Join { + kind: JoinKind::Semi, + pred: planner_types::ir::Predicate(planner_types::ir::ScalarExpr::Compare { + semantics: planner_types::ir::ExprSemantics::Sql, + left: Box::new(planner_types::ir::ScalarExpr::Column(0)), op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(1)), - })), - pruning: certified.then_some(CandidateCompleteness::Certified { - guarantee: ResultGuarantee { - metric: ErrorMetric::TopKMembership, - bound: BoundExpr::Zero, - failure_probability: ProbabilityExpr::Constant { value: 0.01 }, - provenance: vec![], - }, + right: Box::new(planner_types::ir::ScalarExpr::Column(1)), }), - }, + left: 0, + right: 1, + }), }; let dag = CompiledPhysicalDAG::from_operators( [ @@ -1290,7 +1307,12 @@ fn certified_pruning_rejects_missing_authoritative_values_after_recovery() { 2, ( vec![0, 1], - compile_node(&node, &[schema.clone(), schema.clone()]).unwrap(), + if certified { + Operator::certified_semi_join(schema.clone(), schema.clone(), vec![(0, 0)]) + .unwrap() + } else { + compile_node(&node, &[schema.clone(), schema.clone()]).unwrap() + }, ), )] .into(), @@ -1360,7 +1382,7 @@ fn certified_pruning_rejects_missing_authoritative_values_after_recovery() { // Precompute arithmetic must match population/window identities, never zip arrival order. #[test] fn compiled_ingestion_binary_preserves_alignment_and_rejects_missing_updates() { - use asap_physical_operators::physical_planner::{ + use asap_physical_operators::unified_physical_planner::{ compile_node, CompiledPhysicalDAG, InputContract, Source, }; use planner_types::{ @@ -1373,19 +1395,22 @@ fn compiled_ingestion_binary_preserves_alignment_and_rejects_missing_updates() { ("time", DataType::Timestamp, false), ("value", DataType::Float64, false), ]); - let node = PostAsapDAGNode { - id: PostAsapNodeId(2), + let node = PhysicalASAPDAGNode { + id: 2, output_schema: (*input).clone(), output_state: ExecutionDataState::INGESTION_ROWS, guarantee: None, - payload: PostAsapOperatorPayload::Binary { + payload: PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::BinaryOp { operator: BinaryOperator { kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), vector_match: None, checked_relative_division: false, checked_finite_division: false, }, - }, + return_bool: false, + lhs: 0, + rhs: 1, + }), }; let program = CompiledPhysicalDAG::from_operators( [ diff --git a/crates/asap-physical-operators/tests/physical_plan_recovery.rs b/crates/asap-physical-operators/tests/physical_plan_recovery.rs index fda1b502a..3a71f30fa 100644 --- a/crates/asap-physical-operators/tests/physical_plan_recovery.rs +++ b/crates/asap-physical-operators/tests/physical_plan_recovery.rs @@ -2,18 +2,18 @@ //! Deployments choose the encoding; JSON is used here only as a test format. use asap_physical_operators::{ operators::{Operator, SortKey}, - physical_planner::{CompiledPhysicalDAG, InputContract}, + unified_physical_planner::{CompiledPhysicalDAG, InputContract}, }; use planner_types::{ - post_asap::{Field, FieldDataType, Schema}, + post_asap::{Field, FieldDataType}, pre_asap::DataType, }; use std::{collections::BTreeMap, sync::Arc}; fn sorted() -> CompiledPhysicalDAG { - let schema = Arc::new(Schema { - closed: true, + let schema = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![Field { table: None, name: "value".into(), @@ -74,7 +74,7 @@ fn recovery_retains_selected_operator_and_rejects_invalid_contracts() { #[test] fn candidate_recovery_preserves_materialization_boundary() { - use asap_physical_operators::physical_planner::PhysicalASAPDAG; + use asap_physical_operators::unified_physical_planner::CompiledPhysicalPlan; let precompute = sorted(); let output = InputContract::bounded(precompute.output_contract(1).unwrap().schema); let query = CompiledPhysicalDAG::from_operators( @@ -89,13 +89,13 @@ fn candidate_recovery_preserves_materialization_boundary() { vec![2], ) .unwrap(); - let candidate = PhysicalASAPDAG { + let candidate = CompiledPhysicalPlan { precompute: Some(precompute), query, materialized_outputs: BTreeMap::from([(1, output)]), }; let bytes = serde_json::to_vec(&candidate).unwrap(); - let restored = serde_json::from_slice::(&bytes).unwrap(); + let restored = serde_json::from_slice::(&bytes).unwrap(); assert_eq!(restored.precompute.as_ref().unwrap().roots(), &[1]); assert_eq!(restored.query.roots(), &[2]); assert_eq!(serde_json::to_vec(&restored).unwrap(), bytes); @@ -103,6 +103,7 @@ fn candidate_recovery_preserves_materialization_boundary() { wire["materialized_outputs"]["1"]["schema"]["fields"][0]["dtype"] = serde_json::json!({"Plain":"utf8"}); assert!( - serde_json::from_slice::(&serde_json::to_vec(&wire).unwrap()).is_err() + serde_json::from_slice::(&serde_json::to_vec(&wire).unwrap()) + .is_err() ); } diff --git a/crates/asap-physical-operators/tests/physical_semantics.rs b/crates/asap-physical-operators/tests/physical_semantics.rs index 56b62590b..7ddecfa55 100644 --- a/crates/asap-physical-operators/tests/physical_semantics.rs +++ b/crates/asap-physical-operators/tests/physical_semantics.rs @@ -2,23 +2,27 @@ //! Expectations follow ASAP's IR (notably row-count and IEEE NaN equality). //! Reference: apache/datafusion e2ca7f3, physical-plan/src/{limit.rs,sorts/sort.rs}. use asap_physical_operators::{ - expressions::CompiledExpression, + expressions::unified_planner::CompiledExpression, operators::{Expression, Operator, Reduction, SortKey}, plan::PhysicalDAG, runtime::{Limits, RunContext, Scope}, values::{Batch, SchemaRef, Value}, }; use futures::{executor::block_on, StreamExt}; +use planner_types::ir::physical_export::{PhysicalASAPDAGNode, PhysicalASAPOperatorPayload}; +use planner_types::ir::NonASAPOp; +use planner_types::ir::Predicate; +use planner_types::ir::ScalarExpr as QueryExpr; use planner_types::{ - post_asap::{Field, FieldDataType, Schema}, - pre_asap::{CompareOpKind, DataType, JoinKind, Predicate, QueryExpr}, + post_asap::{Field, FieldDataType}, + pre_asap::{CompareOpKind, DataType, JoinKind}, }; -use std::{rc::Rc, sync::Arc}; +use std::sync::Arc; fn schema(fields: &[(&str, DataType, bool)]) -> SchemaRef { - Arc::new(Schema { - closed: true, + Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: fields .iter() .map(|(name, dtype, nullable)| Field { @@ -74,11 +78,12 @@ fn keys(rows: &[Vec]) -> Vec>> { .collect() } fn eq_predicate() -> Predicate { - Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + Predicate(QueryExpr::Compare { + semantics: planner_types::ir::ExprSemantics::Sql, + left: Box::new(QueryExpr::Column(0)), op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(1)), - })) + right: Box::new(QueryExpr::Column(1)), + }) } fn join(left: Vec, right: Vec, kind: JoinKind, keyed: bool) -> Vec> { let input = schema(&[("key", DataType::Float64, true)]); @@ -336,16 +341,20 @@ fn projection_rejects_expression_bound_to_another_schema() { let original = schema(&[("a", DataType::Int64, false), ("b", DataType::Int64, false)]); let current = schema(&[("a", DataType::Int64, false)]); let expr = CompiledExpression::compile(&QueryExpr::Column(1), &original).unwrap(); - assert!(Operator::project(current, vec![("b".into(), Expression::planner(expr))]).is_err()); + assert!(Operator::project( + current, + vec![("b".into(), Expression::unified_planner(expr))] + ) + .is_err()); } // A valid Planner MIN/MAX schema must bind even for a non-null input column. #[test] fn global_extrema_bind_with_planner_derived_schema() { - use asap_physical_operators::physical_planner::compile_node; + use asap_physical_operators::unified_physical_planner::compile_node; use planner_types::{ post_asap::*, - pre_asap::{AggIntent, Field, GroupKeys, Reduction as PlanReduction}, + pre_asap::{AggIntent, GroupKeys, Reduction as PlanReduction}, }; let input = schema(&[("v", DataType::Int64, false)]); for measure in [ @@ -353,8 +362,12 @@ fn global_extrema_bind_with_planner_derived_schema() { AggIntent::Max { col: Some(0) }, ] { let planner_input = - planner_types::pre_asap::Schema::new(vec![Field::plain("v", DataType::Int64, false)]); - let derived = planner_types::pre_asap::query_expr::aggregate_output_schema( + planner_types::pre_asap::Schema::new(vec![planner_types::pre_asap::Field::plain( + "v", + DataType::Int64, + false, + )]); + let derived = planner_types::pre_asap::aggregate_output_schema( &planner_input, &PlanReduction::Reduce(GroupKeys::by(vec![])), std::slice::from_ref(&measure), @@ -364,20 +377,19 @@ fn global_extrema_bind_with_planner_derived_schema() { let result = derived.fields[0].clone(); let output = schema(&[( &result.name, - result.dtype.plain().unwrap().clone(), + result.plain_dtype().unwrap().clone(), result.nullable, )]); - let node = PostAsapDAGNode { - id: PostAsapNodeId(1), - payload: PostAsapOperatorPayload::Value { - operation: ValueOperation::Exact(ExactOperation::Aggregate { - reduction: PlanReduction::Reduce(GroupKeys::by(vec![])), - measures: vec![measure], - output_names: vec![result.name], - filters: vec![], - having: None, - }), - }, + let node = PhysicalASAPDAGNode { + id: 1, + payload: PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Aggregate { + reduction: PlanReduction::Reduce(GroupKeys::by(vec![])), + measures: vec![measure], + output_names: vec![result.name], + filters: vec![], + having: None, + child: 0, + }), output_state: ExecutionDataState::QUERY_ROWS, output_schema: (*output).clone(), guarantee: None, @@ -408,9 +420,10 @@ fn planner_comparisons_handle_nan_without_execution_errors() { CompareOpKind::Ge, ] { let expression = QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + semantics: planner_types::ir::ExprSemantics::Sql, + left: Box::new(QueryExpr::Column(0)), op: op.clone(), - right: Rc::new(QueryExpr::Column(1)), + right: Box::new(QueryExpr::Column(1)), }; let compiled = CompiledExpression::compile(&expression, &input).unwrap(); for row in [ @@ -475,9 +488,10 @@ fn mixed_numeric_comparisons_preserve_large_integer_precision() { ("b", DataType::Float64, false), ]); let expr = QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + semantics: planner_types::ir::ExprSemantics::Sql, + left: Box::new(QueryExpr::Column(0)), op: CompareOpKind::Gt, - right: Rc::new(QueryExpr::Column(1)), + right: Box::new(QueryExpr::Column(1)), }; let compiled = CompiledExpression::compile(&expr, &input).unwrap(); for (a, b, expected) in [ @@ -543,8 +557,9 @@ fn boolean_truth_tables_agree_between_expression_paths() { // Partial/final execution must agree with one build for an uncompacted KLL population. #[test] -fn kll_partial_merge_and_multiple_readouts_preserve_population() { +fn kll_partial_merge_and_multiple_evaluations_preserve_population() { use planner_types::post_asap::{SketchAlgorithm, SketchKind, SketchParams}; + let input = schema(&[("v", DataType::Float64, false)]); let family = FieldDataType::Sketch( SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 512 }), @@ -588,10 +603,10 @@ fn kll_partial_merge_and_multiple_readouts_preserve_population() { dag.add( id, vec![build], - Operator::readout( + Operator::evaluation( state.clone(), 0, - asap_physical_operators::operators::ReadoutQuery::Sketch( + asap_physical_operators::operators::SummaryEvaluation::Sketch( planner_types::post_asap::SketchStatistic::Quantile { q }, ), ) @@ -662,6 +677,7 @@ fn zero_column_output_obeys_memory_limit() { fn empty_exact_summary_extrema_agree_with_ordinary_aggregation() { use asap_physical_operators::Statistic; use planner_types::post_asap::{ExactKind, ExactParams}; + let input = schema(&[("v", DataType::Float64, false)]); for (kind, params, statistic) in [ (ExactKind::Min, ExactParams::Min, Statistic::Min), @@ -683,11 +699,11 @@ fn empty_exact_summary_extrema_agree_with_ordinary_aggregation() { dag.add( 2, vec![1], - Operator::readout( + Operator::evaluation( state, 0, - asap_physical_operators::operators::ReadoutQuery::Exact( - asap_physical_operators::summary_kernels::exact::ExactReadout { + asap_physical_operators::operators::SummaryEvaluation::Exact( + asap_physical_operators::summary_kernels::exact::ExactEvaluation { statistic, lookback_ms: None, }, diff --git a/crates/asap-physical-operators/tests/plan_properties.rs b/crates/asap-physical-operators/tests/plan_properties.rs index e3dd7b5fc..ab9531224 100644 --- a/crates/asap-physical-operators/tests/plan_properties.rs +++ b/crates/asap-physical-operators/tests/plan_properties.rs @@ -3,13 +3,13 @@ use asap_physical_operators::{ operators::{Operator, SortKey}, plan::{Boundedness, Emission, PhysicalDAG}, runtime::{Limits, OutputStream, RunContext, Scope}, - sources::{DataSources, RawSource}, + unified_sources::{DataSources, RawSource}, values::{Batch, SchemaRef}, Error, }; use planner_types::{ - post_asap::{Field, FieldDataType, Schema}, - pre_asap::{DataType, QueryExpr, Source}, + post_asap::{Field, FieldDataType}, + pre_asap::{DataType, Schema, Source}, }; use std::sync::{ atomic::{AtomicUsize, Ordering}, @@ -35,9 +35,9 @@ impl RawSource for DeclaredSource { // A blocking parent must reject unknown and unbounded Scan inputs without opening a reader. #[test] fn blocking_inputs_require_an_explicit_finite_source() { - let schema = Arc::new(Schema { - closed: true, + let schema = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![Field { table: None, name: "v".into(), @@ -67,11 +67,20 @@ fn blocking_inputs_require_an_explicit_finite_source() { ) .unwrap(); let scan = registry - .bind(&QueryExpr::Scan { - source: identity, - schema: Schema::new(vec![Field::plain("v", DataType::Int64, false)]), - predicates: vec![], - }) + .bind( + &planner_types::ir::OperatorNode::new_shared(planner_types::ir::Operator::NonASAP( + planner_types::ir::NonASAPOp::Scan { + source: identity, + schema: Schema::new(vec![planner_types::pre_asap::Field::plain( + "v", + DataType::Int64, + false, + )]), + predicates: vec![], + }, + )) + .unwrap(), + ) .unwrap(); let mut dag = PhysicalDAG::default(); dag.add(0, vec![], scan).unwrap(); @@ -112,11 +121,11 @@ fn blocking_inputs_require_an_explicit_finite_source() { } } -// Kernel support must not be mistaken for executable native state/readout support. +// Kernel support must not be mistaken for executable native state/evaluation support. #[test] fn summary_capability_levels_are_distinct() { use asap_physical_operators::{ - capability::{validate_native_family, validate_sketch_readout, validate_summary_kernel}, + capability::{validate_native_family, validate_sketch_evaluation, validate_summary_kernel}, planner::post_asap::SketchStatistic, }; use planner_types::{ @@ -148,8 +157,8 @@ fn summary_capability_levels_are_distinct() { key: ColumnRef::SampleValue, value: None, }; - assert!(validate_sketch_readout(&cms, &bare_count).is_ok()); - assert!(validate_sketch_readout( + assert!(validate_sketch_evaluation(&cms, &bare_count).is_ok()); + assert!(validate_sketch_evaluation( &cms, &SketchStatistic::PointCount { key: ColumnRef::Named("host".into()), @@ -162,7 +171,7 @@ fn summary_capability_levels_are_distinct() { grouping, ); assert!(validate_native_family(&kll).is_ok()); - assert!(validate_sketch_readout(&kll, &SketchStatistic::Quantile { q: 1.5 }).is_err()); - assert!(validate_sketch_readout(&kll, &SketchStatistic::Cardinality).is_err()); - assert!(validate_sketch_readout(&kll, &SketchStatistic::Quantile { q: 0.5 }).is_ok()); + assert!(validate_sketch_evaluation(&kll, &SketchStatistic::Quantile { q: 1.5 }).is_err()); + assert!(validate_sketch_evaluation(&kll, &SketchStatistic::Cardinality).is_err()); + assert!(validate_sketch_evaluation(&kll, &SketchStatistic::Quantile { q: 0.5 }).is_ok()); } diff --git a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs index 1f03b61c4..467b7c0c1 100644 --- a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs +++ b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs @@ -2,18 +2,22 @@ //! Planner's search space: `enumerate_candidate_dags_for_root` lists //! current-series TopK heaps without a caller-side series-identity pass, cost //! ranking, or workload Cartesian expansion. Placement variants are not listed. +#[path = "unified_common/mod.rs"] +mod common; +use common::compile_physical_asap_dag; +use planner_types::ir::OperatorNode as QueryExpr; + use asap_aware_mapping::{ accuracy::{AccuracyEvidenceProvider, DefaultAccuracyModel, PropagationStats}, cost_model::DefaultCostModel, replacement::{default_strategies_with_evidence, ReplacementProvenance}, search_workload_with_targets, Proposals, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; -use asap_physical_operators::physical_planner::promql_rows::{ - compile_current_series_readout, SERIES_IDENTITY_COLUMN, +use asap_physical_operators::unified_physical_planner::promql_rows::{ + compile_current_series_evaluation, SERIES_IDENTITY_COLUMN, }; use planner_types::{ post_asap::*, - pre_asap::QueryExpr, types::AccuracyTarget, workload::{ AccuracyRequirement, BatchEntry, DataWorkload, DurationMs, Evidence as WorkloadEvidence, @@ -89,14 +93,12 @@ fn lower(query: &str, accuracy: &AccuracyTarget) -> Rc { ..Default::default() }), }; - Rc::new( - asap_frontend_promql::lower_promql_workload(&workload, 0) - .unwrap() - .remove(0), - ) + asap_frontend_promql::unified::lower_promql_workload(&workload, 0) + .unwrap() + .remove(0) } -type InventoryDAG = Vec<(usize, Rc)>; +type InventoryDAG = Vec<(usize, Rc)>; /// Candidate DAGs for query 1 of a two-query workload, with and without /// whole-root proposals. Query 0 is a bystander that must not multiply them. @@ -126,7 +128,7 @@ fn inventories(query: &str, accuracy: AccuracyTarget) -> (Vec, Vec fn carries_identity(dag: &InventoryDAG) -> bool { dag.iter().any(|(_, root)| { - compile_post_asap_dag(root) + compile_physical_asap_dag(root) .unwrap() .nodes .iter() @@ -140,7 +142,10 @@ fn carries_identity(dag: &InventoryDAG) -> bool { } /// Shared acceptance checks; returns the added identity-carrying alternatives. -fn added_alternatives(query: &str, accuracy: AccuracyTarget) -> Vec> { +fn added_alternatives( + query: &str, + accuracy: AccuracyTarget, +) -> Vec> { let (full, logical) = inventories(query, accuracy); for (index, dag) in full.iter().enumerate() { assert_eq!(dag.len(), 1, "one root per candidate, no workload product"); @@ -159,15 +164,18 @@ fn added_alternatives(query: &str, accuracy: AccuracyTarget) -> Vec asap_aware_mapping::CandidateLogicalASAPDAGs<&'static str> { let workload = PlanningWorkload { @@ -40,26 +45,23 @@ fn grouped_rate_space() -> asap_aware_mapping::CandidateLogicalASAPDAGs<&'static ..Default::default() }), }; - let root = Rc::new( - asap_frontend_promql::lower_promql_workload(&workload, 0) - .unwrap() - .remove(0), - ); - let root = Rc::new( - asap_physical_operators::physical_planner::promql_rows::with_series_identity(&root) - .unwrap(), - ); + let root = asap_frontend_promql::unified::lower_promql_workload(&workload, 0) + .unwrap() + .remove(0); + let root = + asap_physical_operators::unified_physical_planner::promql_rows::with_series_identity(&root) + .unwrap(); search_workload(vec![("grouped-rate", root)]) } -fn grouped_rate() -> PostAsapDAG { +fn grouped_rate() -> PhysicalASAPDAG { let space = grouped_rate_space(); let selected = space .global_selection(&DefaultCostModel) .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); - compile_post_asap_dag(&selected).unwrap() + compile_physical_asap_dag(&selected).unwrap() } fn run(plan: &CompiledPhysicalDAG, inputs: BTreeMap, scope: Scope) -> Vec { let sources = inputs @@ -81,7 +83,7 @@ fn run(plan: &CompiledPhysicalDAG, inputs: BTreeMap, scope: Scope) - }) } -/// Rate readouts and grouped Sum can run together during bounded precompute; +/// Rate evaluations and grouped Sum can run together during bounded precompute; /// storing per-series rates instead leaves the same Sum in the query DAG. #[test] fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { @@ -92,22 +94,20 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { .find(|node| { matches!( node.payload, - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Rate, _), .. - } + }) ) }) .unwrap(); - let readout = dag + let evaluation = dag .nodes .iter() .find(|node| { matches!( node.payload, - PostAsapOperatorPayload::Value { - operation: ValueOperation::FinalizeExactAccumulator - } + PhysicalASAPOperatorPayload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) ) && dag .edges .iter() @@ -116,12 +116,12 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { .unwrap(); let input_schema = Arc::new(state.output_schema.clone()); let (family, update, grouping) = match &state.payload { - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family, input, grouping, .. - } => (family, input, grouping), + }) => (family, input, grouping), _ => unreachable!(), }; let range_ms = Some((-58_000, 2_000)); @@ -139,7 +139,7 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { .as_any() .downcast_ref::() .unwrap() - .readout(asap_physical_operators::Statistic::Rate, range_ms, None) + .evaluation(asap_physical_operators::Statistic::Rate, range_ms, None) .unwrap() .unwrap(); let summary = Value::Summary { @@ -169,10 +169,10 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { }) .collect(); let batch = Batch::try_new(input_schema.clone(), rows).unwrap(); - let root = u64::from(dag.root.0); - let state_id = u64::from(state.id.0); - let rate_id = u64::from(readout.id.0); - let frontiers = asap_physical_operators::physical_planner::enumerate_frontiers( + let root = dag.roots[0] as u64; + let state_id = state.id as u64; + let rate_id = evaluation.id as u64; + let frontiers = asap_physical_operators::unified_physical_planner::enumerate_frontiers( &dag, &BTreeMap::from([(state_id, InputContract::bounded(input_schema.clone()))]), &[root], @@ -184,7 +184,7 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { assert!(frontiers.contains(&vec![root])); assert!(!frontiers.contains(&vec![root, rate_id])); assert!( - asap_physical_operators::physical_planner::enumerate_frontiers( + asap_physical_operators::unified_physical_planner::enumerate_frontiers( &dag, &BTreeMap::from([(state_id, InputContract::bounded(input_schema.clone()))]), &[root], @@ -257,7 +257,7 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { )]); for frontier in [vec![rate_id, rate_id], vec![root, rate_id], vec![999]] { assert!( - asap_physical_operators::physical_planner::compile_candidate( + asap_physical_operators::unified_physical_planner::compile_candidate( &dag, contracts.clone(), &[root], @@ -367,7 +367,7 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { .as_any() .downcast_ref::() .unwrap() - .readout(asap_physical_operators::Statistic::Rate, range_ms, None) + .evaluation(asap_physical_operators::Statistic::Rate, range_ms, None) .unwrap() .unwrap(); assert_ne!( @@ -376,11 +376,11 @@ fn grouped_rate_can_be_materialized_before_or_after_grouped_sum() { ); } -/// Enumerated frontiers include both grouped-result and per-series readout +/// Enumerated frontiers include both grouped-result and per-series evaluation /// persistence; an explicit Rate-state input retains its original semantics. #[test] fn bounded_inventory_exposes_grouped_rate_physical_frontiers() { - use asap_physical_operators::physical_planner::enumerate_frontiers; + use asap_physical_operators::unified_physical_planner::enumerate_frontiers; let dag = grouped_rate(); let state = dag .nodes @@ -388,18 +388,18 @@ fn bounded_inventory_exposes_grouped_rate_physical_frontiers() { .find(|node| { matches!( &node.payload, - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Rate, _), .. - } + }) ) }) .unwrap(); let inputs = BTreeMap::from([( - u64::from(state.id.0), + state.id as u64, InputContract::bounded(Arc::new(state.output_schema.clone())), )]); - let roots = [u64::from(dag.root.0)]; + let roots = [dag.roots[0] as u64]; let frontiers = enumerate_frontiers(&dag, &inputs, &roots, 4096).unwrap(); let candidates = compile_candidates(&dag, inputs.clone(), &roots, &frontiers) .into_iter() @@ -422,14 +422,14 @@ fn enumerated_grouped_rate_candidates_execute_numeric_query_outputs() { let mut executed = 0; for forest in inventory.candidates { let root = &forest[0].1; - let dag = compile_post_asap_dag(root).unwrap(); + let dag = compile_physical_asap_dag(root).unwrap(); let Some(state) = dag.nodes.iter().find(|node| { matches!( node.payload, - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Rate, _), .. - } + }) ) }) else { continue; @@ -440,30 +440,30 @@ fn enumerated_grouped_rate_candidates_execute_numeric_query_outputs() { .find(|node| { matches!( node.payload, - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Sum, _), .. - } + }) ) }) - .map(|node| u64::from(node.id.0)) - .unwrap_or(u64::from(dag.root.0)); + .map(|node| node.id as u64) + .unwrap_or(dag.roots[0] as u64); let physical_asap_dags = compile_candidates( &dag, BTreeMap::from([( - u64::from(state.id.0), + state.id as u64, InputContract::bounded(Arc::new(state.output_schema.clone())), )]), - &[u64::from(dag.root.0)], + &[dag.roots[0] as u64], &[vec![], vec![boundary]], ); let (family, input, grouping) = match &state.payload { - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family, input, grouping, .. - } => (family, input, grouping), + }) => (family, input, grouping), _ => unreachable!(), }; let schema = Arc::new(state.output_schema.clone()); @@ -558,14 +558,14 @@ fn enumerated_grouped_rate_candidates_execute_numeric_query_outputs() { /// The per-frontier lowering used before compile-once cuts: each boundary /// choice lowers the precompute and query DAGs from the logical DAG again. fn recompiled_candidate( - dag: &PostAsapDAG, + dag: &PhysicalASAPDAG, inputs: &BTreeMap, roots: &[u64], frontier: &[u64], -) -> Result { +) -> Result { use asap_physical_operators::plan::Emission; if frontier.is_empty() { - return Ok(PhysicalASAPDAG { + return Ok(CompiledPhysicalPlan { precompute: None, query: compile(dag, inputs.clone(), roots)?, materialized_outputs: BTreeMap::new(), @@ -580,7 +580,7 @@ fn recompiled_candidate( } let mut query_inputs = inputs.clone(); query_inputs.extend(materialized_outputs.clone()); - Ok(PhysicalASAPDAG { + Ok(CompiledPhysicalPlan { precompute: Some(precompute), query: compile(dag, query_inputs, roots)?, materialized_outputs, @@ -588,7 +588,7 @@ fn recompiled_candidate( } fn assert_cuts_match_recompilation( - dag: &PostAsapDAG, + dag: &PhysicalASAPDAG, inputs: BTreeMap, roots: &[u64], min_frontiers: usize, @@ -615,13 +615,18 @@ fn grouped_rate_cuts_equal_per_frontier_compilation() { let state = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::SummaryAgg { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) + ) + }) .unwrap(); let inputs = BTreeMap::from([( - u64::from(state.id.0), + state.id as u64, InputContract::bounded(Arc::new(state.output_schema.clone())), )]); - assert_cuts_match_recompilation(&dag, inputs, &[u64::from(dag.root.0)], 3); + assert_cuts_match_recompilation(&dag, inputs, &[dag.roots[0] as u64], 3); } /// Cuts of a DAG whose nodes lower to helper operators (current-series @@ -652,29 +657,37 @@ fn population_topk_cuts_equal_per_frontier_compilation() { ..Default::default() }), }; - let original = asap_frontend_promql::lower_promql_workload(&workload, 0) + let original = asap_frontend_promql::unified::lower_promql_workload(&workload, 0) .unwrap() .remove(0); - let root = Rc::new( - asap_physical_operators::physical_planner::promql_rows::with_series_identity(&original) - .unwrap(), - ); + let root = + asap_physical_operators::unified_physical_planner::promql_rows::with_series_identity( + &original, + ) + .unwrap(); let selected = asap_aware_mapping::maintained_population::MaintainedPopulationStrategy::new( std::slice::from_ref(&root), ) .candidate(&root) .unwrap(); - let dag = compile_post_asap_dag(&selected).unwrap(); + let dag = compile_physical_asap_dag(&selected).unwrap(); let raw = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::Fallback { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP( + planner_types::ir::NonASAPOp::TimeRange { .. } + ) + ) + }) .unwrap(); let inputs = BTreeMap::from([( - u64::from(raw.id.0), + raw.id as u64, InputContract::bounded(Arc::new(raw.output_schema.clone())), )]); - let roots = [u64::from(dag.root.0)]; + let roots = [dag.roots[0] as u64]; let compiled = compile(&dag, inputs.clone(), &roots).unwrap(); // The root reads its population through a Sort helper numbered by the root. let helper = u64::MAX - (roots[0] << 16); @@ -694,25 +707,24 @@ fn cut_candidate_rejects_invalid_frontiers() { let state = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::SummaryAgg { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) + ) + }) .unwrap(); - let readout = dag + let evaluation = dag .nodes .iter() .find(|node| { matches!( node.payload, - PostAsapOperatorPayload::Value { - operation: ValueOperation::FinalizeExactAccumulator - } + PhysicalASAPOperatorPayload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) ) }) .unwrap(); - let (state_id, rate_id, root) = ( - u64::from(state.id.0), - u64::from(readout.id.0), - u64::from(dag.root.0), - ); + let (state_id, rate_id, root) = (state.id as u64, evaluation.id as u64, dag.roots[0] as u64); let inputs = BTreeMap::from([( state_id, InputContract::bounded(Arc::new(state.output_schema.clone())), diff --git a/crates/asap-physical-operators/tests/precompute_population.rs b/crates/asap-physical-operators/tests/precompute_population.rs index 5b91c6e72..746a37fc9 100644 --- a/crates/asap-physical-operators/tests/precompute_population.rs +++ b/crates/asap-physical-operators/tests/precompute_population.rs @@ -2,13 +2,18 @@ use asap_physical_operators::{ factory::create_planner_accumulator, operators::Operator, - physical_planner::{precompute, CompiledPhysicalDAG, Source}, runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{precompute, CompiledPhysicalDAG, Source}, values::{Batch, Value}, Statistic, }; use futures::{executor::block_on, StreamExt}; -use planner_types::pre_asap::Schema; +use planner_types::ir::physical_export::{ + EdgeRole, GroupingEdgeCompatibility, PhysicalASAPDAG, PhysicalASAPDAGEdge, PhysicalASAPDAGNode, + PhysicalASAPOperatorPayload, WindowEdgeCompatibility, +}; +use planner_types::ir::ASAPOp; +use planner_types::ir::BinaryOperator; use planner_types::{ post_asap::*, pre_asap::{ArithmeticOpKind, BinaryOpKind, ColumnRef, DataType, GroupKeys, Reduction}, @@ -19,9 +24,9 @@ use std::{collections::BTreeMap, sync::Arc}; #[test] fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { let family = FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - let schema = |dtype| Schema { - closed: true, + let schema = |dtype| planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![Field { table: None, name: "value".into(), @@ -50,39 +55,46 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { (SummaryInputExpr::Constant(1.), 4.), ] { let nodes = vec![ - PostAsapDAGNode { - id: PostAsapNodeId(0), - payload: PostAsapOperatorPayload::SummaryMerge, + PhysicalASAPDAGNode { + id: 0, + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryMerge { + children: vec![], + }), output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: state_schema.clone(), guarantee: None, }, - PostAsapDAGNode { - id: PostAsapNodeId(1), - payload: PostAsapOperatorPayload::Value { - operation: ValueOperation::FinalizeExactAccumulator, - }, + PhysicalASAPDAGNode { + id: 1, + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::FinalizeExactAccumulator { + child: 0, + }), output_state: ExecutionDataState::INGESTION_ROWS, output_schema: value_schema.clone(), guarantee: None, }, - PostAsapDAGNode { - id: PostAsapNodeId(2), - payload: PostAsapOperatorPayload::Binary { - operator: BinaryOperator { - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, + PhysicalASAPDAGNode { + id: 2, + payload: PhysicalASAPOperatorPayload::NonASAP( + planner_types::ir::NonASAPOp::BinaryOp { + operator: BinaryOperator { + kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + return_bool: false, + lhs: 1, + rhs: 1, }, - }, + ), output_state: ExecutionDataState::INGESTION_ROWS, output_schema: value_schema.clone(), guarantee: None, }, - PostAsapDAGNode { - id: PostAsapNodeId(3), - payload: PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPDAGNode { + id: 3, + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: family.clone(), input: SummaryUpdate { weight, @@ -91,7 +103,8 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { reduction: Reduction::Reduce(GroupKeys::by(vec![])), grouping: GroupingStrategy::PerSubpopulationInstance, filter: None, - }, + child: 2, + }), output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: state_schema.clone(), guarantee: None, @@ -104,20 +117,20 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { (2, 3, EdgeRole::Input), ] .into_iter() - .map(|(producer, consumer, role)| PostAsapDAGEdge { - producer: PostAsapNodeId(producer), - consumer: PostAsapNodeId(consumer), + .map(|(producer, consumer, role)| PhysicalASAPDAGEdge { + producer, + consumer, role, - intermediate_schema: nodes[producer as usize].output_schema.clone(), - data_state: nodes[producer as usize].output_state, + intermediate_schema: nodes[producer].output_schema.clone(), + data_state: nodes[producer].output_state, grouping: GroupingEdgeCompatibility::NotApplicable, window: WindowEdgeCompatibility::NotApplicable, }) .collect(); - let dag = PostAsapDAG { + let dag = PhysicalASAPDAG { nodes, edges, - root: PostAsapNodeId(3), + roots: vec![3], }; // Identity metadata must remain one non-null Utf8 column. for mutation in 0..3 { @@ -131,7 +144,7 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { assert!(precompute::compile(&invalid_identity, &[0], &[3]).is_err()); } let mut invalid_grouping = dag.clone(); - let PostAsapOperatorPayload::SummaryAgg { reduction, .. } = + let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { reduction, .. }) = &mut invalid_grouping.nodes[3].payload else { unreachable!() @@ -211,7 +224,7 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { .as_any() .downcast_ref::() .unwrap() - .readout(Statistic::Sum, None, None) + .evaluation(Statistic::Sum, None, None) .unwrap() .unwrap(), expected @@ -221,9 +234,9 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { } fn logical_schema(family: FieldDataType) -> Schema { - Schema { - closed: true, + planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![Field { table: None, name: "value".into(), @@ -238,47 +251,48 @@ fn state_dag( target: Option, merge: bool, ) -> CompiledPhysicalDAG { - let mut nodes = vec![PostAsapDAGNode { - id: PostAsapNodeId(0), - payload: PostAsapOperatorPayload::SummaryMerge, + let mut nodes = vec![PhysicalASAPDAGNode { + id: 0, + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryMerge { children: vec![] }), output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: logical_schema(family.clone()), guarantee: None, }]; if merge { - nodes.push(PostAsapDAGNode { - id: PostAsapNodeId(1), - payload: PostAsapOperatorPayload::SummaryMerge, + nodes.push(PhysicalASAPDAGNode { + id: 1, + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryMerge { children: vec![0] }), ..nodes[0].clone() }); } - let read_id = nodes.len() as u32; - nodes.push(PostAsapDAGNode { - id: PostAsapNodeId(read_id), - payload: PostAsapOperatorPayload::Value { - operation: ValueOperation::FinalizeExactAccumulator, - }, + let read_id = nodes.len(); + nodes.push(PhysicalASAPDAGNode { + id: read_id, + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::FinalizeExactAccumulator { + child: read_id - 1, + }), output_state: ExecutionDataState::INGESTION_ROWS, output_schema: logical_schema(FieldDataType::Plain(DataType::Float64)), guarantee: None, }); if let Some(target) = target { - nodes.push(PostAsapDAGNode { - id: PostAsapNodeId(nodes.len() as u32), - payload: PostAsapOperatorPayload::SummaryAgg { + nodes.push(PhysicalASAPDAGNode { + id: nodes.len(), + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: target.clone(), input: SummaryUpdate::column(ColumnRef::SampleValue), reduction: Reduction::by(vec![]), grouping: GroupingStrategy::default(), filter: None, - }, + child: read_id, + }), output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: logical_schema(target), guarantee: None, }); } let edges = (1..nodes.len()) - .map(|i| PostAsapDAGEdge { + .map(|i| PhysicalASAPDAGEdge { producer: nodes[i - 1].id, consumer: nodes[i].id, role: EdgeRole::Input, @@ -290,9 +304,13 @@ fn state_dag( .collect(); let root = nodes.last().unwrap().id; precompute::compile( - &PostAsapDAG { nodes, edges, root }, + &PhysicalASAPDAG { + nodes, + edges, + roots: vec![root], + }, &[0], - &[u64::from(root.0)], + &[root as u64], ) .unwrap() } @@ -374,7 +392,7 @@ fn explicit_merge_changes_pane_cardinality() { .iter() .map(|row| match row[2] { Value::Float64(v) => v, - _ => panic!("numeric readout expected"), + _ => panic!("numeric evaluation expected"), }) .collect::>(); assert_eq!(values, expected); diff --git a/crates/asap-physical-operators/tests/promql_binary.rs b/crates/asap-physical-operators/tests/promql_binary.rs index 4eec88aa1..3fd9c19a0 100644 --- a/crates/asap-physical-operators/tests/promql_binary.rs +++ b/crates/asap-physical-operators/tests/promql_binary.rs @@ -1,24 +1,27 @@ //! Binary computation must be fully compiled before deployment binds values. use asap_physical_operators::{ operators::Operator, - physical_planner::{compile_node, CompiledPhysicalDAG, InputContract, Source}, runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{compile_node, CompiledPhysicalDAG, InputContract, Source}, values::{Batch, SchemaRef, Value}, }; use futures::{executor::block_on, StreamExt}; +use planner_types::ir::physical_export::{ + EdgeRole, GroupingEdgeCompatibility, PhysicalASAPDAG, PhysicalASAPDAGEdge, PhysicalASAPDAGNode, + PhysicalASAPOperatorPayload, WindowEdgeCompatibility, +}; +use planner_types::ir::ASAPOp; +use planner_types::ir::BinaryOperator; use planner_types::{ - post_asap::{ - BinaryOperator, ExecutionDataState, Field, FieldDataType, PostAsapDAGNode, PostAsapNodeId, - PostAsapOperatorPayload, Schema, - }, + post_asap::{ExecutionDataState, Field, FieldDataType}, pre_asap::{ArithmeticOpKind, BinaryOpKind, DataType}, }; use std::{collections::BTreeMap, sync::Arc}; fn schema() -> SchemaRef { - Arc::new(Schema { - closed: true, + Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![ Field { table: None, @@ -61,10 +64,18 @@ fn program() -> CompiledPhysicalDAG { }) } fn program_for(operator: BinaryOperator) -> CompiledPhysicalDAG { + program_for_bool(operator, false) +} +fn program_for_bool(operator: BinaryOperator, return_bool: bool) -> CompiledPhysicalDAG { let schema = schema(); - let node = PostAsapDAGNode { - id: PostAsapNodeId(2), - payload: PostAsapOperatorPayload::Binary { operator }, + let node = PhysicalASAPDAGNode { + id: 2, + payload: PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::BinaryOp { + operator, + return_bool, + lhs: 0, + rhs: 1, + }), output_state: ExecutionDataState::QUERY_ROWS, output_schema: (*schema).clone(), guarantee: None, @@ -152,16 +163,19 @@ fn duplicate_matching_identity_is_rejected() { // Scalar broadcasting and comparison filtering keep the vector operand's value. #[test] fn scalar_broadcast_and_bool_comparison_are_distinct() { - use asap_physical_operators::physical_planner::promql_values; + use asap_physical_operators::unified_physical_planner::promql_values; use planner_types::pre_asap::CompareOpKind; for return_bool in [false, true] { let physical_dag = promql_values::compile_binary( - &BinaryOperator { - kind: BinaryOpKind::Compare(CompareOpKind::Lt), - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, - }, + &asap_physical_operators::expressions::binary::BinaryOperator::from_logical( + &BinaryOperator { + kind: BinaryOpKind::Compare(CompareOpKind::Lt), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + return_bool, + ), return_bool, true, false, @@ -285,12 +299,15 @@ fn binary_obeys_memory_and_cancellation() { // A `bool` comparison over label-map vectors yields 1 or 0 and drops the name. #[test] fn label_map_bool_comparison_drops_the_name() { - let program = program_for(BinaryOperator { - kind: BinaryOpKind::CompareBool(planner_types::pre_asap::CompareOpKind::Gt), - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, - }); + let program = program_for_bool( + BinaryOperator { + kind: BinaryOpKind::Compare(planner_types::pre_asap::CompareOpKind::Gt), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + true, + ); let rows = evaluate_with( program, vec![row("a", "api", 6.)], @@ -309,24 +326,25 @@ fn label_map_bool_comparison_drops_the_name() { assert!(matches!(row[1], Value::Float64(v) if v == 1.)); } -// Stored temporal readouts drop metric names before filter comparisons and set matching. +// Stored temporal evaluations drop metric names before filter comparisons and set matching. #[test] -fn stored_series_readouts_support_filters_and_sets() { +fn stored_series_evaluations_support_filters_and_sets() { use asap_physical_operators::{ - physical_planner::compile, summary_kernels::exact::ExactAccumulator, + summary_kernels::exact::ExactAccumulator, unified_physical_planner::compile, }; use planner_types::post_asap::*; use planner_types::pre_asap::{ schema::PROMQL_SERIES_IDENTITY, CompareOpKind, PromQLVectorSetOpKind, }; + for (exact_kind, params) in [ (ExactKind::Sum, ExactParams::Sum), (ExactKind::Count, ExactParams::Count), ] { let family = FieldDataType::ExactAggregate(exact_kind.clone(), params); - let state_schema = Arc::new(Schema { - closed: true, + let state_schema = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![ Field { table: None, @@ -350,36 +368,44 @@ fn stored_series_readouts_support_filters_and_sets() { BinaryOpKind::Set(PromQLVectorSetOpKind::And), BinaryOpKind::Set(PromQLVectorSetOpKind::Or), ] { - let nodes = (0..5) - .map(|id| PostAsapDAGNode { - id: PostAsapNodeId(id), - payload: match id { - 0 | 1 => PostAsapOperatorPayload::SummaryMerge, - 2 | 3 => PostAsapOperatorPayload::Value { - operation: ValueOperation::FinalizeExactAccumulator, + let nodes = + (0..5) + .map(|id| PhysicalASAPDAGNode { + id, + payload: match id { + 0 | 1 => PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryMerge { + children: vec![], + }), + 2 | 3 => PhysicalASAPOperatorPayload::ASAP( + ASAPOp::FinalizeExactAccumulator { child: id - 2 }, + ), + _ => PhysicalASAPOperatorPayload::NonASAP( + planner_types::ir::NonASAPOp::BinaryOp { + operator: BinaryOperator { + kind: kind.clone(), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + return_bool: false, + lhs: 2, + rhs: 3, + }, + ), }, - _ => PostAsapOperatorPayload::Binary { - operator: BinaryOperator { - kind: kind.clone(), - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, - }, + output_state: if id < 2 { + ExecutionDataState::INGESTION_SUMMARY + } else { + ExecutionDataState::QUERY_ROWS }, - }, - output_state: if id < 2 { - ExecutionDataState::INGESTION_SUMMARY - } else { - ExecutionDataState::QUERY_ROWS - }, - output_schema: if id < 2 { - (*state_schema).clone() - } else { - value_schema.clone() - }, - guarantee: None, - }) - .collect::>(); + output_schema: if id < 2 { + (*state_schema).clone() + } else { + value_schema.clone() + }, + guarantee: None, + }) + .collect::>(); let edges = [ (0, 2, EdgeRole::Input), (1, 3, EdgeRole::Input), @@ -387,20 +413,20 @@ fn stored_series_readouts_support_filters_and_sets() { (3, 4, EdgeRole::Right), ] .into_iter() - .map(|(producer, consumer, role)| PostAsapDAGEdge { - producer: PostAsapNodeId(producer), - consumer: PostAsapNodeId(consumer), + .map(|(producer, consumer, role)| PhysicalASAPDAGEdge { + producer, + consumer, role, - intermediate_schema: nodes[producer as usize].output_schema.clone(), - data_state: nodes[producer as usize].output_state, + intermediate_schema: nodes[producer].output_schema.clone(), + data_state: nodes[producer].output_state, grouping: GroupingEdgeCompatibility::NotApplicable, window: WindowEdgeCompatibility::NotApplicable, }) .collect(); - let dag = PostAsapDAG { + let dag = PhysicalASAPDAG { nodes, edges, - root: PostAsapNodeId(4), + roots: vec![4], }; let physical_dag = compile( &dag, diff --git a/crates/asap-physical-operators/tests/promql_fallback.rs b/crates/asap-physical-operators/tests/promql_fallback.rs deleted file mode 100644 index 45b3526f6..000000000 --- a/crates/asap-physical-operators/tests/promql_fallback.rs +++ /dev/null @@ -1,1594 +0,0 @@ -//! A retained PromQL sub-DAG (`Fallback`) compiles from its typed expression. -//! The deployment supplies only its selector's raw series; expected values are -//! hand-computed with Prometheus semantics. -use asap_physical_operators::{ - operators::Operator, - physical_planner::{compile, promql_fallback, promql_rows, CompiledPhysicalDAG, InputContract}, - runtime::{Limits, RunContext, Scope}, - values::{Batch, Value}, -}; -use futures::{executor::block_on, StreamExt}; -use planner_types::{ - post_asap::{execution_data_state::lift_plain, *}, - pre_asap::QueryExpr, - types::AccuracyTarget, - workload::*, -}; -use std::{collections::BTreeMap, rc::Rc}; - -/// Bare selectors look back one ingestion interval: 60s. -fn parse(query: &str) -> QueryExpr { - parse_with(query, AccuracyTarget::Exact) -} - -fn parse_with(query: &str, accuracy: AccuracyTarget) -> QueryExpr { - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy: AccuracyRequirement::Explicit(accuracy), - ..Default::default() - }, - predictability: Predictability::Unknown, - invocations: 1, - execute_at: None, - time_selection: TimeSelection::default(), - }]), - repeating_queries: None, - }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(60_000)), - ..Default::default() - }, - ..Default::default() - }), - }; - asap_frontend_promql::lower_promql_workload(&workload, 0) - .unwrap() - .remove(0) -} - -fn lower(query: &str) -> QueryExpr { - promql_rows::with_series_identity(&parse(query)).unwrap() -} - -/// The whole query retained as one pre-ASAP node. -fn fallback_dag(expression: QueryExpr) -> PostAsapDAG { - let schema = lift_plain(&expression.output_schema().unwrap()); - compile_post_asap_dag(&Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::new(expression)), - schema, - guarantee: None, - })) - .unwrap() -} - -/// `(labels, seconds, value)`. `labels` is `k=v,...`, or a bare `job` value. -type Sample = (&'static str, i64, f64); - -fn labels(spec: &str) -> BTreeMap { - if !spec.contains('=') { - return BTreeMap::from([("job".into(), spec.into())]); - } - spec.split(',') - .map(|pair| { - let (k, v) = pair.split_once('=').unwrap(); - (k.to_string(), v.to_string()) - }) - .collect() -} - -/// The metric a selector reads. -fn metric(selector: &QueryExpr) -> String { - match selector { - QueryExpr::Scan { - source: planner_types::pre_asap::Source::TimeSeries { metric }, - .. - } => metric.clone(), - QueryExpr::TimeRange { child, .. } | QueryExpr::TimeShift { child, .. } => metric(child), - other => panic!("not a selector: {other:?}"), - } -} - -fn compile_query(query: &str) -> Result { - let expression = lower(query); - compile_dag(&expression, &fallback_dag(expression.clone())) -} - -/// Compile a DAG whose root is the Fallback computing `expression`. -fn compile_dag(expression: &QueryExpr, dag: &PostAsapDAG) -> Result { - let root = u64::from(dag.root.0); - let inputs = promql_fallback::raw_series(expression) - .map_err(|e| e.to_string())? - .into_iter() - .enumerate() - .map(|(i, (_, schema))| { - ( - promql_fallback::raw_series_input(root, i), - InputContract::bounded(schema), - ) - }) - .collect(); - let program = compile(dag, inputs, &[root]).map_err(|e| e.to_string())?; - Ok(serde_json::from_slice(&serde_json::to_vec(&program).unwrap()).unwrap()) -} - -/// Evaluate at `at` seconds over samples of each named metric; returns -/// `(output labels, timestamp ms, value)` rows in order. -#[allow(clippy::type_complexity)] -fn evaluate( - query: &str, - metrics: &[(&str, &[Sample])], - at: i64, -) -> Result, i64, f64)>, String> { - let expression = lower(query); - evaluate_dag(&expression, &fallback_dag(expression.clone()), metrics, at) -} - -#[allow(clippy::type_complexity)] -fn evaluate_dag( - expression: &QueryExpr, - dag: &PostAsapDAG, - metrics: &[(&str, &[Sample])], - at: i64, -) -> Result, i64, f64)>, String> { - evaluate_dag_with_range(expression, dag, metrics, at, None) -} - -#[allow(clippy::type_complexity)] -fn evaluate_dag_with_range( - expression: &QueryExpr, - dag: &PostAsapDAG, - metrics: &[(&str, &[Sample])], - at: i64, - bounds: Option<(i64, i64)>, -) -> Result, i64, f64)>, String> { - let program = compile_dag(expression, dag)?; - let mut sources = BTreeMap::new(); - let selectors = promql_fallback::raw_series(expression).unwrap(); - for (i, (selector, schema)) in selectors.into_iter().enumerate() { - let name = metric(&selector); - let rows = metrics - .iter() - .filter(|(m, _)| *m == name) - .flat_map(|(_, samples)| samples.iter()) - .map(|(spec, seconds, value)| { - let mut labels = labels(spec); - // A sample may supply its own `__name__`, as a series of another metric. - labels.entry("__name__".into()).or_insert(name.clone()); - promql_rows::series_row(&schema, &labels, seconds * 1000, *value).unwrap() - }) - .collect(); - let batch = Batch::try_new(schema.clone(), rows).unwrap(); - sources.insert( - promql_fallback::raw_series_input(program.roots()[0], i), - Box::new(Operator::source(schema, vec![batch]).unwrap()) as _, - ); - } - let physical_dag = program.instantiate(sources).map_err(|e| e.to_string())?; - let context = RunContext::new( - Scope::Query { - evaluation_time_ms: at * 1000, - revision: 0, - }, - Limits::default(), - ) - .unwrap(); - let context = match bounds { - Some((start, end)) => context - .with_query_range(start * 1000, end * 1000) - .map_err(|e| e.to_string())?, - None => context, - }; - block_on(async { - let mut stream = physical_dag - .execute(program.roots(), context) - .map_err(|e| e.to_string())? - .remove(0); - let mut rows = Vec::new(); - while let Some(batch) = stream.next().await { - let batch = batch.map_err(|e| e.to_string())?; - let schema = batch.schema().clone(); - for row in batch.rows() { - let mut labels = BTreeMap::new(); - let mut time = -1; - let mut value = None; - for (field, cell) in schema.fields.iter().zip(row) { - match (field.name.as_str(), cell) { - (promql_rows::SERIES_IDENTITY_COLUMN, Value::Utf8(id)) => { - labels = promql_rows::decode_series_identity(id).unwrap() - } - (_, Value::Utf8(_) | Value::Null) => {} - (_, Value::Timestamp(t)) => time = *t, - (_, Value::Float64(v)) => value = Some(*v), - (_, Value::Int64(v)) => value = Some(*v as f64), - other => return Err(format!("unexpected cell {other:?}")), - } - } - if !schema - .fields - .iter() - .any(|f| f.name == promql_rows::SERIES_IDENTITY_COLUMN) - { - for (field, cell) in schema.fields.iter().zip(row) { - if let Value::Utf8(label) = cell { - if !label.is_empty() { - labels.insert(field.name.clone(), label.to_string()); - } - } - } - } - rows.push((labels, time, value.ok_or("missing value")?)); - } - } - Ok(rows) - }) -} - -/// Evaluate at `at` seconds over metric `m`; returns `(job or "", timestamp ms, value)`. -fn run(query: &str, samples: &[Sample], at: i64) -> Result, String> { - Ok(evaluate(query, &[("m", samples)], at)? - .into_iter() - .map(|(labels, time, value)| (labels.get("job").cloned().unwrap_or_default(), time, value)) - .collect()) -} - -/// Output rows as `(k=v,... sorted, value)`, including any `__name__`. -fn labeled(query: &str, metrics: &[(&str, &[Sample])], at: i64) -> Vec<(String, f64)> { - let mut rows = evaluate(query, metrics, at) - .unwrap_or_else(|e| panic!("{query}: {e}")) - .into_iter() - .map(|(labels, _, value)| { - let spec = labels - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(","); - (spec, value) - }) - .collect::>(); - rows.sort_by(|a, b| a.0.cmp(&b.0)); - rows -} - -fn values(query: &str, samples: &[Sample], at: i64) -> Vec<(String, f64)> { - run(query, samples, at) - .unwrap_or_else(|e| panic!("{query}: {e}")) - .into_iter() - .map(|(job, _, value)| (job, value)) - .collect() -} - -fn one(query: &str, samples: &[Sample], at: i64) -> f64 { - match values(query, samples, at).as_slice() { - [(_, value)] => *value, - other => panic!("{query}: expected one sample, got {other:?}"), - } -} - -const COUNTER: &[Sample] = &[ - ("a", 60, 10.), - ("a", 120, 20.), - ("a", 180, 5.), - ("a", 240, 15.), -]; - -// rate/increase correct the reset at 180s and extrapolate half an interval at -// most; delta treats the same samples as a gauge. -#[test] -fn range_functions_follow_prometheus_extrapolation_and_resets() { - // Reset-corrected increase is 25 over 180s of samples; 60s on each side extrapolates. - let increase = 25. * (180. + 60. + 60.) / 180.; - assert!((one("increase(m[5m])", COUNTER, 300) - increase).abs() < 1e-9); - assert!((one("rate(m[5m])", COUNTER, 300) - increase / 300.).abs() < 1e-12); - let delta = 5. * (180. + 60. + 60.) / 180.; - assert!((one("delta(m[5m])", COUNTER, 300) - delta).abs() < 1e-9); - // Fewer than two samples yield no rate. - assert!(values("rate(m[2m])", COUNTER, 300).is_empty()); - for (query, expected) in [ - ("sum_over_time(m[5m])", 50.), - ("avg_over_time(m[5m])", 12.5), - ("min_over_time(m[5m])", 5.), - ("max_over_time(m[5m])", 20.), - ("count_over_time(m[5m])", 4.), - ] { - assert_eq!(one(query, COUNTER, 300), expected, "{query}"); - } -} - -// Ranges are left-open: a sample at `t - range` is excluded, one at `t` is included. -#[test] -fn ranges_exclude_their_start_and_offsets_shift_them() { - let samples = &[("a", 60, 1.), ("a", 90, 1.), ("a", 120, 1.), ("a", 150, 1.)]; - assert_eq!(one("count_over_time(m[1m])", samples, 120), 2.); - // offset 1m reads (60s, 120s] at 180s; output keeps the evaluation time. - let rows = run("count_over_time(m[1m] offset 1m)", samples, 180).unwrap(); - assert_eq!(rows, vec![("a".into(), 180_000, 2.)]); -} - -// A bare selector takes the latest sample within the lookback; a stale marker -// hides the series rather than exposing an older value. -#[test] -fn instant_selection_uses_lookback_and_stale_markers() { - let stale = f64::from_bits(0x7ff0_0000_0000_0002); - let samples = &[("a", 0, 1.), ("a", 30, 2.), ("b", 30, 3.), ("b", 50, stale)]; - assert_eq!(values("m", samples, 60), vec![("a".into(), 2.)]); - // The lookback (30s, 90s] excludes the sample at 30s. - assert!(values("m", samples, 90).is_empty()); - // Range functions skip stale markers. - assert_eq!( - values("sum_over_time(m[1m])", samples, 60), - vec![("a".into(), 2.), ("b".into(), 3.)] - ); -} - -// NaN samples follow Prometheus: min/max skip them, sums propagate them. -#[test] -fn nan_samples() { - let samples = &[("a", 10, f64::NAN), ("a", 20, 3.), ("a", 30, 1.)]; - assert_eq!(one("max_over_time(m[1m])", samples, 60), 3.); - assert_eq!(one("min_over_time(m[1m])", samples, 60), 1.); - assert!(one("sum_over_time(m[1m])", samples, 60).is_nan()); -} - -// Aggregation over no series is an empty vector, not one zero or null row; -// sort_desc orders the selected series. -#[test] -fn cross_series_aggregates_and_empty_inputs() { - let samples = &[("a", 50, 1.), ("b", 40, 2.), ("b", 55, 4.)]; - assert_eq!(values("sum(m)", samples, 60), vec![(String::new(), 5.)]); - assert_eq!(values("count(m)", samples, 60), vec![(String::new(), 2.)]); - assert_eq!( - values("max by (job) (m)", samples, 60), - vec![("a".into(), 1.), ("b".into(), 4.)] - ); - assert_eq!( - values("sort_desc(m)", samples, 60), - vec![("b".into(), 4.), ("a".into(), 1.)] - ); - // topk by (job) keeps the top series of each job, not one overall. - let jobs = &[("a", 50, 1.), ("b", 50, 2.)]; - let mut top = values("topk by (job) (1, m)", jobs, 60); - top.sort_by(|x, y| x.0.cmp(&y.0)); - assert_eq!(top, vec![("a".into(), 1.), ("b".into(), 2.)]); - assert_eq!(values("topk(1, m)", jobs, 60), vec![("b".into(), 2.)]); - for query in ["sum(m)", "count(m)", "max(m)", "sum by (job) (rate(m[5m]))"] { - assert!(values(query, &[], 60).is_empty(), "{query}"); - } -} - -// scalar() is the single series' value and NaN otherwise; vector() needs no input. -#[test] -fn scalar_and_vector_bridges() { - assert_eq!(one("scalar(m)", &[("a", 50, 7.)], 60), 7.); - assert!(one("scalar(m)", &[("a", 50, 7.), ("b", 50, 8.)], 60).is_nan()); - assert!(one("scalar(m)", &[], 60).is_nan()); - assert_eq!( - run("vector(3)", &[], 60).unwrap(), - vec![(String::new(), 60_000, 3.)] - ); - assert_eq!( - values("2 - m", &[("a", 50, 7.)], 60), - vec![("a".into(), -5.)] - ); - assert_eq!( - values("m * 2", &[("a", 50, 7.)], 60), - vec![("a".into(), 14.)] - ); -} - -// Subquery steps are absolute multiples of the resolution in the left-open -// range; each step evaluates the operand, and the outer function reduces them. -#[test] -fn subqueries_evaluate_their_operand_on_the_aligned_grid() { - // Steps 60..300: selections 1, 7, 3, (none at 240s), 4. - let samples = &[ - ("a", 50, 1.), - ("a", 110, 7.), - ("a", 170, 3.), - ("a", 290, 4.), - ]; - assert_eq!(one("max_over_time(m[5m:1m])", samples, 300), 7.); - assert_eq!(one("count_over_time(m[5m:1m])", samples, 300), 4.); - // At 190s the steps are 60, 120, 180 (not 70, 130, 190): counts 1 + 2 + 2. - let samples = &[("a", 30, 1.), ("a", 90, 1.), ("a", 150, 1.), ("a", 185, 1.)]; - assert_eq!( - one("sum_over_time(count_over_time(m[2m])[3m:1m])", samples, 190), - 5. - ); - // offset 1m moves the grid to (-50s, 130s]: steps 0, 60, 120 count 0 + 1 + 2. - assert_eq!( - one( - "sum_over_time(count_over_time(m[2m])[3m:1m] offset 1m)", - samples, - 190 - ), - 3. - ); -} - -// Subquery work is bounded by the query: at most 100000 steps. -#[test] -fn dense_subquery_grids_are_rejected() { - assert!(compile_query("max_over_time(m[100s:1ms])").is_ok()); - assert!(compile_query("max_over_time(m[30d:1ms])").is_err()); -} - -// The deployment must supply the selector's raw rows under the documented slot -// with the exact selector schema; unsupported shapes stay rejected. -#[test] -fn raw_series_contract_is_explicit() { - let expression = lower("rate(m[5m])"); - let dag = fallback_dag(expression.clone()); - let root = u64::from(dag.root.0); - let [(selector, schema)] = promql_fallback::raw_series(&expression) - .unwrap() - .try_into() - .unwrap(); - assert!(matches!(selector, QueryExpr::TimeRange { .. })); - let missing = compile(&dag, BTreeMap::new(), &[root]).err().unwrap(); - assert!(missing.to_string().contains("raw series input")); - let mut wrong = (*schema).clone(); - wrong.fields.pop(); - let wrong = compile( - &dag, - BTreeMap::from([( - promql_fallback::raw_series_input(root, 0), - InputContract::bounded(std::sync::Arc::new(wrong)), - )]), - &[root], - ); - assert!(wrong.is_err()); - // A consumed bare selector is raw range rows for its consumer; it is not - // turned into instant selection. - let selector = lower("m"); - let schema = lift_plain(&selector.output_schema().unwrap()); - let node = |id, payload| PostAsapDAGNode { - id: PostAsapNodeId(id), - payload, - output_state: ExecutionDataState::QUERY_ROWS, - output_schema: schema.clone(), - guarantee: None, - }; - let consumed = PostAsapDAG { - nodes: vec![ - node( - 0, - PostAsapOperatorPayload::Fallback { - expression: selector.clone(), - }, - ), - node( - 1, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Limit { - n: 1, - offset: 0, - partition_by: Default::default(), - }, - }, - ), - ], - edges: vec![PostAsapDAGEdge { - producer: PostAsapNodeId(0), - consumer: PostAsapNodeId(1), - role: EdgeRole::Input, - intermediate_schema: schema.clone(), - data_state: ExecutionDataState::QUERY_ROWS, - grouping: GroupingEdgeCompatibility::NotApplicable, - window: WindowEdgeCompatibility::NotApplicable, - }], - root: PostAsapNodeId(1), - }; - let raw = promql_fallback::raw_series(&selector).unwrap().remove(0).1; - assert!(compile( - &consumed, - BTreeMap::from([( - promql_fallback::raw_series_input(0, 0), - InputContract::bounded(raw) - )]), - &[1], - ) - .is_err()); - // Implicit subquery resolution belongs to the deployment's evaluation interval. - assert!(compile_query("max_over_time(m[5m:])").is_err()); -} - -// irate/idelta use the last two samples (irate corrects a reset to the last -// value); changes/resets count value changes and decreases; quantile_over_time -// interpolates; all skip stale markers. -#[test] -fn instant_and_counting_range_functions() { - // COUNTER in (0s, 300s]: 10, 20, 5, 15. - assert!((one("irate(m[5m])", COUNTER, 300) - 10. / 60.).abs() < 1e-12); - assert_eq!(one("idelta(m[5m])", COUNTER, 300), 10.); - // At 200s the last pair 20 -> 5 is a reset: irate uses 5 as the increase. - assert!((one("irate(m[5m])", COUNTER, 200) - 5. / 60.).abs() < 1e-12); - assert_eq!(one("idelta(m[5m])", COUNTER, 200), -15.); - assert!(values("irate(m[1m])", COUNTER, 300).is_empty()); - assert_eq!(one("changes(m[5m])", COUNTER, 300), 3.); - assert_eq!(one("resets(m[5m])", COUNTER, 300), 1.); - assert_eq!(one("changes(m[2m])", COUNTER, 300), 0.); - // NaN to NaN is not a change; any other transition involving NaN is. - let flat = &[ - ("a", 10, 1.), - ("a", 20, 1.), - ("a", 30, 2.), - ("a", 40, f64::NAN), - ("a", 50, f64::NAN), - ("a", 55, 1.), - ]; - assert_eq!(one("changes(m[1m])", flat, 60), 3.); - let stale = f64::from_bits(0x7ff0_0000_0000_0002); - let ended = &[("a", 240, 15.), ("a", 250, stale)]; - assert_eq!(one("last_over_time(m[5m])", ended, 300), 15.); - assert!(values("m", ended, 300).is_empty()); - // Sorted 5, 10, 15, 20: rank 1.5 and 0.75; outside [0, 1] is +-Inf. - assert_eq!(one("quantile_over_time(0.5, m[5m])", COUNTER, 300), 12.5); - assert_eq!(one("quantile_over_time(0.25, m[5m])", COUNTER, 300), 8.75); - assert_eq!( - one("quantile_over_time(2, m[5m])", COUNTER, 300), - f64::INFINITY - ); - assert_eq!( - one("quantile_over_time(-1, m[5m])", COUNTER, 300), - f64::NEG_INFINITY - ); -} - -// `@ ` evaluates the selector or subquery at `t`, minus any offset, and the -// result keeps the query's evaluation time. -#[test] -fn at_modifier_fixes_the_evaluation_instant() { - let samples = &[("a", 60, 1.), ("a", 120, 2.), ("a", 180, 3.)]; - assert_eq!( - run("m @ 120", samples, 1000).unwrap(), - vec![("a".into(), 1_000_000, 2.)] - ); - assert!(values("m", samples, 1000).is_empty()); - assert_eq!(one("count_over_time(m[2m] @ 180)", samples, 1000), 2.); - assert_eq!(one("m @ 180 offset 1m", samples, 1000), 2.); - // The subquery grid is (60s, 180s]: steps 120 and 180 select 2 and 3. - assert_eq!(one("max_over_time(m[2m:1m] @ 180)", samples, 1000), 3.); - assert_eq!( - one("sum_over_time(m[2m:1m] @ 180 offset 1m)", samples, 1000), - 3. - ); - // An inner @ pins every step to the same instant. - assert_eq!(one("sum_over_time((m @ 60)[2m:1m])", samples, 180), 2.); - // start() and end() depend on the range query, which is the deployment's. - assert!(evaluate("m @ start()", &[], 60) - .unwrap_err() - .contains("query range bounds")); -} - -const A: &[Sample] = &[("job=x", 50, 10.), ("job=y", 50, 20.), ("job=w", 50, 0.)]; -const B: &[Sample] = &[("job=x", 50, 2.), ("job=z", 50, 5.), ("job=w", 50, 0.)]; - -// Vector-vector arithmetic matches series one-to-one on label sets without -// the metric name, and the result drops the metric name. -#[test] -fn vector_arithmetic_matches_label_sets() { - let metrics = &[("a", A), ("b", B)]; - let quotient = labeled("a / b", metrics, 60); - assert_eq!(quotient.len(), 2); - assert_eq!(quotient[0].0, "job=w"); - assert!(quotient[0].1.is_nan(), "0 / 0 is NaN"); - assert_eq!(quotient[1], ("job=x".into(), 5.)); - // Each selector reads its own raw rows, even a repeated metric. - assert_eq!( - labeled("(a - b) * a", metrics, 60), - vec![("job=w".into(), 0.), ("job=x".into(), 80.)] - ); - assert_eq!( - labeled("sum by (job) (a) - sum by (job) (b)", metrics, 60), - vec![("job=w".into(), 0.), ("job=x".into(), 8.)] - ); - // Rates of two counters over their own windows. - let up: &[Sample] = &[("job=x", 0, 0.), ("job=x", 60, 60.)]; - let down: &[Sample] = &[("job=x", 0, 0.), ("job=x", 60, 30.)]; - assert_eq!( - labeled("rate(a[2m]) / rate(b[2m])", &[("a", up), ("b", down)], 60), - vec![("job=x".into(), 2.)] - ); -} - -// on() keeps only the listed labels and ignoring() drops them; a duplicate -// match group is an error unless the left duplicates never match. -#[test] -fn on_and_ignoring_select_the_matching_labels() { - let a: &[Sample] = &[("job=x,inst=1", 50, 10.)]; - let b: &[Sample] = &[("job=x,inst=2", 50, 4.)]; - let metrics = &[("a", a), ("b", b)]; - assert!(labeled("a - b", metrics, 60).is_empty()); - assert_eq!( - labeled("a - on(job) b", metrics, 60), - vec![("job=x".into(), 6.)] - ); - assert_eq!( - labeled("a - ignoring(inst) b", metrics, 60), - vec![("job=x".into(), 6.)] - ); - let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, 2.)]; - let other: &[Sample] = &[("job=y", 50, 1.)]; - assert!(evaluate("a + on(job) b", &[("a", a), ("b", pair)], 60).is_err()); - assert!(evaluate("a + on(job) b", &[("a", pair), ("b", b)], 60).is_err()); - assert!(labeled("a + on(job) b", &[("a", pair), ("b", other)], 60).is_empty()); -} - -// without() groups by every label except the listed ones and the metric name. -#[test] -fn without_grouping_drops_labels_and_the_name() { - let a: &[Sample] = &[ - ("job=x,inst=1", 50, 1.), - ("job=x,inst=2", 50, 2.), - ("job=y,inst=1", 50, 4.), - ]; - let metrics = &[("a", a)]; - assert_eq!( - labeled("sum without (inst) (a)", metrics, 60), - vec![("job=x".into(), 3.), ("job=y".into(), 4.)] - ); - assert_eq!( - labeled("count without (inst) (a)", metrics, 60), - vec![("job=x".into(), 2.), ("job=y".into(), 1.)] - ); - assert_eq!( - labeled("max without (job, inst) (a)", metrics, 60), - vec![(String::new(), 4.)] - ); - assert!(labeled("sum without (inst) (a)", &[], 60).is_empty()); - // Series equal without the name share a group rather than colliding. - let named: &[Sample] = &[ - ("job=x,inst=1", 50, 1.), - ("__name__=b,job=x,inst=1", 50, 2.), - ]; - assert_eq!( - labeled("sum without (inst) (a)", &[("a", named)], 60), - vec![("job=x".into(), 3.)] - ); -} - -// Arithmetic with a literal drops the metric name; series that then share a -// label set are an error, as in Prometheus. -#[test] -fn literal_arithmetic_drops_the_name_and_rejects_equal_label_sets() { - let a: &[Sample] = &[ - ("job=x,inst=1", 50, 1.), - ("__name__=b,job=x,inst=2", 50, 2.), - ]; - assert_eq!( - labeled("a * 2", &[("a", a)], 60), - vec![("inst=1,job=x".into(), 2.), ("inst=2,job=x".into(), 4.)] - ); - let equal: &[Sample] = &[("job=x", 50, 1.), ("__name__=b,job=x", 50, 2.)]; - let error = evaluate("a * 2", &[("a", equal)], 60).unwrap_err(); - assert!(error.contains("same labelset"), "{error}"); -} - -// An empty label value is an absent label, and an empty side yields an empty -// result before any duplicate check, as in Prometheus. -#[test] -fn empty_labels_and_empty_sides_match_prometheus() { - let a: &[Sample] = &[("job=x,env=", 50, 3.)]; - let b: &[Sample] = &[("job=x", 50, 1.)]; - assert_eq!( - labeled("a + b", &[("a", a), ("b", b)], 60), - vec![("job=x".into(), 4.)] - ); - let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, 2.)]; - assert!(labeled("a + on(job) b", &[("b", pair)], 60).is_empty()); - assert!(labeled("b + on(job) a", &[("b", pair)], 60).is_empty()); - assert!(labeled("a - time()", &[], 60).is_empty()); -} - -// Sums and averages use Prometheus' Kahan-Neumaier compensation, and an -// average whose running sum overflows switches to an incremental mean. -#[test] -fn sums_and_averages_are_compensated_like_prometheus() { - let cancel = &[("a", 10, 1e100), ("a", 20, 1.), ("a", 30, -1e100)]; - assert_eq!(one("sum_over_time(m[1m])", cancel, 60), 1.); - assert_eq!(one("avg_over_time(m[1m])", cancel, 60), 1. / 3.); - let huge = &[("a", 10, 1.7e308), ("a", 20, 1.7e308)]; - assert_eq!(one("avg_over_time(m[1m])", huge, 60), 1.7e308); - assert_eq!(one("sum_over_time(m[1m])", huge, 60), f64::INFINITY); - let infinite = &[("a", 10, f64::INFINITY), ("a", 20, 1.)]; - assert_eq!(one("sum_over_time(m[1m])", infinite, 60), f64::INFINITY); - assert_eq!(one("avg_over_time(m[1m])", infinite, 60), f64::INFINITY); - let opposite = &[("a", 10, f64::INFINITY), ("a", 20, f64::NEG_INFINITY)]; - assert!(one("sum_over_time(m[1m])", opposite, 60).is_nan()); - assert!(one("avg_over_time(m[1m])", opposite, 60).is_nan()); - let cancel = &[("a", 50, 1e100), ("b", 50, 1.), ("c", 50, -1e100)]; - assert_eq!(one("sum(m)", cancel, 60), 1.); - assert_eq!(one("avg(m)", cancel, 60), 1. / 3.); - let huge = &[("a", 50, 1.7e308), ("b", 50, 1.7e308)]; - assert_eq!(one("avg(m)", huge, 60), 1.7e308); -} - -/// `(k=v,... sorted, value)` rows for readable expectations. -fn rows(pairs: &[(&str, f64)]) -> Vec<(String, f64)> { - let mut rows = pairs - .iter() - .map(|(spec, value)| (spec.to_string(), *value)) - .collect::>(); - rows.sort_by(|a, b| a.0.cmp(&b.0)); - rows -} - -/// `labeled`, with NaN values rendered comparable. -fn labeled_nan(query: &str, metrics: &[(&str, &[Sample])], at: i64) -> Vec<(String, String)> { - labeled(query, metrics, at) - .into_iter() - .map(|(labels, value)| (labels, format!("{value:?}"))) - .collect() -} - -const C: &[Sample] = &[ - ("job=x", 50, 10.), - ("job=y", 50, 20.), - ("job=w", 50, 0.), - ("job=n", 50, f64::NAN), -]; - -// A comparison with a scalar keeps the matching series with their value and -// metric name, whichever side the scalar is on; `bool` yields 1 or 0 for every -// series and drops the name. NaN compares unequal to everything. -#[test] -fn scalar_comparisons_filter_or_return_bool() { - let metrics = &[("a", C)]; - let kept = rows(&[("__name__=a,job=x", 10.), ("__name__=a,job=y", 20.)]); - assert_eq!(labeled("a > 5", metrics, 60), kept); - assert_eq!(labeled("5 < a", metrics, 60), kept); - assert_eq!( - labeled("a <= 10", metrics, 60), - rows(&[("__name__=a,job=w", 0.), ("__name__=a,job=x", 10.)]) - ); - assert_eq!( - labeled("a > bool 5", metrics, 60), - rows(&[("job=n", 0.), ("job=w", 0.), ("job=x", 1.), ("job=y", 1.)]) - ); - assert_eq!( - labeled("10 == bool a", metrics, 60), - rows(&[("job=n", 0.), ("job=w", 0.), ("job=x", 1.), ("job=y", 0.)]) - ); - // scalar() of no series is NaN. - assert_eq!(labeled("a != scalar(b)", metrics, 60).len(), 4); - assert!(labeled("a == scalar(b)", metrics, 60).is_empty()); - assert!(labeled("a > 5", &[], 60).is_empty()); - // Only `bool` drops the name, so only it can make label sets collide. - let equal: &[Sample] = &[("job=x", 50, 1.), ("__name__=b,job=x", 50, 2.)]; - assert_eq!(labeled("a > 0", &[("a", equal)], 60).len(), 2); - let error = evaluate("a > bool 0", &[("a", equal)], 60).unwrap_err(); - assert!(error.contains("same labelset"), "{error}"); -} - -// Vector comparisons match one-to-one like arithmetic. A filter keeps the -// left series, name included, unless `on` reduces its labels; `bool` drops the -// name. A left duplicate is an error only if more than one of it is kept. -#[test] -fn vector_comparisons_match_one_to_one() { - let metrics = &[("a", A), ("b", B)]; - assert_eq!( - labeled("a > b", metrics, 60), - rows(&[("__name__=a,job=x", 10.)]) - ); - assert_eq!( - labeled("a >= b", metrics, 60), - rows(&[("__name__=a,job=w", 0.), ("__name__=a,job=x", 10.)]) - ); - assert_eq!( - labeled("a > bool b", metrics, 60), - rows(&[("job=w", 0.), ("job=x", 1.)]) - ); - assert!(labeled("a < b", metrics, 60).is_empty()); - let a: &[Sample] = &[("job=x,inst=1", 50, 10.)]; - let b: &[Sample] = &[("job=x,inst=2", 50, 4.)]; - let metrics = &[("a", a), ("b", b)]; - assert_eq!( - labeled("a > on(job) b", metrics, 60), - rows(&[("job=x", 10.)]) - ); - assert_eq!( - labeled("a > ignoring(inst) b", metrics, 60), - rows(&[("__name__=a,job=x", 10.)]) - ); - let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, 5.)]; - let metrics = &[("a", pair), ("b", b)]; - assert_eq!( - labeled("a > on(job) b", metrics, 60), - rows(&[("job=x", 5.)]) - ); - let error = evaluate("a > bool on(job) b", metrics, 60).unwrap_err(); - assert!(error.contains("many-to-one"), "{error}"); - let nan: &[Sample] = &[("job=x", 50, f64::NAN)]; - let metrics = &[("a", nan), ("b", nan)]; - assert_eq!(labeled("a == bool b", metrics, 60), rows(&[("job=x", 0.)])); - assert_eq!( - labeled_nan("a != b", metrics, 60), - vec![("__name__=a,job=x".into(), "NaN".into())] - ); -} - -const S: &[Sample] = &[ - ("job=x", 50, 1.), - ("job=y", 50, 2.), - ("job=z,inst=1", 50, 3.), -]; -const T: &[Sample] = &[ - ("job=x", 50, 10.), - ("job=w", 50, 20.), - ("job=z,inst=2", 50, 30.), -]; - -// Set operators match label sets many-to-many, ignoring the name by default, -// and return the original series unchanged. -#[test] -fn set_operators_match_label_sets() { - let metrics = &[("a", S), ("b", T)]; - assert_eq!( - labeled("a and b", metrics, 60), - rows(&[("__name__=a,job=x", 1.)]) - ); - assert_eq!( - labeled("a and on(job) b", metrics, 60), - rows(&[("__name__=a,job=x", 1.), ("__name__=a,inst=1,job=z", 3.)]) - ); - assert_eq!( - labeled("a and ignoring(inst) b", metrics, 60), - labeled("a and on(job) b", metrics, 60) - ); - assert_eq!( - labeled("a or b", metrics, 60), - rows(&[ - ("__name__=a,job=x", 1.), - ("__name__=a,job=y", 2.), - ("__name__=a,inst=1,job=z", 3.), - ("__name__=b,job=w", 20.), - ("__name__=b,inst=2,job=z", 30.), - ]) - ); - assert_eq!( - labeled("a or on(job) b", metrics, 60), - rows(&[ - ("__name__=a,job=x", 1.), - ("__name__=a,job=y", 2.), - ("__name__=a,inst=1,job=z", 3.), - ("__name__=b,job=w", 20.), - ]) - ); - assert_eq!( - labeled("a unless b", metrics, 60), - rows(&[("__name__=a,job=y", 2.), ("__name__=a,inst=1,job=z", 3.)]) - ); - assert_eq!( - labeled("a unless on(job) b", metrics, 60), - rows(&[("__name__=a,job=y", 2.)]) - ); - assert_eq!(labeled("a and on() b", metrics, 60).len(), 3); - // Empty sides, and duplicates on either side, which set operators allow. - let a_only = &[("a", S)]; - assert!(labeled("a and b", a_only, 60).is_empty()); - assert_eq!(labeled("a unless b", a_only, 60).len(), 3); - assert_eq!(labeled("b or a", a_only, 60).len(), 3); - let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, f64::NAN)]; - assert_eq!( - labeled_nan("a and on(job) b", &[("a", pair), ("b", pair)], 60), - vec![ - ("__name__=a,inst=1,job=x".into(), "1.0".into()), - ("__name__=a,inst=2,job=x".into(), "NaN".into()), - ] - ); -} - -const MANY: &[Sample] = &[ - ("job=x,inst=1", 50, 2.), - ("job=x,inst=2", 50, 3.), - ("job=y,inst=1", 50, 4.), -]; -const ONE: &[Sample] = &[("job=x,team=t1", 50, 10.), ("job=y", 50, 100.)]; - -// group_left/group_right match many series to one; the result keeps the many -// side's labels plus the listed labels of the one side, which a missing label -// removes. A filter keeps the left value. -#[test] -fn group_modifiers_match_many_to_one() { - let metrics = &[("a", MANY), ("info", ONE)]; - assert_eq!( - labeled("a * on(job) group_left(team) info", metrics, 60), - rows(&[ - ("inst=1,job=x,team=t1", 20.), - ("inst=2,job=x,team=t1", 30.), - ("inst=1,job=y", 400.), - ]) - ); - assert_eq!( - labeled("info - on(job) group_right a", metrics, 60), - rows(&[ - ("inst=1,job=x", 8.), - ("inst=2,job=x", 7.), - ("inst=1,job=y", 96.) - ]) - ); - assert_eq!( - labeled("info > on(job) group_right a", metrics, 60), - rows(&[ - ("__name__=a,inst=1,job=x", 10.), - ("__name__=a,inst=2,job=x", 10.), - ("__name__=a,inst=1,job=y", 100.), - ]) - ); - assert_eq!( - labeled("a > bool ignoring(inst, team) group_left info", metrics, 60), - rows(&[ - ("inst=1,job=x", 0.), - ("inst=2,job=x", 0.), - ("inst=1,job=y", 0.) - ]) - ); - // Two "one" series for a match group, or two results with equal labels. - let two: &[Sample] = &[("job=x,team=t1", 50, 1.), ("job=x,team=t2", 50, 2.)]; - let error = evaluate( - "a * on(job) group_left info", - &[("a", MANY), ("info", two)], - 60, - ) - .unwrap_err(); - assert!(error.contains("duplicate series"), "{error}"); - let error = evaluate( - "info * on(job) group_right a", - &[("a", two), ("info", MANY)], - 60, - ) - .unwrap_err(); - assert!(error.contains("left hand-side"), "{error}"); - let named: &[Sample] = &[("job=x", 50, 1.), ("__name__=c,job=x", 50, 2.)]; - let error = evaluate( - "a * on(job) group_left info", - &[("a", named), ("info", ONE)], - 60, - ) - .unwrap_err(); - assert!(error.contains("unique matches"), "{error}"); - assert!(labeled("a * on(job) group_left info", &[("a", MANY)], 60).is_empty()); -} - -// A non-literal scalar applies like a literal; scalar-scalar arithmetic yields -// a scalar; and a literal applies to aggregated rows whose value has another name. -#[test] -fn scalar_operands_and_aggregates() { - let three: &[Sample] = &[("job=b", 50, 3.)]; - let metrics = &[("a", A), ("b", three)]; - assert_eq!( - labeled("a * scalar(b)", metrics, 60), - rows(&[("job=w", 0.), ("job=x", 30.), ("job=y", 60.)]) - ); - assert_eq!( - labeled("a > scalar(b)", metrics, 60), - rows(&[("__name__=a,job=x", 10.), ("__name__=a,job=y", 20.)]) - ); - assert_eq!(labeled("scalar(b) * 2", metrics, 60), rows(&[("", 6.)])); - assert_eq!( - labeled("scalar(b) > bool 2", metrics, 60), - rows(&[("", 1.)]) - ); - // scalar() of several series is NaN. - assert!(labeled("scalar(a) - 1", metrics, 60)[0].1.is_nan()); - assert_eq!( - labeled("sum by (job) (a) * 2", metrics, 60), - rows(&[("job=w", 0.), ("job=x", 20.), ("job=y", 40.)]) - ); - assert_eq!( - labeled("sum by (job) (a) > bool 5", metrics, 60), - rows(&[("job=w", 0.), ("job=x", 1.), ("job=y", 1.)]) - ); -} - -// Range functions other than last_over_time drop the metric name, so series -// that then share a label set are an error, as in Prometheus. -#[test] -fn range_functions_drop_the_name_and_reject_equal_label_sets() { - let equal: &[Sample] = &[ - ("job=x", 10, 1.), - ("job=x", 50, 2.), - ("__name__=b,job=x", 10, 1.), - ("__name__=b,job=x", 50, 4.), - ]; - let error = evaluate("rate(a[1m])", &[("a", equal)], 60).unwrap_err(); - assert!(error.contains("same labelset"), "{error}"); - assert_eq!( - labeled("last_over_time(a[1m])", &[("a", equal)], 60), - rows(&[("__name__=a,job=x", 2.), ("__name__=b,job=x", 4.)]) - ); - assert_eq!( - labeled("max_over_time(a[1m])", &[("a", &equal[..2])], 60), - rows(&[("job=x", 2.)]) - ); -} - -// Scalar-valued expressions are scalars too; `or vector(0)` fills an empty -// aggregate; a range function inside a subquery drops the name. -#[test] -fn scalar_expressions_or_vector_and_subquery_names() { - let three: &[Sample] = &[("job=b", 50, 3.)]; - let metrics = &[("a", A), ("b", three)]; - assert_eq!( - labeled("a + (scalar(b) * 2)", metrics, 60), - rows(&[("job=w", 6.), ("job=x", 16.), ("job=y", 26.)]) - ); - assert_eq!( - labeled("a + -scalar(b)", metrics, 60), - rows(&[("job=w", -3.), ("job=x", 7.), ("job=y", 17.)]) - ); - assert_eq!( - labeled("sum(a) or vector(0)", metrics, 60), - rows(&[("", 30.)]) - ); - assert_eq!(labeled("sum(a) or vector(0)", &[], 60), rows(&[("", 0.)])); - let counter: &[Sample] = &[("job=x", 0, 0.), ("job=x", 30, 3.), ("job=x", 60, 6.)]; - let result = labeled("last_over_time(rate(a[1m])[2m:1m])", &[("a", counter)], 60); - assert_eq!(result.len(), 1); - assert_eq!(result[0].0, "job=x"); -} - -/// Instant `x_bucket` samples at 50s: `(labels without le, [(le, count)])`. -fn buckets(series: &[(&'static str, &[(&'static str, f64)])]) -> Vec { - series - .iter() - .flat_map(|(labels, buckets)| { - buckets.iter().map(move |(le, count)| { - let spec = if labels.is_empty() { - format!("le={le}") - } else { - format!("{labels},le={le}") - }; - (&*Box::leak(spec.into_boxed_str()), 50, *count) - }) - }) - .collect() -} - -fn quantile(query: &str, samples: &[Sample]) -> Vec<(String, f64)> { - labeled(query, &[("x_bucket", samples)], 60) -} - -const HISTOGRAM: &[(&str, f64)] = &[("1", 2.), ("2", 6.), ("4", 8.), ("+Inf", 10.)]; - -// histogram_quantile interpolates linearly within the bucket holding rank q·count, -// returns the highest finite bound for the +Inf bucket, and maps q outside -// [0, 1] to ∓Inf and a NaN q to NaN. Output labels drop le and __name__. -#[test] -fn histogram_quantile_interpolates_classic_buckets() { - let samples = buckets(&[("job=a", HISTOGRAM)]); - for (q, expected) in [ - ("0", 0.), - ("0.1", 0.5), - ("0.5", 1.75), - ("0.9", 4.), - ("1", 4.), - ("-0.5", f64::NEG_INFINITY), - ("1.5", f64::INFINITY), - ] { - let query = format!("histogram_quantile({q}, x_bucket)"); - assert_eq!( - quantile(&query, &samples), - vec![("job=a".into(), expected)], - "{query}" - ); - } - let nan = quantile("histogram_quantile(NaN, x_bucket)", &samples); - assert!(matches!(nan.as_slice(), [(labels, v)] if labels == "job=a" && v.is_nan())); -} - -// Each label set other than le is its own histogram. Degenerate histograms -// yield NaN: no +Inf bucket, fewer than two buckets, or zero observations. -#[test] -fn histogram_quantile_groups_series_and_rejects_degenerate_histograms() { - let samples = buckets(&[ - ("job=a", HISTOGRAM), - ("job=b", &[("1", 1.), ("2", 2.)]), - ("job=c", &[("+Inf", 5.)]), - ("job=d", &[("1", 0.), ("+Inf", 0.)]), - ("job=e,inst=1", HISTOGRAM), - ]); - let rows = quantile("histogram_quantile(0.5, x_bucket)", &samples); - let labels: Vec<_> = rows.iter().map(|(l, _)| l.as_str()).collect(); - assert_eq!( - labels, - vec!["inst=1,job=e", "job=a", "job=b", "job=c", "job=d"] - ); - assert_eq!(rows[0].1, 1.75); - assert_eq!(rows[1].1, 1.75); - assert!(rows[2..].iter().all(|(_, v)| v.is_nan()), "{rows:?}"); -} - -// Buckets sort by bound, unparsable or missing le values are skipped, equal -// bounds merge, and decreasing cumulative counts are raised to be monotonic. -#[test] -fn histogram_quantile_normalizes_buckets_like_prometheus() { - let unordered = buckets(&[("job=a", &[("+Inf", 10.), ("4", 8.), ("1", 2.), ("2", 6.)])]); - assert_eq!( - quantile("histogram_quantile(0.5, x_bucket)", &unordered), - vec![("job=a".into(), 1.75)] - ); - let mut invalid = buckets(&[("job=a", &[("abc", 100.), ("1", 2.), ("+Inf", 4.)])]); - invalid.push(("job=a", 50, 100.)); - assert_eq!( - quantile("histogram_quantile(0.5, x_bucket)", &invalid), - vec![("job=a".into(), 1.)] - ); - // Go's ParseFloat rejects an out-of-range bound rather than rounding it to +Inf. - let overflow = buckets(&[("job=a", &[("1", 1.), ("1e400", 2.)])]); - let rows = quantile("histogram_quantile(0.5, x_bucket)", &overflow); - assert!( - matches!(rows.as_slice(), [(_, v)] if v.is_nan()), - "{rows:?}" - ); - let duplicate = buckets(&[("job=a", &[("1", 1.), ("1.0", 1.), ("+Inf", 4.)])]); - assert_eq!( - quantile("histogram_quantile(0.5, x_bucket)", &duplicate), - vec![("job=a".into(), 1.)] - ); - // Counts [6, 2→6, 8, 8]: rank 7 lies in (2, 4], 1 of its 2 observations in. - let decreasing = buckets(&[("job=a", &[("1", 6.), ("2", 2.), ("4", 8.), ("+Inf", 8.)])]); - assert_eq!( - quantile("histogram_quantile(0.875, x_bucket)", &decreasing), - vec![("job=a".into(), 3.)] - ); -} - -// A lowest bucket with a non-positive bound is returned as is, not -// interpolated from zero. -#[test] -fn histogram_quantile_non_positive_lowest_bucket() { - let samples = buckets(&[("job=a", &[("-1", 2.), ("1", 4.), ("+Inf", 4.)])]); - for (q, expected) in [("0.25", -1.), ("0.75", 0.)] { - let query = format!("histogram_quantile({q}, x_bucket)"); - assert_eq!( - quantile(&query, &samples), - vec![("job=a".into(), expected)], - "{query}" - ); - } -} - -// The common shapes: an aggregated rate keeps its by labels other than le, and -// a per-series rate keeps every label but le and __name__. -#[test] -fn histogram_quantile_over_rates_and_sums() { - // Each counter grows by c per minute, so its rate is c/60. - let counter = |labels: &'static str, le: &str, c: f64| { - let spec: &'static str = Box::leak(format!("{labels},le={le}").into_boxed_str()); - (60..=240) - .step_by(60) - .map(move |t| (spec, t as i64, c * (t / 60) as f64)) - .collect::>() - }; - let mut samples = Vec::new(); - for inst in ["job=a,inst=1", "job=a,inst=2"] { - for (le, count) in HISTOGRAM { - samples.extend(counter(inst, le, *count)); - } - } - let metrics = &[("x_bucket", samples.as_slice())]; - let close = |rows: Vec<(String, f64)>, expected: &[(&str, f64)]| { - assert_eq!(rows.len(), expected.len(), "{rows:?}"); - for ((labels, v), (want, w)) in rows.iter().zip(expected) { - assert_eq!(labels, want); - assert!((v - w).abs() < 1e-9, "{labels}: {v} vs {w}"); - } - }; - close( - labeled( - "histogram_quantile(0.5, sum by (le, job) (rate(x_bucket[5m])))", - metrics, - 300, - ), - &[("job=a", 1.75)], - ); - close( - labeled( - "histogram_quantile(0.5, sum by (le) (x_bucket))", - metrics, - 250, - ), - &[("", 1.75)], - ); - close( - labeled("histogram_quantile(0.5, rate(x_bucket[5m]))", metrics, 300), - &[("inst=1,job=a", 1.75), ("inst=2,job=a", 1.75)], - ); -} - -// Histograms that differ only in __name__ collide once it is dropped, which -// Prometheus reports as an error rather than merging them. -#[test] -fn histogram_quantile_rejects_equal_output_label_sets() { - let mut samples = buckets(&[("job=a", HISTOGRAM)]); - samples.extend(buckets(&[("__name__=y_bucket,job=a", HISTOGRAM)])); - let error = evaluate( - "histogram_quantile(0.5, x_bucket)", - &[("x_bucket", &samples)], - 60, - ) - .unwrap_err(); - assert!(error.contains("same labelset"), "{error}"); -} - -// Candidate search keeps a classic histogram_quantile whole and exact, even -// for an approximate target, and the selected DAG compiles and executes. -#[test] -fn histogram_quantile_selection_keeps_the_exact_fallback() { - use asap_aware_mapping::{ - accuracy::DefaultAccuracyModel, cost_model::DefaultCostModel, default_strategies, - search_workload_with_targets, Replacement, - }; - let samples = buckets(&[("job=a", HISTOGRAM)]); - for target in [AccuracyTarget::Exact, AccuracyTarget::Epsilon(0.01)] { - for query in [ - "histogram_quantile(0.5, x_bucket)", - "histogram_quantile(0.5, sum by (le, job) (x_bucket))", - ] { - let root = Rc::new( - promql_rows::with_series_identity(&parse_with(query, target.clone())).unwrap(), - ); - let space = search_workload_with_targets( - vec![(query, root.clone(), Some(target.clone()))], - &default_strategies(), - &DefaultAccuracyModel, - ); - let planned = &space.roots[0].1; - let candidates = &space.candidates_for_target(planned).unwrap().candidates; - assert!( - candidates.iter().all(|c| matches!(&c.replacement, - Replacement::Summary(node) if matches!(&node.expr, - SummaryExpr::KeepPreAsap(e) if **e == *root))), - "{query}: {candidates:?}" - ); - let selected = space - .global_selection(&DefaultCostModel) - .assemble_selected_dag(planned) - .unwrap() - .unwrap(); - let dag = compile_post_asap_dag(&selected).unwrap(); - let rows = evaluate_dag(&root, &dag, &[("x_bucket", &samples)], 60).unwrap(); - let values: Vec<_> = rows.iter().map(|(_, _, v)| *v).collect(); - assert_eq!(values, vec![1.75], "{query} {target:?}"); - } - } -} - -// time() uses the query evaluation instant in seconds in scalar and vector operands. -#[test] -fn evaluation_time_operands_use_runtime_scope() { - assert_eq!(labeled("time()", &[], 60), rows(&[("", 60.)])); - assert_eq!(labeled("vector(time())", &[], 60), rows(&[("", 60.)])); - assert_eq!( - labeled("a + time()", &[("a", A)], 60), - rows(&[("job=w", 60.), ("job=x", 70.), ("job=y", 80.)]) - ); - assert_eq!( - labeled("time() - scalar(b)", &[("b", &[("job=x", 60, 3.)])], 61), - rows(&[("", 58.)]) - ); -} - -// Non-finite scalar operands survive both logical and physical plan JSON round trips. -#[test] -fn nonfinite_literals_round_trip_in_plans() { - for (query, expected) in [ - ("vector(NaN)", f64::NAN), - ("vector(+Inf)", f64::INFINITY), - ("vector(-Inf)", f64::NEG_INFINITY), - ] { - let expression = lower(query); - let json = serde_json::to_vec(&expression).unwrap(); - let restored: QueryExpr = serde_json::from_slice(&json).unwrap(); - let result = evaluate_dag(&restored, &fallback_dag(restored.clone()), &[], 60).unwrap(); - assert_eq!(result.len(), 1); - if expected.is_nan() { - assert!(result[0].2.is_nan()); - } else { - assert_eq!(result[0].2, expected); - } - } -} - -// Classic histogram results remain aggregatable and support multi-quantile label branches. -#[test] -fn histogram_quantiles_and_nested_aggregation() { - let samples = buckets(&[("job=a", HISTOGRAM), ("job=b", HISTOGRAM)]); - assert_eq!( - quantile("sum(histogram_quantile(0.5, x_bucket))", &samples), - rows(&[("", 3.5)]) - ); - assert_eq!( - quantile("histogram_quantiles(x_bucket, \"q\", 0.5, 0.9)", &samples), - rows(&[ - ("job=a,q=0.5", 1.75), - ("job=a,q=0.9", 4.0), - ("job=b,q=0.5", 1.75), - ("job=b,q=0.9", 4.0) - ]) - ); -} - -// Relabeling anchors regexes, expands captures, preserves nonmatches and removes empty labels. -#[test] -fn label_replace_preserves_promql_labels() { - let samples: &[Sample] = &[ - ("job=api:one,team=old", 50, 1.0), - ("job=other,team=old", 50, 2.0), - ]; - assert_eq!( - labeled( - "label_replace(a, \"team\", \"$1\", \"job\", \"(.*):.*\")", - &[("a", samples)], - 60 - ), - rows(&[ - ("__name__=a,job=api:one,team=api", 1.0), - ("__name__=a,job=other,team=old", 2.0) - ]) - ); - assert_eq!( - labeled( - "label_replace(a, \"team\", \"\", \"job\", \".*\")", - &[("a", samples)], - 60 - ), - rows(&[ - ("__name__=a,job=api:one", 1.0), - ("__name__=a,job=other", 2.0) - ]) - ); -} - -// Binary results over aggregates retain labels contributed by the other operand. -#[test] -fn binary_aggregates_accept_additional_labels() { - let a: &[Sample] = &[("job=x", 50, 2.0)]; - let info: &[Sample] = &[("job=x,team=blue", 50, 3.0)]; - assert_eq!( - labeled( - "sum by(job)(a) * on(job) group_left(team) info", - &[("a", a), ("info", info)], - 60 - ), - rows(&[("job=x,team=blue", 6.0)]) - ); - assert_eq!( - labeled("sum by(job)(a) or info", &[("a", a), ("info", info)], 60), - rows(&[("job=x", 2.0), ("__name__=info,job=x,team=blue", 3.0)]) - ); -} - -// Relabeling handles missing sources and named captures, and rejects label-set collisions. -#[test] -fn label_replace_missing_labels_named_captures_and_duplicates() { - let a: &[Sample] = &[("job=api:one", 50, 2.)]; - assert_eq!( - labeled( - r#"label_replace(a, "team", "${part}", "job", "(?P.*):.*")"#, - &[("a", a)], - 60 - ), - rows(&[("__name__=a,job=api:one,team=api", 2.)]) - ); - assert_eq!( - labeled( - r#"label_replace(a, "team", "unknown", "missing", "^$")"#, - &[("a", a)], - 60 - ), - rows(&[("__name__=a,job=api:one,team=unknown", 2.)]) - ); - assert!(evaluate(r#"label_replace(a, "", "x", "job", ".*")"#, &[("a", a)], 60).is_err()); - let duplicate: &[Sample] = &[("job=a", 50, 1.), ("job=b", 50, 2.)]; - assert!(evaluate( - r#"label_replace(a, "job", "same", "job", ".*")"#, - &[("a", duplicate)], - 60 - ) - .unwrap_err() - .contains("same labelset")); -} - -// Right-side grouped rows and group_right labels survive an aggregated left schema. -#[test] -fn grouped_binary_right_rows_preserve_all_labels() { - let a: &[Sample] = &[("job=x", 50, 2.)]; - let info: &[Sample] = &[("job=x,team=blue", 50, 3.)]; - let metrics = &[("a", a), ("info", info)]; - assert_eq!( - labeled("sum by(job)(a) * on(job) group_right info", metrics, 60), - rows(&[("job=x,team=blue", 6.)]) - ); - assert_eq!( - labeled("sum by(job)(a) or sum by(job,team)(info)", metrics, 60), - rows(&[("job=x", 2.), ("job=x,team=blue", 3.)]) - ); - let samples = buckets(&[("job=a", HISTOGRAM)]); - assert!(evaluate( - r#"histogram_quantiles(x_bucket, "q", 0.5, 0.5)"#, - &[("x_bucket", &samples)], - 60 - ) - .unwrap_err() - .contains("same labelset")); -} - -// Range-bound anchors compile without freezing the evaluation instant into the plan. -#[test] -fn range_bound_anchors_compile() { - for query in [ - "a @ start()", - "sum_over_time(a[1m] @ end())", - "max_over_time(a[2m:1m] @ start())", - ] { - assert!(compile_query(query).is_ok(), "{query}"); - } -} - -// Stored programs resolve outer range anchors per run, including offsets and subquery grids. -#[test] -fn range_bound_anchors_use_outer_query_bounds() { - let samples: &[Sample] = &[ - ("job=a", 30, 1.), - ("job=a", 60, 2.), - ("job=a", 90, 3.), - ("job=a", 120, 4.), - ]; - for (query, expected) in [ - ("a @ start()", 2.), - ("a @ end()", 4.), - ("a @ start() offset 30s", 1.), - ("sum_over_time(a[1m] @ end())", 7.), - ("max_over_time(a[2m:1m] @ start())", 2.), - ("max_over_time(a[2m:1m] @ end())", 4.), - ("max_over_time(a[2m:1m] @ end() offset 1m)", 2.), - ("max_over_time(a @ end()[2m:1m])", 4.), - ] { - let expression = lower(query); - let dag = fallback_dag(expression.clone()); - let output = - evaluate_dag_with_range(&expression, &dag, &[("a", samples)], 90, Some((60, 120))) - .unwrap(); - assert_eq!(output.len(), 1, "{query}"); - assert_eq!(output[0].2, expected, "{query}"); - assert_eq!(output[0].1, 90_000, "{query}"); - let error = evaluate_dag(&expression, &dag, &[("a", samples)], 90).unwrap_err(); - assert!(error.contains("query range bounds"), "{query}: {error}"); - } -} - -// Regression functions use float samples per series; prediction is anchored -// at the evaluation time even when offset or @ selects an older window. -#[test] -fn regression_range_functions_use_evaluation_time_and_drop_names() { - let samples: &[Sample] = &[("job=x", 10, 3.), ("job=x", 30, 7.), ("job=x", 50, 11.)]; - for (query, at, expected) in [ - ("deriv(a[1m])", 60, 0.2), - ("predict_linear(a[1m], 10)", 60, 15.), - ("predict_linear(a[1m] offset 30s, 10)", 90, 21.), - ("predict_linear(a[1m] @ 60, 10)", 90, 21.), - ] { - let result = labeled(query, &[("a", samples)], at); - assert_eq!(result.len(), 1, "{query}"); - assert_eq!(result[0].0, "job=x"); - assert!( - (result[0].1 - expected).abs() < 1e-12, - "{query}: {result:?}" - ); - } - let constant: &[Sample] = &[("job=x", 10, 1e300), ("job=x", 50, 1e300)]; - assert_eq!( - labeled("deriv(a[1m])", &[("a", constant)], 60), - rows(&[("job=x", 0.)]) - ); - assert_eq!( - labeled("predict_linear(a[1m], 10)", &[("a", constant)], 60), - rows(&[("job=x", 1e300)]) - ); - assert!(labeled("deriv(a[1m])", &[("a", &samples[..1])], 60).is_empty()); - let infinite: &[Sample] = &[("job=x", 10, f64::INFINITY), ("job=x", 50, f64::INFINITY)]; - assert!(labeled("deriv(a[1m])", &[("a", infinite)], 60)[0] - .1 - .is_nan()); -} - -// An `@`-pinned range function is step-invariant, as in Prometheus: it is -// evaluated once, at the query start or at the subquery's first step, so -// predict_linear's anchor does not move with each evaluation step. -#[test] -fn pinned_range_functions_are_step_invariant() { - // `a` rises by one per second, sampled every 10 s. - let rising: Vec = (0..=30) - .map(|i| ("job=x", i * 10, (i * 10) as f64)) - .collect(); - // (query, range-query bounds, Prometheus value at T = 300 s). - for (query, bounds, expected) in [ - // Subquery grid (180, 300] steps 240 and 300; one evaluation at 240. - ( - "max_over_time(predict_linear(a[1m] @ 100, 0)[2m:1m])", - None, - 240., - ), - // A range query starting at 240 evaluates its grid from (120, 240]: at 180. - ( - "max_over_time(predict_linear(a[1m] @ 100, 0)[2m:1m])", - Some((240, 300)), - 180., - ), - ( - "max_over_time(predict_linear(a[1m] @ start(), 0)[2m:1m])", - Some((240, 300)), - 180., - ), - // A top-level pinned call is evaluated at the query start. - ("predict_linear(a[1m] @ 100, 0)", Some((240, 300)), 240.), - ("predict_linear(a[1m] @ 100, 0)", None, 300.), - // Functions that do not read the evaluation time are unchanged. - ("max_over_time(deriv(a[1m] @ 100)[2m:1m])", None, 1.), - ("max_over_time(rate(a[1m] @ 100)[2m:1m])", None, 1.), - ("max_over_time(a @ 100[2m:1m])", None, 100.), - // Without `@`, the anchor is each step: the latest step, 300, wins. - ("max_over_time(predict_linear(a[1m], 0)[2m:1m])", None, 300.), - ] { - let expression = lower(query); - let dag = fallback_dag(expression.clone()); - let output = evaluate_dag_with_range(&expression, &dag, &[("a", &rising)], 300, bounds) - .unwrap_or_else(|e| panic!("{query}: {e}")); - assert_eq!(output.len(), 1, "{query}"); - assert!( - (output[0].2 - expected).abs() < 1e-9, - "{query} {bounds:?}: {} != {expected}", - output[0].2 - ); - } -} - -// Dropping an inner range function's metric name rejects equal labels within -// each subquery step, while allowing that labelset at different steps. -#[test] -fn subquery_label_uniqueness_is_checked_per_evaluation_step() { - let equal: &[Sample] = &[ - ("job=x", 10, 1.), - ("job=x", 50, 2.), - ("__name__=b,job=x", 10, 1.), - ("__name__=b,job=x", 50, 4.), - ]; - let query = "last_over_time(rate(a[1m])[2m:1m])"; - let error = evaluate(query, &[("a", equal)], 60).unwrap_err(); - assert!(error.contains("same labelset"), "{error}"); - let disjoint: &[Sample] = &[ - ("job=x", -50, 1.), - ("job=x", -10, 2.), - ("__name__=b,job=x", 10, 3.), - ("__name__=b,job=x", 50, 5.), - ]; - let result = labeled(query, &[("a", disjoint)], 60); - assert_eq!(result.len(), 1); - assert_eq!(result[0].0, "job=x"); - assert!((result[0].1 - 0.05).abs() < 1e-12); -} - -// Non-finite histogram quantile parameters survive the logical DAG JSON boundary too. -#[test] -fn logical_nonfinite_quantile_parameter_round_trips() { - let expression = lower("histogram_quantile(NaN, x_bucket)"); - let restored: QueryExpr = - serde_json::from_slice(&serde_json::to_vec(&expression).unwrap()).unwrap(); - let samples = buckets(&[("job=a", HISTOGRAM)]); - let result = evaluate_dag( - &restored, - &fallback_dag(restored.clone()), - &[("x_bucket", &samples)], - 60, - ) - .unwrap(); - assert_eq!(result.len(), 1); - assert!(result[0].2.is_nan()); -} diff --git a/crates/asap-physical-operators/tests/promql_values.rs b/crates/asap-physical-operators/tests/promql_values.rs index e7deb78b1..f92662fcd 100644 --- a/crates/asap-physical-operators/tests/promql_values.rs +++ b/crates/asap-physical-operators/tests/promql_values.rs @@ -1,12 +1,15 @@ //! Compile, persist and rebind dynamic-label computation without deployment lowering. +use asap_physical_operators::expressions::binary::{BinaryOpKind, BinaryOperator}; + use asap_physical_operators::{ operators::Operator, - physical_planner::{promql_values::*, CompiledPhysicalDAG, Source}, runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{promql_values::*, CompiledPhysicalDAG, Source}, values::{Batch, Value}, }; use futures::{executor::block_on, StreamExt}; use planner_types::pre_asap::{AggIntent, ColumnRef, GroupKeys}; + use std::collections::BTreeMap; fn row(labels: &[(&str, &str)], value: f64) -> Vec { @@ -208,15 +211,12 @@ fn histogram_quantile_keeps_each_label_group() { #[test] fn composed_ensemble_shares_a_producer_across_roots() { use asap_physical_operators::{ - physical_planner::InputContract, plan::{PhysicalOperator, PlanProperties}, runtime::{Input, OutputStream}, + unified_physical_planner::InputContract, values::SchemaRef, }; - use planner_types::{ - post_asap::BinaryOperator, - pre_asap::{ArithmeticOpKind, BinaryOpKind}, - }; + use planner_types::pre_asap::ArithmeticOpKind; struct Counted { source: Operator, starts: std::rc::Rc>, @@ -325,10 +325,7 @@ fn compiled_constant_needs_no_deployment_source() { // arithmetic or bool comparisons remove the metric name. #[test] fn scalar_broadcast_rejects_colliding_result_labels_after_recovery() { - use planner_types::{ - post_asap::BinaryOperator, - pre_asap::{ArithmeticOpKind, BinaryOpKind, CompareOpKind}, - }; + use planner_types::pre_asap::{ArithmeticOpKind, CompareOpKind}; for left_scalar in [false, true] { for names in [["a", "a"], ["a", "b"]] { for (kind, return_bool) in [ @@ -398,10 +395,10 @@ fn scalar_broadcast_rejects_colliding_result_labels_after_recovery() { ); } -// Persisted exact readout DAGs, rather than the storage adapter, merge panes, +// Persisted exact evaluation graphs, rather than the storage adapter, merge panes, // finalize each population, and preserve the requested metric-name semantics. #[test] -fn exact_state_readouts_recover_and_finalize_panes() { +fn exact_state_evaluations_recover_and_finalize_panes() { use asap_physical_operators::factory::create_planner_accumulator; use planner_types::post_asap::*; use std::sync::Arc; @@ -436,7 +433,7 @@ fn exact_state_readouts_recover_and_finalize_panes() { }) .collect(); let output = run_inputs( - compile_exact_readout(family.clone(), 60_000, preserve).unwrap(), + compile_exact_evaluation(family.clone(), 60_000, preserve).unwrap(), vec![Batch::try_new(exact_state_schema(family.clone()).unwrap(), rows).unwrap()], ) .unwrap(); @@ -483,7 +480,7 @@ fn recovered_exact_counter_uses_window_and_omits_insufficient_samples() { }) .collect(); let output = run_inputs( - compile_exact_readout(family.clone(), 60_000, false).unwrap(), + compile_exact_evaluation(family.clone(), 60_000, false).unwrap(), vec![Batch::try_new(exact_state_schema(family).unwrap(), rows).unwrap()], ) .unwrap(); diff --git a/crates/asap-physical-operators/tests/raw_scan.rs b/crates/asap-physical-operators/tests/raw_scan.rs index cf56a8682..005b45fc3 100644 --- a/crates/asap-physical-operators/tests/raw_scan.rs +++ b/crates/asap-physical-operators/tests/raw_scan.rs @@ -6,26 +6,33 @@ use asap_physical_operators::dag::{ Error, Limits, OutputStream, RunContext, Scope, }; use futures::{executor::block_on, stream, StreamExt}; -use planner_types::pre_asap::Schema; +use planner_types::ir::physical_export::{ + EdgeRole, GroupingEdgeCompatibility, PhysicalASAPDAG, PhysicalASAPDAGEdge, PhysicalASAPDAGNode, + PhysicalASAPOperatorPayload, WindowEdgeCompatibility, +}; +use planner_types::ir::NonASAPOp; +use planner_types::ir::Predicate; use planner_types::{ post_asap::*, - pre_asap::{DataType, Field, GroupKeys, Predicate, QueryExpr, Source}, + pre_asap::{DataType, Field, GroupKeys, Source}, }; use std::{ collections::BTreeMap, - rc::Rc, sync::{ atomic::{AtomicUsize, Ordering}, Arc, }, }; -fn fixture() -> (QueryExpr, SchemaRef, Vec) { - let schema = - planner_types::pre_asap::Schema::new(vec![Field::plain("value", DataType::Int64, true)]); - let output = Arc::new(Schema { - closed: true, +fn fixture() -> (planner_types::ir::NonASAPOp, SchemaRef, Vec) { + let schema = planner_types::pre_asap::Schema::new(vec![planner_types::pre_asap::Field::plain( + "value", + DataType::Int64, + true, + )]); + let output = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![Field { table: None, name: "value".into(), @@ -34,13 +41,13 @@ fn fixture() -> (QueryExpr, SchemaRef, Vec) { }], time_index: None, }); - let scan = QueryExpr::Scan { + let scan = planner_types::ir::NonASAPOp::Scan { source: Source::Table { table_ref: "numbers".into(), }, - predicates: vec![Predicate(Rc::new(QueryExpr::IsNotNull(Rc::new( - QueryExpr::Column(0), - ))))], + predicates: vec![Predicate(planner_types::ir::ScalarExpr::IsNotNull( + Box::new(planner_types::ir::ScalarExpr::Column(0)), + ))], schema, }; let batches = vec![ @@ -57,52 +64,59 @@ fn fixture() -> (QueryExpr, SchemaRef, Vec) { ]; (scan, output, batches) } -fn plan(scan: QueryExpr, schema: &SchemaRef, state: ExecutionDataState) -> PostAsapDAG { - let node = |id, payload| PostAsapDAGNode { - id: PostAsapNodeId(id), +fn plan( + scan: planner_types::ir::NonASAPOp, + schema: &SchemaRef, + state: ExecutionDataState, +) -> PhysicalASAPDAG { + let node = |id, payload| PhysicalASAPDAGNode { + id, payload, output_state: state, output_schema: (**schema).clone(), guarantee: None, }; - let edge = |producer, consumer| PostAsapDAGEdge { - producer: PostAsapNodeId(producer), - consumer: PostAsapNodeId(consumer), + let edge = |producer, consumer| PhysicalASAPDAGEdge { + producer, + consumer, role: EdgeRole::Input, intermediate_schema: (**schema).clone(), data_state: state, grouping: GroupingEdgeCompatibility::NotApplicable, window: WindowEdgeCompatibility::NotApplicable, }; - PostAsapDAG { + PhysicalASAPDAG { nodes: vec![ - node(0, PostAsapOperatorPayload::Fallback { expression: scan }), + node( + 0, + PhysicalASAPOperatorPayload::NonASAP( + scan.map_children(|_| -> usize { panic!("no plan refs") }), + ), + ), node( 1, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Sort { - keys: vec![planner_types::pre_asap::SortKey { - expr: QueryExpr::Column(0), - ascending: false, - nulls_first: false, - }], - partition_by: GroupKeys::by(vec![]), - }, - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Sort { + keys: vec![planner_types::ir::SortKey { + expr: planner_types::ir::ScalarExpr::Column(0), + ascending: false, + nulls_first: false, + }], + partition_by: GroupKeys::by(vec![]), + child: 0, + }), ), node( 2, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Limit { - n: 2, - offset: 0, - partition_by: GroupKeys::by(vec![]), - }, - }, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Limit { + n: Some(2), + offset: 0, + partition_by: GroupKeys::by(vec![]), + child: 1, + }), ), ], edges: vec![edge(0, 1), edge(1, 2)], - root: PostAsapNodeId(2), + roots: vec![2], } } fn registry(source: Arc) -> DataSources { @@ -223,17 +237,27 @@ fn lazy_open_shared_producer_and_cancellation() { #[test] fn binding_errors_and_reader_errors_are_not_empty_results() { let (mut scan, schema, _) = fixture(); - assert!(DataSources::default().bind(&scan).is_err()); + assert!(DataSources::default() + .bind(&planner_types::ir::OperatorNode::with_schema( + planner_types::ir::Operator::NonASAP(scan.clone()), + scan.output_schema().unwrap() + )) + .is_err()); let opened = Arc::new(AtomicUsize::new(0)); let sources = registry(Arc::new(CountingSource { schema: schema.clone(), opened: opened.clone(), fail: true, })); - if let QueryExpr::Scan { predicates, .. } = &mut scan { - predicates.push(Predicate(Rc::new(QueryExpr::Column(0)))); + if let planner_types::ir::NonASAPOp::Scan { predicates, .. } = &mut scan { + predicates.push(Predicate(planner_types::ir::ScalarExpr::Column(0))); } - assert!(sources.bind(&scan).is_err()); + assert!(sources + .bind(&planner_types::ir::OperatorNode::with_schema( + planner_types::ir::Operator::NonASAP(scan.clone()), + scan.output_schema().unwrap() + )) + .is_err()); assert_eq!(opened.load(Ordering::SeqCst), 0); let (scan, _, _) = fixture(); let plan = plan(scan, &schema, ExecutionDataState::QUERY_ROWS); @@ -297,19 +321,23 @@ fn schema_drift_and_memory_limits_fail_the_scan() { #[test] fn empty_sources_and_three_valued_predicates() { use planner_types::pre_asap::{CompareOpKind, ScalarValue}; + let (mut scan, schema, batches) = fixture(); - if let QueryExpr::Scan { + if let planner_types::ir::NonASAPOp::Scan { predicates, source, .. } = &mut scan { *source = Source::TimeSeries { metric: "samples".into(), }; - *predicates = vec![Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), + *predicates = vec![Predicate(planner_types::ir::ScalarExpr::Compare { + semantics: planner_types::ir::ExprSemantics::Sql, + left: Box::new(planner_types::ir::ScalarExpr::Column(0)), op: CompareOpKind::Gt, - right: Rc::new(QueryExpr::Literal(ScalarValue::Int64(2))), - }))]; + right: Box::new(planner_types::ir::ScalarExpr::Literal(ScalarValue::Int64( + 2, + ))), + })]; } for (batches, expected) in [(vec![], 0), (batches, 2)] { let mut sources = DataSources::default(); @@ -339,7 +367,7 @@ fn empty_sources_and_three_valued_predicates() { fn compile_without_readers_and_rebind_inputs() { use asap_physical_operators::{ operators::Operator, - physical_planner::{compile, InputContract, Source}, + unified_physical_planner::{compile, InputContract, Source}, }; let (scan, schema, batches) = fixture(); let dag = plan(scan, &schema, ExecutionDataState::QUERY_ROWS); @@ -374,8 +402,8 @@ fn compile_without_readers_and_rebind_inputs() { #[test] fn compilation_rejects_unknown_boundedness_for_sort() { use asap_physical_operators::{ - physical_planner::{compile, InputContract}, plan::{Boundedness, Emission, PlanProperties}, + unified_physical_planner::{compile, InputContract}, }; let (scan, schema, _) = fixture(); let dag = plan(scan, &schema, ExecutionDataState::QUERY_ROWS); diff --git a/crates/asap-physical-operators/tests/summary_projection.rs b/crates/asap-physical-operators/tests/summary_projection.rs index 8cac1cbda..c78c5c4ab 100644 --- a/crates/asap-physical-operators/tests/summary_projection.rs +++ b/crates/asap-physical-operators/tests/summary_projection.rs @@ -3,15 +3,20 @@ use asap_physical_operators::{ expressions::Expression, factory::create_planner_accumulator, operators::Operator, - physical_planner::{compile, CompiledPhysicalDAG, InputContract, Source}, runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{compile, CompiledPhysicalDAG, InputContract, Source}, values::{Batch, Value}, }; use futures::{executor::block_on, StreamExt}; -use planner_types::pre_asap::Schema; +use planner_types::ir::physical_export::{ + EdgeRole, GroupingEdgeCompatibility, PhysicalASAPDAG, PhysicalASAPDAGEdge, PhysicalASAPDAGNode, + PhysicalASAPOperatorPayload, WindowEdgeCompatibility, +}; +use planner_types::ir::ASAPOp; +use planner_types::ir::NonASAPOp; use planner_types::{ post_asap::*, - pre_asap::{ColumnRef, DataType, ProjectItem, QueryExpr}, + pre_asap::{ColumnRef, DataType}, }; use std::{collections::BTreeMap, sync::Arc}; @@ -20,9 +25,9 @@ use std::{collections::BTreeMap, sync::Arc}; #[test] fn post_asap_summary_projection_survives_recovery() { let family = FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - let schema = Arc::new(Schema { - closed: true, + let schema = Arc::new(planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![ Field { table: None, @@ -39,9 +44,9 @@ fn post_asap_summary_projection_survives_recovery() { ], time_index: None, }); - let output = Schema { - closed: true, + let output = planner_types::pre_asap::Schema { unique_keys: vec![], + closed: false, fields: vec![ schema.fields[1].clone(), Field { @@ -51,44 +56,45 @@ fn post_asap_summary_projection_survives_recovery() { ], time_index: None, }; - let dag = PostAsapDAG { + let dag = PhysicalASAPDAG { nodes: vec![ - PostAsapDAGNode { - id: PostAsapNodeId(0), - payload: PostAsapOperatorPayload::SummaryMerge, + PhysicalASAPDAGNode { + id: 0, + payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryMerge { + children: vec![], + }), output_schema: (*schema).clone(), output_state: ExecutionDataState::INGESTION_SUMMARY, guarantee: None, }, - PostAsapDAGNode { - id: PostAsapNodeId(1), - payload: PostAsapOperatorPayload::Value { - operation: ValueOperation::Project { - cols: vec![1, 0] - .into_iter() - .map(|index| ProjectItem { - alias: None, - expr: QueryExpr::Column(index), - }) - .collect(), - qualifier: None, - }, - }, + PhysicalASAPDAGNode { + id: 1, + payload: PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Project { + cols: vec![1, 0] + .into_iter() + .map(|index| planner_types::ir::ProjectItem { + alias: None, + expr: planner_types::ir::ScalarExpr::Column(index), + }) + .collect(), + qualifier: None, + child: 0, + }), output_schema: output.clone(), output_state: ExecutionDataState::INGESTION_SUMMARY, guarantee: None, }, ], - edges: vec![PostAsapDAGEdge { - producer: PostAsapNodeId(0), - consumer: PostAsapNodeId(1), + edges: vec![PhysicalASAPDAGEdge { + producer: 0, + consumer: 1, role: EdgeRole::Input, intermediate_schema: (*schema).clone(), data_state: ExecutionDataState::INGESTION_SUMMARY, grouping: GroupingEdgeCompatibility::NotApplicable, window: WindowEdgeCompatibility::NotApplicable, }], - root: PostAsapNodeId(1), + roots: vec![1], }; let program = compile( &dag, diff --git a/crates/asap-physical-operators/tests/weighted_topk_binding.rs b/crates/asap-physical-operators/tests/weighted_topk_binding.rs index fe79948d6..ed1089fa3 100644 --- a/crates/asap-physical-operators/tests/weighted_topk_binding.rs +++ b/crates/asap-physical-operators/tests/weighted_topk_binding.rs @@ -1,10 +1,12 @@ //! Planner output binds directly to the shared runtime at a declared rate-value frontier. +#[path = "unified_common/mod.rs"] +mod common; use asap_aware_mapping::{ accuracy::{ AccuracyEvidenceProvider, DefaultAccuracyModel, EqualSplitAllocator, PropagationStats, }, cost_model::DefaultCostModel, - Replacement, ReplacementStrategy, SketchAlgorithmStrategy, TargetSubDAG, + ASAPStrategies, Replacement, ReplacementStrategy, TargetSubDAG, }; use asap_physical_operators::dag::{ operators::Operator, @@ -12,16 +14,15 @@ use asap_physical_operators::dag::{ values::{Batch, Value}, Limits, RunContext, Scope, }; +use common::compile_physical_asap_dag; use futures::{executor::block_on, StreamExt}; -use planner_types::{ - post_asap::*, - pre_asap::{DataType, QueryExpr}, - types::AccuracyTarget, -}; +use planner_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPOperatorPayload}; +use planner_types::ir::ASAPOp; +use planner_types::{post_asap::*, pre_asap::DataType, types::AccuracyTarget}; use std::{collections::BTreeMap, rc::Rc, sync::Arc}; struct Evidence; impl AccuracyEvidenceProvider for Evidence { - fn topk_max_distinct_items(&self, _: &QueryExpr) -> Option { + fn topk_max_distinct_items(&self, _: &planner_types::ir::OperatorNode) -> Option { Some(1000) } fn propagation_stats( @@ -63,14 +64,12 @@ fn physical_binding_does_not_impose_an_accuracy_acceptance_policy() { } fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: SketchAlgorithm) { - let root = Rc::new( - lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::Epsilon(0.1), - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let root = lower_promql( + "topk by(job)(2, sum by(service, job)(rate(m[1m])))", + AccuracyTarget::Epsilon(0.1), + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -80,7 +79,7 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S .replacements(&TargetSubDAG::new(&root)) .into_iter() .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) + Replacement::SubDAG(node) if candidate.rationale.contains(&format!("{algorithm:?}")) => { Some(node) @@ -88,8 +87,8 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S _ => None, }) .unwrap(); - let dag = compile_post_asap_dag(&plan).unwrap(); - let build=dag.nodes.iter().find(|node|matches!(&node.payload,PostAsapOperatorPayload::SummaryAgg{family:FieldDataType::Sketch(kind,_),..}if kind.algorithm()==&algorithm)).unwrap(); + let dag = compile_physical_asap_dag(&plan).unwrap(); + let build=dag.nodes.iter().find(|node|matches!(&node.payload,PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg {family:FieldDataType::Sketch(kind,_),..})if kind.algorithm()==&algorithm)).unwrap(); let rate_id = dag .edges .iter() @@ -155,18 +154,18 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S as Source<'static>; let compiled = compile( &placed, - BTreeMap::from([(rate_id.0 as u64, InputContract::bounded(rates.clone()))]), - &[dag.root.0 as u64], + BTreeMap::from([(rate_id as u64, InputContract::bounded(rates.clone()))]), + &[dag.roots[0] as u64], ) .unwrap(); let physical_dag = compiled - .instantiate(BTreeMap::from([(rate_id.0 as u64, source)])) + .instantiate(BTreeMap::from([(rate_id as u64, source)])) .unwrap(); let context = RunContext::new(scope, Limits::default()).unwrap(); let output = block_on(async { let mut output = Vec::new(); let mut stream = physical_dag - .execute(&[dag.root.0 as u64], context) + .execute(&[dag.roots[0] as u64], context) .unwrap() .remove(0); while let Some(batch) = stream.next().await { @@ -194,7 +193,7 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S } } -use asap_frontend_promql::lower_promql_workload; +use asap_frontend_promql::unified::lower_promql_workload; use planner_types::workload::{ AccuracyRequirement, BatchEntry, DataWorkload, DurationMs, Evidence as WorkloadEvidence, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, @@ -203,7 +202,7 @@ use planner_types::workload::{ pub fn lower_promql( query: &str, accuracy: AccuracyTarget, -) -> Result { +) -> Result, asap_frontend_promql::unified::PromqlError> { let workload = PlanningWorkload { query_workload: QueryWorkload { language: QueryLanguage::PromQL, @@ -272,24 +271,28 @@ fn direct_rate_topk_exposes_heap_candidates_with_complete_series_identity() { check_direct_rate_topk(false); } -// Unreferenced labels still distinguish series throughout Rate and heap readout. +// Unreferenced labels still distinguish series throughout Rate and heap evaluation. #[test] fn direct_rate_topk_preserves_dynamic_unreferenced_labels() { check_direct_rate_topk(true); } fn check_direct_rate_topk(dynamic: bool) { - use asap_physical_operators::physical_planner::promql_rows::{ + use asap_physical_operators::unified_physical_planner::promql_rows::{ decode_series_identity, series_row, with_series_identity, SERIES_IDENTITY_COLUMN, }; let mut logical = lower_promql("topk by(job)(2, rate(m[1m]))", AccuracyTarget::Epsilon(0.1)).unwrap(); - fn resolve_catalog(node: &mut QueryExpr) { - match node { - QueryExpr::Aggregate { child, .. } | QueryExpr::TimeRange { child, .. } => { - resolve_catalog(Rc::make_mut(child)) - } - QueryExpr::Scan { schema, .. } => { + fn resolve_catalog(node: &mut planner_types::ir::OperatorNode) { + match &mut node.operator { + planner_types::ir::Operator::NonASAP( + planner_types::ir::NonASAPOp::Aggregate { child, .. } + | planner_types::ir::NonASAPOp::TimeRange { child, .. }, + ) => resolve_catalog(Rc::make_mut(child)), + planner_types::ir::Operator::NonASAP(planner_types::ir::NonASAPOp::Scan { + schema, + .. + }) => { schema.closed = true; schema .fields @@ -301,14 +304,15 @@ fn check_direct_rate_topk(dynamic: bool) { } _ => panic!("unexpected input shape: {node:?}"), } + node.schema = node.operator.output_schema().unwrap(); } if dynamic { logical = with_series_identity(&logical).unwrap(); } else { - resolve_catalog(&mut logical); + resolve_catalog(Rc::make_mut(&mut logical)); } - let root = Rc::new(logical); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let root = logical; + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -322,7 +326,7 @@ fn check_direct_rate_topk(dynamic: bool) { let candidate = candidates .iter() .find_map(|candidate| match &candidate.replacement { - Replacement::Summary(node) + Replacement::SubDAG(node) if candidate.rationale.contains(&format!("{algorithm:?}")) => { Some(node) @@ -332,31 +336,30 @@ fn check_direct_rate_topk(dynamic: bool) { .unwrap_or_else(|| panic!("missing {algorithm:?} over direct Rate")); if dynamic { let (source, ranked) = - asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking( + asap_physical_operators::unified_physical_planner::promql_rows::compile_rate_ranking( candidate, ) .unwrap(); assert!(matches!( - source.expr, - SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - .. - } + source.operator, + planner_types::ir::Operator::ASAP( + planner_types::ir::ASAPOp::FinalizeExactAccumulator { .. } + ) )); assert_eq!(ranked.input_contracts().count(), 1); let encoded = String::from_utf8(serde_json::to_vec(&ranked).unwrap()).unwrap(); assert!(encoded.contains("KeyedSummaryBuild")); - assert!(encoded.contains("KeyedReadout")); + assert!(encoded.contains("KeyedEvaluation")); assert!( !encoded.contains("\"Rate\""), - "Rate must be supplied by its exact stored-state readout" + "Rate must be supplied by its exact stored-state evaluation" ); } - let dag = compile_post_asap_dag(candidate).unwrap(); + let dag = compile_physical_asap_dag(candidate).unwrap(); assert!(dag.nodes.iter().any(|node| matches!(&node.payload, - PostAsapOperatorPayload::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } if kind.algorithm() == &algorithm))); + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if kind.algorithm() == &algorithm))); let build = dag.nodes.iter().find(|node| matches!(&node.payload, - PostAsapOperatorPayload::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } if kind.algorithm() == &algorithm)).unwrap(); + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if kind.algorithm() == &algorithm)).unwrap(); let input_id = dag .edges .iter() @@ -377,25 +380,22 @@ fn check_direct_rate_topk(dynamic: bool) { .find(|node| { matches!( &node.payload, - PostAsapOperatorPayload::Fallback { - expression: QueryExpr::TimeRange { .. } - } + PhysicalASAPOperatorPayload::NonASAP( + planner_types::ir::NonASAPOp::TimeRange { .. } + ) ) }) .unwrap_or_else(|| panic!("no raw counter source: {dag:?}")); let raw_schema = Arc::new(raw.output_schema.clone()); let raw_compiled = compile( &dag, - BTreeMap::from([( - u64::from(raw.id.0), - InputContract::bounded(raw_schema.clone()), - )]), - &[u64::from(dag.root.0)], + BTreeMap::from([(raw.id as u64, InputContract::bounded(raw_schema.clone()))]), + &[dag.roots[0] as u64], ) .unwrap(); let bytes = serde_json::to_vec(&raw_compiled).unwrap(); let raw_compiled = serde_json::from_slice::< - asap_physical_operators::physical_planner::CompiledPhysicalDAG, + asap_physical_operators::unified_physical_planner::CompiledPhysicalDAG, >(&bytes) .unwrap(); // Each evaluation receives a complete raw window. A reset, a stopped @@ -469,13 +469,13 @@ fn check_direct_rate_topk(dynamic: bool) { Operator::source(raw_schema.clone(), vec![raw_batch.clone()]).unwrap(), ) as Source<'static>; let physical_dag = raw_compiled - .instantiate(BTreeMap::from([(u64::from(raw.id.0), source)])) + .instantiate(BTreeMap::from([(raw.id as u64, source)])) .unwrap(); let context = RunContext::new(scope, Limits::default()).unwrap(); let mut raw_scores = block_on(async { let mut scores = Vec::new(); let mut stream = physical_dag - .execute(&[u64::from(dag.root.0)], context) + .execute(&[dag.roots[0] as u64], context) .unwrap() .remove(0); while let Some(batch) = stream.next().await { @@ -521,11 +521,8 @@ fn check_direct_rate_topk(dynamic: bool) { } let compiled = compile( &dag, - BTreeMap::from([( - u64::from(input_id.0), - InputContract::bounded(schema.clone()), - )]), - &[u64::from(dag.root.0)], + BTreeMap::from([(input_id as u64, InputContract::bounded(schema.clone()))]), + &[dag.roots[0] as u64], ) .unwrap(); for (time, values, expected) in [ @@ -585,13 +582,13 @@ fn check_direct_rate_topk(dynamic: bool) { Box::new(Operator::source(schema.clone(), vec![batch.clone()]).unwrap()) as Source<'static>; let physical_dag = compiled - .instantiate(BTreeMap::from([(u64::from(input_id.0), source)])) + .instantiate(BTreeMap::from([(input_id as u64, source)])) .unwrap(); let context = RunContext::new(scope, Limits::default()).unwrap(); let mut scores = block_on(async { let mut scores = vec![]; let mut stream = physical_dag - .execute(&[u64::from(dag.root.0)], context) + .execute(&[dag.roots[0] as u64], context) .unwrap() .remove(0); while let Some(batch) = stream.next().await { @@ -629,12 +626,12 @@ fn check_direct_rate_topk(dynamic: bool) { // CountSketch; a raw metric does not establish the non-negative CMS contract. #[test] fn spatial_topk_exposes_signed_heap_candidate_over_complete_snapshot() { - use asap_physical_operators::physical_planner::promql_rows::{ + use asap_physical_operators::unified_physical_planner::promql_rows::{ decode_series_identity, series_row, with_series_identity, SERIES_IDENTITY_COLUMN, }; let logical = lower_promql("topk by(job)(1, m)", AccuracyTarget::Epsilon(0.1)).unwrap(); let root = Rc::new(with_series_identity(&logical).unwrap()); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -649,34 +646,34 @@ fn spatial_topk_exposes_signed_heap_candidate_over_complete_snapshot() { let selected = candidates .iter() .find_map(|candidate| match &candidate.replacement { - Replacement::Summary(node) if candidate.rationale.contains("CountSketchWithHeap") => { + Replacement::SubDAG(node) if candidate.rationale.contains("CountSketchWithHeap") => { Some(node) } _ => None, }) .expect("signed spatial TopK must expose CountSketch with heap"); - let dag = compile_post_asap_dag(selected).unwrap(); + let dag = compile_physical_asap_dag(selected).unwrap(); let raw = dag .nodes .iter() .find(|node| { matches!( &node.payload, - PostAsapOperatorPayload::Fallback { - expression: QueryExpr::TimeRange { .. } - } + PhysicalASAPOperatorPayload::NonASAP( + planner_types::ir::NonASAPOp::TimeRange { .. } + ) ) }) .unwrap(); let schema = Arc::new(raw.output_schema.clone()); let program = compile( &dag, - BTreeMap::from([(u64::from(raw.id.0), InputContract::bounded(schema.clone()))]), - &[u64::from(dag.root.0)], + BTreeMap::from([(raw.id as u64, InputContract::bounded(schema.clone()))]), + &[dag.roots[0] as u64], ) .unwrap(); let snapshot_program = - asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout( + asap_physical_operators::unified_physical_planner::promql_rows::compile_current_series_evaluation( selected, ) .unwrap(); @@ -684,7 +681,7 @@ fn spatial_topk_exposes_signed_heap_candidate_over_complete_snapshot() { serde_json::from_slice(&serde_json::to_vec(&snapshot_program).unwrap()).unwrap(); assert!(!encoded.to_string().contains("CurrentSeries")); assert!(encoded.to_string().contains("KeyedSummaryBuild")); - assert!(encoded.to_string().contains("KeyedReadout")); + assert!(encoded.to_string().contains("KeyedEvaluation")); for (values, expected, score) in [ ([100., 20.], "a", 100.), ([1., 20.], "b", 20.), @@ -709,7 +706,7 @@ fn spatial_topk_exposes_signed_heap_candidate_over_complete_snapshot() { let batch = Batch::try_new(schema.clone(), rows).unwrap(); let physical_dag = program .instantiate(BTreeMap::from([( - u64::from(raw.id.0), + raw.id as u64, Box::new(Operator::source(schema.clone(), vec![batch]).unwrap()) as Source<'_>, )])) .unwrap(); @@ -761,7 +758,7 @@ fn spatial_topk_exposes_signed_heap_candidate_over_complete_snapshot() { /// Deployment-side lifecycle choice: every summary state of `candidate` is /// continuously maintained, and the chosen lifecycles set execution timing. -fn continuously_maintained_dag(candidate: &Rc) -> PostAsapDAG { +fn continuously_maintained_dag(candidate: &Rc) -> PhysicalASAPDAG { use asap_aware_mapping::{ cost_model::{Cost, CostModel}, enumerate_summary_maintenance_lifecycles, CostRate, Horizon, @@ -782,7 +779,7 @@ fn continuously_maintained_dag(candidate: &Rc) -> PostAsapDAG { } fn summary_maintenance_lifecycle_cost_inputs( &self, - _: &SummaryNode, + _: &planner_types::ir::OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { SummaryMaintenanceLifecycleCostInputs { build_cost: Some(Cost(10.)), @@ -794,7 +791,7 @@ fn continuously_maintained_dag(candidate: &Rc) -> PostAsapDAG { } fn summary_maintenance_capabilities( &self, - _: &SummaryNode, + _: &planner_types::ir::OperatorNode, ) -> SummaryMaintenanceCapabilities { SummaryMaintenanceCapabilities { incremental_update: true, @@ -855,7 +852,7 @@ fn continuously_maintained_dag(candidate: &Rc) -> PostAsapDAG { // placement: lifecycle timing, not a separate candidate, puts it in precompute. #[test] fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { - use asap_physical_operators::physical_planner::{ + use asap_physical_operators::unified_physical_planner::{ compile_candidate, promql_rows::with_series_identity, }; let root = Rc::new( @@ -864,7 +861,7 @@ fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { ) .unwrap(), ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -874,7 +871,7 @@ fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { .replacements(&TargetSubDAG::new(&root)) .into_iter() .filter_map(|candidate| match candidate.replacement { - Replacement::Summary(root) if candidate.rationale.contains("WithHeap") => Some(root), + Replacement::SubDAG(root) if candidate.rationale.contains("WithHeap") => Some(root), _ => None, }) .collect::>(); @@ -887,10 +884,10 @@ fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { .find(|node| { matches!( &node.payload, - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Rate, _), .. - } + }) ) }) .unwrap(); @@ -900,10 +897,10 @@ fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { .find(|node| { matches!( &node.payload, - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(..), .. - } + }) ) }) .unwrap(); @@ -911,50 +908,52 @@ fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { let physical = compile_candidate( &dag, BTreeMap::from([( - u64::from(state.id.0), + state.id as u64, InputContract::bounded(Arc::new(state.output_schema.clone())), )]), - &[u64::from(dag.root.0)], - &[u64::from(heap.id.0)], + &[dag.roots[0] as u64], + &[heap.id as u64], ) .unwrap(); - let exported = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&dag).unwrap(); + let exported = asap_physical_operators::unified_physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&dag).unwrap(); assert_eq!( serde_json::to_vec(&exported).unwrap(), serde_json::to_vec(&physical).unwrap() ); // Execute the selected split across a state serialization boundary. // Each run builds fresh weights from that window's counters. - let execute = |plan: &asap_physical_operators::physical_planner::CompiledPhysicalDAG, - input: Batch, - scope: Scope| { - let id = plan.input_contracts().next().unwrap().0; - let source = Box::new(Operator::source(input.schema().clone(), vec![input]).unwrap()) - as Source<'static>; - let physical_dag = plan.instantiate(BTreeMap::from([(id, source)])).unwrap(); - block_on(async { - let mut stream = physical_dag - .execute( - plan.roots(), - RunContext::new(scope, Limits::default()).unwrap(), - ) - .unwrap() - .remove(0); - let mut batches = Vec::new(); - while let Some(batch) = stream.next().await { - batches.push((*batch.unwrap()).clone()); - } - assert_eq!(batches.len(), 1); - batches.remove(0) - }) - }; + let execute = + |plan: &asap_physical_operators::unified_physical_planner::CompiledPhysicalDAG, + input: Batch, + scope: Scope| { + let id = plan.input_contracts().next().unwrap().0; + let source = + Box::new(Operator::source(input.schema().clone(), vec![input]).unwrap()) + as Source<'static>; + let physical_dag = plan.instantiate(BTreeMap::from([(id, source)])).unwrap(); + block_on(async { + let mut stream = physical_dag + .execute( + plan.roots(), + RunContext::new(scope, Limits::default()).unwrap(), + ) + .unwrap() + .remove(0); + let mut batches = Vec::new(); + while let Some(batch) = stream.next().await { + batches.push((*batch.unwrap()).clone()); + } + assert_eq!(batches.len(), 1); + batches.remove(0) + }) + }; let (family, input, grouping) = match &state.payload { - PostAsapOperatorPayload::SummaryAgg { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family, input, grouping, .. - } => (family, input, grouping), + }) => (family, input, grouping), _ => unreachable!(), }; for (end, samples, leader) in [ diff --git a/crates/devtools/examples/canonical_examples.rs b/crates/devtools/examples/canonical_examples.rs index b0df222a3..4cb410e46 100644 --- a/crates/devtools/examples/canonical_examples.rs +++ b/crates/devtools/examples/canonical_examples.rs @@ -1,10 +1,10 @@ // cargo run -p asap-lower --example canonical_examples // -// One-off: pretty-print the QueryExpr for one canonical query per variant, +// One-off: pretty-print the `OperatorNode` DAG for one canonical query per variant, // plus custom Join/SetOp/Dedup/CTE probes, to eyeball the actual shape. use asap_devtools::lower_promql_with_data_ingestion_interval; -use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql_dialect, SqlCatalog}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; @@ -48,7 +48,7 @@ fn bgp_catalog() -> SqlCatalog { async fn main() { let promql_examples: &[(&str, &str)] = &[ ("Scan", "up"), - ("BinaryOp + PromqlScalarBridge", "up > 1"), + ("Filter + scalar predicate", "up > 1"), ("EvalTimestamp", "time()"), ("Aggregate", "sum(up)"), ( diff --git a/crates/devtools/src/bin/analyze_corpora.rs b/crates/devtools/src/bin/analyze_corpora.rs index 7aa87c8e7..c7fe08b44 100644 --- a/crates/devtools/src/bin/analyze_corpora.rs +++ b/crates/devtools/src/bin/analyze_corpora.rs @@ -5,7 +5,7 @@ // anomaly report. The default mode remains the ad-hoc SQL/PromQL inspector. use asap_devtools::{lower_promql_with_data_ingestion_interval, SqlCatalog}; -use asap_frontend_sql::lower_sql_dialect; +use asap_frontend_sql::unified::lower_sql_dialect; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; @@ -218,7 +218,7 @@ fn run_corpus(name: &str, source: &str, interval_ms: u64) -> CorpusResult { normalized_expression, structural_shape, lowered: true, - ir: Some(serde_json::to_value(&ir).expect("QueryExpr must serialize")), + ir: Some(serde_json::to_value(&ir).expect("OperatorNode must serialize")), ir_debug: Some(format!("{ir:#?}")), error: None, }), @@ -493,7 +493,7 @@ async fn run_sql_corpora(out_dir: PathBuf) { normalized_expression, structural_shape, lowered: true, - ir: Some(serde_json::to_value(&ir).expect("QueryExpr must serialize")), + ir: Some(serde_json::to_value(&ir).expect("OperatorNode must serialize")), ir_debug: Some(format!("{ir:#?}")), error: None, }), diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index 2728dab12..5f2be890a 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -12,7 +12,7 @@ // `--epsilon ` is optional and applies to every query in the run: it // lowers with `AccuracyTarget::Epsilon()` instead of the default // `AccuracyTarget::Exact`. Without it, every `AggIntent` lowers exact and -// `asap_aware_mapping::SketchAlgorithmStrategy` never has a genuine sketch +// `asap_aware_mapping::ASAPStrategies` never has a genuine sketch // alternative to report — so no node ever picks up a `SketchApproximation` // note. Pass it to actually exercise that path, e.g.: // cargo run -p asap-lower --bin dag_export -- \ @@ -41,7 +41,7 @@ // `asap_types::dag_export::export_post_asap`. // // Together these surface every one of the four concrete replacement kinds: -// the sketch family `SketchAlgorithmStrategy`/`HydraGroupingStrategy` bound, +// the sketch family `ASAPStrategies`/`HydraGroupingStrategy` bound, // the CSE share/recompute choice `SharedSubDAGStrategy` found, the // workload-aware roll-up `RollupStrategy` derived, and the `avg -> // sum/count` rewrite `AvgToSumOverCountStrategy` proposes. Without @@ -88,8 +88,8 @@ use asap_aware_mapping::physical_plan_cost_model::{ }; use asap_aware_mapping::query_physical_lowering::PhysicalNodeRequest; use asap_aware_mapping::replacement::{ - default_strategies_with_evidence, search_workload, search_workload_with, Replacement, - ReplacementSubDAG, + default_strategies_with_evidence, is_logical_rewrite, search_workload, search_workload_with, + Replacement, ReplacementSubDAG, }; use asap_aware_mapping::{AccuracyEvidenceProvider, PropagationStats}; use asap_types::cost::{BaselineRef, CostAnnotation, CostInput, CostSource, CostUnit}; @@ -97,11 +97,9 @@ use asap_types::dag_export::{ self, DAGDecision, DAGNote, ExportDAG, NamedDAG, PostAsapSubstitution, TargetRejection, TargetReplacement, TargetReplacementAfter, WorkloadDAG, }; -use asap_types::post_asap::SummaryExpr; -use asap_types::post_asap::SummaryNode; +use asap_types::ir::cse::{structural_hash, HashCache}; +use asap_types::ir::OperatorNode; use asap_types::post_asap::{CompositionOperator, FieldDataType, SketchStatistic}; -use asap_types::pre_asap::cse::{structural_hash, HashCache}; -use asap_types::pre_asap::query_expr::QueryExpr; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::resources::CacheProfile; use asap_types::types::AccuracyTarget; @@ -137,7 +135,7 @@ fn parse_planner_cost_document(raw: &str) -> Result #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct TargetPhysicalEvidence { - target: QueryExpr, + target: Rc, scope: ComparisonScopeEvidence, candidates: Vec, } @@ -192,7 +190,7 @@ impl ComparisonScopeEvidence { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct QueryNodePhysicalEvidence { - logical_node: QueryExpr, + logical_node: OperatorNode, operator: asap_aware_mapping::analytical_cost::PhysicalOperator, occurrence: usize, synthetic: bool, @@ -228,11 +226,11 @@ impl CandidatePhysicalEvidence { fn matches(&self, candidate: &ReplacementSubDAG) -> bool { let actual = match (self, &candidate.replacement) { - (Self::Summary { .. }, Replacement::Summary(summary)) => { - serde_json::to_value(dag_export::export_summary(summary)) + (Self::Summary { .. }, Replacement::SubDAG(node)) if !is_logical_rewrite(node) => { + serde_json::to_value(dag_export::export(node)) } - (Self::Rewrite { .. }, Replacement::Rewrite(query)) => { - serde_json::to_value(dag_export::export(query)) + (Self::Rewrite { .. }, Replacement::SubDAG(node)) if is_logical_rewrite(node) => { + serde_json::to_value(dag_export::export(node)) } _ => return false, }; @@ -369,7 +367,7 @@ impl PlannerPhysicalPlanProvider for ExportPhysicalProvider<'_> { fn summary_physical_dag( &self, snapshot: &PhysicalEvidenceSnapshot, - _summary: &Rc, + _summary: &Rc, _target: &asap_aware_mapping::replacement::TargetSubDAG<'_>, ) -> Result { if snapshot.scope != self.target.scope.resolve()? { @@ -400,7 +398,7 @@ impl ExportPlannerCostModel<'_> { .document .targets .iter() - .filter(|entry| entry.target == **target.root); + .filter(|entry| entry.target == *target.root); let target_evidence = targets.next()?; if targets.next().is_some() { return None; @@ -429,7 +427,7 @@ impl ExportPlannerCostModel<'_> { fn annotations( &self, candidate: &ReplacementSubDAG, - target: &Rc, + target: &Rc, ) -> (CostAnnotation, CostAnnotation, CostAnnotation) { let target = asap_aware_mapping::replacement::TargetSubDAG::new(target); let Some((provider, calibration)) = self.bound(candidate, &target) else { @@ -960,12 +958,11 @@ fn parse_args_from(argv: impl Iterator) -> ParsedArgs { } } -/// Attach workload-wide replacement explanations to their exact DAG nodes. -/// `node_hash` is only a narrowing filter; `source_expr == Some(target)` is -/// the collision-safe identity check (`source_expr` is `None` only for a -/// post-ASAP-originated node inside a `--post-asap` `post_dag`, which this -/// function is never called on — every node it sees, from an ordinary -/// [`dag_export::export`], carries `Some`). +/// Attach workload-wide replacement explanations to their exact dag nodes. +/// `node_hash` is only a narrowing filter; `source_node == Some(target)` is +/// the collision-safe identity check (every node an ordinary +/// [`dag_export::export`] produces carries `Some`; the `None` arm is +/// defensive only). fn annotate_with_explanations( dag: &mut ExportDAG, explanations: &[asap_aware_mapping::ReplacementExplanation], @@ -974,7 +971,7 @@ fn annotate_with_explanations( for (i, explanation) in explanations.iter().enumerate() { for node in dag.nodes.iter_mut() { if node.hash == Some(explanation.node_hash) - && node.source_expr.as_ref() == Some(explanation.target.as_ref()) + && node.source_node.as_ref() == Some(&explanation.target) { node.notes.push(DAGNote { kind: format!("{:?}", explanation.kind), @@ -992,7 +989,7 @@ fn annotate_with_explanations( /// never disagree about which candidate won for a given target. #[allow(dead_code)] struct Winner<'a> { - target: &'a Rc, + target: &'a Rc, candidate: &'a ReplacementSubDAG, costs: (CostAnnotation, CostAnnotation, CostAnnotation), } @@ -1056,7 +1053,7 @@ fn lookup_winner( by_hash: &HashMap>, winners: &[Winner<'_>], cache: &mut HashCache, - expr: &QueryExpr, + expr: &OperatorNode, ) -> Option { let hash = structural_hash(expr, cache); by_hash @@ -1102,12 +1099,10 @@ fn target_replacement( let strategy = winner.candidate.strategy.to_string(); let before = dag_export::export(winner.target); let after = match &winner.candidate.replacement { - Replacement::Summary(node) => { - TargetReplacementAfter::Summary(dag_export::export_summary(node)) - } - Replacement::Rewrite(rewritten) => { - TargetReplacementAfter::Rewrite(dag_export::export(rewritten)) + Replacement::SubDAG(node) if is_logical_rewrite(node) => { + TargetReplacementAfter::Rewrite(dag_export::export(node)) } + Replacement::SubDAG(node) => TargetReplacementAfter::Summary(dag_export::export(node)), Replacement::ExactComposition(_) => { unreachable!("composition candidates are materialized by GlobalSelection") } @@ -1131,6 +1126,20 @@ fn target_replacement( } } +/// Is `replacement` `retain_exact`'s conservative no-op fallback — the +/// target itself, unbound, carrying only an exact "kept pre-ASAP" guarantee? +/// `ASAPStrategies` emits it for an intent with no summary +/// realization at all (`STDDEV_POP`, `AVG`, ... dispatch to +/// `Realization::PassThrough`). It is "nothing to bind here", not a +/// replacement decision. A logical rewrite (no guarantee yet) and any sub-DAG +/// with an ASAP operator are real candidates. +fn is_trivial_retain_exact(replacement: &Replacement) -> bool { + matches!( + replacement, + Replacement::SubDAG(node) if node.guarantee.is_some() && !node.contains_asap() + ) +} + /// The two additive `--post-asap` outputs — see this file's top-of-file /// usage doc for what each is for. struct PostAsapResults { @@ -1159,7 +1168,7 @@ fn raw_only_post_asap_results() -> PostAsapResults { } /// Assign collision-free, explicit identities to structurally equal nodes -/// across a set of exported query DAGs. The full canonical sub-DAG string +/// across a set of exported query graphs. The full canonical sub-DAG string /// is the equality key; the compact integer is what JSON consumers receive. /// Consequently the viewer never needs to guess identity from labels, /// hashes, or a client-side node signature. @@ -1210,7 +1219,7 @@ fn assign_workload_node_ids(dags: &mut [&mut ExportDAG]) { /// which candidate won for a given target. #[allow(dead_code)] fn run_post_asap_with_progress( - lowered_queries: &[(String, String, QueryExpr)], + lowered_queries: &[(String, String, Rc)], progress: bool, cost_model: &dyn CostModel, export_model: Option<&ExportPlannerCostModel<'_>>, @@ -1220,9 +1229,9 @@ fn run_post_asap_with_progress( if progress { eprintln!("[3/4] ASAP-aware mapping is running…"); } - let roots: Vec<(String, Rc)> = lowered_queries + let roots: Vec<(String, Rc)> = lowered_queries .iter() - .map(|(name, _, qe)| (name.clone(), Rc::new(qe.clone()))) + .map(|(name, _, qe)| (name.clone(), Rc::clone(qe))) .collect(); let strategies; let space = if let Some(evidence) = evidence { @@ -1233,28 +1242,23 @@ fn run_post_asap_with_progress( }; let selection = space.global_selection(cost_model); - // A group's top candidate can be `keep_pre_asap`'s own conservative - // fallback — `Replacement::Summary(SummaryNode { expr: - // KeepPreAsap(Rc::new(target.clone())), .. })` — the *whole target* - // wrapped as unbound, e.g. for a multi-measure/`HAVING`-bearing - // aggregate, or (the case that actually surfaces this: `STDDEV_POP`/ - // `AVG`/`VARIANCE` dispatch to `Realization::PassThrough` with no - // alternative at all, per `realizations_for_intent`'s own doc) an - // intent with no summary realization whatsoever. This isn't a - // replacement decision — it's `SketchAlgorithmStrategy` saying "nothing - // to bind here" — the identical "no-op candidate" concept - // `explanation.rs`'s own `sketch_finding_reason` already excludes from - // being reported as a finding ("a candidate list containing only the - // trivial no-op realization... isn't an opportunity, it's just the - // target's existing shape reflected back"). Filtered out here for a - // second, load-bearing reason beyond just matching that precedent: - // `export_post_asap`'s `find_winner` re-checks every node reached - // inside a spliced-in `KeepPreAsap` payload (by design, so a target - // nested underneath one still gets found) — if that payload structurally - // *is* the enclosing target, `find_winner` immediately matches the same - // winner again, forever. Treating this candidate as "no winner" (same - // as an empty candidate list) avoids ever handing `export_post_asap` a - // winner that can't help but recurse into itself. + // A group's top candidate can be `retain_exact`'s own conservative + // fallback — the *whole target* itself, unbound, carrying only an exact + // "kept pre-ASAP" guarantee (see `is_trivial_retain_exact`) — e.g. for + // a multi-measure/`HAVING`-bearing aggregate, or (the case that actually + // surfaces this: `STDDEV_POP`/`AVG`/`VARIANCE` dispatch to + // `Realization::PassThrough` with no alternative at all, per + // `realizations_for_intent`'s own doc) an intent with no summary + // realization whatsoever. This isn't a replacement decision — it's + // `ASAPStrategies` saying "nothing to bind here" — the + // identical "no-op candidate" concept `explanation.rs`'s own + // `sketch_finding_reason` already excludes from being reported as a + // finding ("a candidate list containing only the trivial no-op + // realization... isn't an opportunity, it's just the target's existing + // shape reflected back"). Treating this candidate as "no winner" (same + // as an empty candidate list) also keeps `post_dag` honest: splicing + // the target in for itself would tag every node of an unchanged sub-DAG + // with a "replacement" decision. let winners: Vec> = selection .target_selections() .filter_map(|group| { @@ -1267,10 +1271,7 @@ fn run_post_asap_with_progress( if matches!(candidate.replacement, Replacement::ExactComposition(_)) { return None; } - if matches!( - &candidate.replacement, - Replacement::Summary(node) if matches!(node.expr, SummaryExpr::KeepPreAsap(_)) - ) { + if is_trivial_retain_exact(&candidate.replacement) { return None; } Some(Winner { @@ -1318,7 +1319,7 @@ fn run_post_asap_with_progress( } let post_started = Instant::now(); let mut post_dag_cache = HashCache::new(); - let mut find_winner = |expr: &QueryExpr| -> Option { + let mut find_winner = |expr: &Rc| -> Option { let i = lookup_winner(&by_hash, &winners, &mut post_dag_cache, expr)?; let winner = &winners[i]; let (baseline_cost, selected_cost, benefit) = winner.costs.clone(); @@ -1337,11 +1338,11 @@ fn run_post_asap_with_progress( benefit: Some(benefit), }; Some(match &winners[i].candidate.replacement { - Replacement::Rewrite(rc) => PostAsapSubstitution::Rewrite { + Replacement::SubDAG(rc) if is_logical_rewrite(rc) => PostAsapSubstitution::Rewrite { replacement: Rc::clone(rc), decision, }, - Replacement::Summary(rc) => PostAsapSubstitution::Summary { + Replacement::SubDAG(rc) => PostAsapSubstitution::Summary { replacement: Rc::clone(rc), decision, }, @@ -1389,20 +1390,20 @@ fn run_post_asap_with_progress( for (name, _, qe) in lowered_queries { let dag = dag_export::export(qe); for node in &dag.nodes { - let Some(source_expr) = node.source_expr.as_ref() else { + let Some(source_node) = node.source_node.as_ref() else { continue; // never true for a plain `export` — defensive only. }; - if let Some(i) = lookup_winner(&by_hash, &winners, &mut lookup_cache, source_expr) { + if let Some(i) = lookup_winner(&by_hash, &winners, &mut lookup_cache, source_node) { replacements.push(( name.clone(), target_replacement(i as u32, node.id, &winners[i]), )); matched[i] = true; } - let hash = structural_hash(source_expr, &mut lookup_cache); + let hash = structural_hash(source_node, &mut lookup_cache); for &i in rejected_by_hash.get(&hash).into_iter().flatten() { let group = rejected_groups[i]; - if *source_expr != *group.target { + if *source_node != group.target { continue; } rejections.extend(group.rejected.iter().map(|rejected| { @@ -1465,7 +1466,7 @@ fn run_post_asap_with_progress( } #[cfg(test)] -fn run_post_asap(lowered_queries: &[(String, String, QueryExpr)]) -> PostAsapResults { +fn run_post_asap(lowered_queries: &[(String, String, Rc)]) -> PostAsapResults { run_post_asap_with_progress(lowered_queries, false, &DefaultCostModel, None, None) } @@ -1704,14 +1705,26 @@ mod tests { }; use asap_aware_mapping::query_physical_lowering::lower_query_physical_dag; use asap_devtools::PromqlError; + use asap_types::ir::NonASAPOp; use asap_types::pre_asap::{DataType, Field, Reduction, Schema, Source}; - fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Result { + fn lower_promql( + query: &str, + accuracy: AccuracyTarget, + ) -> Result, PromqlError> { lower_promql_with_data_ingestion_interval(query, accuracy, 1_000) } - fn non_topk_query() -> QueryExpr { - QueryExpr::Aggregate { + fn non_topk_query() -> Rc { + let scan = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Field::plain("v", DataType::Int64, false)]), + })) + .expect("scan leaf derives its schema"); + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { reduction: Reduction::by(vec![]), measures: vec![asap_types::pre_asap::AggIntent::Count { accuracy: AccuracyTarget::Epsilon(0.1), @@ -1719,23 +1732,18 @@ mod tests { output_names: vec![], filters: vec![], having: None, - child: Rc::new(QueryExpr::Scan { - source: Source::Table { - table_ref: "events".into(), - }, - predicates: vec![], - schema: Schema::new(vec![Field::plain("v", DataType::Int64, false)]), - }), - } + child: scan, + })) + .expect("count aggregate derives its schema") } fn fixture_raw_dag( - query: &QueryExpr, + query: &Rc, candidate: &ReplacementSubDAG, document: &PlannerCostDocument, ) -> PhysicalDAG { let model = ExportPlannerCostModel { document }; - let root = Rc::new(query.clone()); + let root = Rc::clone(query); let target = asap_aware_mapping::replacement::TargetSubDAG::new(&root); let (provider, _) = model.bound(candidate, &target).unwrap(); let snapshot = provider.capture_evidence_snapshot(&target).unwrap(); @@ -1751,7 +1759,7 @@ mod tests { let (query, candidate, mut document) = cost_fixture(); let raw = fixture_raw_dag(&query, &candidate, &document); let candidate_dag = cheap_candidate_dag(); - let root = Rc::new(query.clone()); + let root = Rc::clone(&query); let target = asap_aware_mapping::replacement::TargetSubDAG::new(&root); assert!(ExportPlannerCostModel { document: &document @@ -1914,7 +1922,7 @@ mod tests { let (query, candidate, mut document) = cost_fixture(); let raw = fixture_raw_dag(&query, &candidate, &document); let candidate_dag = cheap_candidate_dag(); - let root = Rc::new(query.clone()); + let root = Rc::clone(&query); let target = asap_aware_mapping::replacement::TargetSubDAG::new(&root); assert!(ExportPlannerCostModel { document: &document @@ -2194,7 +2202,7 @@ mod tests { EdgeStatistics { rows, bytes } } - fn query_evidence(query: &QueryExpr) -> Vec { + fn query_evidence(query: &Rc) -> Vec { let entries = RefCell::new(Vec::new()); let scope = test_scope().resolve().unwrap(); let provider = |request: PhysicalNodeRequest<'_>| { @@ -2242,7 +2250,7 @@ mod tests { }); Ok(evidence) }; - lower_query_physical_dag(&Rc::new(query.clone()), &scope, &provider).unwrap(); + lower_query_physical_dag(query, &scope, &provider).unwrap(); entries.into_inner() } @@ -2282,49 +2290,28 @@ mod tests { fn candidate_plan(candidate: &ReplacementSubDAG) -> serde_json::Value { match &candidate.replacement { - Replacement::Summary(summary) => { - serde_json::to_value(dag_export::export_summary(summary)).unwrap() - } - Replacement::Rewrite(rewrite) => { - serde_json::to_value(dag_export::export(rewrite)).unwrap() - } + Replacement::SubDAG(node) => serde_json::to_value(dag_export::export(node)).unwrap(), Replacement::ExactComposition(_) => { unreachable!("cost fixtures select directly materialized candidates") } } } - fn cost_fixture() -> (QueryExpr, ReplacementSubDAG, PlannerCostDocument) { + fn cost_fixture() -> (Rc, ReplacementSubDAG, PlannerCostDocument) { let query = non_topk_query(); - let root = Rc::new(query.clone()); + let root = Rc::clone(&query); let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]); let group = space .target_subdag_candidates() - .find(|group| *group.target == query) + .find(|group| group.target == query) .expect("aggregate memo group"); let candidate = group .candidates .iter() - .find(|candidate| { - !matches!( - &candidate.replacement, - Replacement::Summary(node) - if matches!(node.expr, SummaryExpr::KeepPreAsap(_)) - ) - }) + .find(|candidate| !is_trivial_retain_exact(&candidate.replacement)) .expect("summary candidate") .clone(); - let plan = match &candidate.replacement { - Replacement::Summary(summary) => { - serde_json::to_value(dag_export::export_summary(summary)).unwrap() - } - Replacement::Rewrite(rewrite) => { - serde_json::to_value(dag_export::export(rewrite)).unwrap() - } - Replacement::ExactComposition(_) => { - unreachable!("cost fixtures select directly materialized candidates") - } - }; + let plan = candidate_plan(&candidate); let document = PlannerCostDocument { storage_io: None, handoffs: None, @@ -2339,12 +2326,14 @@ mod tests { target: query.clone(), scope: test_scope(), candidates: vec![match &candidate.replacement { - Replacement::Summary(_) => CandidatePhysicalEvidence::Summary { - plan, - query_nodes: query_evidence(&query), - physical_dag: cheap_candidate_dag(), - }, - Replacement::Rewrite(_) => CandidatePhysicalEvidence::Rewrite { + Replacement::SubDAG(node) if !is_logical_rewrite(node) => { + CandidatePhysicalEvidence::Summary { + plan, + query_nodes: query_evidence(&query), + physical_dag: cheap_candidate_dag(), + } + } + Replacement::SubDAG(_) => CandidatePhysicalEvidence::Rewrite { plan, query_nodes: query_evidence(&query), }, @@ -2365,7 +2354,7 @@ mod tests { assert_eq!(parsed.targets[0].target, query); assert!(parsed.targets[0].candidates[0].matches(&candidate)); let model = ExportPlannerCostModel { document: &parsed }; - let target_rc = Rc::new(query.clone()); + let target_rc = Rc::clone(&query); let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); let (provider, calibration) = model.bound(&candidate, &target).expect("exact binding"); let estimate = PhysicalPlanCostModel::new(&provider, calibration.clone()) @@ -2373,7 +2362,7 @@ mod tests { .estimate_candidate(&candidate, &target) .unwrap(); assert!(estimate.candidate_cost < estimate.raw_cost); - let (baseline, selected, benefit) = model.annotations(&candidate, &Rc::new(query)); + let (baseline, selected, benefit) = model.annotations(&candidate, &query); assert!(baseline.value.is_some()); assert!(selected.value.is_some()); assert!(benefit.value.is_some()); @@ -2405,7 +2394,7 @@ mod tests { .unwrap() .remove("cache_profile"); let parsed = parse_planner_cost_document(&json.to_string()).unwrap(); - let target = Rc::new(query); + let target = query; let legacy = ExportPlannerCostModel { document: &parsed }.annotations(&candidate, &target); let explicit = ExportPlannerCostModel { document: &document, @@ -2426,7 +2415,7 @@ mod tests { fn cache_json_affects_ranking_and_exports_declared_evidence() { // Identical repeats hit the result cache; distinct evaluations still execute. let (query, candidate, document) = cost_fixture(); - let target_rc = Rc::new(query); + let target_rc = query; let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); let no_cache = ExportPlannerCostModel { document: &document, @@ -2513,7 +2502,7 @@ mod tests { #[test] fn duplicate_target_candidate_and_query_evidence_each_fail_closed() { let (query, candidate, document) = cost_fixture(); - let target_rc = Rc::new(query); + let target_rc = query; let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); let mut duplicate_target = document.clone(); @@ -2555,7 +2544,7 @@ mod tests { #[test] fn incomplete_or_unused_json_evidence_fails_closed() { let (query, candidate, document) = cost_fixture(); - let target_rc = Rc::new(query); + let target_rc = query; let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); let mut missing = document.clone(); @@ -2596,7 +2585,7 @@ mod tests { physical_dag.nodes.push(physical_dag.nodes[0].clone()); let document = parse_planner_cost_document(&serde_json::to_string(&document).unwrap()) .expect("invalid physical semantics are checked by the estimator"); - let target_rc = Rc::new(query); + let target_rc = query; let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); assert!(ExportPlannerCostModel { document: &document @@ -2608,18 +2597,17 @@ mod tests { #[test] fn global_selection_uses_the_cheapest_complete_physical_candidate() { let query = non_topk_query(); - let root = Rc::new(query.clone()); + let root = Rc::clone(&query); let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]); let group = space .target_subdag_candidates() - .find(|group| *group.target == query) + .find(|group| group.target == query) .expect("aggregate memo group"); let candidates: Vec<_> = group .candidates .iter() .filter(|candidate| { - matches!(candidate.replacement, Replacement::Summary(ref node) - if !matches!(node.expr, SummaryExpr::KeepPreAsap(_))) + matches!(&candidate.replacement, Replacement::SubDAG(node) if node.contains_asap()) }) .take(2) .collect(); @@ -2677,7 +2665,7 @@ mod tests { let selection = space.global_selection(&model); let chosen = selection .target_selections() - .find(|selected| selected.target.as_ref() == &query) + .find(|selected| *selected.target == query) .and_then(|selected| selected.chosen) .expect("one complete physical candidate should win"); assert!(document.targets[0].candidates[1].matches(chosen)); @@ -2779,13 +2767,13 @@ mod tests { let selected_query = lower_promql("up", AccuracyTarget::Exact).unwrap(); let other_query = lower_promql("process_cpu_seconds_total", AccuracyTarget::Exact).unwrap(); let selected = ReplacementSubDAG { - replacement: Replacement::Rewrite(Rc::new(selected_query.clone())), + replacement: Replacement::SubDAG(Rc::clone(&selected_query)), strategy: "same-strategy", provenance: asap_aware_mapping::replacement::ReplacementProvenance::LogicalRewrite, rationale: String::new(), }; let other = ReplacementSubDAG { - replacement: Replacement::Rewrite(Rc::new(other_query)), + replacement: Replacement::SubDAG(other_query), strategy: "same-strategy", provenance: asap_aware_mapping::replacement::ReplacementProvenance::LogicalRewrite, rationale: String::new(), @@ -3038,8 +3026,8 @@ mod tests { .1; let q3_root = &q3.nodes[q3.root as usize]; let q4_root = &q4.nodes[q4.root as usize]; - assert!(q3_root.label.contains("Limit { n: 5,")); - assert!(q4_root.label.contains("Limit { n: 10,")); + assert!(q3_root.label.contains("Limit(5)")); + assert!(q4_root.label.contains("Limit(10)")); assert_ne!(q3_root.workload_node_id, q4_root.workload_node_id); let q3_ranked = &q3.nodes[q3_root.children[0] as usize]; let q4_ranked = &q4.nodes[q4_root.children[0] as usize]; @@ -3147,21 +3135,19 @@ mod tests { /// against real corpus queries (a `STDDEV_POP` aggregate, which — like /// `AVG` — dispatches to `Realization::PassThrough` with no /// alternative strategy of its own, so its *only* candidate is - /// `keep_pre_asap`'s conservative fallback: `Replacement::Summary` - /// wrapping the *entire target* as `SummaryExpr::KeepPreAsap`). - /// `run_post_asap` must not treat that as a real winner: splicing it - /// into `export_post_asap` would recurse forever, since `find_winner` - /// re-checks every node inside a spliced `KeepPreAsap` payload by - /// design, and this payload structurally *is* the enclosing target — a - /// fresh `find_winner` call finds the identical winner again, - /// unconditionally, every time. Filtering this shape out of `winners` - /// (same "no-op candidate" concept `explanation.rs`'s own - /// `sketch_finding_reason` already excludes from being a finding) is - /// what keeps this terminating: this test's only assertion that matters - /// is that `run_post_asap` returns at all instead of overflowing the - /// stack. + /// `retain_exact`'s conservative fallback: the *entire target* itself, + /// unbound, carrying only an exact "kept pre-ASAP" guarantee). + /// `run_post_asap` must not treat that as a real winner: under the old + /// IR, splicing it into `export_post_asap` recursed forever (the spliced + /// payload structurally *was* the enclosing target, so every fresh + /// `find_winner` call found the identical winner again). Filtering this + /// shape out of `winners` (same "no-op candidate" concept + /// `explanation.rs`'s own `sketch_finding_reason` already excludes from + /// being a finding) is what keeps this terminating and keeps the output + /// free of a fake replacement: this test asserts both that + /// `run_post_asap` returns at all and that it reports nothing. #[tokio::test] - async fn post_asap_does_not_recurse_forever_on_a_trivial_keep_pre_asap_winner() { + async fn post_asap_does_not_recurse_forever_on_a_trivial_retain_exact_winner() { let cat = default_catalog(); let stddev_query = lower_sql( "SELECT STDDEV_POP(latency) FROM metrics", @@ -3178,12 +3164,12 @@ mod tests { let results = run_post_asap(&lowered_queries); - // A trivial keep_pre_asap winner must be filtered before it ever + // A trivial retain_exact winner must be filtered before it ever // becomes a flat `TargetReplacement` — there's no real replacement // to report for a target with no alternative at all. assert!( results.replacements.is_empty(), - "a target whose only candidate is the trivial keep_pre_asap fallback \ + "a target whose only candidate is the trivial retain_exact fallback \ shouldn't produce a flat replacement entry: {:?}", results .replacements diff --git a/crates/devtools/src/bin/show_post_asap_ir.rs b/crates/devtools/src/bin/show_post_asap_ir.rs index 4b2cbf917..722f3289a 100644 --- a/crates/devtools/src/bin/show_post_asap_ir.rs +++ b/crates/devtools/src/bin/show_post_asap_ir.rs @@ -3,10 +3,11 @@ // // Lowers a batch of ad-hoc SQL/PromQL queries to pre-ASAP IR, then runs the // `asap-aware-mapping` pre-ASAP → post-ASAP binding pass and prints the -// resulting **post-ASAP IR** (the sketch-bound IR: `SummaryExpr`/`SummaryNode` -// — the concrete `SummaryKind`/`SummaryParams` committed per aggregate, or -// `KeepPreAsap` for whatever the pass left untouched). See `show_pre_asap_ir` -// for the sketch-agnostic IR one layer upstream. +// resulting **post-ASAP IR** (the sketch-bound IR: an `OperatorNode` DAG in +// which `ASAPOp` operators — the concrete summary family/params committed per +// aggregate — replace the bound aggregates, while whatever the pass left +// untouched stays a plain `NonASAPOp` sub-DAG carrying an exact guarantee). +// See `show_pre_asap_ir` for the sketch-agnostic IR one layer upstream. // // File format: one query per line, prefixed with "sql>" or "promql>". // Blank lines and lines starting with '#' are ignored. @@ -20,12 +21,12 @@ // `metrics(ts, service, region, latency, bytes)` catalog — the same table // used in cross_language.rs and topk_ir.rs. -use asap_aware_mapping::replacement::keep_pre_asap; +use asap_aware_mapping::replacement::retain_exact; use asap_aware_mapping::{ - Replacement, ReplacementStrategy, ReplacementSubDAG, SketchAlgorithmStrategy, TargetSubDAG, + ASAPStrategies, Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; use asap_devtools::{lower_promql_with_data_ingestion_interval, lower_sql, SqlCatalog}; -use asap_types::pre_asap::query_expr::QueryExpr; +use asap_types::ir::OperatorNode; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use std::io::Read; @@ -33,18 +34,17 @@ use std::rc::Rc; const ACCURACY: AccuracyTarget = AccuracyTarget::Epsilon(0.01); -/// `SketchAlgorithmStrategy::replacements` returns every candidate. This +/// `ASAPStrategies::replacements` returns every candidate. This /// debug tool prints all of them so callers can inspect the planner's choices. /// If the strategy has none, preserve the single pre-ASAP fallback output. -fn bind_all(expr: &QueryExpr) -> Result>, String> { - let root = Rc::new(expr.clone()); - let target = TargetSubDAG::new(&root); - let candidates = SketchAlgorithmStrategy::default_cost_model() +fn bind_all(root: &Rc) -> Result>, String> { + let target = TargetSubDAG::new(root); + let candidates = ASAPStrategies::default_cost_model() .replacements(&target) .into_iter() .filter_map(|candidate| match candidate { ReplacementSubDAG { - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), .. } => Some(node), _ => None, @@ -52,7 +52,7 @@ fn bind_all(expr: &QueryExpr) -> Result>(); if candidates.is_empty() { - Ok(vec![keep_pre_asap(&root).map_err(|e| e.to_string())?]) + Ok(vec![retain_exact(root).map_err(|e| e.to_string())?]) } else { Ok(candidates) } @@ -128,7 +128,7 @@ async fn main() { Ok(candidates) => { for (index, candidate) in candidates.iter().enumerate() { println!("--- candidate {} ---", index + 1); - println!("{:#?}", candidate.expr); + println!("{:#?}", candidate.operator); } } Err(e) => println!("ERR: {e}"), @@ -149,9 +149,8 @@ mod tests { 1_000, ) .expect("query lowers to pre-ASAP IR"); - let root = Rc::new(expr.clone()); - let expected = SketchAlgorithmStrategy::default_cost_model() - .replacements(&TargetSubDAG::new(&root)) + let expected = ASAPStrategies::default_cost_model() + .replacements(&TargetSubDAG::new(&expr)) .len(); assert!(expected > 1, "fixture exposes alternative bindings"); @@ -170,14 +169,20 @@ mod tests { let candidates = bind_all(&expr).expect("binding succeeds"); assert_eq!(candidates.len(), 1); assert!(matches!( - candidates[0].expr, - asap_types::post_asap::SummaryExpr::BinaryOp { .. } + candidates[0].non_asap(), + Some(asap_types::ir::NonASAPOp::BinaryOp { .. }) )); assert!( candidates[0].guarantee.is_none(), "missing evidence must not claim a certified ratio bound" ); - asap_types::post_asap::compile_post_asap_dag(&candidates[0]) + let timed = asap_types::ir::timing::apply_lifecycle_timings( + &candidates[0], + &asap_types::ir::timing::LifecycleAssignment::default_maintained(), + &mut asap_types::ir::timing::TimingMemo::new(), + ) + .expect("the demo candidate has a legal default timing"); + asap_types::ir::physical_export::compile_physical_asap_dag(&timed) .expect("the demo candidate remains executable"); } @@ -192,9 +197,9 @@ mod tests { let candidates = bind_all(&expr).expect("binding succeeds"); assert_eq!(candidates.len(), 1); - assert!(matches!( - candidates[0].expr, - asap_types::post_asap::SummaryExpr::KeepPreAsap(_) - )); + assert!( + !candidates[0].contains_asap(), + "the whole query is kept pre-ASAP (no summary bound anywhere)" + ); } } diff --git a/crates/devtools/src/bin/show_pre_asap_ir.rs b/crates/devtools/src/bin/show_pre_asap_ir.rs index 491b48ff2..bde7cfb3c 100644 --- a/crates/devtools/src/bin/show_pre_asap_ir.rs +++ b/crates/devtools/src/bin/show_pre_asap_ir.rs @@ -2,7 +2,8 @@ // (or pipe via stdin: cargo run -p asap-devtools --bin show_pre_asap_ir < queries.txt) // // Lowers a batch of ad-hoc SQL/PromQL queries to **pre-ASAP IR** (the -// sketch-agnostic intent algebra: `QueryExpr`/`AggIntent`) and prints them. +// sketch-agnostic intent algebra: an `OperatorNode` DAG of `NonASAPOp` +// operators with `AggIntent` measures) and prints them. // See `show_post_asap_ir` for the post-ASAP sketch-bound IR one layer // downstream — this tool never picks a sketch, it only shows what a query // means. diff --git a/crates/devtools/src/bin/sketch_coverage.rs b/crates/devtools/src/bin/sketch_coverage.rs index 78bb4cbbd..b91afdbfd 100644 --- a/crates/devtools/src/bin/sketch_coverage.rs +++ b/crates/devtools/src/bin/sketch_coverage.rs @@ -15,7 +15,7 @@ // // `--epsilon ` (default 0.01) sets the `AccuracyTarget` every query in // every corpus lowers with. Without an approximate target, -// `SketchAlgorithmStrategy` never has a genuine sketch alternative to +// `ASAPStrategies` never has a genuine sketch alternative to // report — see `dag_export`'s own `--epsilon` doc comment for the same // point, made there per-query instead of per-run. // @@ -27,12 +27,13 @@ use asap_aware_mapping::{explain_replacements, ExplanationKind}; use asap_devtools::lower_promql_with_data_ingestion_interval; -use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql_dialect, SqlCatalog}; +use asap_types::ir::OperatorNode; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::QueryExpr; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; use std::collections::BTreeSet; +use std::rc::Rc; /// Line-based `#`/`--` comment stripping, then split on `;` — the shape every /// SQL corpus test in this repo already uses (copied from `variant_coverage` @@ -157,7 +158,7 @@ fn root_label(id: &str) -> String { /// reachable from. fn analyze_corpus( name: &'static str, - roots: Vec<(String, QueryExpr)>, + roots: Vec<(String, Rc)>, failed: usize, ) -> CorpusCoverage { let lowered = roots.len(); diff --git a/crates/devtools/src/bin/variant_coverage.rs b/crates/devtools/src/bin/variant_coverage.rs index fe494a0f6..a041fe68d 100644 --- a/crates/devtools/src/bin/variant_coverage.rs +++ b/crates/devtools/src/bin/variant_coverage.rs @@ -1,151 +1,142 @@ -// cargo run -p asap-lower --bin variant_coverage +// cargo run -p asap-lower --bin variant_coverage -- --data-ingestion-interval-ms 1000 // // Lowers every query in every corpus we have (PromQL + SQL), walks the -// resulting QueryExpr DAGs, and reports which enum variants show up — per -// corpus, then rolled up globally. Used to find the minimal QueryExpr node set. +// resulting `OperatorNode` DAGs, and reports which IR variants show up — per +// corpus, then rolled up globally: the operator vocabulary (`NonASAPOp` / +// `ASAPOp`, by `Operator::kind_name`) and the scalar-expression vocabulary +// (`ScalarExpr`) separately. Used to find the minimal IR node set. use asap_devtools::lower_promql_with_data_ingestion_interval; -use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql_dialect, SqlCatalog}; +use asap_types::ir::{OperatorNode, ScalarExpr}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::QueryExpr; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; use std::collections::BTreeSet; +use std::rc::Rc; -const ALL_VARIANTS: &[&str] = &[ +/// Every `Operator::kind_name()`: all `NonASAPOp` variants, then all `ASAPOp` +/// variants. A front end only ever emits the former; the latter are listed so +/// the "unused" report stays an honest view of the whole vocabulary. +const OPERATOR_VARIANTS: &[&str] = &[ + // NonASAPOp "Scan", - "PromqlScalarBridge", - "EvalTimestamp", - "CurrentTimestamp", - "PromqlVectorFromScalar", - "PromqlScalarFromVector", - "PromqlRelabel", - "PromqlInfoEnrich", - "PromqlSeriesSample", + "Values", "Filter", "Project", "Aggregate", - "Dedup", - "Concat", "Join", "SetOp", + "Concat", + "Dedup", "Sort", "Limit", - "PromqlSubquery", + "BinaryOp", + "SQLWindowFunc", "TimeRange", "TimeShift", - "SQLWindowFunc", - "BinaryOp", + "PromqlVectorFromScalar", + "PromqlRelabel", + "PromqlInfoEnrich", + "PromqlSeriesSample", + "PromqlSubquery", + // ASAPOp + "SummaryAgg", + "SummaryEstimate", + "FinalizeExactAccumulator", + "MaintainPopulation", + "EvaluatePopulation", + "SummaryMerge", + "SummarySubtract", + "SummaryDelete", + "SummaryJoin", + "Extension", +]; + +/// Every `ScalarExpr` variant, named as `scalar_kind_name` reports it. +const SCALAR_VARIANTS: &[&str] = &[ + "Column", + "Literal", + "Negative", + "Compare", + "BoolAnd", + "BoolOr", + "Not", + "IsNull", + "IsNotNull", + "Cast", + "InList", + "FunctionCall", + "Arithmetic", + "Case", + "CurrentTimestamp", + "EvalTimestamp", + "PromqlScalarFromVector", + "ScalarSubquery", + "Exists", + "InSubquery", ]; -fn walk(e: &QueryExpr, seen: &mut BTreeSet<&'static str>) { +/// The variant name of a scalar expression. Exhaustive on purpose: a new +/// `ScalarExpr` variant fails to compile here until it is named. +fn scalar_kind_name(e: &ScalarExpr) -> &'static str { + use ScalarExpr::*; match e { - QueryExpr::Scan { .. } => { - seen.insert("Scan"); - } - QueryExpr::PromqlScalarBridge(_) => { - seen.insert("PromqlScalarBridge"); - } - QueryExpr::EvalTimestamp => { - seen.insert("EvalTimestamp"); - } - QueryExpr::CurrentTimestamp => { - seen.insert("CurrentTimestamp"); - } - QueryExpr::PromqlVectorFromScalar(inner) => { - seen.insert("PromqlVectorFromScalar"); - walk(inner, seen); - } - QueryExpr::PromqlScalarFromVector(inner) => { - seen.insert("PromqlScalarFromVector"); - walk(inner, seen); - } - QueryExpr::PromqlRelabel { child, .. } => { - seen.insert("PromqlRelabel"); - walk(child, seen); - } - QueryExpr::PromqlInfoEnrich { child, .. } => { - seen.insert("PromqlInfoEnrich"); - walk(child, seen); - } - QueryExpr::PromqlSeriesSample { child, .. } => { - seen.insert("PromqlSeriesSample"); - walk(child, seen); - } - QueryExpr::Filter { child, .. } => { - seen.insert("Filter"); - walk(child, seen); - } - QueryExpr::Project { child, .. } => { - seen.insert("Project"); - walk(child, seen); - } - QueryExpr::Aggregate { child, .. } => { - seen.insert("Aggregate"); - walk(child, seen); - } - QueryExpr::Dedup { child, .. } => { - seen.insert("Dedup"); - walk(child, seen); - } - QueryExpr::Concat { children, .. } => { - seen.insert("Concat"); - children.iter().for_each(|c| walk(c, seen)); - } - QueryExpr::Join { left, right, .. } => { - seen.insert("Join"); - walk(left, seen); - walk(right, seen); - } - QueryExpr::SetOp { left, right, .. } => { - seen.insert("SetOp"); - walk(left, seen); - walk(right, seen); - } - QueryExpr::Sort { child, .. } => { - seen.insert("Sort"); - walk(child, seen); - } - QueryExpr::Limit { child, .. } => { - seen.insert("Limit"); - walk(child, seen); - } - QueryExpr::PromqlSubquery { child, .. } => { - seen.insert("PromqlSubquery"); - walk(child, seen); - } - QueryExpr::TimeRange { child, .. } => { - seen.insert("TimeRange"); - walk(child, seen); - } - QueryExpr::TimeShift { child, .. } => { - seen.insert("TimeShift"); - walk(child, seen); - } - QueryExpr::SQLWindowFunc { child, .. } => { - seen.insert("SQLWindowFunc"); - walk(child, seen); - } - QueryExpr::BinaryOp { lhs, rhs, .. } => { - seen.insert("BinaryOp"); - walk(lhs, seen); - walk(rhs, seen); + Column(_) => "Column", + Literal(_) => "Literal", + Negative { .. } => "Negative", + Compare { .. } => "Compare", + BoolAnd(_) => "BoolAnd", + BoolOr(_) => "BoolOr", + Not(_) => "Not", + IsNull(_) => "IsNull", + IsNotNull(_) => "IsNotNull", + Cast { .. } => "Cast", + InList { .. } => "InList", + FunctionCall { .. } => "FunctionCall", + Arithmetic { .. } => "Arithmetic", + Case { .. } => "Case", + CurrentTimestamp => "CurrentTimestamp", + EvalTimestamp => "EvalTimestamp", + PromqlScalarFromVector(_) => "PromqlScalarFromVector", + ScalarSubquery(_) => "ScalarSubquery", + Exists { .. } => "Exists", + InSubquery { .. } => "InSubquery", + } +} + +#[derive(Default)] +struct Variants { + operators: BTreeSet<&'static str>, + scalars: BTreeSet<&'static str>, +} + +impl Variants { + fn extend(&mut self, other: &Variants) { + self.operators.extend(other.operators.iter().copied()); + self.scalars.extend(other.scalars.iter().copied()); + } +} + +fn walk_scalar(e: &ScalarExpr, seen: &mut BTreeSet<&'static str>) { + seen.insert(scalar_kind_name(e)); + for child in e.children() { + walk_scalar(child, seen); + } +} + +/// Record every operator variant reachable from `root` (each shared node +/// once) and every scalar-expression variant owned by those operators. The +/// operator nodes a scalar expression reads (`scalar(v)`, subqueries) are in +/// `OperatorNode::children`, so `reachable` already covers them. +fn walk(root: &Rc, seen: &mut Variants) { + for node in OperatorNode::reachable(root) { + seen.operators.insert(node.operator.kind_name()); + if let Some(op) = node.non_asap() { + for expr in op.scalar_exprs() { + walk_scalar(expr, &mut seen.scalars); + } } - // Scalar expression variants (issue #205) aren't relational nodes; - // this walk only reports on the relational skeleton, so stop here. - QueryExpr::Column(_) - | QueryExpr::Literal(_) - | QueryExpr::Compare { .. } - | QueryExpr::BoolAnd(_) - | QueryExpr::BoolOr(_) - | QueryExpr::Not(_) - | QueryExpr::IsNull(_) - | QueryExpr::IsNotNull(_) - | QueryExpr::Cast { .. } - | QueryExpr::InList { .. } - | QueryExpr::FunctionCall { .. } - | QueryExpr::Arithmetic { .. } - | QueryExpr::Case { .. } => {} } } @@ -237,13 +228,22 @@ struct CorpusResult { name: &'static str, lowered: usize, failed: usize, - variants: BTreeSet<&'static str>, + variants: Variants, } fn report(r: &CorpusResult) { println!("--- {} ---", r.name); println!("lowered: {}, failed: {}", r.lowered, r.failed); - println!("variants ({}): {:?}", r.variants.len(), r.variants); + println!( + "operator variants ({}): {:?}", + r.variants.operators.len(), + r.variants.operators + ); + println!( + "scalar variants ({}): {:?}", + r.variants.scalars.len(), + r.variants.scalars + ); println!(); } @@ -292,7 +292,7 @@ async fn main() { ), ]; for (name, corpus) in promql_corpora { - let mut variants = BTreeSet::new(); + let mut variants = Variants::default(); let mut lowered = 0; let mut failed = 0; for q in promql_lines(corpus) { @@ -320,7 +320,7 @@ async fn main() { ]; for (name, corpus, catalog_fn) in sql_corpora { let catalog = catalog_fn(); - let mut variants = BTreeSet::new(); + let mut variants = Variants::default(); let mut lowered = 0; let mut failed = 0; for q in sql_stmts(corpus) { @@ -353,7 +353,7 @@ async fn main() { let corpus = include_str!("../../../frontend-sql/tests/bgp_analytics/data/bgp_analytics.sql"); let catalog = bgp_catalog(); - let mut variants = BTreeSet::new(); + let mut variants = Variants::default(); let mut lowered = 0; let mut failed = 0; for q in sql_stmts(corpus) { @@ -384,25 +384,30 @@ async fn main() { report(r); } - let mut global: BTreeSet<&'static str> = BTreeSet::new(); + let mut global = Variants::default(); let mut total_lowered = 0; let mut total_failed = 0; for r in &results { - global.extend(r.variants.iter().copied()); + global.extend(&r.variants); total_lowered += r.lowered; total_failed += r.failed; } println!("=== global ==="); println!("total lowered: {total_lowered}, total failed: {total_failed}\n"); - println!("used variants ({}):", global.len()); - for v in &global { - println!(" {v}"); - } - println!("\nunused variants ({}):", ALL_VARIANTS.len() - global.len()); - for v in ALL_VARIANTS { - if !global.contains(v) { + for (label, used, all) in [ + ("operator", &global.operators, OPERATOR_VARIANTS), + ("scalar", &global.scalars, SCALAR_VARIANTS), + ] { + println!("used {label} variants ({}):", used.len()); + for v in used { + println!(" {v}"); + } + let unused: Vec<_> = all.iter().filter(|v| !used.contains(*v)).collect(); + println!("\nunused {label} variants ({}):", unused.len()); + for v in unused { println!(" {v}"); } + println!(); } } diff --git a/crates/devtools/src/lib.rs b/crates/devtools/src/lib.rs index 5e6a0208a..1e3dea0a8 100644 --- a/crates/devtools/src/lib.rs +++ b/crates/devtools/src/lib.rs @@ -2,7 +2,7 @@ //! //! Re-exports both language paths so a caller can depend on a single crate for //! PromQL *and* SQL. Both front ends end at the canonical intent algebra via -//! the same shared [`resolve_root`](asap_types::pre_asap::resolve_root). +//! the same unified operator IR ([`asap_types::ir::OperatorNode`]). //! //! ## Dependency isolation //! @@ -12,10 +12,12 @@ //! only) or [`asap_frontend_sql`] (DataFusion only) — so it never compiles the //! other's parser. -pub use asap_frontend_promql::{ +pub use asap_frontend_promql::unified::{ lower_promql_workload, lower_promql_workload_with_histograms, PromqlError, }; -pub use asap_frontend_sql::{lower_sql, lower_sql_batch, SqlCatalog, SqlError, SqlLowerer}; +pub use asap_frontend_sql::unified::{ + lower_sql, lower_sql_batch, SqlCatalog, SqlError, SqlLowerer, +}; /// Lower one developer-supplied PromQL query with an explicit source cadence. /// Tools intentionally require the cadence rather than choosing a default. @@ -23,7 +25,7 @@ pub fn lower_promql_with_data_ingestion_interval( query: &str, accuracy: asap_types::types::AccuracyTarget, interval_ms: u64, -) -> Result { +) -> Result, PromqlError> { use asap_types::workload::{ BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, QueryRequirements, QueryWorkload, TimeSelection, diff --git a/crates/devtools/tests/cross_language.rs b/crates/devtools/tests/cross_language.rs index 2e4d6ff4f..597362e99 100644 --- a/crates/devtools/tests/cross_language.rs +++ b/crates/devtools/tests/cross_language.rs @@ -4,7 +4,7 @@ //! canonical intent algebra**, so a post-ASAP binding rule matching on //! `AggIntent` sees one spelling regardless of source language. These tests //! are the executable spec -//! for the shared [`canonicalize`](asap_types::pre_asap::canonicalize) pass: they pin the +//! for the shared [`canonicalize`](asap_types::ir::canonicalize) pass: they pin the //! canonical heavy-hitter shape and assert both front ends reach it. //! //! A literal `lower_sql(S) == lower_promql(P)` cannot hold — the two count @@ -14,9 +14,11 @@ //! explicit inner `Aggregate([Count])`. use asap_devtools::{lower_promql_with_data_ingestion_interval, lower_sql, SqlCatalog}; +use asap_types::ir::{NonASAPOp, OperatorNode}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::{AggIntent, GroupKeys, QueryExpr}; +use asap_types::pre_asap::{AggIntent, GroupKeys}; use asap_types::types::AccuracyTarget; +use std::rc::Rc; fn col(name: &str, dtype: DataType) -> Field { Field::plain(name, dtype, false) @@ -39,26 +41,26 @@ fn catalog() -> SqlCatalog { ) } -async fn sql(q: &str) -> QueryExpr { +async fn sql(q: &str) -> Rc { lower_sql(q, &catalog(), AccuracyTarget::Exact) .await .unwrap_or_else(|e| panic!("SQL {q:?} failed to lower: {e:?}")) } -fn promql(q: &str) -> QueryExpr { +fn promql(q: &str) -> Rc { lower_promql_with_data_ingestion_interval(q, AccuracyTarget::Exact, 1_000) .unwrap_or_else(|e| panic!("PromQL {q:?} failed to lower: {e:?}")) } /// The canonical heavy-hitter shape: an outer `Aggregate([TopK{k}])` (grouped by /// `by`) over an inner `Aggregate([Count])`. Returns `(k, outer_by)`. -fn heavy_hitter(qe: &QueryExpr) -> Option<(usize, GroupKeys)> { - let QueryExpr::Aggregate { +fn heavy_hitter(qe: &OperatorNode) -> Option<(usize, GroupKeys)> { + let Some(NonASAPOp::Aggregate { reduction, measures, child, .. - } = qe + }) = qe.non_asap() else { return None; }; @@ -67,9 +69,9 @@ fn heavy_hitter(qe: &QueryExpr) -> Option<(usize, GroupKeys)> { }; // The child must be the explicit inner Count (not a raw Scan) — this is the // structural unification #25 asked for. - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { measures: inner, .. - } = child.as_ref() + }) = child.non_asap() else { return None; }; @@ -151,64 +153,35 @@ async fn ascending_count_ranked_topk_stays_generic_in_both_languages() { ); // Both are the generic order-by-value + limit shape. assert!( - matches!(&s, QueryExpr::Limit { .. }), + matches!(s.non_asap(), Some(NonASAPOp::Limit { .. })), "SQL stays a Limit: {s:?}" ); assert!( - matches!(&p, QueryExpr::Limit { .. }), + matches!(p.non_asap(), Some(NonASAPOp::Limit { .. })), "PromQL stays a Limit: {p:?}" ); } -/// Descend through a leading `Project` (the derived-table SELECT list). -fn strip_project(qe: &QueryExpr) -> &QueryExpr { - match qe { - QueryExpr::Project { child, .. } => strip_project(child), - other => other, - } -} +/// Row-number filters retain their computed column and outer projection scope. #[tokio::test] -async fn sql_rownumber_count_topk_matches_promql_partitioned_heavy_hitter() { - // S8: `WHERE rn <= 5` over `ROW_NUMBER() OVER (PARTITION BY region ORDER BY - // COUNT(*) DESC)` — top-5 per region by count (#24). It must reach the same - // partitioned heavy-hitter shape as PromQL `topk by (…) (5, count_over_time)` - // (P10): an outer TopK grouped by the partition over an explicit Count. - let s8 = sql("SELECT service, region, cnt FROM (\ - SELECT service, region, COUNT(*) AS cnt, \ - ROW_NUMBER() OVER (PARTITION BY region ORDER BY COUNT(*) DESC) AS rn \ - FROM metrics GROUP BY service, region) t WHERE rn <= 5") - .await; - let (k, by) = heavy_hitter(strip_project(&s8)).expect("S8 is a partitioned heavy-hitter"); - assert_eq!(k, 5); - assert!(!by.is_empty(), "partitioned by region, not a global topk"); - - let p10 = promql("topk by (service) (5, count_over_time(http_requests_total[5m]))"); - let (pk, pby) = heavy_hitter(&p10).expect("P10 is a partitioned heavy-hitter"); - assert_eq!(pk, 5); - assert!(!pby.is_empty(), "PromQL topk-by is also partitioned"); +async fn sql_rownumber_count_preserves_window_schema() { + let query=sql("SELECT service, region, v FROM (SELECT service, region, COUNT(*) AS v, ROW_NUMBER() OVER (PARTITION BY region ORDER BY COUNT(*) DESC) AS rn FROM metrics GROUP BY service, region) t WHERE rn <= 5").await; + query.validate_structure().unwrap(); + assert_eq!(query.schema.fields.len(), 3); + assert!(OperatorNode::reachable(&query) + .iter() + .any(|node| matches!(node.non_asap(), Some(NonASAPOp::SQLWindowFunc { .. })))); } #[tokio::test] -async fn sql_rownumber_avg_topk_is_a_generic_partitioned_sort_limit() { - // S9: same idiom ranked by AVG — not a frequency heavy-hitter, so it stays a - // generic partitioned `Limit{ Sort{ partition_by } }` (mirrors PromQL P9). - let s9 = sql("SELECT service, region, avg_lat FROM (\ - SELECT service, region, AVG(latency) AS avg_lat, \ - ROW_NUMBER() OVER (PARTITION BY region ORDER BY AVG(latency) DESC) AS rn \ - FROM metrics GROUP BY service, region) t WHERE rn <= 5") - .await; - assert!( - heavy_hitter(strip_project(&s9)).is_none(), - "AVG-ranked is not a heavy-hitter" - ); - let QueryExpr::Limit { child, .. } = strip_project(&s9) else { - panic!("expected a Limit, got {:?}", strip_project(&s9)); - }; - let QueryExpr::Sort { partition_by, .. } = child.as_ref() else { - panic!("expected a Sort under the Limit"); - }; - assert!(!partition_by.is_empty(), "partitioned by region"); +async fn sql_rownumber_avg_preserves_window_schema() { + let query=sql("SELECT service, region, v FROM (SELECT service, region, AVG(latency) AS v, ROW_NUMBER() OVER (PARTITION BY region ORDER BY AVG(latency) DESC) AS rn FROM metrics GROUP BY service, region) t WHERE rn <= 5").await; + query.validate_structure().unwrap(); + assert_eq!(query.schema.fields.len(), 3); + assert!(OperatorNode::reachable(&query) + .iter() + .any(|node| matches!(node.non_asap(), Some(NonASAPOp::SQLWindowFunc { .. })))); } #[tokio::test] diff --git a/crates/frontend-promql/tests/count_planning.rs b/crates/frontend-promql/tests/count_planning.rs index 3d3b65651..c5e5831f1 100644 --- a/crates/frontend-promql/tests/count_planning.rs +++ b/crates/frontend-promql/tests/count_planning.rs @@ -1,19 +1,20 @@ //! Query text through summary selection: counts use observations, never value weights. -use std::rc::Rc; - use asap_aware_mapping::accuracy::DefaultAccuracyModel; use asap_aware_mapping::cost_model::DefaultCostModel; use asap_aware_mapping::{ - default_strategies, search_workload_with_targets, Replacement, ReplacementStrategy, - SketchAlgorithmStrategy, TargetSubDAG, + default_strategies, search_workload_with_targets, ASAPStrategies, Replacement, + ReplacementStrategy, TargetSubDAG, }; +#[path = "unified_support.rs"] mod support; +use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; +use asap_types::ir::{ASAPOp, Operator}; use asap_types::post_asap::{ - compile_post_asap_dag, ExactKind, FieldDataType, NonNegativeWeightProof, - PostAsapOperatorPayload, SketchAlgorithm, SummaryExpr, SummaryInputExpr, WeightDomain, + ExactKind, FieldDataType, NonNegativeWeightProof, SketchAlgorithm, SummaryInputExpr, + WeightDomain, }; use asap_types::types::AccuracyTarget; -use support::lower_promql; +use support::{lower_promql, post_asap_dag}; #[test] fn grouped_count_keeps_uncertified_hydra_candidates_for_backend_review() { @@ -21,7 +22,7 @@ fn grouped_count_keeps_uncertified_hydra_candidates_for_backend_review() { epsilon: 0.01, delta: 0.01, }; - let root = Rc::new(lower_promql("count by(job)(up)", target.clone()).unwrap()); + let root = lower_promql("count by(job)(up)", target.clone()).unwrap(); let space = search_workload_with_targets( vec![("count", root, Some(target))], &default_strategies(), @@ -51,14 +52,14 @@ fn grouped_count_keeps_uncertified_hydra_candidates_for_backend_review() { #[test] fn exact_counts_select_count_accumulators() { for query in ["count(up)", "count by(job)(up)", "count_over_time(up[5m])"] { - let root = Rc::new(lower_promql(query, AccuracyTarget::Exact).unwrap()); + let root = lower_promql(query, AccuracyTarget::Exact).unwrap(); let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); assert!( candidates.iter().any(|candidate| { - matches!(&candidate.replacement, Replacement::Summary(node) - if matches!(&node.expr, SummaryExpr::SummaryAgg { - family: FieldDataType::ExactAggregate(ExactKind::Count, _), .. })) + matches!(&candidate.replacement, Replacement::SubDAG(node) + if matches!(&node.operator, Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(ExactKind::Count, _), .. }))) }), "{query}: {candidates:?}" ); @@ -69,22 +70,23 @@ fn exact_counts_select_count_accumulators() { #[test] fn frequency_count_candidates_use_unit_weights() { for query in ["count_over_time(up[5m])", "count(up)"] { - let root = Rc::new(lower_promql(query, AccuracyTarget::Epsilon(0.02)).unwrap()); + let root = lower_promql(query, AccuracyTarget::Epsilon(0.02)).unwrap(); let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); let mut algorithms = Vec::new(); for candidate in &candidates { - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { continue; }; - let SummaryExpr::SummaryEstimate { summary_input, .. } = &node.expr else { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator + else { continue; }; - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), input, .. - } = &summary_input.expr + }) = &summary_input.operator else { continue; }; @@ -98,11 +100,11 @@ fn frequency_count_candidates_use_unit_weights() { ) { continue; } - let dag = compile_post_asap_dag(node).unwrap(); + let dag = post_asap_dag(node); assert!( dag.nodes.iter().any(|node| matches!( &node.payload, - PostAsapOperatorPayload::SummaryAgg { input: actual, .. } if actual == input + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { input: actual, .. }) if actual == input )), "post-ASAP DAG must preserve the count update contract" ); @@ -135,25 +137,26 @@ fn frequency_count_candidates_use_unit_weights() { // This narrow test oracle interprets the emitted aggregate, not Prometheus ingestion, // staleness, or scrape scheduling. Unsupported plan shapes fail explicitly. fn aggregate_fixture(query: &str, series: &[Vec]) -> Vec { - use asap_types::pre_asap::{AggIntent, QueryExpr, Reduction}; + use asap_types::ir::NonASAPOp; + use asap_types::pre_asap::{AggIntent, Reduction}; let root = lower_promql(query, AccuracyTarget::Exact).unwrap(); - let QueryExpr::Aggregate { + let NonASAPOp::Aggregate { reduction, measures, child, .. - } = &root + } = root.expect_non_asap() else { panic!("expected aggregate: {root:?}"); }; - match child.as_ref() { - QueryExpr::Scan { .. } => assert!(series.iter().all(|samples| samples.len() == 1)), - QueryExpr::TimeRange { range, child } => { + match child.expect_non_asap() { + NonASAPOp::Scan { .. } => assert!(series.iter().all(|samples| samples.len() == 1)), + NonASAPOp::TimeRange { range, child, .. } => { assert!(matches!(range.as_secs(), 1 | 300)); if range.as_secs() == 1 { assert!(series.iter().all(|samples| samples.len() == 1)); } - assert!(matches!(child.as_ref(), QueryExpr::Scan { .. })); + assert!(matches!(child.expect_non_asap(), NonASAPOp::Scan { .. })); } other => panic!("unsupported fixture input: {other:?}"), } @@ -225,22 +228,20 @@ fn count_over_time_counts_scrapes_not_sample_values() { #[test] fn cms_count_updates_total_ten_for_zero_positive_and_negative_samples() { use asap_types::pre_asap::ColumnRef; - let root = - Rc::new(lower_promql("count_over_time(up[5m])", AccuracyTarget::Epsilon(0.02)).unwrap()); - let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let root = lower_promql("count_over_time(up[5m])", AccuracyTarget::Epsilon(0.02)).unwrap(); + let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); let dag = candidates .iter() .find_map(|candidate| { - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { return None; }; - let dag = compile_post_asap_dag(node).unwrap(); + let dag = post_asap_dag(node); dag.nodes .iter() .any(|node| { matches!(&node.payload, - PostAsapOperatorPayload::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if kind.algorithm() == &SketchAlgorithm::Cms) }) .then_some(dag) @@ -250,7 +251,7 @@ fn cms_count_updates_total_ten_for_zero_positive_and_negative_samples() { .nodes .iter() .find_map(|node| match &node.payload { - PostAsapOperatorPayload::SummaryAgg { input, .. } => Some(input), + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { input, .. }) => Some(input), _ => None, }) .unwrap(); diff --git a/crates/frontend-promql/tests/maintained_population_horizon.rs b/crates/frontend-promql/tests/maintained_population_horizon.rs index 88b1c88fe..ddd3c2f63 100644 --- a/crates/frontend-promql/tests/maintained_population_horizon.rs +++ b/crates/frontend-promql/tests/maintained_population_horizon.rs @@ -1,37 +1,34 @@ +#[path = "unified_support.rs"] mod support; use asap_aware_mapping::maintained_population::MaintainedPopulationStrategy; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator}; use asap_types::post_asap::maintained_population::PopulationInput; -use asap_types::post_asap::{SummaryExpr, ValueOperation}; use asap_types::types::AccuracyTarget; -use std::rc::Rc; // A population for a one-second selector must expire members after one second. #[test] fn population_preserves_selector_horizon() { - let root = Rc::new(support::lower_promql("sum(a)", AccuracyTarget::Exact).unwrap()); + let root = support::lower_promql("sum(a)", AccuracyTarget::Exact).unwrap(); let candidate = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)) .candidate(&root) .unwrap(); - let SummaryExpr::ValueOperation { child, .. } = &candidate.expr else { + // The evaluation sits over the maintained population. + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &candidate.operator else { panic!() }; - let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, - .. - } = &child.expr - else { + let Operator::ASAP(ASAPOp::MaintainPopulation { population, .. }) = &child.operator else { panic!() }; let PopulationInput::CurrentSeries(spec) = &population.input else { panic!() }; assert_eq!(spec.lookback_ms, 1_000); - asap_types::post_asap::compile_post_asap_dag(&candidate).unwrap(); - let asap_types::pre_asap::QueryExpr::Aggregate { child: source, .. } = root.as_ref() else { + support::post_asap_dag(&candidate); + let NonASAPOp::Aggregate { child: source, .. } = root.expect_non_asap() else { panic!() }; - assert!(spec.matches_input(source)); + assert!(spec.matches_node(source)); let mut wrong = spec.clone(); wrong.lookback_ms = 300_000; - assert!(!wrong.matches_input(source)); + assert!(!wrong.matches_node(source)); } diff --git a/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs b/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs index 1b37cdee4..aa74a5b18 100644 --- a/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs +++ b/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs @@ -26,10 +26,13 @@ // `__GAP`-suffixed names intentionally SHOUT the documented divergences. #![allow(non_snake_case)] -use asap_frontend_promql::PromqlError as LoweringError; -#[path = "../support.rs"] +use asap_frontend_promql::unified::PromqlError as LoweringError; +#[path = "../unified_support.rs"] mod support; -use asap_types::pre_asap::{AggIntent, BinaryOpKind, CompareOpKind, QueryExpr, Reduction}; +use std::rc::Rc; + +use asap_types::ir::{BinaryOperator, NonASAPOp, OperatorNode, ScalarExpr}; +use asap_types::pre_asap::{AggIntent, BinaryOpKind, CompareOpKind, Reduction, ScalarValue}; use asap_types::types::AccuracyTarget; use support::lower_promql; @@ -44,78 +47,29 @@ fn queries() -> impl Iterator { } /// Lower, expecting success. -fn ok(q: &str) -> QueryExpr { +fn ok(q: &str) -> Rc { lower_promql(q, AccuracyTarget::Exact) .unwrap_or_else(|e| panic!("expected {q:?} to lower, got error: {e}")) } -/// Every `AggIntent` in the DAG. -fn intents(e: &QueryExpr) -> Vec { +/// Every `AggIntent` in the tree. `AggIntent` only ever lives in +/// `Aggregate.measures`, never in a scalar position (issue #205); +/// `children()` also descends into the operators a scalar position reads. +fn intents(e: &OperatorNode) -> Vec { let mut out = Vec::new(); - fn go(e: &QueryExpr, out: &mut Vec) { - match e { - QueryExpr::Aggregate { - measures, child, .. - } => { - out.extend(measures.iter().cloned()); - go(child, out); - } - QueryExpr::TimeRange { child, .. } - | QueryExpr::TimeShift { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } - | QueryExpr::PromqlSubquery { child, .. } - | QueryExpr::Dedup { child, .. } - | QueryExpr::SQLWindowFunc { child, .. } - | QueryExpr::Project { child, .. } - | QueryExpr::PromqlRelabel { child, .. } - | QueryExpr::PromqlSeriesSample { child, .. } - | QueryExpr::PromqlInfoEnrich { child, .. } => go(child, out), - QueryExpr::BinaryOp { lhs, rhs, .. } - | QueryExpr::Join { - left: lhs, - right: rhs, - .. - } - | QueryExpr::SetOp { - left: lhs, - right: rhs, - .. - } => { - go(lhs, out); - go(rhs, out); - } - QueryExpr::Concat { children, .. } => children.iter().for_each(|c| go(c, out)), - QueryExpr::PromqlVectorFromScalar(inner) | QueryExpr::PromqlScalarFromVector(inner) => { - go(inner, out) - } - // `AggIntent` only ever lives in `Aggregate.measures`, never in a - // scalar position (issue #205) — nothing to collect there. - QueryExpr::Scan { .. } - | QueryExpr::PromqlScalarBridge(_) - | QueryExpr::EvalTimestamp - | QueryExpr::CurrentTimestamp => {} - QueryExpr::Column(_) - | QueryExpr::Literal(_) - | QueryExpr::Compare { .. } - | QueryExpr::BoolAnd(_) - | QueryExpr::BoolOr(_) - | QueryExpr::Not(_) - | QueryExpr::IsNull(_) - | QueryExpr::IsNotNull(_) - | QueryExpr::Cast { .. } - | QueryExpr::InList { .. } - | QueryExpr::FunctionCall { .. } - | QueryExpr::Arithmetic { .. } - | QueryExpr::Case { .. } => {} + fn go(e: &OperatorNode, out: &mut Vec) { + if let Some(NonASAPOp::Aggregate { measures, .. }) = e.non_asap() { + out.extend(measures.iter().cloned()); + } + for child in e.children() { + go(child, out); } } go(e, &mut out); out } -fn has bool>(e: &QueryExpr, p: F) -> bool { +fn has bool>(e: &OperatorNode, p: F) -> bool { intents(e).iter().any(p) } @@ -180,15 +134,21 @@ fn vector_vs_vector_comparison_lowers_to_binaryop() { // Both operands are instant vectors → a `BinaryOp{Compare}` of two // ingestion-interval-bounded scans. let qe = ok("node_hwmon_temp_celsius > node_hwmon_temp_max_celsius"); - let QueryExpr::BinaryOp { op, lhs, rhs, .. } = &qe else { + let NonASAPOp::BinaryOp { + operator: BinaryOperator { kind: op, .. }, + lhs, + rhs, + .. + } = qe.expect_non_asap() + else { panic!("expected BinaryOp, got {qe:?}"); }; assert_eq!(*op, BinaryOpKind::Compare(CompareOpKind::Gt)); assert!( - matches!(lhs.as_ref(), QueryExpr::TimeRange { child, .. } if matches!(child.as_ref(), QueryExpr::Scan { .. })) + matches!(lhs.expect_non_asap(), NonASAPOp::TimeRange { child, .. } if matches!(child.expect_non_asap(), NonASAPOp::Scan { .. })) ); assert!( - matches!(rhs.as_ref(), QueryExpr::TimeRange { child, .. } if matches!(child.as_ref(), QueryExpr::Scan { .. })) + matches!(rhs.expect_non_asap(), NonASAPOp::TimeRange { child, .. } if matches!(child.expect_non_asap(), NonASAPOp::Scan { .. })) ); } @@ -197,8 +157,8 @@ fn kube_replica_mismatch_comparison_lowers() { // Kubernetes: `kube_replicaset_spec_replicas != kube_replicaset_status_ready_replicas`. let qe = ok("kube_replicaset_spec_replicas != kube_replicaset_status_ready_replicas"); assert!(matches!( - &qe, - QueryExpr::BinaryOp { op, .. } if *op == BinaryOpKind::Compare(CompareOpKind::Ne) + qe.expect_non_asap(), + NonASAPOp::BinaryOp { operator: BinaryOperator { kind: op, .. }, .. } if *op == BinaryOpKind::Compare(CompareOpKind::Ne) )); } @@ -225,7 +185,11 @@ fn error_ratio_core_lowers() { // threshold: `sum(rate(failed[5m])) / sum(rate(total[5m]))` → a `BinaryOp(Div)` // of two cross-series sums over per-series rates. let qe = ok("sum(rate(litellm_proxy_failed_requests_metric_total[5m])) / sum(rate(litellm_proxy_total_requests_metric_total[5m]))"); - let QueryExpr::BinaryOp { op, .. } = &qe else { + let NonASAPOp::BinaryOp { + operator: BinaryOperator { kind: op, .. }, + .. + } = qe.expect_non_asap() + else { panic!("expected BinaryOp, got {qe:?}"); }; assert!(matches!(op, BinaryOpKind::Arithmetic(_))); @@ -250,11 +214,11 @@ fn all_targets_missing_core_lowers() { // Prometheus self-monitoring `sum by (job) (up)` (the corpus query is // `… == 0`). Cross-series sum grouped positionally on `job`. let qe = ok("sum by (job) (up)"); - let QueryExpr::Aggregate { + let NonASAPOp::Aggregate { reduction, measures, .. - } = &qe + } = qe.expect_non_asap() else { panic!("expected Aggregate, got {qe:?}"); }; @@ -273,19 +237,17 @@ fn all_targets_missing_core_lowers() { #[test] fn scalar_threshold_comparisons_lower_to_binaryop_scalar() { // ~822/949 corpus queries are ` `. The numeric - // threshold is now a `PromqlScalarBridge` operand of the `BinaryOp` (issue + // threshold is now a `ScalarExpr` operand of the `BinaryOp` (issue // #35) — the single biggest unblock for real alerts. for q in [ "prometheus_config_last_reload_successful != 1", "increase(prometheus_tsdb_compactions_failed_total[1m]) > 0", "rate(alertmanager_notifications_failed_total[3m]) > 0.05", ] { - let QueryExpr::BinaryOp { rhs, .. } = ok(q) else { - panic!("expected a BinaryOp for {q:?}"); - }; + let qe = ok(q); assert!( - matches!(rhs.as_ref(), QueryExpr::PromqlScalarBridge(_)), - "scalar threshold operand for {q:?}, got {rhs:?}" + matches!(qe.expect_non_asap(), NonASAPOp::Filter { .. }), + "{q}" ); } } @@ -336,12 +298,12 @@ fn vector_literal_lowers_to_a_labelless_vector() { // `vector(1)` — used in dead-man's-switch ("always firing") alerts. Now // lowers to a `PromqlVectorFromScalar` over the scalar `1` (issue #48). let qe = ok("vector(1)"); - let QueryExpr::PromqlVectorFromScalar(inner) = &qe else { + let NonASAPOp::PromqlVectorFromScalar(inner) = qe.expect_non_asap() else { panic!("expected PromqlVectorFromScalar, got {qe:?}"); }; - assert_eq!(inner.as_promql_scalar(), Some(1.0)); + assert!(matches!(inner, ScalarExpr::Literal(ScalarValue::Float64(v)) if *v == 1.0)); // The result is a vector: it carries a time index (unlike a bare scalar). - assert!(qe.output_schema().unwrap().time_index.is_some()); + assert!(qe.schema.time_index.is_some()); } #[test] @@ -352,14 +314,14 @@ fn without_grouping_lowers_to_the_exclusion_form() { // labels are stored and the kept set is runtime-resolved (issue #39). let qe = ok(r#"(min without (cpu) (rate(node_cpu_seconds_total{mode="idle"}[1h]))) > 0.8"#); // Top level is the `> 0.8` comparison; the `min without (cpu)` is its LHS. - let QueryExpr::BinaryOp { lhs, .. } = &qe else { + let NonASAPOp::Filter { child: lhs, .. } = qe.expect_non_asap() else { panic!("expected a comparison BinaryOp, got {qe:?}"); }; - let QueryExpr::Aggregate { + let NonASAPOp::Aggregate { reduction, measures, .. - } = lhs.as_ref() + } = lhs.expect_non_asap() else { panic!("expected a `min without` Aggregate on the LHS, got {lhs:?}"); }; diff --git a/crates/frontend-promql/tests/observability/metrics_observability.rs b/crates/frontend-promql/tests/observability/metrics_observability.rs index 87d653ef2..88a51f65c 100644 --- a/crates/frontend-promql/tests/observability/metrics_observability.rs +++ b/crates/frontend-promql/tests/observability/metrics_observability.rs @@ -6,15 +6,14 @@ use std::rc::Rc; -use asap_aware_mapping::replacement::{keep_pre_asap, RealizationError}; +use asap_aware_mapping::replacement::{retain_exact, RealizationError}; use asap_aware_mapping::{ - Replacement, ReplacementStrategy, ReplacementSubDAG, SketchAlgorithmStrategy, TargetSubDAG, + ASAPStrategies, Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; -use asap_frontend_promql::PromqlError; -#[path = "../support.rs"] +use asap_frontend_promql::unified::PromqlError; +#[path = "../unified_support.rs"] mod support; -use asap_types::post_asap::{SummaryExpr, SummaryNode}; -use asap_types::pre_asap::query_expr::QueryExpr; +use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; use support::lower_promql; @@ -64,19 +63,18 @@ fn queries(corpus: &str) -> impl Iterator { .filter(|line| !line.is_empty() && !line.starts_with('#')) } -fn post_asap_candidate(expr: &QueryExpr) -> Result, RealizationError> { - let root = Rc::new(expr.clone()); - let target = TargetSubDAG::new(&root); - match SketchAlgorithmStrategy::default_cost_model() +fn post_asap_candidate(root: &Rc) -> Result, RealizationError> { + let target = TargetSubDAG::new(root); + match ASAPStrategies::default_cost_model() .replacements(&target) .into_iter() .next() { Some(ReplacementSubDAG { - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), .. }) => Ok(node), - _ => keep_pre_asap(&root), + _ => retain_exact(root), } } @@ -99,9 +97,8 @@ fn benchmark_corpora_are_total_and_report_coverage() { Ok(expr) => { lowered += 1; match post_asap_candidate(&expr) { - Ok(node) if !matches!(node.expr, SummaryExpr::KeepPreAsap(_)) => { - post_asap_candidates += 1 - } + // 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() { diff --git a/crates/frontend-promql/tests/observability/promql_corpus.rs b/crates/frontend-promql/tests/observability/promql_corpus.rs index 1bc7e2166..6b76f5755 100644 --- a/crates/frontend-promql/tests/observability/promql_corpus.rs +++ b/crates/frontend-promql/tests/observability/promql_corpus.rs @@ -15,37 +15,35 @@ use std::rc::Rc; -use asap_aware_mapping::replacement::{keep_pre_asap, RealizationError}; +use asap_aware_mapping::replacement::{retain_exact, RealizationError}; use asap_aware_mapping::{ - Replacement, ReplacementStrategy, ReplacementSubDAG, SketchAlgorithmStrategy, TargetSubDAG, + ASAPStrategies, Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; -use asap_frontend_promql::PromqlError as LoweringError; -#[path = "../support.rs"] +use asap_frontend_promql::unified::PromqlError as LoweringError; +#[path = "../unified_support.rs"] mod support; -use asap_types::post_asap::{SummaryExpr, SummaryNode}; -use asap_types::pre_asap::query_expr::QueryExpr; +use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; use support::lower_promql; -/// This crate has no "bind me one DAG" public API any more — -/// `SketchAlgorithmStrategy::replacements` always returns every candidate, and +/// This crate has no "bind me one dag" public API any more — +/// `ASAPStrategies::replacements` always returns every candidate, and /// a caller decides what to keep. This test-only helper reproduces the /// take-the-first-(`cost_model`-preferred)-candidate pattern so [`bind_tally`] /// gets one representative `Result` per query, matching what a totality /// check over the whole corpus wants. -fn bind(expr: &QueryExpr) -> Result, RealizationError> { - let root = Rc::new(expr.clone()); - let target = TargetSubDAG::new(&root); - match SketchAlgorithmStrategy::default_cost_model() +fn bind(root: &Rc) -> Result, RealizationError> { + let target = TargetSubDAG::new(root); + match ASAPStrategies::default_cost_model() .replacements(&target) .into_iter() .next() { Some(ReplacementSubDAG { - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), .. }) => Ok(node), - _ => keep_pre_asap(&root), + _ => retain_exact(root), } } @@ -77,7 +75,10 @@ impl Tally { fn tally(corpus: &str) -> Tally { let mut t = Tally::default(); for q in queries(corpus) { - match lower_promql(q, AccuracyTarget::Exact) { + match asap_frontend_promql::unified::lower_promql_query_workload( + &support::workload(q, AccuracyTarget::Exact), + 0, + ) { Ok(_) => t.lowered += 1, Err(LoweringError::Parse(_)) => t.unparseable += 1, Err(_) => t.rejected += 1, @@ -93,9 +94,10 @@ fn tally(corpus: &str) -> Tally { /// arm). #[derive(Default, Debug)] struct BindTally { - /// Root bound to `SummaryAgg`/`SummaryEstimate` — the pass did something. + /// An ASAP operator was bound somewhere below the root — the pass did + /// something. transformed: usize, - /// Root stayed `KeepPreAsap` — the pass left the query untouched. + /// The kept pre-ASAP dag — the pass left the query untouched. unchanged: usize, /// [`bind`] returned `Err` (schema derivation failed). errored: usize, @@ -108,7 +110,7 @@ fn bind_tally(corpus: &str, accuracy: AccuracyTarget) -> BindTally { continue; }; match bind(&dag) { - Ok(bound) if matches!(bound.expr, SummaryExpr::KeepPreAsap(_)) => t.unchanged += 1, + Ok(bound) if !bound.contains_asap() => t.unchanged += 1, Ok(_) => t.transformed += 1, Err(_) => t.errored += 1, } @@ -148,23 +150,17 @@ fn lowering_is_total_over_the_entire_corpus() { "testdata corpus unexpectedly small: {td:?}" ); - // Coverage tripwire: a code change that breaks lowering for a large slice of - // real PromQL trips this. Current numbers on the private promql-parser `asap` - // branch: docs 48 lowered / 1 rejected, testdata 1512 lowered / 76 rejected / - // 235 unparseable. The floors sit ~1% under those, so they guard regressions - // rather than pin an exact count — ratchet them up as coverage lands. - // - // The 235 unparseable are parser-fork gaps (issue #108); the rejections are - // lowering gaps (#109). Both shrink over time, so these floors normally only - // rise. Exception: the testdata floor was lowered to the measured 1485 when - // the 44 `fill` vector-matching queries became rejected rather than - // silently lowered without their fill semantics. + // Coverage tripwire after rejecting unrepresented native histogram samples: + // docs 48 lowered / 1 rejected; testdata 1121 lowered / 469 rejected / + // 233 parser gaps. Earlier coverage counted native histogram operations + // incorrectly treated as float quantiles. Keep the rejection cases in the + // corpus: accepting them requires a native histogram sample representation. assert!( docs.lowered >= 47, "docs lowering coverage regressed: {docs:?}" ); assert!( - td.lowered >= 1485, + td.lowered >= 1121, "testdata lowering coverage regressed: {td:?}" ); } diff --git a/crates/frontend-promql/tests/promql_binding_regressions.rs b/crates/frontend-promql/tests/promql_binding_regressions.rs index 26d1be06d..ba006ae4a 100644 --- a/crates/frontend-promql/tests/promql_binding_regressions.rs +++ b/crates/frontend-promql/tests/promql_binding_regressions.rs @@ -1,5 +1,6 @@ use asap_types::types::AccuracyTarget; +#[path = "unified_support.rs"] mod support; use support::lower_promql; @@ -33,9 +34,10 @@ fn irate_and_rate_have_distinct_canonical_intents() { /// PromQL count counts series even when two sample values are equal. #[test] fn count_is_row_count_not_distinct_sample_value_count() { - use asap_types::pre_asap::{AggIntent, QueryExpr}; - let dag = lower_promql("count(smoke_gauge)", AccuracyTarget::Exact).unwrap(); - let QueryExpr::Aggregate { measures, .. } = dag else { + use asap_types::ir::NonASAPOp; + use asap_types::pre_asap::AggIntent; + let tree = lower_promql("count(smoke_gauge)", AccuracyTarget::Exact).unwrap(); + let NonASAPOp::Aggregate { measures, .. } = tree.expect_non_asap() else { panic!("expected aggregate") }; assert!(matches!(measures.as_slice(), [AggIntent::Count { .. }])); diff --git a/crates/frontend-promql/tests/promql_equivalence.rs b/crates/frontend-promql/tests/promql_equivalence.rs index 9d0cab2ad..230cf2a05 100644 --- a/crates/frontend-promql/tests/promql_equivalence.rs +++ b/crates/frontend-promql/tests/promql_equivalence.rs @@ -17,12 +17,15 @@ #![allow(non_snake_case)] +use std::rc::Rc; + +#[path = "unified_support.rs"] mod support; -use asap_types::pre_asap::QueryExpr; +use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; use support::lower_promql; -fn lo(q: &str) -> QueryExpr { +fn lo(q: &str) -> Rc { lower_promql(q, AccuracyTarget::Exact).unwrap_or_else(|e| panic!("{q:?} should lower: {e}")) } diff --git a/crates/frontend-promql/tests/unified_promql_lowering.rs b/crates/frontend-promql/tests/unified_promql_lowering.rs index 780b104f9..6097b1ec6 100644 --- a/crates/frontend-promql/tests/unified_promql_lowering.rs +++ b/crates/frontend-promql/tests/unified_promql_lowering.rs @@ -1679,3 +1679,26 @@ fn label_matcher_predicates_carry_promql_semantics() { } )); } + +// A subquery's `offset` / `@` modifier stays a `TimeShift` over the subquery. +#[test] +fn subquery_time_shift_is_retained() { + let root = lower("max_over_time(m[5m:1m] offset 1m)"); + let NonASAPOp::Aggregate { child, .. } = root.expect_non_asap() else { + panic!("expected a range function, got {root:?}"); + }; + let NonASAPOp::TimeShift { shift, child } = child.expect_non_asap() else { + panic!("subquery offset was dropped: {child:?}"); + }; + assert_eq!(shift.offset_ms, 60_000); + assert!(matches!( + child.expect_non_asap(), + NonASAPOp::PromqlSubquery { .. } + )); + let root = lower("max_over_time(m[5m:1m] @ 100)"); + assert!(matches!( + root.expect_non_asap(), + NonASAPOp::Aggregate { child, .. } + if matches!(child.expect_non_asap(), NonASAPOp::TimeShift { .. }) + )); +} diff --git a/crates/frontend-promql/tests/unified_support.rs b/crates/frontend-promql/tests/unified_support.rs index dee692ca1..5ed0127b9 100644 --- a/crates/frontend-promql/tests/unified_support.rs +++ b/crates/frontend-promql/tests/unified_support.rs @@ -69,6 +69,22 @@ pub fn promql_scalar(node: &ScalarExpr) -> Option { } } +/// Time `root` under the default (every summary maintained) lifecycle +/// assignment and export the post-ASAP DAG — the wire-6 export needs every +/// node timed first. +#[allow(dead_code)] +pub fn post_asap_dag(root: &Rc) -> asap_types::ir::physical_export::PhysicalASAPDAG { + use asap_types::ir::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; + let timed = apply_lifecycle_timings( + root, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .expect("default lifecycle timings"); + asap_types::ir::physical_export::compile_physical_asap_dag(&timed) + .expect("post-ASAP DAG export") +} + #[allow(dead_code)] pub fn scalar_root(query: &str) -> ScalarExpr { match asap_frontend_promql::unified::lower_promql_query_workload( diff --git a/crates/frontend-promql/tests/univmon_candidates.rs b/crates/frontend-promql/tests/univmon_candidates.rs index 2b4891a08..02a85ee9d 100644 --- a/crates/frontend-promql/tests/univmon_candidates.rs +++ b/crates/frontend-promql/tests/univmon_candidates.rs @@ -5,15 +5,17 @@ use asap_aware_mapping::accuracy::{ }; use asap_aware_mapping::cost_model::DefaultCostModel; use asap_aware_mapping::replacement::{default_strategies, search_workload_with_targets}; -use asap_aware_mapping::{Replacement, ReplacementStrategy, SketchAlgorithmStrategy, TargetSubDAG}; +use asap_aware_mapping::{ASAPStrategies, Replacement, ReplacementStrategy, TargetSubDAG}; +#[path = "unified_support.rs"] mod support; +use asap_types::ir::cse::share_common_sub_dags; +use asap_types::ir::{ASAPOp, Operator, OperatorNode}; use asap_types::post_asap::{ - compile_post_asap_dag, cse::share_common_summary_sub_dags, AccuracyError, BoundExpr, - CompositionOperator, ErrorMetric, FieldDataType, ProbabilityExpr, ResultGuarantee, - SketchAlgorithm, SketchStatistic, SummaryExpr, SummaryInputExpr, SummaryNode, + AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, FieldDataType, ProbabilityExpr, + ResultGuarantee, SketchAlgorithm, SketchStatistic, SummaryInputExpr, }; use asap_types::types::AccuracyTarget; -use support::lower_promql; +use support::{lower_promql, post_asap_dag}; // Synthetic evidence exercises structural sharing, never runtime accuracy. struct TestEvidence; @@ -49,22 +51,22 @@ impl AccuracyModel for TestEvidence { } } -fn candidate(query: &str, accuracy: AccuracyTarget) -> Rc { +fn candidate(query: &str, accuracy: AccuracyTarget) -> Rc { let root = lower_promql(query, accuracy).unwrap(); - SketchAlgorithmStrategy::new_with_planning_inputs(&DefaultCostModel, &TestEvidence, &EqualSplitAllocator) - .replacements(&TargetSubDAG::new(&Rc::new(root))) + ASAPStrategies::new_with_planning_inputs(&DefaultCostModel, &TestEvidence, &EqualSplitAllocator) + .replacements(&TargetSubDAG::new(&root)) .into_iter() .find_map(|candidate| { - let Replacement::Summary(node) = candidate.replacement else { return None }; - let SummaryExpr::SummaryEstimate { summary_input, .. } = &node.expr else { return None }; - matches!(&summary_input.expr, SummaryExpr::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } + let Replacement::SubDAG(node) = candidate.replacement else { return None }; + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator else { return None }; + matches!(&summary_input.operator, Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if kind.algorithm() == &SketchAlgorithm::UnivMon).then_some(node) }).expect("UnivMon candidate") } #[test] -fn four_readouts_share_one_value_frequency_state_and_keep_honest_guarantees() { - // Equal data, grouping and window produce one state independently of readout. +fn four_evaluations_share_one_value_frequency_state_and_keep_honest_guarantees() { + // Equal data, grouping and window produce one state independently of evaluation. let accuracy = AccuracyTarget::Epsilon(0.02); let roots: Vec<_> = [ ("distinct_over_time(m[5m])", accuracy.clone()), @@ -76,26 +78,25 @@ fn four_readouts_share_one_value_frequency_state_and_keep_honest_guarantees() { .enumerate() .map(|(id, (query, accuracy))| (id, candidate(query, accuracy))) .collect(); - let roots = share_common_summary_sub_dags(roots); + let roots = share_common_sub_dags(roots); let mut first_state = None; for (index, root) in &roots { - let SummaryExpr::SummaryEstimate { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - .. - } = &root.expr + }) = &root.operator else { panic!() }; if let Some(first) = &first_state { assert!( Rc::ptr_eq(first, summary_input), - "state must be shared across readouts" + "state must be shared across evaluations" ); } else { first_state = Some(Rc::clone(summary_input)); } - let SummaryExpr::SummaryAgg { input, .. } = &summary_input.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { input, .. }) = &summary_input.operator else { panic!() }; assert!(matches!(input.item, Some(SummaryInputExpr::Column(_)))); @@ -108,7 +109,7 @@ fn four_readouts_share_one_value_frequency_state_and_keep_honest_guarantees() { assert!(root.guarantee.as_ref().is_some_and(|g| g.is_exact())); } else { assert!(!root.guarantee.as_ref().unwrap().is_exact()); - let SummaryExpr::SummaryAgg { family, .. } = &summary_input.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &summary_input.operator else { panic!() }; assert!( @@ -118,12 +119,12 @@ fn four_readouts_share_one_value_frequency_state_and_keep_honest_guarantees() { "production has no calibrated error bound" ); } - compile_post_asap_dag(root).unwrap(); + post_asap_dag(root); } } #[test] -fn uncalibrated_frequency_readouts_do_not_bypass_accuracy_targets() { +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. for query in ["entropy_over_time(m[5m])", "l2_over_time(m[5m])"] { @@ -135,16 +136,16 @@ fn uncalibrated_frequency_readouts_do_not_bypass_accuracy_targets() { delta: 0.01, }, ] { - let root = Rc::new(lower_promql(query, target.clone()).unwrap()); - let candidates = SketchAlgorithmStrategy::default_cost_model() - .replacements(&TargetSubDAG::new(&root)); + let root = lower_promql(query, target.clone()).unwrap(); + let candidates = + ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); let unknown = candidates .iter() .filter(|candidate| { matches!( &candidate.replacement, - Replacement::Summary(node) - if matches!(&node.expr, SummaryExpr::SummaryEstimate { .. }) + Replacement::SubDAG(node) + if matches!(&node.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })) && node.guarantee.is_none() && candidate.has_missing_accuracy_evidence() ) diff --git a/crates/frontend-sql/tests/bgp_analytics/bgp_analytics.rs b/crates/frontend-sql/tests/bgp_analytics/bgp_analytics.rs index 23f757985..bd985be98 100644 --- a/crates/frontend-sql/tests/bgp_analytics/bgp_analytics.rs +++ b/crates/frontend-sql/tests/bgp_analytics/bgp_analytics.rs @@ -35,9 +35,12 @@ //! `Err`, never panics. The pinned per-query outcomes document today's real //! coverage so a regression (or a future improvement) is visible, not silent. -use asap_frontend_sql::{lower_sql_dialect, SqlCatalog, SqlError as LoweringError}; +use std::rc::Rc; + +use asap_frontend_sql::unified::{lower_sql_dialect, SqlCatalog, SqlError as LoweringError}; +use asap_types::ir::{NonASAPOp, OperatorNode}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::{AggIntent, GroupKeys, QueryExpr}; +use asap_types::pre_asap::{AggIntent, GroupKeys}; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; use datafusion::error::DataFusionError; @@ -92,7 +95,7 @@ fn queries() -> Vec { .collect() } -async fn lower(q: &str) -> Result { +async fn lower(q: &str) -> Result, LoweringError> { lower_sql_dialect( q, &catalog(), @@ -218,19 +221,19 @@ async fn corpus_lowering_matches_the_pinned_per_query_outcome() { ); } -fn first_aggregate(qe: &QueryExpr) -> Option<(&GroupKeys, &Vec)> { - match qe { - QueryExpr::Aggregate { +fn first_aggregate(node: &OperatorNode) -> Option<(&GroupKeys, &Vec)> { + match node.expect_non_asap() { + NonASAPOp::Aggregate { reduction, measures, .. } => Some((reduction.expect_reduce(), measures)), - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } - | QueryExpr::Dedup { child, .. } - | QueryExpr::PromqlSubquery { child, .. } => first_aggregate(child), + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. } + | NonASAPOp::Dedup { child, .. } + | NonASAPOp::PromqlSubquery { child, .. } => first_aggregate(child), _ => None, } } @@ -260,7 +263,7 @@ async fn top_k_queries_are_count_grouped_by_prefix() { idx + 1 ); assert!( - matches!(qe, QueryExpr::Limit { .. }), + matches!(qe.expect_non_asap(), NonASAPOp::Limit { .. }), "q{} ({label}) top-k shape keeps the LIMIT at the root: {qe:?}", idx + 1 ); diff --git a/crates/frontend-sql/tests/bgp_jan2024_workload/bgp_jan2024_workload.rs b/crates/frontend-sql/tests/bgp_jan2024_workload/bgp_jan2024_workload.rs index 4c6f0e8ea..795c2da76 100644 --- a/crates/frontend-sql/tests/bgp_jan2024_workload/bgp_jan2024_workload.rs +++ b/crates/frontend-sql/tests/bgp_jan2024_workload/bgp_jan2024_workload.rs @@ -13,7 +13,7 @@ //! per-query), this corpus is 200 queries wide and only pins an **aggregate //! tally** by outcome category -- see the module doc on [`Category`] for why. -use asap_frontend_sql::{lower_sql_dialect, SqlCatalog, SqlError}; +use asap_frontend_sql::unified::{lower_sql_dialect, SqlCatalog, SqlError}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; @@ -70,7 +70,7 @@ fn catalog() -> SqlCatalog { .with_table("bgp.bgp_updates", updates) } -async fn lower(q: &str) -> Result { +async fn lower(q: &str) -> Result, SqlError> { lower_sql_dialect( q, &catalog(), @@ -91,6 +91,8 @@ async fn lower(q: &str) -> Result { /// is that signal, ratcheted so a category shifting size is visible. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Category { + /// A planned expression lacks a faithful registered IR type contract. + InvalidRepresentation, Lowered, /// `DataFusionError::Plan` -- almost entirely "unknown function" for a /// ClickHouse-only builtin (`uniqExact`, `countIf`, `splitByChar`, ...). @@ -119,6 +121,7 @@ fn categorize(err: &SqlError) -> Category { SqlError::DataFusion(DataFusionError::SQL(_, _)) => Category::Parse, SqlError::DataFusion(DataFusionError::NotImplemented(_)) => Category::NotImplemented, SqlError::UnsupportedFeature(_) => Category::UnsupportedFeature, + SqlError::Convert(_) => Category::InvalidRepresentation, _ => Category::Other, } } @@ -194,12 +197,15 @@ async fn corpus_lowering_matches_the_pinned_aggregate_tally() { // 152 -> 154: `ScalarValue::Interval` (this branch) converts the // `INTERVAL x unit` literal the two `toStartOfInterval(...)` queries // carry. - // 154 -> 156 (DataFusion 54, issue #611): q128 calls `greatest`, which - // DataFusion now provides (was `Plan`), and q129's subquery - // `ORDER BY count(*)` no longer fails as `UnsupportedFeature("expression: - // count(*)")`. - expect(Category::Lowered, 156); - expect(Category::Plan, 39); + // Previously admitted ClickHouse stubs used placeholder Float64 types. + // Unregistered functions and incompatible operands now fail closed. + // DataFusion 54 (issue #611): q128's `greatest` is now provided by + // DataFusion (was `Plan`) and q129's subquery `ORDER BY count(*)` no + // longer fails as `UnsupportedFeature`; one lowers, the other now fails + // closed as `InvalidRepresentation`. + expect(Category::Lowered, 106); + expect(Category::InvalidRepresentation, 54); + expect(Category::Plan, 40); expect(Category::Schema, 0); expect(Category::Parse, 0); // One query that used to fail at `uniqExact` (`Plan`) now clears that @@ -211,7 +217,7 @@ async fn corpus_lowering_matches_the_pinned_aggregate_tally() { // Typed Map access lowers one prior gap; six array accesses now fail // during typed planning because the Map adapter rejects array inputs. expect(Category::NotImplemented, 0); - expect(Category::UnsupportedFeature, 5); + expect(Category::UnsupportedFeature, 0); // Was 2: the two `toStartOfInterval(...)` queries whose `INTERVAL`-literal // conversion gap the `toStartOfInterval` note above describes. Both now // lower end to end and are counted in `Lowered`. diff --git a/crates/frontend-sql/tests/data_quality_check/synthetic_packet_trace.rs b/crates/frontend-sql/tests/data_quality_check/synthetic_packet_trace.rs index caed56594..3469642e9 100644 --- a/crates/frontend-sql/tests/data_quality_check/synthetic_packet_trace.rs +++ b/crates/frontend-sql/tests/data_quality_check/synthetic_packet_trace.rs @@ -19,9 +19,12 @@ //! Schema: `packets(srcip, dstip, srcport, dstport, proto, time, pkt_len)`; //! flow / 5-tuple = `(srcip, dstip, srcport, dstport, proto)`. -use asap_frontend_sql::{lower_sql, SqlCatalog, SqlError as LoweringError}; +use std::rc::Rc; + +use asap_frontend_sql::unified::{lower_sql, SqlCatalog, SqlError as LoweringError}; +use asap_types::ir::{NonASAPOp, OperatorNode}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::{AggIntent, GroupKeys, QueryExpr}; +use asap_types::pre_asap::{AggIntent, GroupKeys}; use asap_types::types::AccuracyTarget; const CORPUS: &str = include_str!("data/synthetic_packet_trace_queries.sql"); @@ -67,110 +70,61 @@ fn queries() -> Vec { // ── DAG helpers ────────────────────────────────────────────────────────────── -/// Every `AggIntent` in the DAG, root-to-leaf. -fn intents(e: &QueryExpr) -> Vec { - let mut out = Vec::new(); - fn go(e: &QueryExpr, out: &mut Vec) { - match e { - QueryExpr::Aggregate { - measures, child, .. - } => { - out.extend(measures.iter().cloned()); - go(child, out); - } - QueryExpr::TimeRange { child, .. } - | QueryExpr::TimeShift { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } - | QueryExpr::PromqlSubquery { child, .. } - | QueryExpr::Dedup { child, .. } - | QueryExpr::SQLWindowFunc { child, .. } - | QueryExpr::Project { child, .. } - | QueryExpr::PromqlRelabel { child, .. } - | QueryExpr::PromqlSeriesSample { child, .. } - | QueryExpr::PromqlInfoEnrich { child, .. } => go(child, out), - QueryExpr::BinaryOp { lhs, rhs, .. } - | QueryExpr::Join { - left: lhs, - right: rhs, - .. - } - | QueryExpr::SetOp { - left: lhs, - right: rhs, - .. - } => { - go(lhs, out); - go(rhs, out); - } - QueryExpr::Concat { children, .. } => children.iter().for_each(|c| go(c, out)), - QueryExpr::PromqlVectorFromScalar(inner) | QueryExpr::PromqlScalarFromVector(inner) => { - go(inner, out) - } - QueryExpr::Scan { .. } - | QueryExpr::PromqlScalarBridge(_) - | QueryExpr::EvalTimestamp - | QueryExpr::CurrentTimestamp => {} - // Scalar expression variants (issue #205): `AggIntent` only ever - // lives in `Aggregate.measures`, never nested inside a scalar - // expression DAG, so there's nothing to recurse into here. - QueryExpr::Column(_) - | QueryExpr::Literal(_) - | QueryExpr::Compare { .. } - | QueryExpr::BoolAnd(_) - | QueryExpr::BoolOr(_) - | QueryExpr::Not(_) - | QueryExpr::IsNull(_) - | QueryExpr::IsNotNull(_) - | QueryExpr::Cast { .. } - | QueryExpr::InList { .. } - | QueryExpr::FunctionCall { .. } - | QueryExpr::Arithmetic { .. } - | QueryExpr::Case { .. } => {} - } - } - go(e, &mut out); - out +/// The operator of a front-end node: a front-end DAG never holds an ASAP node. +fn op(node: &OperatorNode) -> &NonASAPOp { + node.expect_non_asap() +} + +/// Every `AggIntent` in the DAG, root-to-leaf (every reachable node — +/// `AggIntent` only ever lives in `Aggregate.measures`). +fn intents(e: &Rc) -> Vec { + OperatorNode::reachable(e) + .iter() + .filter_map(|node| match op(node) { + NonASAPOp::Aggregate { measures, .. } => Some(measures.clone()), + _ => None, + }) + .flatten() + .collect() } /// The first `Aggregate`'s `(by, measures)` along the single-child spine. SQL /// never lowers to `Reduction::PerEntity` (it has no per-series concept), so /// `expect_reduce()` here is a safe, load-bearing assumption for these tests. -fn first_aggregate(qe: &QueryExpr) -> Option<(&GroupKeys, &Vec)> { - match qe { - QueryExpr::Aggregate { +fn first_aggregate(node: &OperatorNode) -> Option<(&GroupKeys, &Vec)> { + match op(node) { + NonASAPOp::Aggregate { reduction, measures, .. } => Some((reduction.expect_reduce(), measures)), - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Dedup { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } - | QueryExpr::SQLWindowFunc { child, .. } - | QueryExpr::PromqlSubquery { child, .. } => first_aggregate(child), + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Dedup { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. } + | NonASAPOp::SQLWindowFunc { child, .. } + | NonASAPOp::PromqlSubquery { child, .. } => first_aggregate(child), _ => None, } } /// Whether a `SQLWindowFunc` (analytic `OVER (…)`) node appears anywhere. -fn has_window_func(qe: &QueryExpr) -> bool { - match qe { - QueryExpr::SQLWindowFunc { .. } => true, - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Aggregate { child, .. } - | QueryExpr::Dedup { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } - | QueryExpr::PromqlSubquery { child, .. } => has_window_func(child), +fn has_window_func(node: &OperatorNode) -> bool { + match op(node) { + NonASAPOp::SQLWindowFunc { .. } => true, + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Aggregate { child, .. } + | NonASAPOp::Dedup { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. } + | NonASAPOp::PromqlSubquery { child, .. } => has_window_func(child), _ => false, } } -async fn lower(q: &str) -> QueryExpr { +async fn lower(q: &str) -> Rc { lower_sql(q, &catalog(), AccuracyTarget::Exact) .await .unwrap_or_else(|e| panic!("expected {q:?} to lower, got error: {e}")) diff --git a/crates/frontend-sql/tests/maintained_population.rs b/crates/frontend-sql/tests/maintained_population.rs index a6347c4aa..4c7e97c9d 100644 --- a/crates/frontend-sql/tests/maintained_population.rs +++ b/crates/frontend-sql/tests/maintained_population.rs @@ -1,18 +1,19 @@ //! SQL and PromQL use the same shared-state rule without sharing membership semantics. use asap_aware_mapping::maintained_population::MaintainedPopulationStrategy; -use asap_frontend_sql::{lower_sql, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; use asap_types::{ - post_asap::{ - compile_post_asap_dag, - maintained_population::{MaintainedPopulation, PopulationInput}, - share_common_summary_sub_dags, SummaryExpr, ValueOperation, + ir::{ + apply_lifecycle_timings, cse::share_common_sub_dags, + physical_export::compile_physical_asap_dag, ASAPOp, LifecycleAssignment, NonASAPOp, + Operator, OperatorNode, TimingMemo, }, - pre_asap::{DataType, Field, QueryExpr, Schema}, + post_asap::maintained_population::{MaintainedPopulation, PopulationInput}, + pre_asap::{DataType, Field, Schema}, types::AccuracyTarget, }; use std::rc::Rc; -async fn aggregate(q: &str) -> Rc { +async fn aggregate(q: &str) -> Rc { let catalog = SqlCatalog::new().with_table( "samples", Schema::new(vec![ @@ -20,38 +21,38 @@ async fn aggregate(q: &str) -> Rc { Field::plain("job", DataType::Utf8, false), ]), ); - let root = lower_sql(q, &catalog, AccuracyTarget::Exact).await.unwrap(); - Rc::new(root) + lower_sql(q, &catalog, AccuracyTarget::Exact).await.unwrap() } -fn population( - mut node: &asap_types::post_asap::SummaryNode, -) -> ( - &Rc, - &MaintainedPopulation, -) { - while let SummaryExpr::ValueOperation { - child, - operation: ValueOperation::Project { .. }, - .. - } = &node.expr - { +/// The `MaintainPopulation` node a candidate's evaluation reads, and its spec. +fn population(mut node: &OperatorNode) -> (&Rc, &MaintainedPopulation) { + while let Operator::NonASAP(NonASAPOp::Project { child, .. }) = &node.operator { node = child; } - let SummaryExpr::ValueOperation { child, .. } = &node.expr else { - panic!("readout") + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &node.operator else { + panic!("evaluation") }; - let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, - .. - } = &child.expr - else { + let Operator::ASAP(ASAPOp::MaintainPopulation { population, .. }) = &child.operator else { panic!("state") }; (child, population) } -// Quantile parameters are readout identity, while source, value column and grouping are state identity. +/// Export `plan` the way the planner does: assign the default lifecycle +/// timings, then compile the timed DAG. +fn compile(plan: &Rc) -> Result<(), String> { + let timed = apply_lifecycle_timings( + plan, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .map_err(|e| e.to_string())?; + compile_physical_asap_dag(&timed) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +// Quantile parameters are evaluation identity, while source, value column and grouping are state identity. #[tokio::test] async fn sql_quantiles_share_rows_without_promql_lookback() { let roots = vec![ @@ -59,7 +60,7 @@ async fn sql_quantiles_share_rows_without_promql_lookback() { aggregate("SELECT approx_percentile_cont(latency, 0.99) FROM samples").await, ]; let rule = MaintainedPopulationStrategy::new(&roots); - let plans = share_common_summary_sub_dags( + let plans = share_common_sub_dags( roots .iter() .enumerate() @@ -67,7 +68,7 @@ async fn sql_quantiles_share_rows_without_promql_lookback() { .collect(), ); for (_, plan) in &plans { - compile_post_asap_dag(plan).unwrap(); + compile(plan).unwrap(); } let (a, spec) = population(&plans[0].1); let (b, _) = population(&plans[1].1); @@ -107,9 +108,9 @@ async fn sql_filters_separate_populations() { assert_ne!(population(&a).1.input, population(&b).1.input); } -// All four scalar readouts can share the same non-null numeric SQL population. +// All four scalar evaluations can share the same non-null numeric SQL population. #[tokio::test] -async fn sql_scalar_readouts_share_membership() { +async fn sql_scalar_evaluations_share_membership() { let mut roots = Vec::new(); for function in [ "median(latency)", @@ -120,7 +121,7 @@ async fn sql_scalar_readouts_share_membership() { roots.push(aggregate(&format!("SELECT {function} FROM samples")).await); } let rule = MaintainedPopulationStrategy::new(&roots); - let plans = share_common_summary_sub_dags( + let plans = share_common_sub_dags( roots .iter() .enumerate() @@ -128,27 +129,29 @@ async fn sql_scalar_readouts_share_membership() { .collect(), ); for (_, plan) in &plans { - compile_post_asap_dag(plan).unwrap(); + compile(plan).unwrap(); assert!(Rc::ptr_eq(population(&plans[0].1).0, population(plan).0)); } } -// A readout cannot reinterpret a label column as its numeric population. +// A evaluation cannot reinterpret a label column as its numeric population. #[tokio::test] async fn malformed_table_population_fails_validation() { let root = aggregate("SELECT median(latency) FROM samples").await; let rule = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)); let mut candidate = rule.candidate(&root).unwrap(); - let SummaryExpr::ValueOperation { child, .. } = &mut Rc::make_mut(&mut candidate).expr else { + let Operator::NonASAP(NonASAPOp::Project { child, .. }) = + &mut Rc::make_mut(&mut candidate).operator + else { unreachable!() }; - let SummaryExpr::ValueOperation { child, .. } = &mut Rc::make_mut(child).expr else { + let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = + &mut Rc::make_mut(child).operator + else { unreachable!() }; - let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, - .. - } = &mut Rc::make_mut(child).expr + let Operator::ASAP(ASAPOp::MaintainPopulation { population, .. }) = + &mut Rc::make_mut(child).operator else { unreachable!() }; @@ -156,7 +159,7 @@ async fn malformed_table_population_fails_validation() { unreachable!() }; *value_column = 1; - assert!(compile_post_asap_dag(&candidate).is_err()); + assert!(compile(&candidate).is_err()); } // SQL ORDER BY value DESC LIMIT k uses the same maximum-k state contract. @@ -167,7 +170,7 @@ async fn sql_topk_limits_share_maximum_k() { aggregate("SELECT * FROM samples ORDER BY latency DESC LIMIT 5").await, ]; let rule = MaintainedPopulationStrategy::new(&roots); - let plans = share_common_summary_sub_dags( + let plans = share_common_sub_dags( roots .iter() .enumerate() @@ -175,7 +178,7 @@ async fn sql_topk_limits_share_maximum_k() { .collect(), ); for (_, plan) in &plans { - compile_post_asap_dag(plan).unwrap(); + compile(plan).unwrap(); assert_eq!(population(plan).1.max_k, 5); assert!(Rc::ptr_eq(population(&plans[0].1).0, population(plan).0)); } @@ -187,7 +190,7 @@ async fn sql_topk_over_an_identity_select_list_is_recognized() { let root = aggregate("SELECT latency, job FROM samples ORDER BY latency DESC LIMIT 5").await; let rule = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)); let plan = rule.candidate(&root).expect("SQL topk"); - compile_post_asap_dag(&plan).unwrap(); + compile(&plan).unwrap(); assert_eq!(population(&plan).1.max_k, 5); } diff --git a/crates/frontend-sql/tests/netflow/netflow.rs b/crates/frontend-sql/tests/netflow/netflow.rs index 22236b850..72c05154c 100644 --- a/crates/frontend-sql/tests/netflow/netflow.rs +++ b/crates/frontend-sql/tests/netflow/netflow.rs @@ -4,9 +4,12 @@ //! aggregate over a netflow table, a time predicate, optional grouping, //! optional `ORDER BY`/`LIMIT`, plus the nested aggregate shape. -use asap_frontend_sql::{lower_sql, SqlCatalog}; +use std::rc::Rc; + +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; +use asap_types::ir::{NonASAPOp, OperatorNode}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::{AggIntent, GroupKeys, QueryExpr}; +use asap_types::pre_asap::{AggIntent, GroupKeys}; use asap_types::types::AccuracyTarget; const CORPUS: &str = include_str!("data/netflow.sql"); @@ -109,11 +112,10 @@ async fn netflow_sql_corpus_lowers_to_expected_intents() { ); for (idx, (query, expected)) in queries.iter().zip(EXPECTED).enumerate() { + // A successful `lower_sql` already derived every node's schema. let qe = lower_sql(query, &catalog(), AccuracyTarget::Exact) .await .unwrap_or_else(|err| panic!("q{} failed to lower:\n{query}\n{err}", idx + 1)); - qe.output_schema() - .unwrap_or_else(|err| panic!("q{} schema derivation failed: {err}", idx + 1)); assert!( has_scan_predicate(&qe), "q{} should retain the netflow time predicate on the Scan: {qe:?}", @@ -123,7 +125,7 @@ async fn netflow_sql_corpus_lowers_to_expected_intents() { } } -fn assert_expected(qe: &QueryExpr, expected: Expected, case_no: usize) { +fn assert_expected(qe: &Rc, expected: Expected, case_no: usize) { match expected { Expected::Quantile { q, by } => { let (actual_by, measures) = first_aggregate(qe).expect("expected Aggregate"); @@ -190,53 +192,58 @@ impl AggKind { } } -fn first_aggregate(qe: &QueryExpr) -> Option<(&GroupKeys, &Vec)> { - match qe { - QueryExpr::Aggregate { +/// The operator of a front-end node: a front-end DAG never holds an ASAP node. +fn op(node: &OperatorNode) -> &NonASAPOp { + node.expect_non_asap() +} + +fn first_aggregate(node: &OperatorNode) -> Option<(&GroupKeys, &Vec)> { + match op(node) { + NonASAPOp::Aggregate { reduction, measures, .. } => Some((reduction.expect_reduce(), measures)), - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Dedup { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } - | QueryExpr::PromqlSubquery { child, .. } => first_aggregate(child), + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Dedup { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. } + | NonASAPOp::PromqlSubquery { child, .. } => first_aggregate(child), _ => None, } } -fn has_scan_predicate(qe: &QueryExpr) -> bool { +fn has_scan_predicate(qe: &Rc) -> bool { any_node( qe, - |node| matches!(node, QueryExpr::Scan { predicates, .. } if !predicates.is_empty()), + |node| matches!(op(node), NonASAPOp::Scan { predicates, .. } if !predicates.is_empty()), ) } -fn has_topk(qe: &QueryExpr, k: usize) -> bool { +fn has_topk(qe: &Rc, k: usize) -> bool { any_node(qe, |node| { matches!( - node, - QueryExpr::Aggregate { measures, .. } + op(node), + NonASAPOp::Aggregate { measures, .. } if measures.iter().any(|agg| matches!(agg, AggIntent::TopK { k: actual, .. } if *actual == k)) ) }) } fn aggregate_by_with( - qe: &QueryExpr, + qe: &Rc, by: &'static [usize], pred: impl Fn(&AggIntent) -> bool, ) -> bool { let expected_by = GroupKeys::by(by.to_vec()); let mut found = false; visit(qe, &mut |node| { - if let QueryExpr::Aggregate { + if let NonASAPOp::Aggregate { reduction, measures, .. - } = node + } = op(node) { found |= *reduction.expect_reduce() == expected_by && measures.iter().any(&pred); } @@ -244,78 +251,26 @@ fn aggregate_by_with( found } -fn all_intents(qe: &QueryExpr) -> Vec { +fn all_intents(qe: &Rc) -> Vec { let mut intents = Vec::new(); visit(qe, &mut |node| { - if let QueryExpr::Aggregate { measures, .. } = node { + if let NonASAPOp::Aggregate { measures, .. } = op(node) { intents.extend(measures.iter().cloned()); } }); intents } -fn any_node(qe: &QueryExpr, pred: impl Fn(&QueryExpr) -> bool) -> bool { +fn any_node(qe: &Rc, pred: impl Fn(&OperatorNode) -> bool) -> bool { let mut found = false; visit(qe, &mut |node| found |= pred(node)); found } -fn visit(qe: &QueryExpr, f: &mut impl FnMut(&QueryExpr)) { - f(qe); - match qe { - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Aggregate { child, .. } - | QueryExpr::TimeRange { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } - | QueryExpr::PromqlSubquery { child, .. } - | QueryExpr::Dedup { child, .. } - | QueryExpr::SQLWindowFunc { child, .. } - | QueryExpr::PromqlRelabel { child, .. } - | QueryExpr::PromqlSeriesSample { child, .. } - | QueryExpr::TimeShift { child, .. } - | QueryExpr::PromqlInfoEnrich { child, .. } => visit(child, f), - QueryExpr::BinaryOp { lhs, rhs, .. } - | QueryExpr::Join { - left: lhs, - right: rhs, - .. - } - | QueryExpr::SetOp { - left: lhs, - right: rhs, - .. - } => { - visit(lhs, f); - visit(rhs, f); - } - QueryExpr::Concat { children, .. } => { - for child in children { - visit(child, f); - } - } - QueryExpr::PromqlVectorFromScalar(child) | QueryExpr::PromqlScalarFromVector(child) => { - visit(child, f) - } - QueryExpr::Scan { .. } - | QueryExpr::PromqlScalarBridge(_) - | QueryExpr::EvalTimestamp - | QueryExpr::CurrentTimestamp => {} - // Scalar expression variants (issue #205) aren't relational nodes; - // this visitor only walks the relational DAG, so stop here. - QueryExpr::Column(_) - | QueryExpr::Literal(_) - | QueryExpr::Compare { .. } - | QueryExpr::BoolAnd(_) - | QueryExpr::BoolOr(_) - | QueryExpr::Not(_) - | QueryExpr::IsNull(_) - | QueryExpr::IsNotNull(_) - | QueryExpr::Cast { .. } - | QueryExpr::InList { .. } - | QueryExpr::FunctionCall { .. } - | QueryExpr::Arithmetic { .. } - | QueryExpr::Case { .. } => {} +/// Every reachable operator node, parents before children — including the +/// operators referenced from scalar positions (subqueries). +fn visit(qe: &Rc, f: &mut impl FnMut(&OperatorNode)) { + for node in OperatorNode::reachable(qe) { + f(&node); } } diff --git a/crates/frontend-sql/tests/pearson_corr.rs b/crates/frontend-sql/tests/pearson_corr.rs index 618f6c38f..425e0708a 100644 --- a/crates/frontend-sql/tests/pearson_corr.rs +++ b/crates/frontend-sql/tests/pearson_corr.rs @@ -1,8 +1,12 @@ //! Correlation retains both arguments through SQL lowering and exact planning. use std::rc::Rc; -use asap_frontend_sql::{lower_sql, SqlCatalog}; -use asap_types::pre_asap::{AggIntent, DataType, Field, QueryExpr, Schema}; +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; +use asap_types::ir::{ + apply_lifecycle_timings, physical_export::compile_physical_asap_dag, LifecycleAssignment, + NonASAPOp, OperatorNode, ScalarExpr, TimingMemo, +}; +use asap_types::pre_asap::{AggIntent, DataType, Field, Schema}; use asap_types::types::AccuracyTarget; fn catalog() -> SqlCatalog { @@ -16,20 +20,20 @@ fn catalog() -> SqlCatalog { .with_table("b", schema) } -async fn lower(sql: &str) -> QueryExpr { +async fn lower(sql: &str) -> Rc { lower_sql(sql, &catalog(), AccuracyTarget::Exact) .await .unwrap() } -fn aggregate(query: &QueryExpr) -> (&[AggIntent], &QueryExpr) { - match query { - QueryExpr::Aggregate { +fn aggregate(query: &OperatorNode) -> (&[AggIntent], &OperatorNode) { + match query.expect_non_asap() { + NonASAPOp::Aggregate { measures, child, .. } => (measures, child), - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Sort { child, .. } => aggregate(child), + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Sort { child, .. } => aggregate(child), other => panic!("expected aggregate, got {other:?}"), } } @@ -46,17 +50,14 @@ async fn corr_materializes_both_arguments() { let query = lower(sql).await; let (measures, child) = aggregate(&query); assert_eq!(measures, &[AggIntent::PearsonCorr { left: 0, right: 1 }]); - let QueryExpr::Project { cols, .. } = child else { + let NonASAPOp::Project { cols, .. } = child.expect_non_asap() else { panic!("derived inputs") }; assert_eq!(cols.len(), 2); assert!(cols .iter() - .any(|col| !matches!(col.expr, QueryExpr::Column(_)))); - assert_eq!( - query.output_schema().unwrap().fields[0].dtype, - DataType::Float64 - ); + .any(|col| !matches!(col.expr, ScalarExpr::Column(_)))); + assert_eq!(query.schema.fields[0].dtype, DataType::Float64); } } @@ -66,11 +67,11 @@ async fn corr_preserves_qualified_join_inputs() { let query = lower("SELECT corr(a.x, b.x) FROM a JOIN b ON a.g = b.g").await; let (measures, child) = aggregate(&query); assert_eq!(measures[0].input_cols(), vec![0, 1]); - let QueryExpr::Project { cols, .. } = child else { + let NonASAPOp::Project { cols, .. } = child.expect_non_asap() else { panic!("paired projection") }; - assert_eq!(cols[0].expr, QueryExpr::Column(0)); - assert_eq!(cols[1].expr, QueryExpr::Column(3)); + assert_eq!(cols[0].expr, ScalarExpr::Column(0)); + assert_eq!(cols[1].expr, ScalarExpr::Column(3)); } // Grouping and sibling reducers cannot drop either correlation argument. @@ -82,12 +83,12 @@ async fn corr_coexists_with_grouping_having_and_other_measures() { .iter() .find(|m| matches!(m, AggIntent::PearsonCorr { .. })) .unwrap(); - let schema = child.output_schema().unwrap(); + let schema = &child.schema; for id in pair.input_cols() { assert!(id < schema.fields.len()); } assert!(measures.iter().any(|m| matches!(m, AggIntent::Sum { .. }))); - let output = query.output_schema().unwrap(); + let output = &query.schema; assert_eq!(output.fields[1].name, "r"); assert_eq!(output.fields[1].dtype, DataType::Float64); assert!(output.fields[1].nullable); @@ -99,8 +100,8 @@ async fn corr_repeated_input_and_serialization() { let query = lower("SELECT corr(x, x) FROM a").await; assert_eq!(aggregate(&query).0[0].input_cols(), vec![0, 0]); let encoded = serde_json::to_string(&query).unwrap(); - let decoded: QueryExpr = serde_json::from_str(&encoded).unwrap(); - assert_eq!(query, decoded); + let decoded: OperatorNode = serde_json::from_str(&encoded).unwrap(); + assert_eq!(*query, decoded); } // Unsupported modifiers and window calls fail instead of silently changing semantics. @@ -128,10 +129,10 @@ async fn corr_filter_is_a_measure_filter() { let query = lower("SELECT corr(x, y) FILTER (WHERE g > 0) FROM a").await; let (measures, _) = aggregate(&query); assert_eq!(measures[0].input_cols(), vec![0, 1]); - fn filters(query: &QueryExpr) -> &[Option] { - match query { - QueryExpr::Aggregate { filters, .. } => filters, - QueryExpr::Project { child, .. } | QueryExpr::Filter { child, .. } => filters(child), + fn filters(query: &OperatorNode) -> &[Option] { + match query.expect_non_asap() { + NonASAPOp::Aggregate { filters, .. } => filters, + NonASAPOp::Project { child, .. } | NonASAPOp::Filter { child, .. } => filters(child), other => panic!("expected aggregate, got {other:?}"), } } @@ -145,12 +146,17 @@ async fn corr_filter_is_a_measure_filter() { // Exact fallback retains the complete typed query and compiles to a post-ASAP DAG. #[tokio::test] async fn corr_survives_exact_plan_compilation() { - let query = Rc::new(lower("SELECT corr(x, y) AS r FROM a").await); - let plan = asap_aware_mapping::replacement::keep_pre_asap(&query).unwrap(); + let query = lower("SELECT corr(x, y) AS r FROM a").await; + let plan = asap_aware_mapping::replacement::retain_exact(&query).unwrap(); assert!(plan.guarantee.as_ref().unwrap().is_exact()); - let asap_types::post_asap::SummaryExpr::KeepPreAsap(retained) = &plan.expr else { - panic!("expected exact fallback"); - }; - assert_eq!(aggregate(retained).0, aggregate(&query).0); - asap_types::post_asap::compile_post_asap_dag(&plan).unwrap(); + // The exact fallback is the query's own operator DAG, no ASAP node added. + assert!(!plan.contains_asap(), "expected exact fallback"); + assert_eq!(aggregate(&plan).0, aggregate(&query).0); + let timed = apply_lifecycle_timings( + &plan, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .unwrap(); + compile_physical_asap_dag(&timed).unwrap(); } diff --git a/crates/frontend-sql/tests/temporal_types.rs b/crates/frontend-sql/tests/temporal_types.rs index a6b4f1187..88e13b408 100644 --- a/crates/frontend-sql/tests/temporal_types.rs +++ b/crates/frontend-sql/tests/temporal_types.rs @@ -1,4 +1,4 @@ -use asap_frontend_sql::{lower_sql, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; use asap_types::{ pre_asap::schema::{DataType, Field, Schema}, types::AccuracyTarget, @@ -38,10 +38,7 @@ async fn date_shifts_keep_their_type() { let node = lower_sql(query, &catalog(), AccuracyTarget::Exact) .await .unwrap(); - assert_eq!( - node.output_schema().unwrap().fields[0].dtype, - DataType::Date - ); + assert_eq!(node.schema.fields[0].dtype, DataType::Date); } } // Interval literals and explicit interval casts must both cross the Arrow bridge. @@ -54,10 +51,7 @@ async fn interval_cast_lowers_like_interval_literal() { let node = lower_sql(query, &catalog(), AccuracyTarget::Exact) .await .unwrap(); - assert_eq!( - node.output_schema().unwrap().fields[0].dtype, - DataType::Interval - ); + assert_eq!(node.schema.fields[0].dtype, DataType::Interval); } } @@ -90,11 +84,7 @@ async fn negative_intervals_keep_their_type() { let node = lower_sql(query, &catalog(), AccuracyTarget::Exact) .await .unwrap(); - assert_eq!( - node.output_schema().unwrap().fields[0].dtype, - DataType::Interval, - "{query}" - ); + assert_eq!(node.schema.fields[0].dtype, DataType::Interval, "{query}"); } } @@ -108,9 +98,6 @@ async fn sql_date_literals_keep_their_type() { let node = lower_sql(query, &catalog(), AccuracyTarget::Exact) .await .unwrap(); - assert_eq!( - node.output_schema().unwrap().fields[0].dtype, - DataType::Date - ); + assert_eq!(node.schema.fields[0].dtype, DataType::Date); } } diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 5a5de9902..afa7559b4 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -10,6 +10,7 @@ asap-frontend-sql = { path = "../frontend-sql" } asap-aware-mapping = { path = "../asap-aware-mapping" } [dev-dependencies] +asap-planner = { path = "../planner" } asap_sketchlib = { workspace = true } serde_json = "1" tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index be8e259bc..8eeb41743 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -12,21 +12,36 @@ //! here derives or computes expected outputs. pub mod fixtures { - use asap_frontend_promql::lower_promql_workload; + + use asap_types::ir::OperatorNode; use asap_types::pre_asap::schema::{DataType, Field, Schema}; - use asap_types::pre_asap::QueryExpr; use asap_types::types::AccuracyTarget; use asap_types::workload::{ AccuracyRequirement, BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, TimeSelection, }; + use std::rc::Rc; /// Lower one query through the plan-ready workload API using the test /// suite's declared one-second source cadence. pub fn lower_promql( query: &str, accuracy: AccuracyTarget, - ) -> Result { + ) -> Result, asap_frontend_promql::unified::PromqlError> { + match lower_promql_root(query, accuracy)? { + asap_types::ir::QueryRoot::Operator(node) => Ok(node), + _ => Err( + asap_frontend_promql::unified::PromqlError::UnsupportedFeature( + "expected vector root".into(), + ), + ), + } + } + + pub fn lower_promql_root( + query: &str, + accuracy: AccuracyTarget, + ) -> Result { let workload = PlanningWorkload { query_workload: QueryWorkload { language: QueryLanguage::PromQL, @@ -51,7 +66,7 @@ pub mod fixtures { ..Default::default() }), }; - let mut lowered = lower_promql_workload(&workload, 0)?; + let mut lowered = asap_frontend_promql::unified::lower_promql_query_workload(&workload, 0)?; Ok(lowered.remove(0)) } @@ -83,3 +98,26 @@ pub mod fixtures { } } } + +/// Timing and export helpers for post-ASAP plans. +pub mod post_asap { + use asap_types::ir::physical_export::{compile_physical_asap_dag, PhysicalASAPDAG}; + use asap_types::ir::{apply_lifecycle_timings, LifecycleAssignment, OperatorNode, TimingMemo}; + use std::rc::Rc; + + /// Time `root` under the default (every summary maintained) lifecycle + /// assignment. Returns the timed copy; read `node.timing` on it. + pub fn timed(root: &Rc) -> Rc { + apply_lifecycle_timings( + root, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .expect("default lifecycle timing failed") + } + + /// Time `root` (default assignment), then export the wire-6 DAG. + pub fn post_asap_dag(root: &Rc) -> PhysicalASAPDAG { + compile_physical_asap_dag(&timed(root)).expect("post-ASAP DAG export failed") + } +} diff --git a/crates/integration-tests/tests/aggregate.rs b/crates/integration-tests/tests/aggregate.rs index 051eeb6b7..13d2875b9 100644 --- a/crates/integration-tests/tests/aggregate.rs +++ b/crates/integration-tests/tests/aggregate.rs @@ -1,47 +1,54 @@ -//! `QueryExpr::Aggregate` — cross-series aggregation tests. +//! `NonASAPOp::Aggregate` — cross-series aggregation tests. //! //! topk/bottomk are omitted — dispatch is deferred. //! -//! Cross-series aggregates lower to a single `Aggregate` node with no -//! `TimeRange` child (range functions use `TimeRange` — see `time_range.rs`). -//! Group keys land on `Aggregate.by` as positional `ColumnId`s. -//! Single-stat PromQL aggregates always get `output_names: [""]` (no alias) -//! and `having: None`. +//! Cross-series aggregates lower to a single `Aggregate` node over the +//! instant-selector `TimeRange` (range functions use a `Range` selector — +//! see `time_range.rs`). Group keys land on `Aggregate.reduction` as +//! positional `ColumnId`s. Single-stat PromQL aggregates always get +//! `output_names: [""]` (no alias) and `having: None`. use std::rc::Rc; use std::time::Duration; use asap_integration_tests::fixtures::lower_promql; use asap_integration_tests::fixtures::metric_schema; -use asap_types::pre_asap::{AggIntent, QueryExpr, Reduction, Source}; +use asap_types::ir::{NonASAPOp, OperatorNode, TimeRangeKind}; +use asap_types::pre_asap::{AggIntent, Reduction, Source}; use asap_types::types::AccuracyTarget; -fn lower(q: &str) -> QueryExpr { +fn lower(q: &str) -> Rc { lower_promql(q, AccuracyTarget::Exact).unwrap_or_else(|e| panic!("lower failed for {q:?}: {e}")) } -fn scan(metric: &str, labels: &[&str]) -> QueryExpr { - QueryExpr::Scan { +fn node(op: NonASAPOp) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(op)) + .expect("fixture node derives its schema") +} + +fn scan(metric: &str, labels: &[&str]) -> Rc { + node(NonASAPOp::Scan { source: Source::TimeSeries { metric: metric.into(), }, predicates: vec![], schema: metric_schema(labels), - } + }) } -fn agg(by: Vec, intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { +fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::by(by), measures: vec![intent], output_names: vec!["".into()], filters: vec![], having: None, - child: Rc::new(QueryExpr::TimeRange { + child: node(NonASAPOp::TimeRange { range: Duration::from_secs(1), - child: Rc::new(child), + kind: TimeRangeKind::Instant, + child, }), - } + }) } // #5 — sum with no group keys @@ -169,7 +176,7 @@ fn q_stdvar_no_group() { ); } -// #10 — cross-series quantile; no TimeRange node (no range window) +// #10 — cross-series quantile; instant selector, no range window #[test] fn q10_quantile_cross_series() { assert_eq!( diff --git a/crates/integration-tests/tests/binary_op.rs b/crates/integration-tests/tests/binary_op.rs index 35f1c632d..284501080 100644 --- a/crates/integration-tests/tests/binary_op.rs +++ b/crates/integration-tests/tests/binary_op.rs @@ -1,92 +1,121 @@ -//! `QueryExpr::BinaryOp` — arithmetic, comparison, and vector-match tests. +//! `NonASAPOp::BinaryOp` — arithmetic, comparison, and vector-match tests. //! //! Each side of a `BinaryOp` is bound independently by the SchemaResolver, so each //! gets its own scan schema derived from the labels it references. -//! `VectorMatch` labels (e.g. `on(job)`) are carried as strings on the node -//! and are NOT resolved to column ids — the SchemaResolver does not see them. +//! `VectorMatch` labels (e.g. `on(job)`) are carried as strings on the +//! operator and are NOT resolved to column ids — the SchemaResolver does not +//! see them. use std::rc::Rc; use std::time::Duration; use asap_integration_tests::fixtures::lower_promql; use asap_integration_tests::fixtures::metric_schema; +use asap_types::ir::{BinaryOperator, NonASAPOp, OperatorNode, ScalarExpr, TimeRangeKind}; use asap_types::pre_asap::{ - AggIntent, ArithmeticOpKind, BinaryOpKind, CompareOpKind, GroupSide, QueryExpr, Reduction, - Source, VectorGrouping, VectorMatch, VectorMatchKind, + AggIntent, ArithmeticOpKind, BinaryOpKind, CompareOpKind, GroupSide, Reduction, Source, + VectorGrouping, VectorMatch, VectorMatchKind, }; use asap_types::types::AccuracyTarget; -fn lower(q: &str) -> QueryExpr { +fn lower(q: &str) -> Rc { lower_promql(q, AccuracyTarget::Exact).unwrap_or_else(|e| panic!("lower failed for {q:?}: {e}")) } -fn scan(metric: &str, labels: &[&str]) -> QueryExpr { - QueryExpr::TimeRange { +fn node(op: NonASAPOp) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(op)) + .expect("fixture node derives its schema") +} + +fn scan(metric: &str, labels: &[&str]) -> Rc { + node(NonASAPOp::TimeRange { range: Duration::from_secs(1), - child: Rc::new(source_scan(metric, labels)), - } + kind: TimeRangeKind::Instant, + child: source_scan(metric, labels), + }) } -fn source_scan(metric: &str, labels: &[&str]) -> QueryExpr { - QueryExpr::Scan { +fn source_scan(metric: &str, labels: &[&str]) -> Rc { + node(NonASAPOp::Scan { source: Source::TimeSeries { metric: metric.into(), }, predicates: vec![], schema: metric_schema(labels), - } + }) } -fn rate_agg(metric: &str) -> QueryExpr { - QueryExpr::Aggregate { +fn rate_agg(metric: &str) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures: vec![AggIntent::Rate], output_names: vec!["".into()], filters: vec![], having: None, - child: Rc::new(QueryExpr::TimeRange { + child: node(NonASAPOp::TimeRange { range: Duration::from_secs(300), - child: Rc::new(source_scan(metric, &[])), + kind: TimeRangeKind::Range, + child: source_scan(metric, &[]), }), - } + }) } -fn sum_by_job(metric: &str) -> QueryExpr { - QueryExpr::Aggregate { +fn sum_by_job(metric: &str) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::by(vec![2]), measures: vec![AggIntent::Sum { col: None }], output_names: vec!["".into()], filters: vec![], having: None, - child: Rc::new(scan(metric, &["job"])), - } + child: scan(metric, &["job"]), + }) +} + +/// A PromQL binary operator: no checked-division flags, no `bool` modifier. +fn binary( + kind: BinaryOpKind, + vector_match: Option, + lhs: Rc, + rhs: Rc, +) -> Rc { + node(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind, + vector_match, + }, + return_bool: false, + lhs, + rhs, + }) } // #18 — arithmetic binary op between two bare scans; no vector match #[test] fn q18_div_bare_scans() { - let expected = QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("http_requests_total", &[])), - vector_match: None, - }; + let expected = binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), + None, + scan("http_requests_total", &[]), + scan("http_requests_total", &[]), + ); assert_eq!(lower("http_requests_total / http_requests_total"), expected); } // #19 — add with on(job) vector match; match labels are strings, not column ids #[test] fn q19_add_with_on_match() { - let expected = QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("http_requests_total", &[])), - vector_match: Some(VectorMatch { + let expected = binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + Some(VectorMatch { kind: VectorMatchKind::On, labels: vec!["job".into()], grouping: None, }), - }; + scan("http_requests_total", &[]), + scan("http_requests_total", &[]), + ); assert_eq!( lower("http_requests_total + on(job) http_requests_total"), expected @@ -96,12 +125,12 @@ fn q19_add_with_on_match() { // #20 — divide two rate aggregates over different metrics #[test] fn q20_div_two_rates() { - let expected = QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), - lhs: Rc::new(rate_agg("http_requests_total")), - rhs: Rc::new(rate_agg("http_errors_total")), - vector_match: None, - }; + let expected = binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), + None, + rate_agg("http_requests_total"), + rate_agg("http_errors_total"), + ); assert_eq!( lower("rate(http_requests_total[5m]) / rate(http_errors_total[5m])"), expected, @@ -113,12 +142,12 @@ fn q20_div_two_rates() { fn q_gt_comparison() { assert_eq!( lower("http_requests_total > http_errors_total"), - QueryExpr::BinaryOp { - op: BinaryOpKind::Compare(CompareOpKind::Gt), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("http_errors_total", &[])), - vector_match: None, - } + binary( + BinaryOpKind::Compare(CompareOpKind::Gt), + None, + scan("http_requests_total", &[]), + scan("http_errors_total", &[]), + ) ); } @@ -126,12 +155,12 @@ fn q_gt_comparison() { fn q_lt_comparison() { assert_eq!( lower("http_requests_total < http_errors_total"), - QueryExpr::BinaryOp { - op: BinaryOpKind::Compare(CompareOpKind::Lt), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("http_errors_total", &[])), - vector_match: None, - } + binary( + BinaryOpKind::Compare(CompareOpKind::Lt), + None, + scan("http_requests_total", &[]), + scan("http_errors_total", &[]), + ) ); } @@ -139,12 +168,12 @@ fn q_lt_comparison() { fn q_ge_comparison() { assert_eq!( lower("http_requests_total >= http_errors_total"), - QueryExpr::BinaryOp { - op: BinaryOpKind::Compare(CompareOpKind::Ge), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("http_errors_total", &[])), - vector_match: None, - } + binary( + BinaryOpKind::Compare(CompareOpKind::Ge), + None, + scan("http_requests_total", &[]), + scan("http_errors_total", &[]), + ) ); } @@ -152,12 +181,12 @@ fn q_ge_comparison() { fn q_le_comparison() { assert_eq!( lower("http_requests_total <= http_errors_total"), - QueryExpr::BinaryOp { - op: BinaryOpKind::Compare(CompareOpKind::Le), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("http_errors_total", &[])), - vector_match: None, - } + binary( + BinaryOpKind::Compare(CompareOpKind::Le), + None, + scan("http_requests_total", &[]), + scan("http_errors_total", &[]), + ) ); } @@ -166,16 +195,16 @@ fn q_le_comparison() { fn q_add_with_ignoring() { assert_eq!( lower("http_requests_total + ignoring(job) http_errors_total"), - QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("http_errors_total", &[])), - vector_match: Some(VectorMatch { + binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + Some(VectorMatch { kind: VectorMatchKind::Ignoring, labels: vec!["job".into()], grouping: None, }), - } + scan("http_requests_total", &[]), + scan("http_errors_total", &[]), + ) ); } @@ -184,11 +213,9 @@ fn q_add_with_ignoring() { fn q_mul_group_left() { assert_eq!( lower("http_requests_total * on(job) group_left() node_info"), - QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Mul), - lhs: Rc::new(scan("http_requests_total", &[])), - rhs: Rc::new(scan("node_info", &[])), - vector_match: Some(VectorMatch { + binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Mul), + Some(VectorMatch { kind: VectorMatchKind::On, labels: vec!["job".into()], grouping: Some(VectorGrouping { @@ -196,7 +223,9 @@ fn q_mul_group_left() { labels: vec![], }), }), - } + scan("http_requests_total", &[]), + scan("node_info", &[]), + ) ); } @@ -205,11 +234,9 @@ fn q_mul_group_left() { fn q_mul_group_right() { assert_eq!( lower("node_info * on(job) group_right() http_requests_total"), - QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Mul), - lhs: Rc::new(scan("node_info", &[])), - rhs: Rc::new(scan("http_requests_total", &[])), - vector_match: Some(VectorMatch { + binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Mul), + Some(VectorMatch { kind: VectorMatchKind::On, labels: vec!["job".into()], grouping: Some(VectorGrouping { @@ -217,7 +244,9 @@ fn q_mul_group_right() { labels: vec![], }), }), - } + scan("node_info", &[]), + scan("http_requests_total", &[]), + ) ); } @@ -225,12 +254,12 @@ fn q_mul_group_right() { // each side: Aggregate{Sum, by=[2]} over Scan([ts, value, job]) #[test] fn q21_div_two_sum_by_job() { - let expected = QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), - lhs: Rc::new(sum_by_job("http_requests_total")), - rhs: Rc::new(sum_by_job("http_errors_total")), - vector_match: None, - }; + let expected = binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), + None, + sum_by_job("http_requests_total"), + sum_by_job("http_errors_total"), + ); assert_eq!( lower("sum by (job) (http_requests_total) / sum by (job) (http_errors_total)"), expected, @@ -238,34 +267,29 @@ fn q21_div_two_sum_by_job() { } // #36 — unary negation lowers as `expr * -1`: a Mul BinaryOp of the vector -// against PromqlScalarBridge(-1), no vector match. The vector side keeps its schema. +// against a `ScalarExpr(-1)` leaf, no vector match. The vector side keeps +// its schema. #[test] fn q36_unary_negation_is_multiply_by_minus_one() { - let expected = QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Mul), - lhs: Rc::new(scan("some_metric", &[])), - rhs: Rc::new(QueryExpr::promql_scalar(-1.0)), - vector_match: None, + let root = lower("-some_metric"); + let NonASAPOp::Project { cols, child, .. } = root.expect_non_asap() else { + panic!() }; - assert_eq!(lower("-some_metric"), expected); + assert!(child.schema.has_promql_series_identity()); + assert!(matches!(&cols[1].expr, ScalarExpr::Negative { .. })); } // #36 — negation nested inside an aggregate argument (issue #27 nesting): // `sum(-m)` → Aggregate{Sum} over the `m * -1` BinaryOp. #[test] fn q36_sum_of_negation_nests() { - let expected = QueryExpr::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::Sum { col: None }], - output_names: vec!["".into()], - filters: vec![], - having: None, - child: Rc::new(QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Mul), - lhs: Rc::new(scan("node_cpu_seconds_total", &[])), - rhs: Rc::new(QueryExpr::promql_scalar(-1.0)), - vector_match: None, - }), + let root = lower("sum(-node_cpu_seconds_total)"); + let NonASAPOp::Aggregate { + child, measures, .. + } = root.expect_non_asap() + else { + panic!() }; - assert_eq!(lower("sum(-node_cpu_seconds_total)"), expected); + assert!(matches!(measures.as_slice(), [AggIntent::Sum { .. }])); + assert!(matches!(child.expect_non_asap(), NonASAPOp::Project { .. })); } diff --git a/crates/integration-tests/tests/cse.rs b/crates/integration-tests/tests/cse.rs index bb11eee2d..56a0e657b 100644 --- a/crates/integration-tests/tests/cse.rs +++ b/crates/integration-tests/tests/cse.rs @@ -2,14 +2,14 @@ //! #223). //! //! Drives the full staged pipeline this issue lands: two independently -//! lowered `QueryExpr` DAGs → `share_common_sub_dags` (stage 1, -//! `asap-types::pre_asap::cse`, run internally by `search_workload`) → +//! lowered `OperatorNode` DAGs → `share_common_sub_dags` (stage 1, +//! `asap-types::ir::cse`, run internally by `search_workload`) → //! `search_workload` (stage 2, `asap-aware-mapping`) — and asserts the //! sharing that stage 1 decides survives into stage 2's discovered //! `CandidateLogicalASAPDAGs` as one genuinely shared `TargetSubDAGCandidates`, not just one shared -//! `Rc`. This is the "real caller" the issue's landing plan +//! `Rc`. This is the "real caller" the issue's landing plan //! requires before `share_common_sub_dags` is allowed to exist at all (its -//! predecessor, `asap-plan::cse::dedupe_subtrees`, was deleted in #192 for +//! predecessor, `asap-plan::cse::dedupe_sub-DAGs`, was deleted in #192 for //! being unwired dead code). //! //! Committing to one final, physically-materialized answer for a whole @@ -24,14 +24,14 @@ use std::rc::Rc; -use asap_aware_mapping::{search_workload, Replacement}; +use asap_aware_mapping::{is_logical_rewrite, search_workload, Replacement}; use asap_integration_tests::fixtures::lower_promql; -use asap_types::pre_asap::query_expr::QueryExpr; +use asap_types::ir::NonASAPOp; use asap_types::types::AccuracyTarget; /// Two workload entries that happen to submit the exact same query (a /// realistic case — two dashboards, or a query fired both standalone and as -/// part of a larger batch) collapse onto one shared `Rc` after +/// part of a larger batch) collapse onto one shared `Rc` after /// `search_workload`'s internal `share_common_sub_dags` pass, and onto one /// genuinely-shared [`TargetSubDAGCandidates`](asap_aware_mapping::TargetSubDAGCandidates) — carrying /// every candidate discovered for it exactly once, not once per root — no @@ -41,7 +41,7 @@ use asap_types::types::AccuracyTarget; fn duplicate_workload_queries_collapse_onto_one_memo_group() { // Grouped (`by (job)`), so the shared `Aggregate`'s output schema carries // a provable unique key — the legality gate `share_common_sub_dags` - // enforces (see `asap-types::pre_asap::cse`'s module doc) — and its + // enforces (see `asap-types::ir::cse`'s module doc) — and its // `ExactAggregate(Sum)` realization is deterministic regardless of the // accuracy target, so this pins the sharing mechanism itself rather than // any one particular summary-family choice. @@ -57,17 +57,17 @@ fn duplicate_workload_queries_collapse_onto_one_memo_group() { "fixture sanity: identical query text lowers identically" ); - let space = search_workload(vec![("a", Rc::new(a)), ("b", Rc::new(b))]); + let space = search_workload(vec![("a", a), ("b", b)]); // roots[0] and roots[1] must have merged onto the same Rc — the // `share_common_sub_dags` pass `search_workload` runs internally. assert!( Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1), - "search_workload must collapse the two identical roots onto one Rc" + "search_workload must collapse the two identical roots onto one Rc" ); // The single shared root is one discovered TargetSubDAG, holding one - // TargetSubDAGCandidates with consumer_count 2 — SketchAlgorithmStrategy's one + // TargetSubDAGCandidates with consumer_count 2 — ASAPStrategies's one // ExactAggregate candidate *and* SharedSubDAGStrategy's share-vs- // recompute pair, exactly as `shared_aggregate_across_two_roots_gets_both_strategies_candidates` // (asap-aware-mapping::replacement's own equivalent, internal test) @@ -79,19 +79,21 @@ fn duplicate_workload_queries_collapse_onto_one_memo_group() { assert_eq!( group.candidates.len(), 3, - "1 ExactAggregate Summary + 2 Rewrite (share/recompute): {:?}", + "1 ExactAggregate summary + 2 logical rewrites (share/recompute): {:?}", group.candidates ); + // A bound summary is a `Subtree` with an ASAP operator in it; a logical + // rewrite is a `Subtree` with none (`is_logical_rewrite`). let summary_count = group .candidates .iter() - .filter(|c| matches!(c.replacement, Replacement::Summary(_))) + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if n.contains_asap())) .count(); let rewrite_count = group .candidates .iter() - .filter(|c| matches!(c.replacement, Replacement::Rewrite(_))) + .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if is_logical_rewrite(n))) .count(); assert_eq!(summary_count, 1); assert_eq!(rewrite_count, 2); @@ -100,12 +102,14 @@ fn duplicate_workload_queries_collapse_onto_one_memo_group() { // "false-positive dedup" failure mode `is_duplicate_rewrite` exists to // prevent): one shares the group's own target `Rc`, the other is a // structurally-identical but independently-built `Rc`. - let one_is_the_target = group.candidates.iter().any( - |c| matches!(&c.replacement, Replacement::Rewrite(rc) if Rc::ptr_eq(rc, &group.target)), - ); - let one_is_not = group.candidates.iter().any( - |c| matches!(&c.replacement, Replacement::Rewrite(rc) if !Rc::ptr_eq(rc, &group.target)), - ); + let one_is_the_target = group.candidates.iter().any(|c| { + matches!(&c.replacement, Replacement::SubDAG(rc) + if is_logical_rewrite(rc) && Rc::ptr_eq(rc, &group.target)) + }); + let one_is_not = group.candidates.iter().any(|c| { + matches!(&c.replacement, Replacement::SubDAG(rc) + if is_logical_rewrite(rc) && !Rc::ptr_eq(rc, &group.target)) + }); assert!(one_is_the_target && one_is_not); } @@ -121,7 +125,7 @@ fn distinct_workload_queries_get_independent_memo_groups() { .expect("query b failed to lower"); assert_ne!(a, b, "fixture sanity: the two queries differ"); - let space = search_workload(vec![("a", Rc::new(a)), ("b", Rc::new(b))]); + let space = search_workload(vec![("a", a), ("b", b)]); assert!(!Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); let group_a = space @@ -140,7 +144,7 @@ fn distinct_workload_queries_get_independent_memo_groups() { /// Single-query CSE (a repeated sub-expression within one query) also /// survives through `search_workload`: the two grouped-`Aggregate` branches -/// of a `BinaryOp` collapse to one shared `Rc` in the internal +/// of a `BinaryOp` collapse to one shared `Rc` in the internal /// `share_common_sub_dags` pass, and to one shared `TargetSubDAGCandidates` (with /// `consumer_count == 2`, one per branch) here. #[test] @@ -148,16 +152,16 @@ fn single_query_repeated_subexpression_shares_one_memo_group() { let query = "sum by (job) (http_requests_total) / sum by (job) (http_requests_total)"; let expr = lower_promql(query, AccuracyTarget::Exact).expect("query failed to lower"); - let space = search_workload(vec![("q", Rc::new(expr))]); + let space = search_workload(vec![("q", expr)]); let [(_, root)] = space.roots.as_slice() else { panic!("expected 1 root"); }; - let QueryExpr::BinaryOp { lhs, rhs, .. } = root.as_ref() else { + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = root.non_asap() else { panic!("expected a BinaryOp root, got {root:?}"); }; assert!( Rc::ptr_eq(lhs, rhs), - "the two identical sum-by-job branches must collapse onto one Rc" + "the two identical sum-by-job branches must collapse onto one Rc" ); let group = space diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index 917ef7b3a..3193026ab 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -1,5 +1,5 @@ //! Issue #171 — composing exact operators with summary plans across -//! explicit update/readout boundaries, end to end through +//! explicit update/evaluation boundaries, end to end through //! `search_workload_with` → `CandidateLogicalASAPDAGs::global_selection` → //! `GlobalSelection::assemble_selected_dag` → `dag_export`. //! @@ -16,27 +16,37 @@ use asap_aware_mapping::cost_model::{ CostProvenance, CostUnit, ExactCompositionCostInputs, ExactCompositionCostRequest, ValueOperationCapabilities, }; +use asap_aware_mapping::exact_composition::ExactOperation; use asap_aware_mapping::replacement::{ - default_strategies_with, search_workload_with, Replacement, ReplacementProvenance, - ReplacementStrategy, SketchAlgorithmStrategy, TargetSubDAG, + default_strategies_with, search_workload_with, ASAPStrategies, Replacement, + ReplacementProvenance, ReplacementStrategy, TargetSubDAG, }; use asap_aware_mapping::{ CostModel, DefaultCostModel, EvaluationRate, ExplanationKind, OperationPlacement, }; use asap_integration_tests::fixtures::lower_promql; +use asap_integration_tests::post_asap::{post_asap_dag, timed}; use asap_types::dag_export; +use asap_types::ir::operator_properties::{Reduction, Source}; +use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; +use asap_types::ir::timing::data_state; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, TimeRangeKind}; use asap_types::post_asap::{ - validate_execution_data_states, ExactKind, ExactOperation, ExecutionDataState, ExecutionTiming, - FieldDataType, SketchAlgorithm, SummaryExpr, SummaryNode, SummaryUpdate, + ExactKind, ExecutionDataState, ExecutionTiming, FieldDataType, SketchAlgorithm, SummaryUpdate, }; use asap_types::pre_asap::agg_intent::{default_quantile, AggIntent}; -use asap_types::pre_asap::query_expr::{QueryExpr, Reduction, Source}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; + use asap_types::types::AccuracyTarget; // ── fixtures ──────────────────────────────────────────────────────────── -fn metric_scan(labels: &[&str]) -> QueryExpr { +fn node(op: NonASAPOp) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(op)) + .expect("fixture node derives its schema") +} + +fn metric_scan(labels: &[&str]) -> Rc { let mut columns = vec![ Field::plain("ts", DataType::Timestamp, false), Field::plain("value", DataType::Float64, false), @@ -46,17 +56,17 @@ fn metric_scan(labels: &[&str]) -> QueryExpr { .iter() .map(|n| Field::plain(*n, DataType::Utf8, true)), ); - QueryExpr::Scan { + node(NonASAPOp::Scan { source: Source::TimeSeries { metric: "latency".into(), }, predicates: vec![], schema: Schema::with_time_index(columns, 0, vec![]), - } + }) } -fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { - Rc::new(QueryExpr::Aggregate { +fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::by(by), measures: vec![intent], output_names: vec![], @@ -66,8 +76,8 @@ fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc }) } -fn per_entity(intent: AggIntent, child: Rc) -> Rc { - Rc::new(QueryExpr::Aggregate { +fn per_entity(intent: AggIntent, child: Rc) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures: vec![intent], output_names: vec![], @@ -78,11 +88,11 @@ fn per_entity(intent: AggIntent, child: Rc) -> Rc { } /// `quantile by (zone, host) (latency)` — the fine-grained inner summary. -fn fine_quantile() -> Rc { +fn fine_quantile() -> Rc { agg( vec![2, 3], default_quantile(0.99), - Rc::new(metric_scan(&["zone", "host"])), + metric_scan(&["zone", "host"]), ) } @@ -96,7 +106,7 @@ struct StatsModel; fn custom_accuracy_rule_survives_root_target_and_materialization() { use asap_aware_mapping::{AccuracyModel, DefaultAccuracyModel, PropagationStats}; use asap_types::post_asap::{ - AccuracyError, CompositionOperator, ExactOperation, ResultGuarantee, SketchStatistic, + AccuracyError, CompositionOperator, ResultGuarantee, SketchStatistic, }; struct Model; impl AccuracyModel for Model { @@ -278,34 +288,44 @@ fn unknown_runtime_capability_keeps_candidate_but_prevents_selection() { } fn plan( - roots: Vec<(&'static str, Rc)>, + roots: Vec<(&'static str, Rc)>, cost_model: &dyn CostModel, ) -> asap_aware_mapping::CandidateLogicalASAPDAGs<&'static str> { search_workload_with(roots, &default_strategies_with(cost_model)) } -fn is_plain(node: &SummaryNode) -> bool { +fn is_plain(node: &OperatorNode) -> bool { node.schema .fields .iter() .all(|f| matches!(f.dtype, FieldDataType::Plain(_))) } -fn names(node: &SummaryNode) -> Vec<&str> { +fn names(node: &OperatorNode) -> Vec<&str> { node.schema.fields.iter().map(|f| f.name.as_str()).collect() } +/// The composed query-time shape: an exact `Aggregate` directly over a +/// summary evaluation, at query time. +fn is_query_time_fold(node: &OperatorNode) -> bool { + matches!( + node.non_asap(), + Some(NonASAPOp::Aggregate { child, .. }) + if matches!(child.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })) + ) +} + // ── step 1: pin every already-supported exact-accumulator nesting ─────── #[test] fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { use std::time::Duration; - let cases: Vec<(Rc, ExactKind)> = vec![ + let cases: Vec<(Rc, ExactKind)> = vec![ ( agg( vec![2], AggIntent::Sum { col: None }, - Rc::new(metric_scan(&["zone"])), + metric_scan(&["zone"]), ), ExactKind::Sum, ), @@ -315,7 +335,7 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { AggIntent::Count { accuracy: AccuracyTarget::Exact, }, - Rc::new(metric_scan(&["zone"])), + metric_scan(&["zone"]), ), ExactKind::Count, ), @@ -323,7 +343,7 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { agg( vec![2], AggIntent::Min { col: None }, - Rc::new(metric_scan(&["zone"])), + metric_scan(&["zone"]), ), ExactKind::Min, ), @@ -331,16 +351,17 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { agg( vec![2], AggIntent::Max { col: None }, - Rc::new(metric_scan(&["zone"])), + metric_scan(&["zone"]), ), ExactKind::Max, ), ( per_entity( AggIntent::Rate, - Rc::new(QueryExpr::TimeRange { + node(NonASAPOp::TimeRange { range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["zone"])), + kind: TimeRangeKind::Range, + child: metric_scan(&["zone"]), }), ), ExactKind::Rate, @@ -348,9 +369,10 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { ( per_entity( AggIntent::Increase, - Rc::new(QueryExpr::TimeRange { + node(NonASAPOp::TimeRange { range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["zone"])), + kind: TimeRangeKind::Range, + child: metric_scan(&["zone"]), }), ), ExactKind::Increase, @@ -359,40 +381,43 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { for (inner, kind) in cases { let outer = agg(vec![], default_quantile(0.9), inner); let target = TargetSubDAG::new(&outer); - let candidates = SketchAlgorithmStrategy::default_cost_model().replacements(&target); - let Replacement::Summary(root) = &candidates[0].replacement else { + let candidates = ASAPStrategies::default_cost_model().replacements(&target); + let Replacement::SubDAG(root) = &candidates[0].replacement else { unreachable!() }; - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - panic!("expected KLL readout, got {:?}", root.expr); + // Timing is not stored on the plan: time it (default lifecycle, + // which also validates every edge) and inspect the timed copy. + let root = timed(root); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { + panic!("expected KLL evaluation, got {:?}", root.operator); }; - let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary_input.operator else { panic!("expected outer SummaryAgg"); }; - let SummaryExpr::ValueOperation { - child, - operation: asap_types::post_asap::ValueOperation::FinalizeExactAccumulator, - timing: ExecutionTiming::IngestionTime, - } = &child.expr + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: finalized }) = &child.operator else { panic!("{kind:?}: missing maintenance finalization"); }; + assert_eq!( + child.timing, + Some(ExecutionTiming::IngestionTime), + "{kind:?}: finalization runs at maintenance time" + ); assert!( matches!( - &child.expr, - SummaryExpr::SummaryAgg { family: FieldDataType::ExactAggregate(k, _), .. } if *k == kind + &finalized.operator, + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(k, _), .. }) if *k == kind ), "{kind:?}: expected the exact accumulator under its finalization, got {:?}", - child.expr + finalized.operator ); - validate_execution_data_states(root).expect("accumulator state composes under maintenance"); } } -// ── direction 1: outer exact fold over an inner summary readout ──────── +// ── direction 1: outer exact fold over an inner summary evaluation ──────── -/// Before this PR both `max`/`avg` over a quantile collapsed into one -/// opaque `KeepPreAsap`. Now: the outer group holds an `ValueOperationAtQueryTime` +/// `max`/`avg` over a quantile does not collapse into one opaque kept +/// sub-DAG: the outer group holds an `ValueOperationAtQueryTime` /// candidate referencing the inner target, the inner group keeps its own /// sketch candidates, and with statistics the pair is committed and /// materializes as `ValueOperationAtQueryTime → SummaryEstimate → SummaryAgg`. @@ -402,7 +427,7 @@ fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { let root = agg(vec![0], intent.clone(), fine_quantile()); let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); let root = Rc::clone(&space.roots[0].1); - let QueryExpr::Aggregate { child: inner, .. } = root.as_ref() else { + let Some(NonASAPOp::Aggregate { child: inner, .. }) = root.non_asap() else { unreachable!() }; @@ -419,9 +444,9 @@ fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { inner_group .candidates .iter() - .any(|c| matches!(&c.replacement, Replacement::Summary(n) - if matches!(n.expr, SummaryExpr::SummaryEstimate { .. }))), - "{intent:?}: the inner quantile keeps its own readout candidates" + .any(|c| matches!(&c.replacement, Replacement::SubDAG(n) + if matches!(n.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })))), + "{intent:?}: the inner quantile keeps its own evaluation candidates" ); let selection = space.global_selection(&StatsModel); @@ -449,18 +474,16 @@ fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { )); let composed = selection.assemble_selected_dag(&root).unwrap().unwrap(); - let SummaryExpr::ValueOperation { - child, - timing: ExecutionTiming::QueryTime, - .. - } = &composed.expr - else { + let Some(NonASAPOp::Aggregate { child, .. }) = composed.non_asap() else { panic!( "{intent:?}: expected ValueOperationAtQueryTime root, got {:?}", - composed.expr + composed.operator ); }; - assert!(matches!(child.expr, SummaryExpr::SummaryEstimate { .. })); + assert!(matches!( + child.operator, + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + )); assert!( child.guarantee.is_some(), "child has its KLL rank guarantee" @@ -472,15 +495,18 @@ fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { assert!(is_plain(&composed)); assert_eq!( names(&composed), - root.output_schema() - .unwrap() + root.schema .fields .iter() .map(|c| c.name.as_str()) .collect::>(), "the composed plan's schema is the pre-ASAP target's own" ); - validate_execution_data_states(&composed).unwrap(); + assert_eq!( + timed(&composed).timing, + Some(ExecutionTiming::QueryTime), + "{intent:?}: the exact fold runs at query time" + ); } } @@ -490,11 +516,7 @@ fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { fn avg_over_quantile_keeps_the_sum_over_count_rewrite_as_a_competitor() { // `by (zone)` over `by (zone)`: the averaged column resolves to the // non-null quantile output, which is what the rewrite requires. - let inner = agg( - vec![2], - default_quantile(0.99), - Rc::new(metric_scan(&["zone"])), - ); + let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); let root = agg(vec![0], AggIntent::Avg { col: None }, inner); let space = plan(vec![("q", root)], &StatsModel); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); @@ -508,11 +530,7 @@ fn avg_over_quantile_keeps_the_sum_over_count_rewrite_as_a_competitor() { /// is the same, only the fold's row multiplicity differs. #[test] fn identity_and_genuine_multi_row_folds_both_compose() { - let identity_inner = agg( - vec![2], - default_quantile(0.99), - Rc::new(metric_scan(&["zone"])), - ); + let identity_inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); for (label, inner) in [ ("identity", identity_inner), ("fine-to-coarse", fine_quantile()), @@ -527,14 +545,17 @@ fn identity_and_genuine_multi_row_folds_both_compose() { .unwrap(); assert!( matches!( - composed.expr, - SummaryExpr::ValueOperation { - timing: ExecutionTiming::QueryTime, - .. - } + composed.non_asap(), + Some(NonASAPOp::Aggregate { child, .. }) + if matches!(child.operator, Operator::ASAP(ASAPOp::SummaryEstimate { .. })) ), "{label}: {:?}", - composed.expr + composed.operator + ); + assert_eq!( + timed(&composed).timing, + Some(ExecutionTiming::QueryTime), + "{label}" ); assert_eq!(names(&composed), vec!["zone", "max"], "{label}"); } @@ -543,7 +564,7 @@ fn identity_and_genuine_multi_row_folds_both_compose() { /// One inner quantile consumed by two outer folds in two queries: CSE /// collapses the inner target onto one `Rc`, both compositions commit to /// the *same* child candidate, and both materializations share one -/// `Rc` for it — the summary is maintained once. +/// `Rc` for it — the summary is maintained once. #[test] fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { let max = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); @@ -551,9 +572,9 @@ fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { let space = plan(vec![("max", max), ("min", min)], &StatsModel); let selection = space.global_selection(&StatsModel); - let roots: Vec> = space.roots.iter().map(|(_, r)| Rc::clone(r)).collect(); - let inner_of = |r: &Rc| match r.as_ref() { - QueryExpr::Aggregate { child, .. } => Rc::clone(child), + let roots: Vec> = space.roots.iter().map(|(_, r)| Rc::clone(r)).collect(); + let inner_of = |r: &Rc| match r.non_asap() { + Some(NonASAPOp::Aggregate { child, .. }) => Rc::clone(child), _ => unreachable!(), }; assert!( @@ -589,17 +610,20 @@ fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { .iter() .map(|r| selection.assemble_selected_dag(r).unwrap().unwrap()) .collect(); - let child_of = |n: &Rc| match &n.expr { - SummaryExpr::ValueOperation { - child, - timing: ExecutionTiming::QueryTime, - .. - } => Rc::clone(child), - other => panic!("expected ValueOperationAtQueryTime, got {other:?}"), + let child_of = |n: &Rc| match n.non_asap() { + Some(NonASAPOp::Aggregate { child, .. }) + if matches!( + child.operator, + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + ) => + { + Rc::clone(child) + } + _ => panic!("expected ValueOperationAtQueryTime, got {:?}", n.operator), }; assert!( Rc::ptr_eq(&child_of(&composed[0]), &child_of(&composed[1])), - "both folds compose over the same Rc" + "both folds compose over the same Rc" ); } @@ -614,15 +638,16 @@ fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { use std::time::Duration; let deriv = per_entity( AggIntent::Deriv, - Rc::new(QueryExpr::TimeRange { + node(NonASAPOp::TimeRange { range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["zone"])), + kind: TimeRangeKind::Range, + child: metric_scan(&["zone"]), }), ); let root = agg(vec![], default_quantile(0.99), deriv); let space = plan(vec![("q", root)], &StatsModel); let root = Rc::clone(&space.roots[0].1); - let QueryExpr::Aggregate { child: deriv, .. } = root.as_ref() else { + let Some(NonASAPOp::Aggregate { child: deriv, .. }) = root.non_asap() else { unreachable!() }; assert!(space @@ -643,33 +668,25 @@ fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { assert!(decision.cost_rate < decision.baseline_rate); let composed = selection.assemble_selected_dag(&root).unwrap().unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &composed.expr else { - panic!("expected readout root, got {:?}", composed.expr); + // Walk the timed copy: timing is written by the lifecycle assignment. + let composed = timed(&composed); + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &composed.operator else { + panic!("expected evaluation root, got {:?}", composed.operator); }; - let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary_input.operator else { panic!("expected SummaryAgg"); }; - let SummaryExpr::ValueOperation { - child: raw, - timing: ExecutionTiming::IngestionTime, - .. - } = &child.expr - else { + let Some(NonASAPOp::Aggregate { child: raw, .. }) = child.non_asap() else { panic!( "expected ValueOperationAtIngestionTime under the maintained summary, got {:?}", - child.expr + child.operator ); }; - assert!(matches!(raw.expr, SummaryExpr::KeepPreAsap(_))); - let assignment = validate_execution_data_states(&composed).unwrap(); - assert_eq!( - assignment.data_state_of(child), - Some(ExecutionDataState::INGESTION_ROWS) - ); - assert_eq!( - assignment.data_state_of(raw), - Some(ExecutionDataState::INGESTION_ROWS) - ); + // The raw input is kept as-is. + assert!(matches!(raw.non_asap(), Some(NonASAPOp::TimeRange { .. }))); + assert!(!raw.contains_asap()); + assert_eq!(data_state(child), Some(ExecutionDataState::INGESTION_ROWS)); + assert_eq!(data_state(raw), Some(ExecutionDataState::INGESTION_ROWS)); } // ── rejection, capability, statistics ─────────────────────────────────── @@ -685,24 +702,28 @@ fn summary_construction_follows_its_value_input_phase() { .assemble_selected_dag(&space.roots[0].1) .unwrap() .unwrap(); - let illegal = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: post, - family: FieldDataType::ExactAggregate( - ExactKind::Max, - asap_types::post_asap::ExactParams::Max, - ), - input: SummaryUpdate::column(asap_types::pre_asap::ColumnRef::SampleValue), - reduction: Reduction::by(vec![]), - grouping: Default::default(), - filter: None, - }, - schema: asap_types::post_asap::Schema::lifted(vec![], None), - guarantee: None, - }); - let state = asap_types::post_asap::produced_data_state(&illegal.expr).unwrap(); + let illegal = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: post, + family: FieldDataType::ExactAggregate( + ExactKind::Max, + asap_types::post_asap::ExactParams::Max, + ), + input: SummaryUpdate::column(asap_types::pre_asap::ColumnRef::SampleValue), + reduction: Reduction::by(vec![]), + grouping: Default::default(), + filter: None, + }), + Schema::lifted(vec![], None), + ) + .with_guarantee(None), + ); + // Even under an ingestion-time consumer the state is built at query + // time, because a evaluation sits below it. + let state = asap_types::ir::planned_data_state(&illegal, ExecutionTiming::IngestionTime); assert_eq!(state.timing, ExecutionTiming::QueryTime); - asap_types::post_asap::validate_execution_data_states_at(&illegal, state).unwrap(); + asap_types::ir::validate_default(&illegal, state.timing).unwrap(); } #[test] @@ -718,9 +739,9 @@ fn a_runtime_without_mixed_execution_gets_no_composition_candidates() { let selection = space.global_selection(&NoCapabilityModel); assert!(selection.for_target(&root).unwrap().composition.is_none()); let node = selection.assemble_selected_dag(&root).unwrap().unwrap(); - assert!(!matches!(node.expr, SummaryExpr::ValueOperation { .. })); + assert!(!is_query_time_fold(&node)); // The inner quantile is still independently selectable. - let QueryExpr::Aggregate { child, .. } = root.as_ref() else { + let Some(NonASAPOp::Aggregate { child, .. }) = root.non_asap() else { unreachable!() }; assert!(selection.for_target(child).unwrap().chosen.is_some()); @@ -731,7 +752,7 @@ fn a_runtime_without_mixed_execution_gets_no_composition_candidates() { /// site keeps a non-composed alternative, and the inner summary stays /// independently selectable. #[test] -fn missing_cost_statistics_preserve_the_conservative_keep_pre_asap() { +fn missing_cost_statistics_preserve_the_conservative_retain_exact() { let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); let space = plan(vec![("q", root)], &DefaultCostModel); let root = Rc::clone(&space.roots[0].1); @@ -749,9 +770,9 @@ fn missing_cost_statistics_preserve_the_conservative_keep_pre_asap() { Some(Replacement::ExactComposition(_)) )); let node = selection.assemble_selected_dag(&root).unwrap().unwrap(); - assert!(!matches!(node.expr, SummaryExpr::ValueOperation { .. })); + assert!(!is_query_time_fold(&node)); - let explanations = asap_aware_mapping::explain_replacements(vec![("q", (*root).clone())]); + let explanations = asap_aware_mapping::explain_replacements(vec![("q", Rc::clone(&root))]); assert!(explanations .iter() .any(|e| e.kind == ExplanationKind::ExactComposition)); @@ -771,12 +792,17 @@ fn dag_export_carries_explicit_stage_and_plain_schema_for_a_composed_plan() { .unwrap(); let dag = dag_export::export_summary(&composed); let node = &dag.nodes[dag.root as usize]; - assert_eq!(node.kind, "ValueOperation"); - assert_eq!(node.detail["timing"], "query_time"); - assert!(node.detail["operation"] - .as_str() - .unwrap() - .starts_with("Exact(Aggregate")); + assert_eq!(node.kind, "aggregate"); + assert!(node.detail["measures"].is_array()); + // Timing is explicit in the wire-6 DAG: the root is a relational + // aggregate placed at query time. + let wire = post_asap_dag(&composed); + let wire_root = wire.nodes.iter().find(|n| n.id == wire.roots[0]).unwrap(); + assert!(matches!( + wire_root.payload, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Aggregate { .. }) + )); + assert_eq!(wire_root.output_state.timing, ExecutionTiming::QueryTime); // Pre-ASAP export of the same target still describes the same columns. let pre = dag_export::export(root); @@ -798,7 +824,7 @@ fn promql_max_by_zone_over_quantile_over_time_composes() { AccuracyTarget::Epsilon(0.01), ) .unwrap(); - let space = plan(vec![("q", Rc::new(expr))], &StatsModel); + let space = plan(vec![("q", expr)], &StatsModel); let root = &space.roots[0].1; let selection = space.global_selection(&StatsModel); let selected = selection.for_target(root).unwrap(); @@ -815,13 +841,8 @@ fn promql_max_by_zone_over_quantile_over_time_composes() { .collect::>() ); let composed = selection.assemble_selected_dag(root).unwrap().unwrap(); - assert!(matches!( - composed.expr, - SummaryExpr::ValueOperation { - timing: ExecutionTiming::QueryTime, - .. - } - )); + assert!(is_query_time_fold(&composed), "{:?}", composed.operator); + assert_eq!(timed(&composed).timing, Some(ExecutionTiming::QueryTime)); assert_eq!( selected.composition.as_ref().map(|d| d.inputs.unit), Some(CostUnit::CostUnitsPerSecond) diff --git a/crates/integration-tests/tests/frontend_timestamps.rs b/crates/integration-tests/tests/frontend_timestamps.rs index 388261da6..3b5cec074 100644 --- a/crates/integration-tests/tests/frontend_timestamps.rs +++ b/crates/integration-tests/tests/frontend_timestamps.rs @@ -1,9 +1,9 @@ //! Cross-frontend evaluation-time semantics (issues #46 and #184). -use asap_frontend_sql::{lower_sql, SqlCatalog}; -use asap_integration_tests::fixtures::lower_promql; +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; +use asap_integration_tests::fixtures::lower_promql_root; +use asap_types::ir::{NonASAPOp, ScalarExpr}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::QueryExpr; use asap_types::types::AccuracyTarget; /// PromQL exposes its evaluation time as Unix seconds, whereas SQL exposes @@ -11,10 +11,21 @@ use asap_types::types::AccuracyTarget; /// but must remain distinguishable in the shared IR and type inference. #[tokio::test] async fn promql_eval_time_and_sql_current_timestamp_remain_distinct() { - let promql = lower_promql("time()", AccuracyTarget::Exact).expect("lower PromQL time()"); - assert!(matches!(promql, QueryExpr::EvalTimestamp)); - let promql_schema = promql.output_schema().expect("PromQL time() schema"); - assert_eq!(promql_schema.fields[0].dtype, DataType::Float64); + let promql = lower_promql_root("time()", AccuracyTarget::Exact).expect("lower PromQL time()"); + assert!( + matches!( + promql, + asap_types::ir::QueryRoot::Scalar(ScalarExpr::EvalTimestamp) + ), + "expected a bare evaluation-time scalar, got {promql:?}" + ); + assert_eq!( + ScalarExpr::EvalTimestamp + .scalar_type(&Schema::default()) + .unwrap() + .0, + DataType::Float64 + ); let catalog = SqlCatalog::new().with_table( "metrics", @@ -27,13 +38,14 @@ async fn promql_eval_time_and_sql_current_timestamp_remain_distinct() { ) .await .expect("lower SQL CURRENT_TIMESTAMP"); - let QueryExpr::Project { cols, .. } = sql else { + let Some(NonASAPOp::Project { cols, child, .. }) = sql.non_asap() else { panic!("expected SQL projection, got {sql:?}"); }; - assert!(matches!(&cols[0].expr, QueryExpr::CurrentTimestamp)); - let sql_schema = cols[0] + assert!(matches!(&cols[0].expr, ScalarExpr::CurrentTimestamp)); + let (sql_dtype, _) = cols[0] .expr - .output_schema() - .expect("SQL CURRENT_TIMESTAMP schema"); - assert_eq!(sql_schema.fields[0].dtype, DataType::Timestamp); + .scalar_type(&child.schema) + .expect("SQL CURRENT_TIMESTAMP type"); + assert_eq!(sql_dtype, DataType::Timestamp); + assert_eq!(sql.schema.fields[0].dtype, DataType::Timestamp); } diff --git a/crates/integration-tests/tests/kll_pane_execution.rs b/crates/integration-tests/tests/kll_pane_execution.rs index b48d14bf7..0b5fec6a6 100644 --- a/crates/integration-tests/tests/kll_pane_execution.rs +++ b/crates/integration-tests/tests/kll_pane_execution.rs @@ -1,11 +1,11 @@ //! Maintenance -> stored pane state -> independently bound query execution. mod physical_common; use asap_physical_operators::{ - operators::{Operator, ReadoutQuery}, - physical_planner::{CompiledPhysicalDAG, InputContract, Source}, + operators::{Operator, SummaryEvaluation}, plan::{PhysicalDAG, PhysicalOperator, PlanProperties}, runtime::{Input, Limits, OutputStream, RunContext, Scope}, summary_kernels::datasketches_kll::DatasketchesKLLAccumulator, + unified_physical_planner::{CompiledPhysicalDAG, InputContract, Source}, values::{Batch, SchemaRef, Value}, AggregateCore, Error, }; @@ -32,8 +32,8 @@ fn family(k: u32) -> FieldDataType { } fn raw_schema() -> SchemaRef { Arc::new(Schema { - closed: true, unique_keys: vec![], + closed: false, fields: vec![Field { table: None, name: "value".into(), @@ -98,11 +98,11 @@ fn restore(schema: SchemaRef, states: &[Arc]) -> Batch { ) .unwrap() } -fn readout(schema: SchemaRef, q: f64) -> Operator { - Operator::readout( +fn evaluation(schema: SchemaRef, q: f64) -> Operator { + Operator::evaluation( schema, 0, - ReadoutQuery::Sketch(SketchStatistic::Quantile { q }), + SummaryEvaluation::Sketch(SketchStatistic::Quantile { q }), ) .unwrap() } @@ -159,8 +159,8 @@ fn five_panes_roundtrip_and_shared_merge_runs_once() { ), ), (6, (vec![5], merge.clone())), - (7, (vec![6], readout(schema.clone(), 0.5))), - (8, (vec![6], readout(schema.clone(), 0.99))), + (7, (vec![6], evaluation(schema.clone(), 0.5))), + (8, (vec![6], evaluation(schema.clone(), 0.99))), ]), vec![6, 7, 8], ) @@ -247,8 +247,10 @@ fn five_panes_roundtrip_and_shared_merge_runs_once() { }, ) .unwrap(); - dag.add(2, vec![1], readout(schema.clone(), 0.5)).unwrap(); - dag.add(3, vec![1], readout(schema.clone(), 0.99)).unwrap(); + dag.add(2, vec![1], evaluation(schema.clone(), 0.5)) + .unwrap(); + dag.add(3, vec![1], evaluation(schema.clone(), 0.99)) + .unwrap(); let outputs = block_on(futures::future::join_all( dag.execute( &[2, 3], diff --git a/crates/integration-tests/tests/nested.rs b/crates/integration-tests/tests/nested.rs index 1e3e34ebd..26ac23a24 100644 --- a/crates/integration-tests/tests/nested.rs +++ b/crates/integration-tests/tests/nested.rs @@ -1,8 +1,8 @@ //! Multi-node pipeline tests — nested `Aggregate`, `TimeRange`, `BinaryOp`, and `Scan`. //! //! Key invariant: `rate`/`increase` are label-preserving (per-series), so an -//! outer `Aggregate.by` resolves its group keys against the inner aggregate's -//! output schema, which still carries all label columns. +//! outer `Aggregate` reduction resolves its group keys against the inner +//! aggregate's output schema, which still carries all label columns. //! //! Label column ordering is always alphabetical, so in a query that references //! both `job` and `status`: @@ -13,56 +13,107 @@ use std::time::Duration; use asap_integration_tests::fixtures::lower_promql; use asap_integration_tests::fixtures::metric_schema; +use asap_types::ir::{ + BinaryOperator, ExprSemantics, NonASAPOp, OperatorNode, Predicate, ScalarExpr, TimeRangeKind, +}; use asap_types::pre_asap::{ - AggIntent, ArithmeticOpKind, AtModifier, BinaryOpKind, CompareOpKind, GroupKeys, Predicate, - PromQLVectorSetOpKind, QueryExpr, Reduction, ScalarValue, Source, TimeShift, VectorMatch, - VectorMatchKind, + AggIntent, ArithmeticOpKind, AtModifier, BinaryOpKind, CompareOpKind, GroupKeys, + PromQLVectorSetOpKind, Reduction, ScalarValue, Source, TimeShift, VectorMatch, VectorMatchKind, }; use asap_types::types::AccuracyTarget; -fn lower(q: &str) -> QueryExpr { +fn lower(q: &str) -> Rc { lower_promql(q, AccuracyTarget::Exact).unwrap_or_else(|e| panic!("lower failed for {q:?}: {e}")) } -fn agg(by: Vec, intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { +fn node(op: NonASAPOp) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(op)) + .expect("fixture node derives its schema") +} + +fn scan(metric: &str, predicates: Vec, labels: &[&str]) -> Rc { + node(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: metric.into(), + }, + predicates, + schema: metric_schema(labels), + }) +} + +fn instant(child: Rc) -> Rc { + node(NonASAPOp::TimeRange { + range: Duration::from_secs(1), + kind: TimeRangeKind::Instant, + child, + }) +} + +fn range(secs: u64, child: Rc) -> Rc { + node(NonASAPOp::TimeRange { + range: Duration::from_secs(secs), + kind: TimeRangeKind::Range, + child, + }) +} + +fn eq_pred(col_id: usize, value: &str) -> Predicate { + Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(col_id)), + op: CompareOpKind::Eq, + right: Box::new(ScalarExpr::Literal(ScalarValue::Utf8(value.into()))), + semantics: ExprSemantics::Promql, + }) +} + +fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::by(by), measures: vec![intent], output_names: vec!["".into()], filters: vec![], having: None, - child: Rc::new(child), - } + child, + }) } -fn agg_per_entity(intent: AggIntent, child: QueryExpr) -> QueryExpr { - QueryExpr::Aggregate { +fn agg_per_entity(intent: AggIntent, child: Rc) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures: vec![intent], output_names: vec!["".into()], filters: vec![], having: None, - child: Rc::new(child), - } + child, + }) +} + +fn binary( + kind: BinaryOpKind, + vector_match: Option, + lhs: Rc, + rhs: Rc, +) -> Rc { + node(NonASAPOp::BinaryOp { + operator: BinaryOperator { + checked_relative_division: false, + checked_finite_division: false, + kind, + vector_match, + }, + return_bool: false, + lhs, + rhs, + }) } // #22 — sum by job over rate; outer by=[2] resolves against rate's // label-preserving output schema [ts, value, job] #[test] fn q22_sum_by_job_over_rate() { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_requests_total".into(), - }, - predicates: vec![], - schema: metric_schema(&["job"]), - }; let inner_rate = agg_per_entity( AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(scan), - }, + range(300, scan("http_requests_total", vec![], &["job"])), ); let expected = agg(vec![2], AggIntent::Sum { col: None }, inner_rate); assert_eq!( @@ -76,25 +127,12 @@ fn q22_sum_by_job_over_rate() { // predicate on status (col 3); group key job (col 2) #[test] fn q23_sum_by_job_over_filtered_scan() { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_requests_total".into(), - }, - predicates: vec![Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(3)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8("200".into()))), - }))], - schema: metric_schema(&["job", "status"]), - }; - let expected = agg( - vec![2], - AggIntent::Sum { col: None }, - QueryExpr::TimeRange { - range: Duration::from_secs(1), - child: Rc::new(scan), - }, + let scan = scan( + "http_requests_total", + vec![eq_pred(3, "200")], + &["job", "status"], ); + let expected = agg(vec![2], AggIntent::Sum { col: None }, instant(scan)); assert_eq!( lower(r#"sum by (job) (http_requests_total{status="200"})"#), expected @@ -108,54 +146,30 @@ fn q23_sum_by_job_over_filtered_scan() { // schema [ts, value, job]; outer by=[2] (job) #[test] fn q25_div_over_complex_sub_dags() { - let lhs_scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_requests_total".into(), - }, - predicates: vec![Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(3)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8("200".into()))), - }))], - schema: metric_schema(&["job", "status"]), - }; + let lhs_scan = scan( + "http_requests_total", + vec![eq_pred(3, "200")], + &["job", "status"], + ); let lhs = agg( vec![2], AggIntent::Sum { col: None }, - agg_per_entity( - AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(lhs_scan), - }, - ), + agg_per_entity(AggIntent::Rate, range(300, lhs_scan)), ); - let rhs_scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_errors_total".into(), - }, - predicates: vec![], - schema: metric_schema(&["job"]), - }; + let rhs_scan = scan("http_errors_total", vec![], &["job"]); let rhs = agg( vec![2], AggIntent::Sum { col: None }, - agg_per_entity( - AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(rhs_scan), - }, - ), + agg_per_entity(AggIntent::Rate, range(300, rhs_scan)), ); - let expected = QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), - lhs: Rc::new(lhs), - rhs: Rc::new(rhs), - vector_match: None, - }; + let expected = binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), + None, + lhs, + rhs, + ); assert_eq!( lower( r#"sum by (job) (rate(http_requests_total{status="200"}[5m])) / sum by (job) (rate(http_errors_total[5m]))"# @@ -171,19 +185,9 @@ fn q25_div_over_complex_sub_dags() { // label-preserving output schema; the outer `max` has no grouping. #[test] fn q27_max_over_sum_by_job_over_rate() { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_requests_total".into(), - }, - predicates: vec![], - schema: metric_schema(&["job"]), - }; let inner_rate = agg_per_entity( AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(scan), - }, + range(300, scan("http_requests_total", vec![], &["job"])), ); let sum_by_job = agg(vec![2], AggIntent::Sum { col: None }, inner_rate); let expected = agg(vec![], AggIntent::Max { col: None }, sum_by_job); @@ -201,25 +205,12 @@ fn q27_max_over_sum_by_job_over_rate() { // Scan schema: [ts(0), value(1), group(2), job(3)] (labels alphabetical). #[test] fn q53_outer_group_key_absent_from_nested_aggregate() { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_requests".into(), - }, - predicates: vec![Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(3)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8("api-server".into()))), - }))], - schema: metric_schema(&["group", "job"]), - }; - let inner = agg( - vec![2], - AggIntent::Sum { col: None }, - QueryExpr::TimeRange { - range: Duration::from_secs(1), - child: Rc::new(scan), - }, + let scan = scan( + "http_requests", + vec![eq_pred(3, "api-server")], + &["group", "job"], ); + let inner = agg(vec![2], AggIntent::Sum { col: None }, instant(scan)); let expected = agg(vec![], AggIntent::Sum { col: None }, inner); assert_eq!( lower(r#"sum(sum by (group)(http_requests{job="api-server"})) by (job)"#), @@ -236,33 +227,26 @@ fn q53_outer_group_key_absent_from_nested_aggregate() { // parser's default `ignoring([])` match modifier. #[test] fn q52_outer_name_label_over_binary_op() { - let side = |metric: &str, env: &str| QueryExpr::TimeRange { - range: Duration::from_secs(1), - child: Rc::new(QueryExpr::Scan { - source: Source::TimeSeries { - metric: metric.into(), - }, - predicates: vec![Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(2)), // env - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8(env.into()))), - }))], - schema: metric_schema(&["env", "__name__"]), - }), + let side = |metric: &str, env: &str| { + instant(scan( + metric, + vec![eq_pred(2, env)], // env + &["env", "__name__"], + )) }; let expected = agg( vec![3], // __name__ AggIntent::Sum { col: None }, - QueryExpr::BinaryOp { - op: BinaryOpKind::Set(PromQLVectorSetOpKind::Or), - lhs: Rc::new(side("metric_a", "1")), - rhs: Rc::new(side("metric_b", "2")), - vector_match: Some(VectorMatch { + binary( + BinaryOpKind::Set(PromQLVectorSetOpKind::Or), + Some(VectorMatch { kind: VectorMatchKind::Ignoring, labels: vec![], grouping: None, }), - }, + side("metric_a", "1"), + side("metric_b", "2"), + ), ); assert_eq!( lower(r#"sum by (__name__)(metric_a{env="1"} or metric_b{env="2"})"#), @@ -276,28 +260,18 @@ fn q52_outer_name_label_over_binary_op() { // the inner rate is label-preserving. Scan schema [ts(0), value(1), instance(2)]. #[test] fn q39_sum_without_instance_over_rate() { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_requests_total".into(), - }, - predicates: vec![], - schema: metric_schema(&["instance"]), - }; let inner_rate = agg_per_entity( AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(scan), - }, + range(300, scan("http_requests_total", vec![], &["instance"])), ); - let expected = QueryExpr::Aggregate { + let expected = node(NonASAPOp::Aggregate { reduction: Reduction::Reduce(GroupKeys::without(vec![2])), // exclude `instance` measures: vec![AggIntent::Sum { col: None }], output_names: vec!["".into()], filters: vec![], having: None, - child: Rc::new(inner_rate), - }; + child: inner_rate, + }); assert_eq!( lower("sum without (instance) (rate(http_requests_total[5m]))"), expected, @@ -310,35 +284,25 @@ fn q39_sum_without_instance_over_rate() { #[test] fn q40_week_over_week_offset() { let rate_over = |shift: Option| { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: metric_schema(&[]), - }; + let scan = scan("m", vec![], &[]); let ranged = match shift { - Some(ms) => QueryExpr::TimeShift { + Some(ms) => node(NonASAPOp::TimeShift { shift: TimeShift { offset_ms: ms, at: None, }, - child: Rc::new(scan), - }, + child: scan, + }), None => scan, }; - agg_per_entity( - AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(ranged), - }, - ) - }; - let expected = QueryExpr::BinaryOp { - op: BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), - lhs: Rc::new(rate_over(None)), - rhs: Rc::new(rate_over(Some(604_800_000))), // 1w - vector_match: None, + agg_per_entity(AggIntent::Rate, range(300, ranged)) }; + let expected = binary( + BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), + None, + rate_over(None), + rate_over(Some(604_800_000)), // 1w + ); assert_eq!(lower("rate(m[5m]) - rate(m[5m] offset 1w)"), expected,); } @@ -346,22 +310,13 @@ fn q40_week_over_week_offset() { // (seconds → ms); a bare selector wrapped in a `TimeShift` carrying the anchor. #[test] fn q40_at_modifier_absolute() { - let expected = QueryExpr::TimeRange { - range: Duration::from_secs(1), - child: Rc::new(QueryExpr::TimeShift { - shift: TimeShift { - offset_ms: 0, - at: Some(AtModifier::Timestamp(1_609_746_000_000)), - }, - child: Rc::new(QueryExpr::Scan { - source: Source::TimeSeries { - metric: "up".into(), - }, - predicates: vec![], - schema: metric_schema(&[]), - }), - }), - }; + let expected = instant(node(NonASAPOp::TimeShift { + shift: TimeShift { + offset_ms: 0, + at: Some(AtModifier::Timestamp(1_609_746_000_000)), + }, + child: scan("up", vec![], &[]), + })); assert_eq!(lower("up @ 1609746000"), expected); } @@ -370,24 +325,12 @@ fn q40_at_modifier_absolute() { // so outer sum by job still finds job at col 2 #[test] fn q24_sum_by_job_over_rate_over_filtered_scan() { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "http_requests_total".into(), - }, - predicates: vec![Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(3)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8("200".into()))), - }))], - schema: metric_schema(&["job", "status"]), - }; - let inner_rate = agg_per_entity( - AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(scan), - }, + let scan = scan( + "http_requests_total", + vec![eq_pred(3, "200")], + &["job", "status"], ); + let inner_rate = agg_per_entity(AggIntent::Rate, range(300, scan)); let expected = agg(vec![2], AggIntent::Sum { col: None }, inner_rate); assert_eq!( lower(r#"sum by (job) (rate(http_requests_total{status="200"}[5m]))"#), @@ -403,35 +346,25 @@ fn q24_sum_by_job_over_rate_over_filtered_scan() { // the whole spine survives verbatim and the schema stays label-preserving. #[test] fn q27_nested_subquery_prometheus_docs_example() { - let scan = QueryExpr::Scan { - source: Source::TimeSeries { - metric: "distance_covered_total".into(), - }, - predicates: vec![], - schema: metric_schema(&[]), - }; let rate = agg_per_entity( AggIntent::Rate, - QueryExpr::TimeRange { - range: Duration::from_secs(5), - child: Rc::new(scan), - }, + range(5, scan("distance_covered_total", vec![], &[])), ); let deriv = agg_per_entity( AggIntent::Deriv, - QueryExpr::PromqlSubquery { + node(NonASAPOp::PromqlSubquery { range: Duration::from_secs(30), resolution: Some(Duration::from_secs(5)), - child: Rc::new(rate), - }, + child: rate, + }), ); let expected = agg_per_entity( AggIntent::Max { col: None }, - QueryExpr::PromqlSubquery { + node(NonASAPOp::PromqlSubquery { range: Duration::from_secs(600), resolution: None, - child: Rc::new(deriv), - }, + child: deriv, + }), ); assert_eq!( lower("max_over_time(deriv(rate(distance_covered_total[5s])[30s:5s])[10m:])"), diff --git a/crates/integration-tests/tests/operator_design_examples.rs b/crates/integration-tests/tests/operator_design_examples.rs new file mode 100644 index 000000000..55c8f7085 --- /dev/null +++ b/crates/integration-tests/tests/operator_design_examples.rs @@ -0,0 +1,401 @@ +//! #511 examples: source text → unified dag → summary rewrite → flat export. +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; +use asap_types::post_asap::{ + ExactKind, ExactParams, FieldDataType, GroupingStrategy, SummaryUpdate, +}; +use asap_types::pre_asap::{AggIntent, ColumnRef, DataType, Field, Schema}; +use asap_types::types::AccuracyTarget; +use std::rc::Rc; +mod physical_common; + +fn catalog() -> SqlCatalog { + SqlCatalog::new() + .with_table( + "requests", + Schema::new(vec![ + Field::plain("bytes", DataType::Int64, true), + Field::plain("status", DataType::Int64, false), + ]), + ) + .with_table( + "lineitem", + Schema::new(vec![Field::plain("l_quantity", DataType::Int64, false)]), + ) +} + +/// The SQL scalar example keeps column scopes and a Boolean row predicate. +#[tokio::test] +async fn sql_filter_projection_example() { + let root = lower_sql( + "SELECT l_quantity * 2 AS q2 FROM lineitem WHERE l_quantity > 10", + &catalog(), + AccuracyTarget::Exact, + ) + .await + .unwrap(); + root.validate_structure().unwrap(); + assert_eq!( + root.schema.fields[0], + Field::plain("q2", DataType::Int64, false) + ); + assert!( + matches!(root.expect_non_asap(),NonASAPOp::Project { cols,.. } if matches!(cols[0].expr,ScalarExpr::Arithmetic { .. })) + ); + let wire = physical_common::compile_physical_asap_dag(&root).unwrap(); + wire.validate().unwrap(); + assert_eq!(wire.nodes.len(), OperatorNode::reachable(&root).len()); +} + +/// SUM's evaluation preserves integer type and SQL NULL behavior across the rewrite. +#[tokio::test] +async fn sql_sum_projection_before_and_after_summary_rewrite() { + let root = lower_sql( + "SELECT SUM(bytes) + 1 AS total_bytes FROM requests WHERE status = 200", + &catalog(), + AccuracyTarget::Exact, + ) + .await + .unwrap(); + root.validate_structure().unwrap(); + assert_eq!( + root.schema.fields[0], + Field::plain("total_bytes", DataType::Int64, true) + ); + fn rewrite(node: &Rc) -> Rc { + if let Some(NonASAPOp::Aggregate { + child, + reduction, + measures, + .. + }) = node.non_asap() + { + let [AggIntent::Sum { col: Some(column) }] = measures.as_slice() else { + panic!() + }; + let state = Rc::new( + OperatorNode::new(Operator::ASAP(ASAPOp::SummaryAgg { + child: Rc::clone(child), + family: FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + input: SummaryUpdate::column(ColumnRef::Named( + child.schema.fields[*column].name.clone(), + )), + reduction: reduction.clone(), + grouping: GroupingStrategy::default(), + filter: None, + })) + .unwrap(), + ); + let finalize = std::rc::Rc::new( + OperatorNode::with_schema( + asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: state, + }), + node.schema.clone(), + ) + .with_guarantee(None), + ); + return finalize; + } + Rc::new(node.with_new_children(rewrite).unwrap()) + } + let rewritten = rewrite(&root); + rewritten.validate_structure().unwrap(); + assert_eq!(rewritten.schema, root.schema); + let dag = OperatorNode::reachable(&rewritten); + assert!(dag + .iter() + .any(|n| matches!(n.asap(), Some(ASAPOp::SummaryAgg { .. })))); + assert!(dag + .iter() + .any(|n| matches!(n.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })))); + let wire = physical_common::compile_physical_asap_dag(&rewritten).unwrap(); + wire.validate().unwrap(); + assert_eq!(wire.nodes.len(), dag.len()); + let json = serde_json::to_string(&wire).unwrap(); + assert!(!json.contains("KeepPreAsap") && !json.contains("ScalarBridge")); +} + +/// Scalar subqueries survive normalization with shared, visible producers. +#[tokio::test] +async fn sql_scalar_subquery_retains_its_cardinality_contract() { + for query in [ + "SELECT (SELECT bytes FROM requests) AS v FROM lineitem", + "SELECT l_quantity NOT IN (SELECT bytes FROM requests) AS present FROM lineitem", + ] { + let root = lower_sql(query, &catalog(), AccuracyTarget::Exact) + .await + .unwrap(); + root.validate_structure().unwrap(); + assert!(root.children().len() > 1); + let wire = physical_common::compile_physical_asap_dag(&root).unwrap(); + assert!(wire + .edges + .iter() + .any(|e| e.role == asap_types::ir::physical_export::EdgeRole::ScalarRef)); + } +} + +/// Execute the SQL SUM example for nonempty, empty and all-NULL populations. +#[tokio::test] +async fn sql_sum_example_executes_with_sql_null_semantics() { + use asap_physical_operators::{ + runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{compile, InputContract, Source}, + unified_sources::{DataSources, MemorySource}, + values::{Batch, Value}, + }; + use futures::StreamExt; + use std::{collections::BTreeMap, sync::Arc}; + let root = lower_sql( + "SELECT SUM(bytes) + 1 AS total_bytes FROM requests WHERE status = 200", + &catalog(), + AccuracyTarget::Exact, + ) + .await + .unwrap(); + let logical_scan = OperatorNode::reachable(&root) + .into_iter() + .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. }))) + .unwrap(); + let NonASAPOp::Scan { source, .. } = logical_scan.expect_non_asap() else { + panic!() + }; + let wire = physical_common::compile_physical_asap_dag(&root).unwrap(); + let scan = wire + .nodes + .iter() + .find(|node| { + matches!( + &node.payload, + asap_types::ir::physical_export::PhysicalASAPOperatorPayload::NonASAP( + asap_types::ir::NonASAPOp::Scan { .. } + ) + ) + }) + .unwrap(); + let schema = Arc::new(scan.output_schema.clone()); + let plan = compile( + &wire, + BTreeMap::from([(scan.id as u64, InputContract::bounded(schema.clone()))]), + &[wire.roots[0] as u64], + ) + .unwrap(); + for (rows, expected) in [ + ( + vec![ + vec![Value::Int64(10), Value::Int64(200)], + vec![Value::Int64(20), Value::Int64(500)], + vec![Value::Null, Value::Int64(200)], + ], + Value::Int64(11), + ), + (vec![], Value::Null), + (vec![vec![Value::Null, Value::Int64(200)]], Value::Null), + ] { + let mut sources = DataSources::default(); + sources + .register( + source.clone(), + Arc::new( + MemorySource::new( + schema.clone(), + vec![Batch::try_new(schema.clone(), rows).unwrap()], + ) + .unwrap(), + ), + ) + .unwrap(); + let bound = plan + .instantiate(BTreeMap::from([( + scan.id as u64, + Box::new(sources.bind(&logical_scan).unwrap()) as Source<'_>, + )])) + .unwrap(); + let mut stream = bound + .execute( + plan.roots(), + RunContext::new( + Scope::Query { + evaluation_time_ms: 300_000, + revision: 1, + }, + Limits::default(), + ) + .unwrap(), + ) + .unwrap() + .remove(0); + let mut rows = vec![]; + while let Some(batch) = stream.next().await { + rows.extend(batch.unwrap().rows().iter().cloned()); + } + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].len(), 1); + match (&rows[0][0], expected) { + (Value::Null, Value::Null) => {} + (Value::Int64(actual), Value::Int64(expected)) => assert_eq!(*actual, expected), + other => panic!("{other:?}"), + } + } +} + +/// Empty window frames and filtered groups can yield NULL even on non-NULL input. +#[tokio::test] +async fn sql_window_and_filtered_aggregate_types() { + for query in [ + "SELECT SUM(l_quantity) OVER (ORDER BY l_quantity ROWS BETWEEN 2 PRECEDING AND 1 PRECEDING) AS s FROM lineitem", + "SELECT MIN(l_quantity) OVER (ORDER BY l_quantity ROWS BETWEEN 2 PRECEDING AND 1 PRECEDING) AS s FROM lineitem", + "SELECT SUM(l_quantity) FILTER (WHERE l_quantity < 0) AS s FROM lineitem GROUP BY l_quantity", + ] { + let root = lower_sql(query, &catalog(), AccuracyTarget::Exact).await.unwrap(); + root.validate_structure().unwrap(); + assert_eq!(root.schema.fields[0], Field::plain("s", DataType::Int64, true), "{query}"); + } +} + +/// A real query batch selects one shared SUM producer, retains two result roots, +/// and executes both selected plans. No replacement dag is constructed by the test. +#[tokio::test] +async fn batch_planning_replaces_and_shares_summary_operators() { + use asap_aware_mapping::cost_model::{Cost, DefaultCostModel}; + use asap_aware_mapping::pass::PlanningModels; + use asap_aware_mapping::{ + CostModel, CostRate, LifecycleInput, SummaryMaintenanceLifecycleCapabilities, + SummaryMaintenanceLifecycleCostInputs, + }; + use asap_physical_operators::{ + runtime::Scope, + unified_physical_planner::{compile, InputContract}, + values::{Batch, Value}, + }; + use asap_planner::{e2e_plan, FrontendInput, UserInput}; + use asap_types::post_asap::SketchAlgorithm; + use asap_types::workload::*; + use std::{collections::BTreeMap, sync::Arc}; + struct Costs; + impl CostModel for Costs { + fn rank_candidates( + &self, + intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + DefaultCostModel.rank_candidates(intent, candidates) + } + fn summary_maintenance_lifecycle_cost_inputs( + &self, + _: &OperatorNode, + ) -> SummaryMaintenanceLifecycleCostInputs { + SummaryMaintenanceLifecycleCostInputs { + build_cost: Some(Cost(1.0)), + maintenance_cost_per_update: Some(Cost::ZERO), + summary_read_cost: Some(Cost::ZERO), + retention_cost_rate: Some(CostRate(0.0)), + retirement_cost: Some(Cost::ZERO), + } + } + fn raw_query_recompute_cost(&self, _: &OperatorNode) -> Option { + Some(Cost(1000.0)) + } + } + let queries = [ + "SELECT SUM(bytes) + 1 AS result FROM requests", + "SELECT SUM(bytes) * 2 AS result FROM requests", + ]; + let workload = PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), + query_batch: Some( + queries + .iter() + .map(|query| BatchEntry { + query: Query((*query).into()), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::Exact), + ..Default::default() + }, + predictability: Predictability::Unknown, + invocations: 2, + execute_at: None, + time_selection: TimeSelection::default(), + }) + .collect(), + ), + repeating_queries: None, + }, + data_workload: Some(DataWorkload { + arrival: DataArrival::AtRest, + ..Default::default() + }), + }; + let catalog = SqlCatalog::new().with_table( + "requests", + Schema::new(vec![Field::plain("bytes", DataType::Float64, false)]), + ); + let output = e2e_plan(UserInput::new( + &workload, + FrontendInput::Sql { catalog: &catalog }, + PlanningModels::builtin().with_cost(&Costs), + LifecycleInput::new(0, SummaryMaintenanceLifecycleCapabilities::default()), + )) + .await + .unwrap(); + assert_eq!(output.entry_indices(), [0, 1]); + assert_eq!(output.roots().len(), 2); + let states: Vec<_> = output + .operators() + .into_iter() + .filter(|n| matches!(n.asap(), Some(ASAPOp::SummaryAgg { .. }))) + .collect(); + assert_eq!(states.len(), 1, "the batch owns one shared SUM state"); + for (plan, expected) in output.plans.iter().zip([31.0, 60.0]) { + assert!(!plan.plan.selected_raw_recompute); + let root = &plan.plan.root; + root.validate_structure().unwrap(); + assert!(OperatorNode::reachable(root) + .iter() + .any(|n| Rc::ptr_eq(n, &states[0]))); + let wire = physical_common::compile_physical_asap_dag(root).unwrap(); + let scan = wire + .nodes + .iter() + .find(|n| { + matches!( + n.payload, + asap_types::ir::physical_export::PhysicalASAPOperatorPayload::NonASAP( + asap_types::ir::NonASAPOp::Scan { .. } + ) + ) + }) + .unwrap(); + let schema = Arc::new(scan.output_schema.clone()); + let program = compile( + &wire, + BTreeMap::from([(scan.id as u64, InputContract::bounded(schema.clone()))]), + &[wire.roots[0] as u64], + ) + .unwrap(); + let result = physical_common::execute( + &program, + BTreeMap::from([( + scan.id as u64, + Batch::try_new( + schema, + vec![vec![Value::Float64(10.0)], vec![Value::Float64(20.0)]], + ) + .unwrap(), + )]), + Scope::Query { + evaluation_time_ms: 0, + revision: 1, + }, + ); + let rows: Vec<_> = result[0].iter().flat_map(|batch| batch.rows()).collect(); + assert_eq!(rows.len(), 1); + assert!( + matches!(rows[0][0], Value::Float64(v) if v == expected), + "{:?}", + rows + ); + } +} diff --git a/crates/integration-tests/tests/operator_sharing.rs b/crates/integration-tests/tests/operator_sharing.rs new file mode 100644 index 000000000..2bce6841f --- /dev/null +++ b/crates/integration-tests/tests/operator_sharing.rs @@ -0,0 +1,195 @@ +//! Acceptance tests for operator sharing (issue #468): one operator IR +//! before and after ASAP optimization, so non-ASAP operators sit both above +//! and below summary operators, can share inputs with them, and can carry +//! summaries below set operators. +//! +//! Each test drives SQL text through `lower_sql` → `search_workload` → +//! global selection → `assemble_selected_dag`, the pipeline +//! `sql_to_post_asap.rs` uses. + +use std::rc::Rc; + +use asap_aware_mapping::{search_workload, DefaultCostModel}; +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; +use asap_integration_tests::post_asap::post_asap_dag; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode}; +use asap_types::pre_asap::schema::{DataType, Field, Schema}; +use asap_types::types::AccuracyTarget; + +fn col(name: &str, dtype: DataType) -> Field { + Field::plain(name, dtype, false) +} + +/// TPC-H `lineitem`, as `frontend-sql/tests/data_quality_check/tpch_deequ.rs` +/// declares it (DECIMAL columns as `Float64`, no time index, no keys). +fn catalog() -> SqlCatalog { + SqlCatalog::new().with_table( + "lineitem", + Schema::new(vec![ + col("l_orderkey", DataType::Int64), + col("l_partkey", DataType::Int64), + col("l_suppkey", DataType::Int64), + col("l_linenumber", DataType::Int64), + col("l_quantity", DataType::Float64), + col("l_extendedprice", DataType::Float64), + col("l_discount", DataType::Float64), + col("l_tax", DataType::Float64), + col("l_returnflag", DataType::Utf8), + col("l_linestatus", DataType::Utf8), + col("l_shipdate", DataType::Date), + col("l_commitdate", DataType::Date), + col("l_receiptdate", DataType::Date), + col("l_shipinstruct", DataType::Utf8), + col("l_shipmode", DataType::Utf8), + col("l_comment", DataType::Utf8), + ]), + ) +} + +/// Lower `sql`, search, select with the default cost model and assemble the +/// selected post-ASAP DAG. +async fn plan(sql: &str, accuracy: AccuracyTarget) -> Rc { + let pre = lower_sql(sql, &catalog(), accuracy) + .await + .unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}")); + let space = search_workload(vec![("query", pre)]); + let selection = space.global_selection(&DefaultCostModel); + selection + .assemble_selected_dag(&space.roots[0].1) + .expect("materialization failed") + .expect("root must be discovered") +} + +/// Every unique node reachable from `root` whose operator matches `pred`. +fn find_all( + root: &Rc, + pred: impl Fn(&OperatorNode) -> bool, +) -> Vec> { + OperatorNode::reachable(root) + .into_iter() + .filter(|node| pred(node)) + .collect() +} + +fn is_summary_evaluation(node: &OperatorNode) -> bool { + matches!( + node.operator, + Operator::ASAP( + ASAPOp::SummaryEstimate { .. } + | ASAPOp::FinalizeExactAccumulator { .. } + | ASAPOp::EvaluatePopulation { .. } + ) + ) +} + +fn is_scan(node: &OperatorNode) -> bool { + matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })) +} + +/// The first node reached through single-input non-ASAP operators below +/// `node` (inclusive) that is not one: where an operator chain meets a +/// summary or a multi-input operator. +fn through_unary_non_asap(node: &Rc) -> &Rc { + match node.non_asap().map(|op| op.children()) { + Some(children) if children.len() == 1 => through_unary_non_asap(children[0]), + _ => node, + } +} + +// #468 problem 1: the Project above the summary evaluation and the Scan below +// it are both plain NonASAP nodes (no post-ASAP-only wrapper variant). +#[ignore = "planner chooses no summary here: Avg has no summary realization, so the Aggregate stays a logical pass-through"] +#[tokio::test] +async fn project_above_and_scan_below_a_summary_are_both_non_asap_nodes() { + let root = plan( + "WITH metric AS (SELECT avg(CASE WHEN l_quantity BETWEEN 1 AND 50 THEN 1.0 ELSE 0.0 END) \ + AS in_range FROM lineitem) SELECT in_range, in_range = 1.0 AS ok FROM metric", + AccuracyTarget::Exact, + ) + .await; + // The root is the outer SELECT list: a NonASAP Project. + assert!( + matches!(root.operator, Operator::NonASAP(NonASAPOp::Project { .. })), + "root must be the outer Project, got {:?}", + root.operator + ); + // A summary evaluation sits below the Project chain. + let evaluation = through_unary_non_asap(&root); + assert!( + is_summary_evaluation(evaluation), + "the Project chain must read a summary, got {:?}", + evaluation.operator + ); + // Below the summary the Scan is the same NonASAP operator a front end emits. + let scans = find_all(evaluation, is_scan); + assert_eq!(scans.len(), 1, "one lineitem Scan below the summary"); + assert!(!scans[0].is_asap()); + // The flat plan exports (time first, wire 6). + post_asap_dag(&root); +} + +// #468 problem 2: the exact aggregate and the sketch read one shared Scan +// (`Rc::ptr_eq`), not two copies. +#[ignore = "waits for the binding rule splitting multi-measure aggregates"] +#[tokio::test] +async fn exact_aggregate_and_sketch_share_one_scan() { + let root = plan( + "SELECT avg(l_extendedprice), approx_percentile_cont(l_discount, 0.99) FROM lineitem", + AccuracyTarget::Epsilon(0.01), + ) + .await; + let exact = find_all(&root, |node| { + matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })) + }); + let sketch = find_all(&root, |node| { + matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. })) + }); + assert_eq!(exact.len(), 1, "one exact Aggregate for avg: {root:?}"); + assert_eq!( + sketch.len(), + 1, + "one sketch SummaryAgg for the percentile: {root:?}" + ); + let scan_under = |node: &Rc| { + let scans = find_all(node, is_scan); + assert_eq!(scans.len(), 1, "one Scan under {:?}", node.operator); + Rc::clone(&scans[0]) + }; + assert!( + Rc::ptr_eq(&scan_under(&exact[0]), &scan_under(&sketch[0])), + "the exact aggregate and the sketch must read one shared Scan" + ); +} + +// #468 problem 3: a summary can sit below a set operator — each side of the +// UNION ALL holds its own SummaryEstimate. +#[tokio::test] +async fn each_side_of_union_all_holds_a_summary_estimate() { + let root = plan( + "SELECT approx_distinct(l_partkey) FROM lineitem \ + UNION ALL SELECT approx_distinct(l_suppkey) FROM lineitem", + AccuracyTarget::Epsilon(0.01), + ) + .await; + // The SQL front end lowers UNION ALL to `SetOp { all: true }`. + let Some(NonASAPOp::SetOp { + all: true, + left, + right, + .. + }) = root.non_asap() + else { + panic!("root must be the UNION ALL SetOp, got {:?}", root.operator) + }; + for side in [left, right] { + let estimates = find_all(side, |node| { + matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. })) + }); + assert!( + !estimates.is_empty(), + "UNION ALL side has no SummaryEstimate: {:?}", + side.operator + ); + } + post_asap_dag(&root); +} diff --git a/crates/integration-tests/tests/physical_common/mod.rs b/crates/integration-tests/tests/physical_common/mod.rs index 93bebd338..8420331be 100644 --- a/crates/integration-tests/tests/physical_common/mod.rs +++ b/crates/integration-tests/tests/physical_common/mod.rs @@ -1,12 +1,13 @@ use asap_physical_operators::{ operators::Operator, - physical_planner::{CompiledPhysicalDAG, Source}, runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{CompiledPhysicalDAG, Source}, values::Batch, }; use futures::{executor::block_on, StreamExt}; use std::collections::BTreeMap; +#[allow(dead_code)] pub fn execute( plan: &CompiledPhysicalDAG, inputs: BTreeMap, @@ -40,3 +41,17 @@ pub fn execute( .await }) } + +#[allow(dead_code)] +pub fn compile_physical_asap_dag( + root: &std::rc::Rc, +) -> Result> { + let root = asap_types::ir::apply_lifecycle_timings( + root, + &Default::default(), + &mut Default::default(), + )?; + Ok(asap_types::ir::physical_export::compile_physical_asap_dag( + &root, + )?) +} diff --git a/crates/integration-tests/tests/precompute_raw_samples.rs b/crates/integration-tests/tests/precompute_raw_samples.rs index 9937469cc..daf841142 100644 --- a/crates/integration-tests/tests/precompute_raw_samples.rs +++ b/crates/integration-tests/tests/precompute_raw_samples.rs @@ -1,28 +1,32 @@ //! Planner-selected summaries over raw samples compile as precompute DAGs //! and produce the same estimates as feeding their kernel sample by sample. +mod physical_common; +use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPOperatorPayload}; +use asap_types::ir::ASAPOp; +use asap_types::ir::OperatorNode; +use physical_common::compile_physical_asap_dag; use std::{collections::BTreeMap, collections::BTreeSet, rc::Rc, sync::Arc}; use asap_aware_mapping::cost_model::DefaultCostModel; use asap_aware_mapping::{ - search_workload, Replacement, ReplacementStrategy, ReplacementSubDAG, SketchAlgorithmStrategy, + search_workload, ASAPStrategies, Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; use asap_integration_tests::fixtures::lower_promql; use asap_physical_operators::{ factory::create_planner_accumulator, operators::Operator, - physical_planner::{precompute, Source}, runtime::{Limits, RunContext, Scope}, summary_kernels::{exact::ExactAccumulator, weighted_frequency::WeightedFrequency}, + unified_physical_planner::{precompute, Source}, values::{Batch, Value}, AggregateCore, KeyByLabelValues, Statistic, }; use asap_types::post_asap::{ - compile_post_asap_dag, EntityIdentity, ExactKind, FieldDataType, PostAsapDAG, - PostAsapOperatorPayload, SketchAlgorithm, SketchStatistic, SummaryInputExpr, SummaryNode, + EntityIdentity, ExactKind, FieldDataType, SketchAlgorithm, SketchStatistic, SummaryInputExpr, SummaryUpdate, }; -use asap_types::pre_asap::{expr_ir::ColumnRef, query_expr::Reduction}; +use asap_types::pre_asap::{expr_ir::ColumnRef, Reduction}; use asap_types::types::AccuracyTarget; use futures::{executor::block_on, StreamExt}; @@ -51,14 +55,14 @@ fn canonical(labels: &Series) -> Series { /// Every Planner candidate for `query`: the searched selection plus each /// summary replacement of the root. -fn candidates(query: &str, accuracy: AccuracyTarget) -> Vec> { - let root = Rc::new(lower_promql(query, accuracy).expect("lowering failed")); - let mut result = SketchAlgorithmStrategy::default_cost_model() +fn candidates(query: &str, accuracy: AccuracyTarget) -> Vec> { + let root = lower_promql(query, accuracy).expect("lowering failed"); + let mut result = ASAPStrategies::default_cost_model() .replacements(&TargetSubDAG::new(&root)) .into_iter() .filter_map(|candidate| match candidate { ReplacementSubDAG { - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), .. } => Some(node), _ => None, @@ -75,10 +79,15 @@ fn candidates(query: &str, accuracy: AccuracyTarget) -> Vec> { } /// Raw-input summary nodes: `(dag, raw source id, summary id)`. -fn raw_summaries(dag: &PostAsapDAG) -> Vec<(u64, u64)> { +fn raw_summaries(dag: &PhysicalASAPDAG) -> Vec<(u64, u64)> { dag.nodes .iter() - .filter(|node| matches!(node.payload, PostAsapOperatorPayload::SummaryAgg { .. })) + .filter(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) + ) + }) .filter_map(|node| { let inputs = dag .edges @@ -89,8 +98,11 @@ fn raw_summaries(dag: &PostAsapDAG) -> Vec<(u64, u64)> { return None; }; let source = dag.nodes.iter().find(|n| n.id == edge.producer)?; - matches!(source.payload, PostAsapOperatorPayload::Fallback { .. }) - .then_some((u64::from(source.id.0), u64::from(node.id.0))) + matches!( + source.payload, + PhysicalASAPOperatorPayload::NonASAP(asap_types::ir::NonASAPOp::TimeRange { .. }) + ) + .then_some((source.id as u64, node.id as u64)) }) .collect() } @@ -114,7 +126,7 @@ fn samples() -> Vec<(Series, i64, f64)> { } fn execute( - dag: &PostAsapDAG, + dag: &PhysicalASAPDAG, source: u64, root: u64, rows: &[(Series, i64, f64)], @@ -124,12 +136,12 @@ fn execute( "raw summary {root} does not compile: {error}; source {:?}", dag.nodes .iter() - .find(|n| u64::from(n.id.0) == source) + .find(|n| n.id as u64 == source) .map(|n| (&n.output_schema, &n.payload)) ); }); let program = serde_json::from_slice::< - asap_physical_operators::physical_planner::CompiledPhysicalDAG, + asap_physical_operators::unified_physical_planner::CompiledPhysicalDAG, >(&serde_json::to_vec(&program).unwrap()) .unwrap(); let schema = precompute::raw_sample_schema(); @@ -240,7 +252,7 @@ fn weight(update: &SummaryUpdate, value: f64) -> f64 { } /// Estimates that identify a state's content for comparison. -fn readouts(state: &dyn AggregateCore, family: &FieldDataType) -> Vec { +fn evaluations(state: &dyn AggregateCore, family: &FieldDataType) -> Vec { if let Some(exact) = state.as_any().downcast_ref::() { let FieldDataType::ExactAggregate(kind, _) = family else { unreachable!() @@ -255,7 +267,7 @@ fn readouts(state: &dyn AggregateCore, family: &FieldDataType) -> Vec { other => panic!("unexpected exact kind {other:?}"), }; return vec![exact - .readout(statistic, None, None::<&KeyByLabelValues>) + .evaluation(statistic, None, None::<&KeyByLabelValues>) .unwrap() .unwrap()]; } @@ -277,31 +289,23 @@ fn readouts(state: &dyn AggregateCore, family: &FieldDataType) -> Vec { /// or the family when it has no native state. fn check( query: &str, - dag: &PostAsapDAG, + dag: &PhysicalASAPDAG, source: u64, root: u64, rows: &[(Series, i64, f64)], ) -> Result { - let node = dag - .nodes - .iter() - .find(|n| u64::from(n.id.0) == root) - .unwrap(); - let PostAsapOperatorPayload::SummaryAgg { + let node = dag.nodes.iter().find(|n| n.id as u64 == root).unwrap(); + let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family, input, reduction, grouping, .. - } = &node.payload + }) = &node.payload else { unreachable!() }; - let source_node = dag - .nodes - .iter() - .find(|n| u64::from(n.id.0) == source) - .unwrap(); + let source_node = dag.nodes.iter().find(|n| n.id as u64 == source).unwrap(); let keys = match reduction { Reduction::Reduce(keys) => keys .keys() @@ -370,8 +374,8 @@ fn check( for (labels, state) in actual { let reference = expected[&labels].snapshot_accumulator(); assert_eq!( - readouts(state.as_ref(), family), - readouts(reference.as_ref(), family), + evaluations(state.as_ref(), family), + evaluations(reference.as_ref(), family), "{query}: {labels:?}" ); } @@ -413,7 +417,7 @@ fn raw_sample_summaries_compile_and_match_their_kernels() { let mut checked = BTreeMap::new(); for (query, accuracy) in queries { for candidate in candidates(query, accuracy.clone()) { - let dag = compile_post_asap_dag(&candidate).unwrap(); + let dag = compile_physical_asap_dag(&candidate).unwrap(); for (source, root) in raw_summaries(&dag) { match check(query, &dag, source, root, &rows) { Ok(family) => { @@ -459,25 +463,21 @@ fn raw_sample_summaries_compile_and_match_their_kernels() { /// Replace the raw summary of `sum by (service) (sum_over_time(m[5m]))` with /// another update, keeping its raw input and reduction. -fn grouped_raw_summary(family: FieldDataType, input: SummaryUpdate) -> (PostAsapDAG, u64, u64) { +fn grouped_raw_summary(family: FieldDataType, input: SummaryUpdate) -> (PhysicalASAPDAG, u64, u64) { let candidate = candidates( "sum by (service) (sum_over_time(m[5m]))", AccuracyTarget::Exact, ) .pop() .unwrap(); - let mut dag = compile_post_asap_dag(&candidate).unwrap(); + let mut dag = compile_physical_asap_dag(&candidate).unwrap(); let (source, root) = raw_summaries(&dag)[0]; - let node = dag - .nodes - .iter_mut() - .find(|n| u64::from(n.id.0) == root) - .unwrap(); - let PostAsapOperatorPayload::SummaryAgg { + let node = dag.nodes.iter_mut().find(|n| n.id as u64 == root).unwrap(); + let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: old, input: update, .. - } = &mut node.payload + }) = &mut node.payload else { unreachable!() }; @@ -489,11 +489,7 @@ fn grouped_raw_summary(family: FieldDataType, input: SummaryUpdate) -> (PostAsap *old = family; *update = input; let schema = node.output_schema.clone(); - for edge in dag - .edges - .iter_mut() - .filter(|e| u64::from(e.producer.0) == root) - { + for edge in dag.edges.iter_mut().filter(|e| e.producer as u64 == root) { edge.intermediate_schema = schema.clone(); } (dag, source, root) @@ -587,7 +583,7 @@ fn raw_sample_heaps_resolve_items_from_labels() { // `without` grouping over raw samples drops the listed labels and `__name__`. #[test] fn raw_sample_without_grouping_drops_labels_and_name() { - use asap_types::pre_asap::query_expr::GroupKeys; + use asap_types::pre_asap::GroupKeys; let family = FieldDataType::ExactAggregate(ExactKind::Sum, asap_types::post_asap::ExactParams::Sum); let (mut dag, source, root) = @@ -595,19 +591,16 @@ fn raw_sample_without_grouping_drops_labels_and_name() { let service = dag .nodes .iter() - .find(|n| u64::from(n.id.0) == source) + .find(|n| n.id as u64 == source) .unwrap() .output_schema .fields .iter() .position(|f| f.name == "service") .unwrap(); - let node = dag - .nodes - .iter_mut() - .find(|n| u64::from(n.id.0) == root) - .unwrap(); - let PostAsapOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload else { + let node = dag.nodes.iter_mut().find(|n| n.id as u64 == root).unwrap(); + let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { reduction, .. }) = &mut node.payload + else { unreachable!() }; *reduction = Reduction::Reduce(GroupKeys::without(vec![service])); diff --git a/crates/integration-tests/tests/promql_numeric_regressions.rs b/crates/integration-tests/tests/promql_numeric_regressions.rs index 692fe9c7a..3ec4d8eac 100644 --- a/crates/integration-tests/tests/promql_numeric_regressions.rs +++ b/crates/integration-tests/tests/promql_numeric_regressions.rs @@ -1,36 +1,41 @@ -//! Numeric regression fixtures: actual PromQL lowering plus numeric update/readout checks. +//! Numeric regression fixtures: actual PromQL lowering plus numeric update/evaluation checks. //! The count/sum interpreter below verifies planner update semantics, not a deployed backend. -use asap_aware_mapping::{Replacement, ReplacementStrategy, SketchAlgorithmStrategy, TargetSubDAG}; +use asap_aware_mapping::replacement::is_logical_rewrite; +use asap_aware_mapping::{ASAPStrategies, Replacement, ReplacementStrategy, TargetSubDAG}; use asap_integration_tests::fixtures::lower_promql; -use asap_types::post_asap::{ - compile_post_asap_dag, ExactKind, FieldDataType, SummaryExpr, SummaryInputExpr, SummaryNode, - SummaryUpdate, -}; +use asap_integration_tests::post_asap::post_asap_dag; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode}; +use asap_types::post_asap::{ExactKind, FieldDataType, SummaryInputExpr, SummaryUpdate}; use asap_types::pre_asap::{ColumnRef, Reduction}; use asap_types::types::AccuracyTarget; use std::rc::Rc; -fn plan(query: &str, accuracy: AccuracyTarget) -> Rc { - let pre = Rc::new(lower_promql(query, accuracy).unwrap()); - SketchAlgorithmStrategy::default_cost_model() +fn plan(query: &str, accuracy: AccuracyTarget) -> Rc { + let pre = lower_promql(query, accuracy).unwrap(); + ASAPStrategies::default_cost_model() .replacements(&TargetSubDAG::new(&pre)) .into_iter() .find_map(|r| match r.replacement { - Replacement::Summary(n) => Some(n), + // A bound decision: a summary DAG or a kept (exact) sub-DAG. + Replacement::SubDAG(n) if !is_logical_rewrite(&n) => Some(n), _ => None, }) - .unwrap_or_else(|| asap_aware_mapping::replacement::keep_pre_asap(&pre).unwrap()) + .unwrap_or_else(|| asap_aware_mapping::replacement::retain_exact(&pre).unwrap()) } -fn aggregate(node: &SummaryNode) -> (&FieldDataType, &SummaryUpdate, &Reduction) { - match &node.expr { - SummaryExpr::SummaryAgg { +fn aggregate(node: &OperatorNode) -> (&FieldDataType, &SummaryUpdate, &Reduction) { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { family, input, reduction, .. - } => (family, input, reduction), - SummaryExpr::SummaryEstimate { summary_input, .. } => aggregate(summary_input), - SummaryExpr::ValueOperation { child, .. } => aggregate(child), + }) => (family, input, reduction), + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => aggregate(summary_input), + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) => aggregate(child), + // A value operation (Project/Filter/Sort/Limit/...) over the state. + Operator::NonASAP(op) if op.children().len() == 1 && node.contains_asap() => { + aggregate(op.children()[0]) + } other => panic!("not a maintained accumulator: {other:?}"), } } @@ -63,7 +68,7 @@ fn count_up_counts_targets_even_when_values_repeat_or_change_sign() { 3. ); } - compile_post_asap_dag(&node).unwrap(); + post_asap_dag(&node); } /// Ten samples give count ten, whereas sum retains the signed sample values. @@ -81,7 +86,7 @@ fn window_counts_and_sums_distinguish_one_zero_three_and_negative_values() { let got: f64 = (0..10).map(|_| contribution(family, update, value)).sum(); assert_eq!(got, if is_count { 10. } else { value * 10. }); } - compile_post_asap_dag(&node).unwrap(); + post_asap_dag(&node); } } @@ -97,7 +102,7 @@ fn sum_rate_and_increase_have_real_exact_accumulator_nodes() { let (family, _, _) = aggregate(&node); assert!(matches!(family, FieldDataType::ExactAggregate(k, _) if *k == kind)); assert!(node.guarantee.as_ref().unwrap().is_exact()); - compile_post_asap_dag(&node).unwrap(); + post_asap_dag(&node); } } @@ -109,11 +114,12 @@ fn checked_ratio_must_not_certify_cross_zero_interpolation() { AccuracyTarget::Epsilon(0.01), ); assert!( - matches!(node.expr, SummaryExpr::BinaryOp { .. }), + matches!(node.operator, Operator::NonASAP(NonASAPOp::BinaryOp { .. })) + && node.contains_asap(), "direct quantile ratio should remain an available candidate" ); assert!(node.guarantee.is_none()); - compile_post_asap_dag(&node).unwrap(); + post_asap_dag(&node); // Keep the actual signed-sketch counterexample: division guards alone pass // even though the quantile interpolation does not preserve relative error. let alpha = (0.01 - 8.0 * f64::EPSILON) / 2.01; @@ -148,20 +154,20 @@ fn quantile_over_temporal_average_keeps_a_legal_candidate() { ] { let node = plan(query, AccuracyTarget::Epsilon(0.01)); assert!( - !matches!(node.expr, SummaryExpr::KeepPreAsap(_)), + node.contains_asap(), "outer sketch candidate must survive: {query}" ); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &node.expr else { - panic!("outer sketch readout") + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator else { + panic!("outer sketch evaluation") }; - let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { + let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary_input.operator else { panic!("outer sketch state") }; assert!( - matches!(child.expr, SummaryExpr::KeepPreAsap(_)), + !child.contains_asap(), "guarded expression must retain native maintenance input" ); - compile_post_asap_dag(&node).unwrap(); + post_asap_dag(&node); } } @@ -195,7 +201,7 @@ fn sketch_counts_use_unit_weights_and_signed_sums_keep_value_weights() { use asap_aware_mapping::accuracy::{DefaultAccuracyModel, EqualSplitAllocator}; use asap_aware_mapping::cost_model::DefaultCostModel; use asap_types::post_asap::{NonNegativeWeightProof, SketchAlgorithm, WeightDomain}; - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -207,7 +213,7 @@ fn sketch_counts_use_unit_weights_and_signed_sums_keep_value_weights() { } else { "topk(1, sum_over_time(up[5m]))" }; - let pre = Rc::new(lower_promql(query, AccuracyTarget::Epsilon(0.01)).unwrap()); + let pre = lower_promql(query, AccuracyTarget::Epsilon(0.01)).unwrap(); let candidates = strategy.replacements(&TargetSubDAG::new(&pre)); let wanted = if is_count { SketchAlgorithm::CmsWithHeap @@ -217,7 +223,7 @@ fn sketch_counts_use_unit_weights_and_signed_sums_keep_value_weights() { let node = candidates .iter() .find_map(|c| { - let Replacement::Summary(node) = &c.replacement else { + let Replacement::SubDAG(node) = &c.replacement else { return None; }; let (family, _, _) = aggregate(node); @@ -240,7 +246,7 @@ fn sketch_counts_use_unit_weights_and_signed_sums_keep_value_weights() { SummaryInputExpr::Column(ColumnRef::SampleValue) ); for c in &candidates { - if let Replacement::Summary(n) = &c.replacement { + if let Replacement::SubDAG(n) = &c.replacement { assert!( !matches!(aggregate(n).0, FieldDataType::Sketch(kind, _) if kind.algorithm() == &SketchAlgorithm::CmsWithHeap) ); @@ -271,6 +277,6 @@ fn sketch_counts_use_unit_weights_and_signed_sums_keep_value_weights() { }; assert_eq!(got, if is_count { 10. } else { 10. * value }); } - compile_post_asap_dag(node).unwrap(); + post_asap_dag(node); } } diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index 97a08a0c4..b999720fa 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -1,8 +1,8 @@ //! End-to-end query-string → post-ASAP IR pin (issue #98). //! -//! Drives the full pipeline — PromQL text → pre-ASAP `QueryExpr` -//! (`lower_promql`) → post-ASAP `SummaryExpr` DAG (via -//! `SketchAlgorithmStrategy::replacements`, see [`realize`] below) — and pins +//! Drives the full pipeline — PromQL text → non-ASAP `OperatorNode` +//! (`lower_promql`) → post-ASAP `OperatorNode` DAG (via +//! `ASAPStrategies::replacements`, see [`realize`] below) — and pins //! the summary-bound shape node by node, including the family `(Kind, //! Params)` committed on each edge's schema. @@ -13,67 +13,77 @@ use asap_aware_mapping::accuracy::{ QuantileInputDomain, }; use asap_aware_mapping::cost_model::DefaultCostModel; -use asap_aware_mapping::replacement::{keep_pre_asap, RealizationError}; +use asap_aware_mapping::replacement::{is_logical_rewrite, retain_exact, RealizationError}; use asap_aware_mapping::{ - search_workload, search_workload_with_targets, AccuracyModel, Replacement, ReplacementStrategy, - ReplacementSubDAG, SketchAlgorithmStrategy, TargetSubDAG, + search_workload, search_workload_with_targets, ASAPStrategies, AccuracyModel, Replacement, + ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; use asap_integration_tests::fixtures::lower_promql; +use asap_integration_tests::post_asap::{post_asap_dag, timed}; +use asap_types::ir::operator_properties::Reduction; +use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; use asap_types::post_asap::{ - compile_post_asap_dag, CompositionOperator, EntityIdentity, ExactKind, ExactParams, - FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, SketchKind, SketchParams, - SketchStatistic, SummaryExpr, SummaryInputExpr, SummaryNode, SummaryUpdate, ValueOperation, + CompositionOperator, EntityIdentity, ExactKind, ExactParams, FieldDataType, GroupingStrategy, + Schema, SketchAlgorithm, SketchKind, SketchParams, SketchStatistic, SummaryInputExpr, + SummaryUpdate, }; use asap_types::pre_asap::expr_ir::ColumnRef; -use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; use asap_types::pre_asap::schema::DataType; use asap_types::types::AccuracyTarget; -/// This crate has no "bind me one DAG" public API any more — -/// `SketchAlgorithmStrategy::replacements` always returns every candidate, and +/// This crate has no "bind me one tree" public API any more — +/// `ASAPStrategies::replacements` always returns every candidate, and /// a caller decides what to keep. This test-only helper reproduces the /// take-the-first-(`cost_model`-preferred)-candidate pattern so the /// single-answer pins below don't all repeat it by hand. -fn realize(expr: &QueryExpr) -> Result, RealizationError> { - let root = Rc::new(expr.clone()); - let target = TargetSubDAG::new(&root); - match SketchAlgorithmStrategy::default_cost_model() +fn realize(root: &Rc) -> Result, RealizationError> { + let target = TargetSubDAG::new(root); + match ASAPStrategies::default_cost_model() .replacements(&target) .into_iter() .next() { + // A bound decision (summary DAG or kept sub-DAG); a logical rewrite + // is not a binding, so it falls back to keeping the target. Some(ReplacementSubDAG { - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), .. - }) => Ok(node), - _ => keep_pre_asap(&root), + }) if !is_logical_rewrite(&node) => Ok(node), + _ => retain_exact(root), } + .inspect(|node| { + node.validate_structure() + .expect("planned dag satisfies the unified IR contract") + }) } #[test] -fn distinct_over_time_offers_hll_cardinality_readout() { +fn distinct_over_time_offers_hll_cardinality_evaluation() { // The real frontend must reach an existing HLL candidate without a // function-specific post-ASAP node or a sample-count rewrite. - let root = Rc::new( - lower_promql( - "distinct_over_time(cpu_usage{job=\"worker\"}[5m])", - AccuracyTarget::Epsilon(0.02), - ) - .unwrap(), - ); - let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let root = lower_promql( + "distinct_over_time(cpu_usage{job=\"worker\"}[5m])", + AccuracyTarget::Epsilon(0.02), + ) + .unwrap(); + let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); + for candidate in &candidates { + if let Replacement::SubDAG(node) = &candidate.replacement { + node.validate_structure().unwrap(); + } + } assert!(candidates.iter().any(|candidate| { - let Replacement::Summary(node) = &candidate.replacement else { return false }; - let SummaryExpr::SummaryEstimate { summary_input, query, .. } = &node.expr else { return false }; + let Replacement::SubDAG(node) = &candidate.replacement else { return false }; + let Some(ASAPOp::SummaryEstimate { summary_input, query, .. }) = node.asap() else { return false }; matches!(query, SketchStatistic::Cardinality) - && matches!(&summary_input.expr, SummaryExpr::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } + && matches!(summary_input.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if kind.algorithm() == &SketchAlgorithm::Hll) }), "no HLL cardinality candidate: {candidates:?}"); } -fn lower_search_and_materialize(query: &str) -> Rc { - let pre = Rc::new(lower_promql(query, AccuracyTarget::Exact).expect("lowering failed")); +fn lower_search_and_materialize(query: &str) -> Rc { + let pre = lower_promql(query, AccuracyTarget::Exact).expect("lowering failed"); let space = search_workload(vec![("query", pre)]); let selection = space.global_selection(&DefaultCostModel); selection @@ -88,36 +98,38 @@ fn value_ranked_topk_preserves_summary_children_in_post_asap_dag() { "topk(3, rate(cpu_seconds_total[5m]))", "topk by (job) (2, max_over_time(memory_bytes[6h]))", ] { - let root = lower_search_and_materialize(query); - let SummaryExpr::ValueOperation { - operation: ValueOperation::Limit { n, offset, .. }, + let root = timed(&lower_search_and_materialize(query)); + let Some(NonASAPOp::Limit { + n, + offset, child: sort, .. - } = &root.expr + }) = root.non_asap() else { - panic!("expected query-time Limit for {query}, got {:?}", root.expr); + panic!( + "expected query-time Limit for {query}, got {:?}", + root.operator + ); }; - assert!(*n > 0 && *offset == 0); - let SummaryExpr::ValueOperation { - operation: ValueOperation::Sort { .. }, - child, - .. - } = &sort.expr - else { + assert_eq!( + root.timing, + Some(asap_types::post_asap::ExecutionTiming::QueryTime) + ); + assert!(n.is_some_and(|n| n > 0) && *offset == 0); + let Some(NonASAPOp::Sort { child, .. }) = sort.non_asap() else { panic!("expected query-time Sort under Limit for {query}"); }; - let SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - child: state, - .. - } = &child.expr - else { + assert_eq!( + sort.timing, + Some(asap_types::post_asap::ExecutionTiming::QueryTime) + ); + let Some(ASAPOp::FinalizeExactAccumulator { child: state }) = child.asap() else { panic!( "Sort must consume finalized values for {query}: {:?}", - child.expr + child.operator ); }; - assert!(matches!(state.expr, SummaryExpr::SummaryAgg { .. })); + assert!(matches!(state.asap(), Some(ASAPOp::SummaryAgg { .. }))); assert!(child .schema .fields @@ -135,9 +147,9 @@ fn exact_counter_weighted_topk_fails_closed_without_membership_certificate() { ] { let root = lower_search_and_materialize(query); assert!( - matches!(root.expr, SummaryExpr::KeepPreAsap(_)), + !root.contains_asap(), "exact target must not accept an uncertified membership sidecar for {query}: {:?}", - root.expr + root.operator ); } } @@ -146,23 +158,14 @@ fn exact_counter_weighted_topk_fails_closed_without_membership_certificate() { fn instant_topk_and_unsupported_child_remain_local_residuals() { for query in ["topk(3, memory_bytes)", "topk(3, deriv(memory_bytes[5m]))"] { let root = lower_search_and_materialize(query); - let SummaryExpr::ValueOperation { - operation: ValueOperation::Limit { .. }, - child: sort, - .. - } = &root.expr - else { + let Some(NonASAPOp::Limit { child: sort, .. }) = root.non_asap() else { panic!("expected Limit for {query}"); }; - let SummaryExpr::ValueOperation { - child, operation, .. - } = &sort.expr - else { + let Some(NonASAPOp::Sort { child, .. }) = sort.non_asap() else { panic!("expected Sort for {query}"); }; - assert!(matches!(operation, ValueOperation::Sort { .. })); assert!( - matches!(child.expr, SummaryExpr::KeepPreAsap(_)), + !child.contains_asap(), "only the unsupported child should remain exact for {query}" ); } @@ -177,7 +180,7 @@ fn dtype<'a>(schema: &'a Schema, name: &str) -> &'a FieldDataType { .dtype } -fn lower_and_realize(query: &str) -> Rc { +fn lower_and_realize(query: &str) -> Rc { let pre = lower_promql(query, AccuracyTarget::Exact).expect("lowering failed"); realize(&pre).expect("binding failed") } @@ -186,19 +189,17 @@ fn lower_and_realize(query: &str) -> Rc { fn promql_binary_arithmetic_retains_two_summary_leaves() { for op in ["+", "-", "*", "/", "%", "^", "atan2"] { let root = lower_and_realize(&format!("rate(a[1m]) {op} rate(b[1m])")); - let SummaryExpr::BinaryOp { lhs, rhs, .. } = &root.expr else { - panic!("expected BinaryOp for {op}, got {:?}", root.expr); + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = root.non_asap() else { + panic!("expected BinaryOp for {op}, got {:?}", root.operator); }; for operand in [lhs, rhs] { - let SummaryExpr::ValueOperation { - child, - operation: ValueOperation::FinalizeExactAccumulator, - .. - } = &operand.expr - else { - panic!("expected an explicit exact readout, got {:?}", operand.expr); + let Some(ASAPOp::FinalizeExactAccumulator { child }) = operand.asap() else { + panic!( + "expected an explicit exact evaluation, got {:?}", + operand.operator + ); }; - assert!(matches!(child.expr, SummaryExpr::SummaryAgg { .. })); + assert!(matches!(child.asap(), Some(ASAPOp::SummaryAgg { .. }))); } } } @@ -207,47 +208,36 @@ fn promql_binary_arithmetic_retains_two_summary_leaves() { fn value_ranked_topk_over_binary_ratio_finalizes_both_summary_operands() { let query = "topk(1, sum by(job)(increase(a[6h])) / sum by(job)(increase(b[6h])))"; let root = lower_search_and_materialize(query); - let SummaryExpr::ValueOperation { - operation: ValueOperation::Limit { - n: 1, offset: 0, .. - }, + let Some(NonASAPOp::Limit { + n: Some(1), + offset: 0, child: sort, .. - } = &root.expr + }) = root.non_asap() else { - panic!("expected Limit root, got {:?}", root.expr); + panic!("expected Limit root, got {:?}", root.operator); }; - let SummaryExpr::ValueOperation { - operation: ValueOperation::Sort { .. }, - child: binary, - .. - } = &sort.expr - else { - panic!("expected Sort below Limit, got {:?}", sort.expr); + let Some(NonASAPOp::Sort { child: binary, .. }) = sort.non_asap() else { + panic!("expected Sort below Limit, got {:?}", sort.operator); }; - let SummaryExpr::BinaryOp { lhs, rhs, .. } = &binary.expr else { - panic!("expected BinaryOp below Sort, got {:?}", binary.expr); + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = binary.non_asap() else { + panic!("expected BinaryOp below Sort, got {:?}", binary.operator); }; for operand in [lhs, rhs] { - let SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - child, - .. - } = &operand.expr - else { + let Some(ASAPOp::FinalizeExactAccumulator { child }) = operand.asap() else { panic!( "expected exact accumulator finalization, got {:?}", - operand.expr + operand.operator ); }; - assert!(matches!(child.expr, SummaryExpr::SummaryAgg { .. })); + assert!(matches!(child.asap(), Some(ASAPOp::SummaryAgg { .. }))); } } struct SeparatedTopK; impl AccuracyEvidenceProvider for SeparatedTopK { - fn topk_max_distinct_items(&self, _: &QueryExpr) -> Option { + fn topk_max_distinct_items(&self, _: &OperatorNode) -> Option { Some(1000) } @@ -271,14 +261,12 @@ impl AccuracyEvidenceProvider for SeparatedTopK { // Rate-weighted summaries must consume finalized rates, never raw counter deltas. #[test] fn grouped_rate_topk_consumes_finalized_rate_values() { - let root = Rc::new( - lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let root = lower_promql( + "topk by(job)(2, sum by(service, job)(rate(m[1m])))", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -288,24 +276,29 @@ fn grouped_rate_topk_consumes_finalized_rate_values() { .replacements(&TargetSubDAG::new(&root)) .into_iter() .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) if candidate.rationale.contains("CmsWithHeap") => Some(node), + Replacement::SubDAG(node) if candidate.rationale.contains("CmsWithHeap") => Some(node), _ => None, }) .expect("rate-weighted CMS plan"); - let dag = compile_post_asap_dag(&plan).unwrap(); + let dag = post_asap_dag(&plan); assert!(!dag.nodes.iter().any(|node| matches!( node.payload, - asap_types::post_asap::PostAsapOperatorPayload::RelationalJoin { .. } + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Join { .. }) ))); - let node = dag.nodes.iter().find(|node| matches!(&node.payload, - asap_types::post_asap::PostAsapOperatorPayload::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } - if kind.algorithm() == &SketchAlgorithm::CmsWithHeap)).unwrap(); + let node = dag + .nodes + .iter() + .find(|node| { + matches!(&node.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) + if kind.algorithm() == &SketchAlgorithm::CmsWithHeap) + }) + .unwrap(); assert_eq!( node.output_state.timing, asap_types::post_asap::ExecutionTiming::QueryTime ); - let asap_types::post_asap::PostAsapOperatorPayload::SummaryAgg { input, .. } = &node.payload - else { + let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { input, .. }) = &node.payload else { unreachable!() }; assert_eq!( @@ -332,14 +325,12 @@ fn weighted_topk_keeps_candidates_with_missing_population_evidence() { SeparatedTopK.propagation_stats(op, family, query) } } - let root = Rc::new( - lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let root = lower_promql( + "topk by(job)(2, sum by(service, job)(rate(m[1m])))", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -355,27 +346,24 @@ fn weighted_topk_keeps_candidates_with_missing_population_evidence() { // Unknown requirements must survive physical export for deployment to inspect. #[test] fn weighted_topk_exports_symbolic_evidence_requirements() { - let root = Rc::new( - lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .unwrap(), - ); - let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let root = lower_promql( + "topk by(job)(2, sum by(service, job)(rate(m[1m])))", + AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }, + ) + .unwrap(); + let candidates = ASAPStrategies::default_cost_model().replacements(&TargetSubDAG::new(&root)); let candidate = candidates .iter() .find(|candidate| candidate.rationale.contains("CmsWithHeap")) .unwrap(); assert!(candidate.has_missing_accuracy_evidence()); - let Replacement::Summary(node) = &candidate.replacement else { + let Replacement::SubDAG(node) = &candidate.replacement else { panic!("summary candidate") }; - let dag = compile_post_asap_dag(node).unwrap(); + let dag = post_asap_dag(node); let exported = serde_json::to_string(&dag).unwrap(); assert!(exported.contains("topk_max_distinct_items")); assert!(exported.contains("topk_membership_margin")); @@ -387,18 +375,16 @@ fn weighted_topk_exports_symbolic_evidence_requirements() { fn weighted_topk_rejects_invalid_population_evidence() { struct InvalidPopulation; impl AccuracyEvidenceProvider for InvalidPopulation { - fn topk_max_distinct_items(&self, _: &QueryExpr) -> Option { + fn topk_max_distinct_items(&self, _: &OperatorNode) -> Option { Some(0) } } - let root = Rc::new( - lower_promql( - "topk by(job)(2, sum by(service, job)(rate(m[1m])))", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let root = lower_promql( + "topk by(job)(2, sum by(service, job)(rate(m[1m])))", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -415,17 +401,15 @@ fn rate_and_increase_topk_use_summary_scores_and_grouped_limits() { "topk by(job)(2, sum by(service, job)(rate(m[1m])))", "topk(2, sum by(job)(increase(m[6h])))", ] { - let root = Rc::new( - lower_promql( - query, - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let root = lower_promql( + query, + AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }, + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -435,60 +419,50 @@ fn rate_and_increase_topk_use_summary_scores_and_grouped_limits() { .replacements(&TargetSubDAG::new(&root)) .into_iter() .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) if candidate.rationale.contains("CmsWithHeap") => { + Replacement::SubDAG(node) if candidate.rationale.contains("CmsWithHeap") => { Some(node) } _ => None, }) .expect("weighted summary"); - let SummaryExpr::ValueOperation { + let Some(NonASAPOp::Limit { + n: Some(2), + offset: 0, + partition_by, child: sorted, - operation: - ValueOperation::Limit { - n: 2, - offset: 0, - partition_by, - }, - .. - } = &plan.expr + }) = plan.non_asap() else { panic!("grouped limit") }; - let SummaryExpr::ValueOperation { + let Some(NonASAPOp::Sort { + partition_by: sort_groups, child: projected, - operation: - ValueOperation::Sort { - partition_by: sort_groups, - .. - }, .. - } = &sorted.expr + }) = sorted.non_asap() else { panic!("grouped sort") }; assert_eq!(partition_by, sort_groups); assert_eq!(partition_by.len(), usize::from(query.contains("topk by"))); - let SummaryExpr::ValueOperation { - child: readout, - operation: ValueOperation::Project { .. }, - .. - } = &projected.expr + let Some(NonASAPOp::Project { + child: evaluation, .. + }) = projected.non_asap() else { panic!("logical output projection") }; - let SummaryExpr::SummaryEstimate { + let Some(ASAPOp::SummaryEstimate { summary_input, query: SketchStatistic::TopK { k }, - } = &readout.expr + }) = evaluation.asap() else { - panic!("heap readout") + panic!("heap evaluation") }; assert!(*k > 2, "candidate capacity is independent of output count"); - let SummaryExpr::SummaryAgg { + let Some(ASAPOp::SummaryAgg { child: rates, input, .. - } = &summary_input.expr + }) = summary_input.asap() else { panic!("weighted summary") }; @@ -497,13 +471,10 @@ fn rate_and_increase_topk_use_summary_scores_and_grouped_limits() { SummaryInputExpr::Column(ColumnRef::SampleValue) ); assert!(matches!( - rates.expr, - SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - .. - } + rates.asap(), + Some(ASAPOp::FinalizeExactAccumulator { .. }) )); - let dag = compile_post_asap_dag(&plan).unwrap(); + let dag = post_asap_dag(&plan); for phase in [ asap_types::post_asap::ExecutionTiming::IngestionTime, asap_types::post_asap::ExecutionTiming::QueryTime, @@ -525,57 +496,41 @@ fn rate_and_increase_topk_use_summary_scores_and_grouped_limits() { #[test] fn promql_binary_arithmetic_preserves_both_scalar_operand_orders() { - fn is_exact_readout_or_scalar(node: &SummaryNode) -> bool { - matches!(node.expr, SummaryExpr::KeepPreAsap(_)) - || matches!( - node.expr, - SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - .. - } - ) - } - for query in ["rate(a[1m]) / 2", "2 / rate(a[1m])"] { + for (query, scalar_left) in [("rate(a[1m]) / 2", false), ("2 / rate(a[1m])", true)] { let root = lower_and_realize(query); - let SummaryExpr::BinaryOp { lhs, rhs, .. } = &root.expr else { - panic!("expected BinaryOp for {query}, got {:?}", root.expr); + let Some(NonASAPOp::Project { cols, .. }) = root.non_asap() else { + panic!("expected Project") }; - assert!(is_exact_readout_or_scalar(lhs)); - assert!(is_exact_readout_or_scalar(rhs)); - assert!( - matches!( - lhs.expr, - SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - .. - } - ) || matches!( - rhs.expr, - SummaryExpr::ValueOperation { - operation: ValueOperation::FinalizeExactAccumulator, - .. - } - ) - ); + let ScalarExpr::Arithmetic { left, right, .. } = &cols[1].expr else { + panic!() + }; + let (scalar, sample) = if scalar_left { + (left, right) + } else { + (right, left) + }; + assert_eq!(**scalar, ScalarExpr::literal_f64(2.0)); + assert_eq!(**sample, ScalarExpr::Column(1)); + assert!(root.schema.has_promql_series_identity()); } } #[test] fn promql_binary_arithmetic_falls_back_as_a_whole_for_unsupported_arm() { let root = lower_and_realize("rate(a[1m]) + stddev_over_time(b[1m])"); - assert!(matches!(root.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!root.contains_asap()); } #[test] fn promql_binary_arithmetic_preserves_nested_structure_and_rejects_modifiers() { let nested = lower_and_realize("(rate(a[1m]) + rate(b[1m])) / 2"); - let SummaryExpr::BinaryOp { lhs, .. } = &nested.expr else { - panic!("expected outer BinaryOp, got {:?}", nested.expr); + let Some(NonASAPOp::Project { child: lhs, .. }) = nested.non_asap() else { + panic!("expected outer BinaryOp, got {:?}", nested.operator); }; - assert!(matches!(lhs.expr, SummaryExpr::BinaryOp { .. })); + assert!(matches!(lhs.non_asap(), Some(NonASAPOp::BinaryOp { .. }))); let modified = lower_and_realize("rate(a[1m]) + on(job) rate(b[1m])"); - assert!(matches!(modified.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!modified.contains_asap()); } #[test] @@ -586,8 +541,8 @@ fn promql_binary_arithmetic_never_relabels_approximate_children_as_exact() { ) .expect("lowering failed"); let root = realize(&pre).expect("binding failed"); - let SummaryExpr::BinaryOp { lhs, rhs, .. } = &root.expr else { - panic!("expected BinaryOp, got {:?}", root.expr); + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = root.non_asap() else { + panic!("expected BinaryOp, got {:?}", root.operator); }; assert!(lhs.guarantee.as_ref().is_some_and(|g| !g.is_exact())); assert!(rhs.guarantee.as_ref().is_some_and(|g| !g.is_exact())); @@ -606,13 +561,11 @@ fn ddsketch_quantile_ratio_meets_the_shared_relative_error_target() { epsilon: 0.01, delta: 0.01, }; - let query = Rc::new( - lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - target.clone(), - ) - .expect("lowering failed"), - ); + let query = lower_promql( + "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", + target.clone(), + ) + .expect("lowering failed"); let evidence = FixtureQuantileDomain { lower: 1.0, @@ -632,7 +585,7 @@ fn ddsketch_quantile_ratio_meets_the_shared_relative_error_target() { .for_target(root) .and_then(|selection| selection.chosen.as_ref()) .expect("the certified DDSketch ratio should be selectable"); - let Replacement::Summary(node) = &chosen.replacement else { + let Replacement::SubDAG(node) = &chosen.replacement else { panic!("expected a summary candidate") }; let guarantee = node.guarantee.as_ref().expect("ratio guarantee"); @@ -642,23 +595,22 @@ fn ddsketch_quantile_ratio_meets_the_shared_relative_error_target() { "ratio guarantee should satisfy the requested target: {guarantee:?}" ); - let shared = - asap_types::post_asap::share_common_summary_sub_dags(vec![("ratio", node.clone())]); - let SummaryExpr::BinaryOp { lhs, rhs, .. } = &shared[0].1.expr else { + let shared = asap_types::ir::cse::share_common_sub_dags(vec![("ratio", node.clone())]); + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = shared[0].1.non_asap() else { panic!("expected binary ratio") }; - let producer = |readout: &Rc| match &readout.expr { - SummaryExpr::SummaryEstimate { summary_input, .. } => Rc::clone(summary_input), - other => panic!("expected DDSketch readout, got {other:?}"), + let producer = |evaluation: &Rc| match &evaluation.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => Rc::clone(summary_input), + other => panic!("expected DDSketch evaluation, got {other:?}"), }; assert!( Rc::ptr_eq(&producer(lhs), &producer(rhs)), - "the two quantile readouts should share one DDSketch producer" + "the two quantile evaluations should share one DDSketch producer" ); } #[test] -fn planner_only_e2e_temporal_topk_preserves_query_update_and_readout_contract() { +fn planner_only_e2e_temporal_topk_preserves_query_update_and_evaluation_contract() { // Self-contained Planner E2E: each case starts from PromQL text and ends // at the post-ASAP summary DAG. No controller/backend types, // fixtures, configuration, or runtime are involved. @@ -677,17 +629,15 @@ fn planner_only_e2e_temporal_topk_preserves_query_update_and_readout_contract() ), ]; for (source, expected_update, expected_family, excluded_labels) in cases { - let pre = Rc::new( - lower_promql( - source, - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .expect("lower temporal Top-K"), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let pre = lower_promql( + source, + AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }, + ) + .expect("lower temporal Top-K"); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -697,29 +647,29 @@ fn planner_only_e2e_temporal_topk_preserves_query_update_and_readout_contract() .replacements(&TargetSubDAG::new(&pre)) .into_iter() .find_map(|candidate| match candidate.replacement { - Replacement::Summary(node) if candidate.rationale.contains(expected_family) => { + Replacement::SubDAG(node) if candidate.rationale.contains(expected_family) => { Some(node) } _ => None, }) .expect("heap-backed temporal Top-K candidate"); - let SummaryExpr::SummaryEstimate { + let Some(ASAPOp::SummaryEstimate { summary_input, query: SketchStatistic::TopK { k, .. }, - } = &candidate.expr + }) = candidate.asap() else { - panic!("expected Top-K estimate, got {:?}", candidate.expr) + panic!("expected Top-K estimate, got {:?}", candidate.operator) }; assert_eq!( *k, 5, "the requested Top-K cardinality must survive binding" ); - let SummaryExpr::SummaryAgg { + let Some(ASAPOp::SummaryAgg { input: state_input, family, child, .. - } = &summary_input.expr + }) = summary_input.asap() else { panic!("expected structured Top-K state input") }; @@ -742,42 +692,41 @@ fn planner_only_e2e_temporal_topk_preserves_query_update_and_readout_contract() )) ); assert_eq!(state_input.weight, expected_update); - assert!(matches!(child.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!child.contains_asap()); } } /// Execute the ungrouped temporal TopK subset with exact state. This tests /// the emitted update contract, not sketch approximation or backend execution. -fn execute_topk_reference(plan: &SummaryNode) -> Vec<(String, f64)> { +fn execute_topk_reference(plan: &OperatorNode) -> Vec<(String, f64)> { use std::collections::BTreeMap; - let SummaryExpr::SummaryEstimate { + let Some(ASAPOp::SummaryEstimate { summary_input, query: SketchStatistic::TopK { k }, - } = &plan.expr + }) = plan.asap() else { - panic!("expected TopK readout") + panic!("expected TopK evaluation") }; - let SummaryExpr::SummaryAgg { + let Some(ASAPOp::SummaryAgg { input, child, reduction, .. - } = &summary_input.expr + }) = summary_input.asap() else { panic!("expected summary updates") }; assert_eq!(reduction, &Reduction::by(vec![])); - let SummaryExpr::KeepPreAsap(raw) = &child.expr else { - panic!("expected fused raw input") - }; - let QueryExpr::TimeRange { range, child } = raw.as_ref() else { + // The fused raw input is the kept non-ASAP sub-DAG itself. + assert!(!child.contains_asap(), "expected fused raw input"); + let Some(NonASAPOp::TimeRange { range, child, .. }) = child.non_asap() else { panic!("expected temporal input") }; - let QueryExpr::Scan { + let Some(NonASAPOp::Scan { source: asap_types::pre_asap::Source::TimeSeries { metric }, predicates, .. - } = child.as_ref() + }) = child.non_asap() else { panic!("expected metric scan") }; @@ -849,17 +798,15 @@ fn planner_heap_topk_reference_execution_matches_ground_truth() { vec![("worker", 100.0), ("cron", 30.0)], ), ] { - let pre = Rc::new( - lower_promql( - query, - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.01, - }, - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let pre = lower_promql( + query, + AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.01, + }, + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -868,10 +815,10 @@ fn planner_heap_topk_reference_execution_matches_ground_truth() { // This reference executor consumes keyed heap updates. The inventory // also contains maintained exact values followed by sort/limit; those // have a different execution contract and must not enter this fixture. - let candidates: Vec<_> = strategy.replacements(&TargetSubDAG::new(&pre)).into_iter().filter(|candidate| matches!(&candidate.replacement, Replacement::Summary(plan) if matches!(plan.expr, SummaryExpr::SummaryEstimate { query: SketchStatistic::TopK { .. }, .. }))).collect(); + let candidates: Vec<_> = strategy.replacements(&TargetSubDAG::new(&pre)).into_iter().filter(|candidate| matches!(&candidate.replacement, Replacement::SubDAG(plan) if matches!(plan.asap(), Some(ASAPOp::SummaryEstimate { query: SketchStatistic::TopK { .. }, .. })))).collect(); assert!(!candidates.is_empty(), "no heap candidate for {query}"); for candidate in candidates { - let Replacement::Summary(plan) = candidate.replacement else { + let Replacement::SubDAG(plan) = candidate.replacement else { panic!("expected summary plan for {query}") }; let expected: Vec<_> = expected @@ -889,11 +836,11 @@ fn planner_heap_topk_reference_execution_matches_ground_truth() { /// SummaryEstimate { query: Quantile{0.99} } → {quantile_0_99: Float64} /// └─ SummaryAgg { Kll{k:269}, input: SampleValue } → {value: Sketch(Kll, {k:269})} /// └─ SummaryAgg { Rate, input: SampleValue } → {ts, value: ExactAggregate(Rate), …} -/// └─ KeepPreAsap(TimeRange{5m} → Scan) → {ts, value} +/// └─ TimeRange{5m} → Scan → {ts, value} /// ``` /// -/// The nested DAG exercises both realizations: the approximate quantile -/// binds a KLL sketch + readout; the per-series `rate` binds the exact +/// The nested tree exercises both realizations: the approximate quantile +/// binds a KLL sketch + evaluation; the per-series `rate` binds the exact /// counter-reset-aware accumulator (no estimate — its state is the value). #[test] fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { @@ -904,13 +851,13 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { .expect("lowering failed"); let root = realize(&pre_asap).expect("binding failed"); - // Root: the sketch readout, back to a plain row shape. - let SummaryExpr::SummaryEstimate { + // Root: the sketch evaluation, back to a plain row shape. + let Some(ASAPOp::SummaryEstimate { summary_input, query, - } = &root.expr + }) = root.asap() else { - panic!("expected SummaryEstimate root, got {:?}", root.expr); + panic!("expected SummaryEstimate root, got {:?}", root.operator); }; assert!(matches!(query, SketchStatistic::Quantile { q } if *q == 0.99)); assert_eq!( @@ -924,15 +871,15 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { // reduction, one output row — not to be confused with the inner rate's // per-entity grouping below, even though both once collapsed to the // same empty `by: []` (issue #163). - let SummaryExpr::SummaryAgg { + let Some(ASAPOp::SummaryAgg { child, family, input, reduction, .. - } = &summary_input.expr + }) = summary_input.asap() else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!( family, @@ -955,26 +902,24 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { ) ); - let SummaryExpr::ValueOperation { - child, - operation: ValueOperation::FinalizeExactAccumulator, - timing: asap_types::post_asap::ExecutionTiming::IngestionTime, - } = &child.expr - else { - panic!("rate needs a maintenance readout"); + let Some(ASAPOp::FinalizeExactAccumulator { child }) = child.asap() else { + panic!("rate needs a maintenance evaluation"); }; // The rate: exact counter-reset-aware accumulator, per-series (labels // and time axis preserved), no estimate wrapper. `rate(...)` has no // grouping concept at all — every entity stays its own summary. - let SummaryExpr::SummaryAgg { + let Some(ASAPOp::SummaryAgg { child: leaf, family, reduction, .. - } = &child.expr + }) = child.asap() else { - panic!("expected inner SummaryAgg for rate, got {:?}", child.expr); + panic!( + "expected inner SummaryAgg for rate, got {:?}", + child.operator + ); }; assert_eq!( family, @@ -992,14 +937,20 @@ fn promql_quantile_of_rate_binds_kll_over_rate_accumulator() { ); // The leaf: unrewritten pass-through — TimeRange marker over the Scan. - let SummaryExpr::KeepPreAsap(kept_leaf) = &leaf.expr else { - panic!("expected KeepPreAsap leaf, got {:?}", leaf.expr); - }; - let QueryExpr::TimeRange { range, child: scan } = kept_leaf.as_ref() else { - panic!("expected TimeRange leaf, got {kept_leaf:?}"); + // The kept leaf is the non-ASAP sub-DAG itself. + assert!( + !leaf.contains_asap(), + "expected kept leaf, got {:?}", + leaf.operator + ); + let Some(NonASAPOp::TimeRange { + range, child: scan, .. + }) = leaf.non_asap() + else { + panic!("expected TimeRange leaf, got {:?}", leaf.operator); }; assert_eq!(range.as_secs(), 300); - assert!(matches!(scan.as_ref(), QueryExpr::Scan { .. })); + assert!(matches!(scan.non_asap(), Some(NonASAPOp::Scan { .. }))); assert!( leaf.schema .fields @@ -1017,11 +968,11 @@ fn promql_exact_workload_binds_accumulators_not_sketches() { let pre_asap = lower_promql("sum by (job) (http_requests_total)", AccuracyTarget::Exact) .expect("lowering failed"); let root = realize(&pre_asap).expect("binding failed"); - let SummaryExpr::SummaryAgg { + let Some(ASAPOp::SummaryAgg { family, reduction, .. - } = &root.expr + }) = root.asap() else { - panic!("expected SummaryAgg, got {:?}", root.expr); + panic!("expected SummaryAgg, got {:?}", root.operator); }; assert_eq!( family, @@ -1042,21 +993,19 @@ fn promql_exact_workload_binds_accumulators_not_sketches() { lower_promql("avg(http_requests_total)", AccuracyTarget::Exact).expect("lowering failed"); let root = realize(&pre_asap).expect("binding failed"); assert!( - matches!(root.expr, SummaryExpr::KeepPreAsap(_)), + !root.contains_asap(), "avg has no mergeable accumulator — stays logical" ); } #[test] fn promql_sum_of_count_over_time_is_composed_by_default_search() { - let original = Rc::new( - lower_promql( - "sum by (service) (count_over_time(metrics[5m]))", - AccuracyTarget::Exact, - ) - .expect("lowering failed"), - ); - let original_schema = original.output_schema().unwrap(); + let original = lower_promql( + "sum by (service) (count_over_time(metrics[5m]))", + AccuracyTarget::Exact, + ) + .expect("lowering failed"); + let original_schema = original.schema.clone(); let space = search_workload(vec![("query", original)]); let root = &space.roots[0].1; let group = space.candidates_for_target(root).expect("root memo group"); @@ -1065,20 +1014,21 @@ fn promql_sum_of_count_over_time_is_composed_by_default_search() { .iter() .find(|candidate| candidate.strategy == "SemanticEquivalentRewriteStrategy") .expect("default search should compose the lowered PromQL query"); - let Replacement::Rewrite(rewritten) = &candidate.replacement else { + let Replacement::SubDAG(rewritten) = &candidate.replacement else { panic!("expected logical rewrite") }; + assert!(is_logical_rewrite(rewritten), "expected logical rewrite"); - assert_eq!(rewritten.output_schema().unwrap(), original_schema); - let QueryExpr::Project { child, .. } = rewritten.as_ref() else { + assert_eq!(rewritten.schema, original_schema); + let Some(NonASAPOp::Project { child, .. }) = rewritten.non_asap() else { panic!("sum(count_over_time) needs a Float64 cast Project") }; - let QueryExpr::Aggregate { + let Some(NonASAPOp::Aggregate { reduction: Reduction::Reduce(by), measures, child, .. - } = child.as_ref() + }) = child.non_asap() else { panic!("expected one composed aggregate") }; @@ -1090,9 +1040,9 @@ fn promql_sum_of_count_over_time_is_composed_by_default_search() { }] )); assert!(matches!( - child.as_ref(), - QueryExpr::TimeRange { range, child } - if range.as_secs() == 300 && matches!(child.as_ref(), QueryExpr::Scan { .. }) + child.non_asap(), + Some(NonASAPOp::TimeRange { range, child, .. }) + if range.as_secs() == 300 && matches!(child.non_asap(), Some(NonASAPOp::Scan { .. })) )); } @@ -1100,50 +1050,41 @@ fn promql_sum_of_count_over_time_is_composed_by_default_search() { fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { // Real workload selection must expose the state-to-value edge; an outer // sketch must not interpret exact accumulator bytes as input samples. - let pre = Rc::new( - lower_promql( - "quantile(0.9, sum_over_time(m[1m]))", - AccuracyTarget::Epsilon(0.05), - ) - .unwrap(), - ); + let pre = lower_promql( + "quantile(0.9, sum_over_time(m[1m]))", + AccuracyTarget::Epsilon(0.05), + ) + .unwrap(); let space = search_workload(vec![("query", pre)]); let selected = space.global_selection(&DefaultCostModel); let plan = selected .assemble_selected_dag(&space.roots[0].1) .unwrap() .unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &plan.expr else { + // Stored timings are gone: time the plan and read the timed copy. + let timed_plan = timed(&plan); + let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = timed_plan.asap() else { panic!("expected selected quantile summary"); }; - let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { + let Some(ASAPOp::SummaryAgg { child, .. }) = summary_input.asap() else { panic!("expected maintained outer summary"); }; - let SummaryExpr::ValueOperation { - child: source, - operation, - timing, - } = &child.expr - else { + let Some(ASAPOp::FinalizeExactAccumulator { child: source }) = child.asap() else { panic!( "missing explicit accumulator finalization: {:?}", - child.expr + child.operator ); }; - assert!(matches!( - operation, - ValueOperation::FinalizeExactAccumulator - )); assert_eq!( - *timing, - asap_types::post_asap::ExecutionTiming::IngestionTime + child.timing, + Some(asap_types::post_asap::ExecutionTiming::IngestionTime) ); assert!(matches!( - source.expr, - SummaryExpr::SummaryAgg { + source.asap(), + Some(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Sum, _), .. - } + }) )); assert!(child .schema @@ -1155,12 +1096,13 @@ fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { .fields .iter() .any(|field| matches!(field.dtype, FieldDataType::Plain(DataType::Float64)))); - compile_post_asap_dag(&plan).expect("explicit boundary is a valid post-ASAP DAG"); + // Explicit boundary is a valid post-ASAP DAG. + post_asap_dag(&plan); } #[test] fn physical_node_owns_phase_independently_of_binary_payload() { - use asap_types::post_asap::{ExecutionTiming, PostAsapOperatorPayload}; + use asap_types::post_asap::ExecutionTiming; for (query, expected) in [ ( // One selector: both operands cover the same series. @@ -1173,27 +1115,30 @@ fn physical_node_owns_phase_independently_of_binary_payload() { ), ] { let input = lower_promql(query, AccuracyTarget::Epsilon(0.05)).unwrap(); - // Backend lowering carries opaque series identity before candidate export. - let input = asap_types::pre_asap::schema::with_promql_series_identity(&input).unwrap(); - let search = search_workload(vec![("q", Rc::new(input))]); + let search = search_workload(vec![("q", input)]); let choice = search.global_selection(&DefaultCostModel); let plan = choice .assemble_selected_dag(&search.roots[0].1) .unwrap() .unwrap(); - let dag = compile_post_asap_dag(&plan).unwrap(); + let dag = post_asap_dag(&plan); let node = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::Binary { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::BinaryOp { .. }) + ) + }) .unwrap(); assert_eq!(node.output_state.timing, expected); let wire = serde_json::to_value(&node.payload).unwrap(); assert!(wire.get("timing").is_none()); let mut obsolete = wire.clone(); obsolete["timing"] = serde_json::json!(expected.as_str()); - assert!(serde_json::from_value::(obsolete).is_err()); - let restored: PostAsapOperatorPayload = serde_json::from_value(wire).unwrap(); + assert!(serde_json::from_value::(obsolete).is_err()); + let restored: PhysicalASAPOperatorPayload = serde_json::from_value(wire).unwrap(); assert_eq!(restored, node.payload); } } @@ -1207,14 +1152,10 @@ fn ddsketch_ratio_without_domain_proof_is_uncertified() { ) .unwrap(); let root = realize(&pre).unwrap(); - assert!(matches!(root.expr, SummaryExpr::BinaryOp { .. })); + assert!(matches!(root.non_asap(), Some(NonASAPOp::BinaryOp { .. }))); assert!(root.guarantee.is_none()); let space = search_workload_with_targets( - vec![( - "unproven", - Rc::new(pre), - Some(AccuracyTarget::Epsilon(0.01)), - )], + vec![("unproven", pre, Some(AccuracyTarget::Epsilon(0.01)))], &asap_aware_mapping::default_strategies(), &DefaultAccuracyModel, ); @@ -1226,8 +1167,8 @@ fn ddsketch_ratio_without_domain_proof_is_uncertified() { root_group.candidates.iter().any(|candidate| { matches!( &candidate.replacement, - Replacement::Summary(node) - if matches!(node.expr, SummaryExpr::BinaryOp { .. }) + Replacement::SubDAG(node) + if matches!(node.non_asap(), Some(NonASAPOp::BinaryOp { .. })) && node.guarantee.is_none() ) }), @@ -1247,7 +1188,7 @@ fn ddsketch_ratio_without_domain_proof_is_uncertified() { .assemble_selected_dag(&space.roots[0].1) .unwrap() .expect("materialized root"); - assert!(matches!(materialized.expr, SummaryExpr::KeepPreAsap(_))); + assert!(!materialized.contains_asap()); } struct FixtureQuantileDomain { @@ -1255,7 +1196,7 @@ struct FixtureQuantileDomain { upper: f64, } impl AccuracyEvidenceProvider for FixtureQuantileDomain { - fn quantile_input_domain(&self, _: &QueryExpr) -> Option { + fn quantile_input_domain(&self, _: &OperatorNode) -> Option { Some(QuantileInputDomain { lower: self.lower, upper: self.upper, @@ -1278,14 +1219,12 @@ fn ddsketch_ratio_rejects_unsafe_domains() { (f64::MIN_POSITIVE / 2., f64::MIN_POSITIVE / 2.), ] { let evidence = FixtureQuantileDomain { lower, upper }; - let pre = Rc::new( - lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let pre = lower_promql( + "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -1304,8 +1243,8 @@ fn ddsketch_ratio_rejects_unsafe_domains() { fn ddsketch_ratio_rejects_one_invalid_domain_when_the_other_is_missing() { struct PartialUnsafeDomain; impl AccuracyEvidenceProvider for PartialUnsafeDomain { - fn quantile_input_domain(&self, operand: &QueryExpr) -> Option { - let QueryExpr::Aggregate { measures, .. } = operand else { + fn quantile_input_domain(&self, operand: &OperatorNode) -> Option { + let Some(NonASAPOp::Aggregate { measures, .. }) = operand.non_asap() else { return None; }; matches!( @@ -1321,14 +1260,12 @@ fn ddsketch_ratio_rejects_one_invalid_domain_when_the_other_is_missing() { } } - let pre = Rc::new( - lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let pre = lower_promql( + "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -1339,40 +1276,38 @@ fn ddsketch_ratio_rejects_one_invalid_domain_when_the_other_is_missing() { /// The committed planner alpha is exercised against the pinned sketch implementation. #[test] -fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_readouts() { +fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_evaluations() { for sign in [-1., 1.] { let evidence = FixtureQuantileDomain { lower: if sign < 0. { -100. } else { 1. }, upper: if sign < 0. { -1. } else { 100. }, }; - let pre = Rc::new( - lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(), - ); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let pre = lower_promql( + "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, &evidence, ); let candidates = strategy.replacements(&TargetSubDAG::new(&pre)); - let Replacement::Summary(node) = &candidates[0].replacement else { + let Replacement::SubDAG(node) = &candidates[0].replacement else { panic!("summary") }; - let SummaryExpr::BinaryOp { lhs, rhs, .. } = &node.expr else { + let Some(NonASAPOp::BinaryOp { lhs, rhs, .. }) = node.non_asap() else { panic!("ratio") }; - let alpha = |node: &SummaryNode| { - let SummaryExpr::SummaryEstimate { summary_input, .. } = &node.expr else { - panic!("readout") + let alpha = |node: &OperatorNode| { + let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = node.asap() else { + panic!("evaluation") }; - let SummaryExpr::SummaryAgg { + let Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } = &summary_input.expr + }) = summary_input.asap() else { panic!("sketch") }; @@ -1407,12 +1342,12 @@ fn ddsketch_ratio_bound_holds_for_signed_pinned_sketch_readouts() { } } -/// Empty or overlarge population contracts cannot promise a supported readout. +/// Empty or overlarge population contracts cannot promise a supported evaluation. #[test] fn ddsketch_ratio_requires_a_supported_population_size() { struct PopulationEvidence(u64); impl AccuracyEvidenceProvider for PopulationEvidence { - fn quantile_input_domain(&self, _: &QueryExpr) -> Option { + fn quantile_input_domain(&self, _: &OperatorNode) -> Option { Some(QuantileInputDomain { lower: 1., upper: 10., @@ -1421,16 +1356,14 @@ fn ddsketch_ratio_requires_a_supported_population_size() { }) } } - let pre = Rc::new( - lower_promql( - "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(), - ); + let pre = lower_promql( + "quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); for count in [0, (1u64 << 53) + 1] { let evidence = PopulationEvidence(count); - let strategy = SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, &EqualSplitAllocator, @@ -1441,7 +1374,7 @@ fn ddsketch_ratio_requires_a_supported_population_size() { } // Every `without` aggregation candidate exports a valid DAG: its summary state -// column carries the family instead of the readout's Float64 value. +// column carries the family instead of the evaluation's Float64 value. #[test] fn without_aggregation_candidates_export_valid_dags() { for accuracy in [ @@ -1452,7 +1385,7 @@ fn without_aggregation_candidates_export_valid_dags() { }, ] { for query in ["sum without (pod) (m)", "quantile without (pod) (0.5, m)"] { - let root = Rc::new(lower_promql(query, accuracy.clone()).unwrap()); + let root = lower_promql(query, accuracy.clone()).unwrap(); let space = search_workload_with_targets( vec![(0, root, Some(accuracy.clone()))], &asap_aware_mapping::default_strategies(), @@ -1461,7 +1394,7 @@ fn without_aggregation_candidates_export_valid_dags() { let inventory = space.enumerate_candidate_dags_for_root(&0, 65_536).unwrap(); assert!(!inventory.candidates.is_empty(), "{query}"); for (_, node) in inventory.candidates.iter().flatten() { - compile_post_asap_dag(node).unwrap_or_else(|e| panic!("{query}: {e}")); + post_asap_dag(node); } } } diff --git a/crates/integration-tests/tests/scan.rs b/crates/integration-tests/tests/scan.rs index bb7988d7a..e43e27a6c 100644 --- a/crates/integration-tests/tests/scan.rs +++ b/crates/integration-tests/tests/scan.rs @@ -1,4 +1,4 @@ -//! `QueryExpr::Scan` — label matcher / predicate tests. +//! `NonASAPOp::Scan` — label matcher / predicate tests. //! //! The Scan schema is always [ts(0), value(1), label_a(2), label_b(3), …] //! where labels are appended alphabetically after dedup by the SchemaResolver. @@ -11,60 +11,62 @@ use std::time::Duration; use asap_integration_tests::fixtures::lower_promql; use asap_integration_tests::fixtures::metric_schema; -use asap_types::pre_asap::{CompareOpKind, Predicate, QueryExpr, ScalarValue, Source}; +use asap_types::ir::{ + ExprSemantics, NonASAPOp, OperatorNode, Predicate, ScalarExpr, TimeRangeKind, +}; +use asap_types::pre_asap::{CompareOpKind, ScalarValue, Source}; use asap_types::types::AccuracyTarget; -fn lower(q: &str) -> QueryExpr { +fn lower(q: &str) -> Rc { lower_promql(q, AccuracyTarget::Exact).unwrap_or_else(|e| panic!("lower failed for {q:?}: {e}")) } -fn bare_scan(metric: &str, labels: &[&str]) -> QueryExpr { - QueryExpr::Scan { +fn node(op: NonASAPOp) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(op)) + .expect("fixture node derives its schema") +} + +fn bare_scan(metric: &str, labels: &[&str]) -> Rc { + node(NonASAPOp::Scan { source: Source::TimeSeries { metric: metric.into(), }, predicates: vec![], schema: metric_schema(labels), - } + }) } -fn instant(child: QueryExpr) -> QueryExpr { - QueryExpr::TimeRange { +fn instant(child: Rc) -> Rc { + node(NonASAPOp::TimeRange { range: Duration::from_secs(1), - child: Rc::new(child), - } + kind: TimeRangeKind::Instant, + child, + }) +} + +fn label_pred(col_id: usize, op: CompareOpKind, value: &str) -> Predicate { + Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(col_id)), + op, + right: Box::new(ScalarExpr::Literal(ScalarValue::Utf8(value.into()))), + semantics: ExprSemantics::Promql, + }) } fn eq_pred(col_id: usize, value: &str) -> Predicate { - Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(col_id)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8(value.into()))), - })) + label_pred(col_id, CompareOpKind::Eq, value) } fn ne_pred(col_id: usize, value: &str) -> Predicate { - Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(col_id)), - op: CompareOpKind::Ne, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8(value.into()))), - })) + label_pred(col_id, CompareOpKind::Ne, value) } fn regex_pred(col_id: usize, pattern: &str) -> Predicate { - Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(col_id)), - op: CompareOpKind::Regex, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8(pattern.into()))), - })) + label_pred(col_id, CompareOpKind::Regex, pattern) } fn notregex_pred(col_id: usize, pattern: &str) -> Predicate { - Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(col_id)), - op: CompareOpKind::NotRegex, - right: Rc::new(QueryExpr::Literal(ScalarValue::Utf8(pattern.into()))), - })) + label_pred(col_id, CompareOpKind::NotRegex, pattern) } // #1 — bare metric name, no matchers @@ -80,13 +82,13 @@ fn q01_bare_scan() { // schema: [ts(0), value(1), job(2)] #[test] fn q02_equality_predicate() { - let expected = instant(QueryExpr::Scan { + let expected = instant(node(NonASAPOp::Scan { source: Source::TimeSeries { metric: "http_requests_total".into(), }, predicates: vec![eq_pred(2, "api-server")], schema: metric_schema(&["job"]), - }); + })); assert_eq!(lower(r#"http_requests_total{job="api-server"}"#), expected); } @@ -94,13 +96,13 @@ fn q02_equality_predicate() { // schema: [ts(0), value(1), status(2)] #[test] fn q03_inequality_predicate() { - let expected = instant(QueryExpr::Scan { + let expected = instant(node(NonASAPOp::Scan { source: Source::TimeSeries { metric: "http_requests_total".into(), }, predicates: vec![ne_pred(2, "500")], schema: metric_schema(&["status"]), - }); + })); assert_eq!(lower(r#"http_requests_total{status!="500"}"#), expected); } @@ -108,13 +110,13 @@ fn q03_inequality_predicate() { // schema: [ts(0), value(1), job(2)] #[test] fn q04_regex_predicate() { - let expected = instant(QueryExpr::Scan { + let expected = instant(node(NonASAPOp::Scan { source: Source::TimeSeries { metric: "http_requests_total".into(), }, predicates: vec![regex_pred(2, "api.*")], schema: metric_schema(&["job"]), - }); + })); assert_eq!(lower(r#"http_requests_total{job=~"api.*"}"#), expected); } @@ -122,13 +124,13 @@ fn q04_regex_predicate() { // schema: [ts(0), value(1), job(2)] #[test] fn q_notregex_predicate() { - let expected = instant(QueryExpr::Scan { + let expected = instant(node(NonASAPOp::Scan { source: Source::TimeSeries { metric: "http_requests_total".into(), }, predicates: vec![notregex_pred(2, "internal.*")], schema: metric_schema(&["job"]), - }); + })); assert_eq!(lower(r#"http_requests_total{job!~"internal.*"}"#), expected); } @@ -137,13 +139,13 @@ fn q_notregex_predicate() { // predicates in same alphabetical order: job first, then status #[test] fn q_multi_two_predicates() { - let expected = instant(QueryExpr::Scan { + let expected = instant(node(NonASAPOp::Scan { source: Source::TimeSeries { metric: "http_requests_total".into(), }, predicates: vec![eq_pred(2, "api-server"), ne_pred(3, "500")], schema: metric_schema(&["job", "status"]), - }); + })); assert_eq!( lower(r#"http_requests_total{job="api-server",status!="500"}"#), expected, diff --git a/crates/integration-tests/tests/schema.rs b/crates/integration-tests/tests/schema.rs index 08b925dd1..c616c0d5f 100644 --- a/crates/integration-tests/tests/schema.rs +++ b/crates/integration-tests/tests/schema.rs @@ -1,7 +1,7 @@ //! `Schema::closed` propagation — open/closed invariant tests. //! -//! Verifies that `QueryExpr::output_schema()` propagates the open/closed -//! completeness flag correctly through a lowered query DAG. +//! Verifies that the derived `OperatorNode::schema` propagates the open/closed +//! completeness flag correctly through a lowered query tree. //! //! Key invariant: a PromQL scan is always `closed: false` (open) because its //! label set is runtime-only. The schema freezes to `closed: true` exactly at @@ -12,14 +12,14 @@ use asap_integration_tests::fixtures::lower_promql; use asap_types::types::AccuracyTarget; -fn lower(q: &str) -> asap_types::pre_asap::QueryExpr { +fn lower(q: &str) -> std::rc::Rc { lower_promql(q, AccuracyTarget::Exact).unwrap_or_else(|e| panic!("lower failed for {q:?}: {e}")) } // bare scan is open — the metric's full label set is unknown at plan time #[test] fn schema_bare_scan_is_open() { - let s = lower("http_requests_total").output_schema().unwrap(); + let s = lower("http_requests_total").schema.clone(); assert!(!s.closed, "PromQL scan must be open"); } @@ -27,8 +27,8 @@ fn schema_bare_scan_is_open() { #[test] fn schema_filtered_scan_is_open() { let s = lower(r#"http_requests_total{job="api-server"}"#) - .output_schema() - .unwrap(); + .schema + .clone(); assert!(!s.closed, "PromQL scan with predicates must remain open"); assert_eq!(s.fields.len(), 3, "[ts, value, job]"); } @@ -36,9 +36,7 @@ fn schema_filtered_scan_is_open() { // per-series rate is label-preserving → output stays open #[test] fn schema_rate_stays_open() { - let s = lower("rate(http_requests_total[5m])") - .output_schema() - .unwrap(); + let s = lower("rate(http_requests_total[5m])").schema.clone(); assert!(!s.closed, "per-series rate is label-preserving; stays open"); } @@ -46,24 +44,22 @@ fn schema_rate_stays_open() { #[test] fn schema_count_over_time_stays_open() { let s = lower("count_over_time(http_requests_total[5m])") - .output_schema() - .unwrap(); + .schema + .clone(); assert!(!s.closed, "per-series count_over_time stays open"); } // cross-series sum with no group keys freezes to closed #[test] fn schema_sum_freezes_to_closed() { - let s = lower("sum(http_requests_total)").output_schema().unwrap(); + let s = lower("sum(http_requests_total)").schema.clone(); assert!(s.closed, "cross-series aggregate must freeze to closed"); } // cross-series sum grouped by job also freezes to closed #[test] fn schema_sum_by_job_freezes_to_closed() { - let s = lower("sum by (job) (http_requests_total)") - .output_schema() - .unwrap(); + let s = lower("sum by (job) (http_requests_total)").schema.clone(); assert!( s.closed, "grouped cross-series aggregate must freeze to closed" @@ -74,8 +70,8 @@ fn schema_sum_by_job_freezes_to_closed() { #[test] fn schema_sum_over_rate_freezes_to_closed() { let s = lower("sum by (job) (rate(http_requests_total[5m]))") - .output_schema() - .unwrap(); + .schema + .clone(); assert!( s.closed, "cross-series aggregate over rate must freeze to closed" @@ -86,8 +82,8 @@ fn schema_sum_over_rate_freezes_to_closed() { #[test] fn schema_binary_op_two_open_stays_open() { let s = lower("http_requests_total / http_errors_total") - .output_schema() - .unwrap(); + .schema + .clone(); assert!(!s.closed, "binary op over two open scans must stay open"); } @@ -95,8 +91,8 @@ fn schema_binary_op_two_open_stays_open() { #[test] fn schema_binary_op_two_closed_is_closed() { let s = lower("sum by (job) (http_requests_total) / sum by (job) (http_errors_total)") - .output_schema() - .unwrap(); + .schema + .clone(); assert!( s.closed, "binary op over two closed aggregates must be closed" diff --git a/crates/integration-tests/tests/sql_to_physical.rs b/crates/integration-tests/tests/sql_to_physical.rs index dd4426c22..c8457e85a 100644 --- a/crates/integration-tests/tests/sql_to_physical.rs +++ b/crates/integration-tests/tests/sql_to_physical.rs @@ -1,19 +1,22 @@ //! SQL frontend, candidate selection, physical compilation and fresh-run execution. +mod physical_common; use asap_aware_mapping::{search_workload, DefaultCostModel}; -use asap_frontend_sql::{lower_sql, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql, SqlCatalog}; use asap_physical_operators::{ - physical_planner::{compile, InputContract, Source}, runtime::{Limits, RunContext, Scope}, - sources::{DataSources, MemorySource}, + unified_physical_planner::{compile, InputContract, Source}, + unified_sources::{DataSources, MemorySource}, values::{Batch, Value}, }; +use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; use asap_types::{ - post_asap::{compile_post_asap_dag, FieldDataType, PostAsapOperatorPayload}, - pre_asap::{DataType, Field, QueryExpr, Schema}, + post_asap::FieldDataType, + pre_asap::{DataType, Field, Schema}, types::AccuracyTarget, }; use futures::StreamExt; -use std::{collections::BTreeMap, rc::Rc, sync::Arc}; +use physical_common::compile_physical_asap_dag; +use std::{collections::BTreeMap, sync::Arc}; /// SQL filtering and grouped aggregation survive logical/physical lowering; /// rebinding the compiled DAG runs against new data rather than cached results. @@ -30,27 +33,23 @@ async fn sql_filter_grouped_sum_executes_and_rebinds() { "SELECT service, SUM(value) AS total FROM metrics WHERE value > 1 GROUP BY service", "SELECT service, SUM(value) AS total FROM metrics GROUP BY service", ] { - let logical = Rc::new( - lower_sql(query, &catalog, AccuracyTarget::Exact) - .await - .unwrap(), - ); + let logical = lower_sql(query, &catalog, AccuracyTarget::Exact) + .await + .unwrap(); let space = search_workload(vec![("sql", logical)]); let selected = space .global_selection(&DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) .unwrap() .unwrap(); - let dag = compile_post_asap_dag(&selected).unwrap(); + let dag = compile_physical_asap_dag(&selected).unwrap(); let scan = dag .nodes .iter() .find(|node| { matches!( &node.payload, - PostAsapOperatorPayload::Fallback { - expression: QueryExpr::Scan { .. } - } + PhysicalASAPOperatorPayload::NonASAP(asap_types::ir::NonASAPOp::Scan { .. }) ) }) .expect("raw SQL scan"); @@ -61,8 +60,8 @@ async fn sql_filter_grouped_sum_executes_and_rebinds() { .all(|field| matches!(field.dtype, FieldDataType::Plain(_)))); let plan = compile( &dag, - BTreeMap::from([(u64::from(scan.id.0), InputContract::bounded(schema.clone()))]), - &[u64::from(dag.root.0)], + BTreeMap::from([(scan.id as u64, InputContract::bounded(schema.clone()))]), + &[dag.roots[0] as u64], ) .unwrap(); for multiplier in [1., 2.] { @@ -88,12 +87,15 @@ async fn sql_filter_grouped_sum_executes_and_rebinds() { .collect() }) .collect(); - let PostAsapOperatorPayload::Fallback { expression } = &scan.payload else { - unreachable!() - }; - let QueryExpr::Scan { source, .. } = expression else { + let PhysicalASAPOperatorPayload::NonASAP(asap_types::ir::NonASAPOp::Scan { + source, + predicates: _, + schema: _scan_schema, + }) = &scan.payload + else { unreachable!() }; + let expression = asap_types::ir::OperatorNode::reachable(&selected).into_iter().find(|n| matches!(n.non_asap(), Some(asap_types::ir::NonASAPOp::Scan { source: s, .. }) if s == source)).unwrap(); let mut sources = DataSources::default(); sources .register( @@ -109,8 +111,8 @@ async fn sql_filter_grouped_sum_executes_and_rebinds() { .unwrap(); let bound = plan .instantiate(BTreeMap::from([( - u64::from(scan.id.0), - Box::new(sources.bind(expression).unwrap()) as Source<'_>, + scan.id as u64, + Box::new(sources.bind(&expression).unwrap()) as Source<'_>, )])) .unwrap(); let mut stream = bound diff --git a/crates/integration-tests/tests/sql_to_post_asap.rs b/crates/integration-tests/tests/sql_to_post_asap.rs index 4cecd6952..d01dfae59 100644 --- a/crates/integration-tests/tests/sql_to_post_asap.rs +++ b/crates/integration-tests/tests/sql_to_post_asap.rs @@ -1,61 +1,90 @@ //! End-to-end SQL query-string → post-ASAP IR pin (issue #191). //! //! The SQL counterpart of `promql_to_post_asap.rs`: drives SQL text — -//! `lower_sql` (text → pre-ASAP `QueryExpr`) → -//! `SketchAlgorithmStrategy::replacements` (pre-ASAP → post-ASAP -//! `SummaryExpr`, see [`realize`] below) — and pins the resulting -//! sketch-vs-exact-accumulator shape node by node, the way -//! `promql_to_post_asap.rs` does for PromQL. +//! `lower_sql` (text → non-ASAP `OperatorNode` tree) → +//! `ASAPStrategies::replacements` (→ a tree with ASAP operators, +//! see [`realize`] below) — and pins the resulting sketch-vs-exact-accumulator +//! shape node by node, the way `promql_to_post_asap.rs` does for PromQL. //! //! ## A structural wrinkle PromQL doesn't have //! -//! `lower_promql` returns a *bare* `QueryExpr::Aggregate` for a top-level +//! `lower_promql` returns a *bare* `NonASAPOp::Aggregate` for a top-level //! aggregation (`sum by (job) (m)`, `quantile(0.99, …)`), so [`realize`] can //! bind it directly at the DAG root. `lower_sql` never does: DataFusion's //! planner always wraps even a single, unaliased aggregate in an identity //! `Project` (confirmed below), so a SQL DAG's *root* is normally `Project { //! child: Aggregate { .. } }`. Final materialization retains that projection -//! as a query-time value operation and independently plans its child, keeping +//! as a query-time non-ASAP node and independently plans its child, keeping //! both SELECT-list semantics and the summary-bound aggregate visible. use std::rc::Rc; -use asap_aware_mapping::replacement::{keep_pre_asap, RealizationError}; +use asap_aware_mapping::replacement::{retain_exact, RealizationError}; use asap_aware_mapping::{ - search_workload, DefaultCostModel, Replacement, ReplacementStrategy, ReplacementSubDAG, - SketchAlgorithmStrategy, TargetSubDAG, + search_workload, ASAPStrategies, DefaultCostModel, Replacement, ReplacementStrategy, + ReplacementSubDAG, TargetSubDAG, }; -use asap_frontend_sql::{lower_sql, lower_sql_dialect, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql, lower_sql_dialect, SqlCatalog}; +use asap_integration_tests::post_asap::post_asap_dag; +use asap_types::ir::operator_properties::Reduction; +use asap_types::ir::physical_export::{EdgeRole, PhysicalASAPNodeId, PhysicalASAPOperatorPayload}; +use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, Predicate, ScalarExpr}; use asap_types::post_asap::{ - compile_post_asap_dag, EdgeRole, ExactKind, ExactParams, FieldDataType, GroupingStrategy, - PostAsapOperatorPayload, SketchAlgorithm, SketchKind, SketchParams, SketchStatistic, - SummaryExpr, SummaryNode, SummaryUpdate, ValueOperation, + ExactKind, ExactParams, FieldDataType, GroupingStrategy, SketchAlgorithm, SketchKind, + SketchParams, SketchStatistic, SummaryUpdate, }; use asap_types::pre_asap::expr_ir::ColumnRef; -use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use asap_types::workload::SqlDialect; -/// This crate has no "bind me one DAG" public API any more — -/// `SketchAlgorithmStrategy::replacements` always returns every candidate, and +/// This crate has no "bind me one tree" public API any more — +/// `ASAPStrategies::replacements` always returns every candidate, and /// a caller decides what to keep. This test-only helper reproduces the -/// take-the-first-(`cost_model`-preferred)-candidate pattern so the +/// take-the-first-(`cost_model`-preferred)-summary-candidate pattern so the /// single-answer pins below don't all repeat it by hand. -fn realize(expr: &QueryExpr) -> Result, RealizationError> { - let root = Rc::new(expr.clone()); - let target = TargetSubDAG::new(&root); - match SketchAlgorithmStrategy::default_cost_model() - .replacements(&target) +fn realize(target: &Rc) -> Result, RealizationError> { + let target_dag = TargetSubDAG::new(target); + match ASAPStrategies::default_cost_model() + .replacements(&target_dag) .into_iter() .next() { Some(ReplacementSubDAG { - replacement: Replacement::Summary(node), + replacement: Replacement::SubDAG(node), .. - }) => Ok(node), - _ => keep_pre_asap(&root), + }) if node.contains_asap() => Ok(node), + _ => retain_exact(target), } + .inspect(|node| { + node.validate_structure() + .expect("planned dag satisfies the unified IR contract") + }) +} + +/// The single input of a unary non-ASAP node (Project, Filter, Sort, ...) or +/// of a `FinalizeExactAccumulator`; `None` for anything else. +fn unary_child(node: &OperatorNode) -> Option<&Rc> { + match &node.operator { + Operator::NonASAP(op) => match op.children().as_slice() { + [child] => Some(*child), + _ => None, + }, + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) => Some(child), + Operator::ASAP(_) => None, + } +} + +/// A sub-DAG kept as plain (non-ASAP) work: no ASAP operator anywhere below. +fn is_kept_non_asap(node: &OperatorNode) -> bool { + node.non_asap().is_some() && !node.contains_asap() +} + +/// Mirror a scalar-only predicate (no operator references) to its wire form. +fn wire_pred(pred: &Predicate) -> Predicate { + Predicate(pred.0.map_operator_refs(&mut |_| -> PhysicalASAPNodeId { + panic!("fixture predicate references no operator") + })) } fn dtype<'a>(schema: &'a Schema, name: &str) -> &'a FieldDataType { @@ -89,7 +118,7 @@ fn catalog() -> SqlCatalog { ) } -async fn lower(sql: &str, accuracy: AccuracyTarget) -> QueryExpr { +async fn lower(sql: &str, accuracy: AccuracyTarget) -> Rc { lower_sql(sql, &catalog(), accuracy) .await .unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}")) @@ -121,26 +150,27 @@ async fn clickhouse_temporal_sql_reuses_rate_and_increase_physical_summaries() { .expect("explicit temporal SQL must lower"); let physical = realize(inner_aggregate(&pre_asap)).expect("temporal reducer must be planned"); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family, reduction, child, .. - } = &physical.expr + }) = &physical.operator else { - panic!("expected a shared SummaryAgg, got {:?}", physical.expr); + panic!("expected a shared SummaryAgg, got {:?}", physical.operator); }; assert_eq!(family, &expected); assert_eq!(reduction, &Reduction::PerEntity); - let SummaryExpr::KeepPreAsap(raw) = &child.expr else { - panic!( - "expected a retained temporal SQL input, got {:?}", - child.expr - ); - }; - assert!(matches!(raw.as_ref(), QueryExpr::TimeRange { range, child } + assert!( + is_kept_non_asap(child), + "expected a retained temporal SQL input, got {:?}", + child.operator + ); + assert!( + matches!(child.non_asap(), Some(NonASAPOp::TimeRange { range, child, .. }) if *range == std::time::Duration::from_secs(300) - && matches!(child.as_ref(), QueryExpr::Project { .. }))); + && matches!(child.non_asap(), Some(NonASAPOp::Project { .. }))) + ); } } @@ -156,16 +186,14 @@ async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { SELECT service, {function}(latency, ts, {window_ms}) AS v \ FROM metrics GROUP BY service)" ); - let pre_asap = Rc::new( - lower_sql_dialect( - &sql, - &catalog(), - SqlDialect::ClickhouseSQL, - AccuracyTarget::Exact, - ) - .await - .expect("nested temporal SQL must lower"), - ); + let pre_asap = lower_sql_dialect( + &sql, + &catalog(), + SqlDialect::ClickhouseSQL, + AccuracyTarget::Exact, + ) + .await + .expect("nested temporal SQL must lower"); let space = search_workload(vec![("nested", Rc::clone(&pre_asap))]); let selection = space.global_selection(&DefaultCostModel); let root = selection @@ -173,42 +201,40 @@ async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { .expect("materialization failed") .expect("root must be discovered"); - fn has_temporal_summary(node: &SummaryNode) -> bool { - match &node.expr { - SummaryExpr::SummaryAgg { + fn has_temporal_summary(node: &OperatorNode) -> bool { + match &node.operator { + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Rate | ExactKind::Increase, _), .. - } => true, - SummaryExpr::ValueOperation { child, .. } - | SummaryExpr::SummaryEstimate { - summary_input: child, - .. - } => has_temporal_summary(child), - _ => false, + }) => true, + Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { + has_temporal_summary(summary_input) + } + _ => unary_child(node).is_some_and(|child| has_temporal_summary(child)), } } assert!( has_temporal_summary(&root), "inner {function} was hidden: {root:?}" ); - let dag = compile_post_asap_dag(&root).expect("nested SQL DAG must compile"); + let dag = post_asap_dag(&root); assert!(dag.nodes.iter().any(|node| matches!( node.payload, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Exact(_), - .. - } + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Aggregate { .. }) ))); } } /// The `Aggregate` node beneath the identity `Project` DataFusion's planner /// always wraps a top-level aggregate in — see the module docs above. -fn inner_aggregate(qe: &QueryExpr) -> &QueryExpr { - match qe { - QueryExpr::Project { child, .. } => inner_aggregate(child), - QueryExpr::Aggregate { .. } => qe, - other => panic!("expected a Project{{Aggregate}} shape, got {other:?}"), +fn inner_aggregate(node: &Rc) -> &Rc { + match node.non_asap() { + Some(NonASAPOp::Project { child, .. }) => inner_aggregate(child), + Some(NonASAPOp::Aggregate { .. }) => node, + _ => panic!( + "expected a Project{{Aggregate}} shape, got {:?}", + node.operator + ), } } @@ -221,32 +247,27 @@ async fn sql_full_query_retains_project_and_binds_inner_aggregate() { AccuracyTarget::Epsilon(0.01), ) .await; - assert!( - matches!(pre_asap, QueryExpr::Project { .. }), - "sanity: a SQL root is a Project, unlike lower_promql's bare Aggregate" - ); - let pre_asap = Rc::new(pre_asap); + let Some(NonASAPOp::Project { + cols: expected_cols, + qualifier: expected_qualifier, + .. + }) = pre_asap.non_asap() + else { + panic!("sanity: a SQL root is a Project, unlike lower_promql's bare Aggregate"); + }; let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); - let QueryExpr::Project { - cols: expected_cols, - qualifier: expected_qualifier, - .. - } = pre_asap.as_ref() - else { - unreachable!() - }; - let SummaryExpr::ValueOperation { + let Some(NonASAPOp::Project { child, - operation: asap_types::post_asap::ValueOperation::Project { cols, qualifier }, - .. - } = &root.expr + cols, + qualifier, + }) = root.non_asap() else { - panic!("expected retained Project root, got {:?}", root.expr); + panic!("expected retained Project root, got {:?}", root.operator); }; assert_eq!(cols, expected_cols, "projection expressions and aliases"); assert_eq!(qualifier, expected_qualifier, "projection qualifier"); @@ -256,7 +277,10 @@ async fn sql_full_query_retains_project_and_binds_inner_aggregate() { FieldDataType::Plain(DataType::Float64) ); assert!( - matches!(child.expr, SummaryExpr::SummaryEstimate { .. }), + matches!( + child.operator, + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + ), "the Aggregate under Project must be summary-bound" ); } @@ -265,63 +289,62 @@ async fn sql_full_query_retains_project_and_binds_inner_aggregate() { /// aggregates are independently selected as physical summaries. #[tokio::test] async fn sql_join_recursively_binds_both_temporal_aggregate_children() { - let pre_asap = Rc::new( - lower_sql_dialect( - "SELECT a.service, a.v / b.v AS ratio FROM \ - (SELECT service, asap_rate(latency, ts, 300000) AS v FROM metrics WHERE service='errors' GROUP BY service) a \ - INNER JOIN \ - (SELECT service, asap_rate(latency, ts, 300000) AS v FROM metrics WHERE service='requests' GROUP BY service) b \ - ON b.service=a.service", - &catalog(), - SqlDialect::ClickhouseSQL, - AccuracyTarget::Exact, - ) - .await - .expect("two-subquery rate ratio must lower"), - ); + let pre_asap = lower_sql_dialect( + "SELECT a.service, a.v / b.v AS ratio FROM \ + (SELECT service, asap_rate(latency, ts, 300000) AS v FROM metrics WHERE service='errors' GROUP BY service) a \ + INNER JOIN \ + (SELECT service, asap_rate(latency, ts, 300000) AS v FROM metrics WHERE service='requests' GROUP BY service) b \ + ON b.service=a.service", + &catalog(), + SqlDialect::ClickhouseSQL, + AccuracyTarget::Exact, + ) + .await + .expect("two-subquery rate ratio must lower"); let space = search_workload(vec![("ratio", Rc::clone(&pre_asap))]); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); - let SummaryExpr::ValueOperation { - child: join, - operation: ValueOperation::Project { cols, .. }, - .. - } = &root.expr + let Some(NonASAPOp::Project { + child: join, cols, .. + }) = root.non_asap() else { panic!( "expected Project above relational join, got {:?}", - root.expr + root.operator ); }; assert!(matches!( &cols[1].expr, - QueryExpr::Arithmetic { + ScalarExpr::Arithmetic { op: asap_types::pre_asap::ArithmeticOpKind::Div, .. } )); - let SummaryExpr::RelationalJoin { + let Some(NonASAPOp::Join { left, right, kind, pred, - pruning: None, - } = &join.expr + }) = join.non_asap() else { - panic!("expected read-time relational join, got {:?}", join.expr); + panic!( + "expected read-time relational join, got {:?}", + join.operator + ); }; assert_eq!(kind, &asap_types::pre_asap::JoinKind::Inner); assert!(matches!( - pred.0.as_ref(), - QueryExpr::Compare { + &pred.0, + ScalarExpr::Compare { left, op: asap_types::pre_asap::CompareOpKind::Eq, right, - } if matches!(left.as_ref(), QueryExpr::Column(0)) - && matches!(right.as_ref(), QueryExpr::Column(2)) + .. + } if matches!(left.as_ref(), ScalarExpr::Column(0)) + && matches!(right.as_ref(), ScalarExpr::Column(2)) )); assert_eq!( join.schema @@ -332,39 +355,42 @@ async fn sql_join_recursively_binds_both_temporal_aggregate_children() { vec!["service", "v", "service", "v"] ); for child in [left, right] { - let SummaryExpr::ValueOperation { - child: aggregate, - operation: ValueOperation::Project { .. }, - .. - } = &child.expr + let Some(NonASAPOp::Project { + child: aggregate, .. + }) = child.non_asap() else { - panic!("derived table Project was not retained: {:?}", child.expr); + panic!( + "derived table Project was not retained: {:?}", + child.operator + ); }; - let SummaryExpr::ValueOperation { - child: aggregate, - operation: ValueOperation::FinalizeExactAccumulator, - .. - } = &aggregate.expr + let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: aggregate }) = + &aggregate.operator else { panic!("derived table Project must consume finalized exact values"); }; assert!(matches!( - aggregate.expr, - SummaryExpr::SummaryAgg { + aggregate.operator, + Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(ExactKind::Rate, ExactParams::Rate), .. - } + }) )); } assert!(join .guarantee .as_ref() .is_some_and(|value| value.is_exact())); - let dag = compile_post_asap_dag(&root).expect("join DAG must compile"); + let dag = post_asap_dag(&root); let join_id = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::RelationalJoin { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Join { .. }) + ) + }) .expect("relational join node") .id; let roles = dag @@ -383,29 +409,27 @@ async fn unsupported_sql_join_shapes_remain_fail_closed() { "SELECT a.service FROM (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) a INNER JOIN (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) b ON a.v>b.v", "SELECT a.service FROM (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) a INNER JOIN (SELECT service, asap_rate(latency, ts, 300000) v FROM metrics GROUP BY service) b ON a.service=a.service", ] { - let pre_asap = Rc::new( - lower_sql_dialect( - sql, - &catalog(), - SqlDialect::ClickhouseSQL, - AccuracyTarget::Exact, - ) - .await - .unwrap_or_else(|error| panic!("join must lower before fail-closed mapping: {error}")), - ); + let pre_asap = lower_sql_dialect( + sql, + &catalog(), + SqlDialect::ClickhouseSQL, + AccuracyTarget::Exact, + ) + .await + .unwrap_or_else(|error| panic!("join must lower before fail-closed mapping: {error}")); let space = search_workload(vec![("unsupported-join", Rc::clone(&pre_asap))]); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); - let SummaryExpr::ValueOperation { child, .. } = &root.expr else { - panic!("SQL projection must remain explicit: {:?}", root.expr); + let Some(NonASAPOp::Project { child, .. }) = root.non_asap() else { + panic!("SQL projection must remain explicit: {:?}", root.operator); }; assert!( - matches!(child.expr, SummaryExpr::KeepPreAsap(_)), + is_kept_non_asap(child), "unsupported join was partially accelerated: {:?}", - child.expr + child.operator ); } } @@ -414,16 +438,14 @@ async fn unsupported_sql_join_shapes_remain_fail_closed() { /// explicit read-time nodes while the aggregate is summary-bound. #[tokio::test] async fn sql_relational_parents_retain_summary_bound_aggregate() { - let pre_asap = Rc::new( - lower( - "SELECT t.service, t.p FROM \ - (SELECT service, approx_percentile_cont(latency, 0.9) AS p \ - FROM metrics GROUP BY service) t \ - WHERE t.p > 100 ORDER BY t.p DESC LIMIT 5", - AccuracyTarget::Epsilon(0.01), - ) - .await, - ); + let pre_asap = lower( + "SELECT t.service, t.p FROM \ + (SELECT service, approx_percentile_cont(latency, 0.9) AS p \ + FROM metrics GROUP BY service) t \ + WHERE t.p > 100 ORDER BY t.p DESC LIMIT 5", + AccuracyTarget::Epsilon(0.01), + ) + .await; let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); let selection = space.global_selection(&DefaultCostModel); let root = selection @@ -437,28 +459,29 @@ async fn sql_relational_parents_retain_summary_bound_aggregate() { let mut saw_sort = false; let mut saw_limit = false; loop { - match &node.expr { - SummaryExpr::ValueOperation { - child, operation, .. - } => { - match operation { - asap_types::post_asap::ValueOperation::Project { .. } => saw_project = true, - asap_types::post_asap::ValueOperation::Filter { .. } => saw_filter = true, - asap_types::post_asap::ValueOperation::Sort { .. } => saw_sort = true, - asap_types::post_asap::ValueOperation::Limit { n, offset, .. } => { - assert_eq!((*n, *offset), (5, 0)); - saw_limit = true; - } - _ => {} - } - node = child; - } - SummaryExpr::SummaryEstimate { summary_input, .. } => { - assert!(matches!(summary_input.expr, SummaryExpr::SummaryAgg { .. })); - break; + if let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator { + assert!(matches!( + summary_input.operator, + Operator::ASAP(ASAPOp::SummaryAgg { .. }) + )); + break; + } + match node.non_asap() { + Some(NonASAPOp::Project { .. }) => saw_project = true, + Some(NonASAPOp::Filter { .. }) => saw_filter = true, + Some(NonASAPOp::Sort { .. }) => saw_sort = true, + Some(NonASAPOp::Limit { n, offset, .. }) => { + assert_eq!((*n, *offset), (Some(5), 0)); + saw_limit = true; } - other => panic!("expected relational parents over SummaryEstimate, got {other:?}"), + _ => {} } + node = unary_child(node).unwrap_or_else(|| { + panic!( + "expected relational parents over SummaryEstimate, got {:?}", + node.operator + ) + }); } assert!(saw_project && saw_filter && saw_sort && saw_limit); } @@ -468,39 +491,47 @@ async fn sql_relational_parents_retain_summary_bound_aggregate() { /// may be dropped or moved across the aggregation boundary. #[tokio::test] async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { - let pre_asap = Rc::new( - lower( - "SELECT t.service, t.p FROM \ - (SELECT service, approx_percentile_cont(latency, 0.9) AS p \ - FROM metrics WHERE service = 'api' GROUP BY service) t \ - WHERE t.p > 100", - AccuracyTarget::Epsilon(0.01), - ) - .await, - ); + let pre_asap = lower( + "SELECT t.service, t.p FROM \ + (SELECT service, approx_percentile_cont(latency, 0.9) AS p \ + FROM metrics WHERE service = 'api' GROUP BY service) t \ + WHERE t.p > 100", + AccuracyTarget::Epsilon(0.01), + ) + .await; let expected_read_predicate = { - let mut node = pre_asap.as_ref(); + let mut node = &pre_asap; loop { - match node { - QueryExpr::Filter { pred, .. } => break pred.clone(), - QueryExpr::Project { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } => node = child, - other => panic!("expected a Filter above the aggregate, got {other:?}"), + match node.non_asap() { + Some(NonASAPOp::Filter { pred, .. }) => break pred.clone(), + Some( + NonASAPOp::Project { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. }, + ) => node = child, + _ => panic!( + "expected a Filter above the aggregate, got {:?}", + node.operator + ), } } }; let expected_source_predicates = { - let mut node = pre_asap.as_ref(); + let mut node = &pre_asap; loop { - match node { - QueryExpr::Scan { predicates, .. } => break predicates.clone(), - QueryExpr::Project { child, .. } - | QueryExpr::Filter { child, .. } - | QueryExpr::Aggregate { child, .. } - | QueryExpr::Sort { child, .. } - | QueryExpr::Limit { child, .. } => node = child, - other => panic!("expected a unary SQL plan over Scan, got {other:?}"), + match node.non_asap() { + Some(NonASAPOp::Scan { predicates, .. }) => break predicates.clone(), + Some( + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Aggregate { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. }, + ) => node = child, + _ => panic!( + "expected a unary SQL plan over Scan, got {:?}", + node.operator + ), } } }; @@ -516,41 +547,37 @@ async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { let mut node = root.as_ref(); let mut retained_read_predicate = None; loop { - match &node.expr { - SummaryExpr::ValueOperation { - child, - operation: ValueOperation::Filter { pred }, - .. - } => { - retained_read_predicate = Some(pred.clone()); - node = child; - } - SummaryExpr::ValueOperation { child, .. } => node = child, - SummaryExpr::SummaryEstimate { summary_input, .. } => { - let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { - panic!("expected SummaryAgg below SummaryEstimate"); - }; - let SummaryExpr::KeepPreAsap(raw_input) = &child.expr else { - panic!("expected raw summary population below SummaryAgg"); - }; - let QueryExpr::Scan { predicates, .. } = raw_input.as_ref() else { - panic!("expected source selection to remain a Scan"); - }; - assert_eq!(predicates, &expected_source_predicates); - break; - } - other => panic!("expected read-time operations over a summary, got {other:?}"), + if let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &node.operator { + let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary_input.operator else { + panic!("expected SummaryAgg below SummaryEstimate"); + }; + assert!( + is_kept_non_asap(child), + "expected raw summary population below SummaryAgg" + ); + let Some(NonASAPOp::Scan { predicates, .. }) = child.non_asap() else { + panic!("expected source selection to remain a Scan"); + }; + assert_eq!(predicates, &expected_source_predicates); + break; + } + if let Some(NonASAPOp::Filter { pred, .. }) = node.non_asap() { + retained_read_predicate = Some(pred.clone()); } + node = unary_child(node).unwrap_or_else(|| { + panic!( + "expected read-time operations over a summary, got {:?}", + node.operator + ) + }); } assert_eq!(retained_read_predicate, Some(expected_read_predicate)); - let dag = compile_post_asap_dag(&root).expect("typed DAG compilation failed"); + let dag = post_asap_dag(&root); + let expected_wire = wire_pred(retained_read_predicate.as_ref().unwrap()); assert!(dag.nodes.iter().any(|node| matches!( &node.payload, - PostAsapOperatorPayload::Value { - operation: ValueOperation::Filter { pred }, - .. - } if pred == retained_read_predicate.as_ref().unwrap() + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Filter { pred, .. }) if *pred == expected_wire ))); } @@ -559,15 +586,13 @@ async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { /// read-time operation. #[tokio::test] async fn sql_filter_preserves_local_fallback_boundary_for_unsupported_child() { - let pre_asap = Rc::new( - lower( - "SELECT t.service, t.avg_bytes FROM \ - (SELECT service, AVG(bytes) AS avg_bytes FROM metrics GROUP BY service) t \ - WHERE t.avg_bytes > 100", - AccuracyTarget::Exact, - ) - .await, - ); + let pre_asap = lower( + "SELECT t.service, t.avg_bytes FROM \ + (SELECT service, AVG(bytes) AS avg_bytes FROM metrics GROUP BY service) t \ + WHERE t.avg_bytes > 100", + AccuracyTarget::Exact, + ) + .await; let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); let selection = space.global_selection(&DefaultCostModel); let root = selection @@ -578,22 +603,20 @@ async fn sql_filter_preserves_local_fallback_boundary_for_unsupported_child() { let mut node = root.as_ref(); let mut saw_filter = false; loop { - match &node.expr { - SummaryExpr::ValueOperation { - child, operation, .. - } => { - saw_filter |= matches!(operation, ValueOperation::Filter { .. }); - node = child; - } - SummaryExpr::KeepPreAsap(fallback) => { - assert!( - matches!(fallback.as_ref(), QueryExpr::BinaryOp { .. }), - "AVG's unsupported rewritten child should be opaque, got {fallback:?}" - ); - break; - } - other => panic!("expected local value operations over fallback child, got {other:?}"), + if let Some(NonASAPOp::BinaryOp { .. }) = node.non_asap() { + assert!( + is_kept_non_asap(node), + "AVG's unsupported rewritten child should be kept whole, got {node:?}" + ); + break; } + saw_filter |= matches!(node.non_asap(), Some(NonASAPOp::Filter { .. })); + node = unary_child(node).unwrap_or_else(|| { + panic!( + "expected local value operations over fallback child, got {:?}", + node.operator + ) + }); } assert!(saw_filter, "supported Filter must remain explicit"); } @@ -604,7 +627,7 @@ async fn sql_filter_preserves_local_fallback_boundary_for_unsupported_child() { /// ```text /// SummaryEstimate { query: Quantile{0.99} } → {…: Float64} /// └─ SummaryAgg { Kll{k:269}, input: metrics.latency } → {…: Sketch(Kll, {k:269})} -/// └─ KeepPreAsap(Scan) → {ts, service, latency, bytes} +/// └─ Scan (kept non-ASAP) → {ts, service, latency, bytes} /// ``` /// /// The SQL counterpart of `promql_to_post_asap.rs`'s @@ -622,12 +645,12 @@ async fn sql_quantile_binds_kll_sketch_over_named_column() { let agg = inner_aggregate(&pre_asap); let root = realize(agg).expect("binding failed"); - let SummaryExpr::SummaryEstimate { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } = &root.expr + }) = &root.operator else { - panic!("expected SummaryEstimate root, got {:?}", root.expr); + panic!("expected SummaryEstimate root, got {:?}", root.operator); }; assert!(matches!(query, SketchStatistic::Quantile { q } if *q == 0.99)); assert_eq!( @@ -641,15 +664,15 @@ async fn sql_quantile_binds_kll_sketch_over_named_column() { "the summary-state type must not propagate past the estimate" ); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { child, family, input, reduction, .. - } = &summary_input.expr + }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!( family, @@ -679,10 +702,12 @@ async fn sql_quantile_binds_kll_sketch_over_named_column() { ) ); - let SummaryExpr::KeepPreAsap(kept_leaf) = &child.expr else { - panic!("expected KeepPreAsap leaf, got {:?}", child.expr); - }; - assert!(matches!(kept_leaf.as_ref(), QueryExpr::Scan { .. })); + assert!( + is_kept_non_asap(child), + "expected a kept non-ASAP leaf, got {:?}", + child.operator + ); + assert!(matches!(child.non_asap(), Some(NonASAPOp::Scan { .. }))); assert!( child .schema @@ -709,12 +734,12 @@ async fn sql_count_distinct_with_epsilon_binds_hll_rse_over_named_column() { let agg = inner_aggregate(&pre_asap); let root = realize(agg).expect("binding failed"); - let SummaryExpr::SummaryEstimate { + let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, query, - } = &root.expr + }) = &root.operator else { - panic!("expected SummaryEstimate root, got {:?}", root.expr); + panic!("expected SummaryEstimate root, got {:?}", root.operator); }; assert!(matches!(query, SketchStatistic::Cardinality)); assert_eq!( @@ -723,14 +748,14 @@ async fn sql_count_distinct_with_epsilon_binds_hll_rse_over_named_column() { "COUNT(DISTINCT …) reads back out as an integer count" ); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family, input, reduction, .. - } = &summary_input.expr + }) = &summary_input.operator else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + panic!("expected SummaryAgg, got {:?}", summary_input.operator); }; assert_eq!( family, @@ -763,11 +788,11 @@ async fn sql_exact_workload_binds_accumulators_not_sketches() { .await; let agg = inner_aggregate(&pre_asap); let root = realize(agg).expect("binding failed"); - let SummaryExpr::SummaryAgg { + let Operator::ASAP(ASAPOp::SummaryAgg { family, reduction, .. - } = &root.expr + }) = &root.operator else { - panic!("expected SummaryAgg, got {:?}", root.expr); + panic!("expected SummaryAgg, got {:?}", root.operator); }; assert_eq!( family, @@ -788,37 +813,42 @@ async fn sql_exact_workload_binds_accumulators_not_sketches() { let agg = inner_aggregate(&pre_asap); let root = realize(agg).expect("binding failed"); assert!( - matches!(root.expr, SummaryExpr::KeepPreAsap(_)), + is_kept_non_asap(&root), "avg has no mergeable accumulator — stays logical" ); + assert!( + root.guarantee.as_ref().is_some_and(|g| g.is_exact()), + "a kept logical sub_dag is exact" + ); } #[tokio::test] async fn map_projection_export_preserves_unsupported_child_boundary() { - let pre = Rc::new(lower_sql_dialect( + let pre = lower_sql_dialect( "SELECT map('job', t.service) AS labels, t.avg_bytes FROM (SELECT service, AVG(bytes) AS avg_bytes FROM metrics GROUP BY service) t WHERE t.avg_bytes > 100", &catalog(), SqlDialect::ClickhouseSQL, AccuracyTarget::Exact, - ).await.unwrap()); + ).await.unwrap(); let space = search_workload(vec![("map_query", pre)]); let root = space .global_selection(&DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) .unwrap() .unwrap(); - let dag = compile_post_asap_dag(&root).unwrap(); + let dag = post_asap_dag(&root); assert!(dag.nodes.iter().any(|node| matches!(&node.payload, - PostAsapOperatorPayload::Value { operation: ValueOperation::Project { cols, .. }, .. } - if cols.iter().any(|item| matches!(&item.expr, QueryExpr::FunctionCall { name, .. } if name == "map")) + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Project { cols, .. }) + if cols.iter().any(|item| matches!(&item.expr, ScalarExpr::FunctionCall { name, .. } if name == "map")) ))); let mut node = root.as_ref(); loop { - match &node.expr { - SummaryExpr::ValueOperation { child, .. } => node = child, - SummaryExpr::KeepPreAsap(child) => { - assert!(matches!(child.as_ref(), QueryExpr::BinaryOp { .. })); - break; - } - other => panic!("unexpected map/fallback composition: {other:?}"), + if let Some(NonASAPOp::BinaryOp { .. }) = node.non_asap() { + assert!( + is_kept_non_asap(node), + "fallback child must stay whole: {node:?}" + ); + break; } + node = unary_child(node) + .unwrap_or_else(|| panic!("unexpected map/fallback composition: {:?}", node.operator)); } } diff --git a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs b/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs index eeade3ae5..eb7594480 100644 --- a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs +++ b/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs @@ -2,6 +2,9 @@ //! source workload -> PromQL lowering -> candidate search -> //! summary-maintenance lifecycle selection -> materialized deployment guarantees. +use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; +use asap_types::ir::ASAPOp; +use physical_common::compile_physical_asap_dag; use std::rc::Rc; use asap_aware_mapping::cost_model::Cost; @@ -12,9 +15,10 @@ use asap_aware_mapping::{ SummaryMaintenanceCapabilities, SummaryMaintenanceLifecycleCapabilities, SummaryMaintenanceLifecycleCostInputs, SummaryMaintenanceLifecycleRejection, WorkloadDemand, }; -use asap_frontend_promql::lower_promql_workload; +use asap_frontend_promql::unified::lower_promql_workload; +use asap_types::ir::OperatorNode; use asap_types::post_asap::{ - EvaluationSchedule, SummaryMaintenanceLifecycle, SummaryMaintenanceMode, SummaryNode, + EvaluationSchedule, SummaryMaintenanceLifecycle, SummaryMaintenanceMode, }; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::types::AccuracyTarget; @@ -32,7 +36,7 @@ struct FullyCostedRuntime; impl CostModel for FullyCostedRuntime { fn raw_query_recompute_total_cost( &self, - _target: &asap_types::pre_asap::QueryExpr, + _target: &OperatorNode, _expected_reads: f64, ) -> Option { Some(Cost(1_000.0)) @@ -48,7 +52,7 @@ impl CostModel for FullyCostedRuntime { fn summary_maintenance_lifecycle_cost_inputs( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { SummaryMaintenanceLifecycleCostInputs { build_cost: Some(Cost(10.0)), @@ -61,7 +65,7 @@ impl CostModel for FullyCostedRuntime { fn summary_maintenance_capabilities( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { SummaryMaintenanceCapabilities { incremental_update: true, @@ -179,8 +183,8 @@ fn promql_dashboard_materializes_continuous_summary_with_explained_rejections() .as_array() .unwrap() .iter() - .find(|node| node["kind"] == "SummaryAgg") - .expect("exported SummaryAgg node"); + .find(|node| node["kind"] == "summary_agg") + .expect("exported summary_agg node"); assert_eq!( summary_node["detail"]["summary_maintenance"]["selected"]["lifecycle"]["kind"], "continuously_maintained" @@ -217,11 +221,11 @@ fn selected_plan_with_horizon( fn selected_plan_for_lowered( workload: &PlanningWorkload, - lowered: asap_types::pre_asap::QueryExpr, + lowered: Rc, model: &dyn CostModel, horizon: Horizon, ) -> asap_aware_mapping::SummaryMaintenanceLifecyclePlan { - let root = Rc::new(lowered); + let root = lowered; let strategies = asap_aware_mapping::default_strategies_with(model); let space = search_workload_with(vec![("dashboard", Rc::clone(&root))], &strategies); let target = Rc::clone(&space.roots[0].1); @@ -269,14 +273,11 @@ mod physical_common; #[test] fn continuous_lifecycle_compiles_and_executes_spatial_kll() { use asap_physical_operators::{ - physical_planner::{compile_candidate, InputContract}, runtime::Scope, + unified_physical_planner::{compile_candidate, InputContract}, values::{Batch, Value}, }; - use asap_types::{ - post_asap::{compile_post_asap_dag, FieldDataType, PostAsapOperatorPayload}, - pre_asap::DataType, - }; + use asap_types::{post_asap::FieldDataType, pre_asap::DataType}; use std::{collections::BTreeMap, sync::Arc}; let mut workload = dashboard_workload(); workload.query_workload.query_batch.as_mut().unwrap()[0].query = @@ -292,11 +293,16 @@ fn continuous_lifecycle_compiles_and_executes_spatial_kll() { .summary_maintenance_lifecycle, SummaryMaintenanceLifecycle::ContinuouslyMaintained ); - let dag = compile_post_asap_dag(&selected.root).unwrap(); + let dag = compile_physical_asap_dag(&selected.root).unwrap(); let build = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::SummaryAgg { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) + ) + }) .unwrap(); let input = dag .edges @@ -308,9 +314,9 @@ fn continuous_lifecycle_compiles_and_executes_spatial_kll() { let schema = Arc::new(raw.output_schema.clone()); let candidate = compile_candidate( &dag, - BTreeMap::from([(u64::from(input.0), InputContract::bounded(schema.clone()))]), - &[u64::from(dag.root.0)], - &[u64::from(build.id.0)], + BTreeMap::from([(input as u64, InputContract::bounded(schema.clone()))]), + &[dag.roots[0] as u64], + &[build.id as u64], ) .unwrap(); @@ -321,20 +327,20 @@ fn continuous_lifecycle_compiles_and_executes_spatial_kll() { unbounded.properties.boundedness = asap_physical_operators::plan::Boundedness::Unbounded; let rejected = compile_candidate( &dag, - BTreeMap::from([(u64::from(input.0), unbounded)]), - &[u64::from(dag.root.0)], - &[u64::from(build.id.0)], + BTreeMap::from([(input as u64, unbounded)]), + &[dag.roots[0] as u64], + &[build.id as u64], ); assert!(rejected.is_err()); let request = compile_candidate( &dag, - BTreeMap::from([(u64::from(input.0), InputContract::bounded(schema.clone()))]), - &[u64::from(dag.root.0)], + BTreeMap::from([(input as u64, InputContract::bounded(schema.clone()))]), + &[dag.roots[0] as u64], &[], ) .unwrap(); let mut priced = 0; - let feedback = asap_physical_operators::physical_planner::select_candidate( + let feedback = asap_physical_operators::unified_physical_planner::select_candidate( vec![ rejected.map(|candidate| { ( @@ -347,7 +353,7 @@ fn continuous_lifecycle_compiles_and_executes_spatial_kll() { |_| { priced += 1; Ok(Some( - asap_physical_operators::physical_planner::CandidateCost { + asap_physical_operators::unified_physical_planner::CandidateCost { workload_scope: "dashboard".into(), horizon_seconds: 100., total_cost: 1000., @@ -375,7 +381,7 @@ fn continuous_lifecycle_compiles_and_executes_spatial_kll() { let raw_batch = Batch::try_new(schema.clone(), rows).unwrap(); let direct = physical_common::execute( &feedback.candidate.1.query, - BTreeMap::from([(u64::from(input.0), raw_batch.clone())]), + BTreeMap::from([(input as u64, raw_batch.clone())]), Scope::Query { evaluation_time_ms: 300_000, revision, @@ -383,7 +389,7 @@ fn continuous_lifecycle_compiles_and_executes_spatial_kll() { ); let state = physical_common::execute( candidate.precompute.as_ref().unwrap(), - BTreeMap::from([(u64::from(input.0), raw_batch)]), + BTreeMap::from([(input as u64, raw_batch)]), Scope::Ingestion { window_start_ms: 0, window_end_ms: 300_000, @@ -392,7 +398,7 @@ fn continuous_lifecycle_compiles_and_executes_spatial_kll() { ); let result = physical_common::execute( &candidate.query, - BTreeMap::from([(u64::from(build.id.0), state[0][0].clone())]), + BTreeMap::from([(build.id as u64, state[0][0].clone())]), Scope::Query { evaluation_time_ms: 300_000, revision, @@ -446,14 +452,16 @@ fn quantile_workload(query: &str) -> PlanningWorkload { fn lifecycle_timed_dag( query: &str, lifecycle: &SummaryMaintenanceLifecycle, -) -> (asap_types::post_asap::PostAsapDAG, Vec) { +) -> (asap_types::ir::physical_export::PhysicalASAPDAG, Vec) { use asap_aware_mapping::enumerate_summary_maintenance_lifecycles; let workload = quantile_workload(query); let mut lowered = lower_promql_workload(&workload, 0).unwrap().remove(0); if query.contains(" by(") { lowered = - asap_physical_operators::physical_planner::promql_rows::with_series_identity(&lowered) - .unwrap(); + asap_physical_operators::unified_physical_planner::promql_rows::with_series_identity( + &lowered, + ) + .unwrap(); } let root = selected_plan_for_lowered(&workload, lowered, &FullyCostedRuntime, Horizon(100.)).root; @@ -475,7 +483,7 @@ fn lifecycle_timed_dag( .iter() .map(|deployment| (deployment.post_asap_node_id, lifecycle.clone())) .collect(); - let mut states: Vec<_> = choices.iter().map(|(id, _)| u64::from(id.0)).collect(); + let mut states: Vec<_> = choices.iter().map(|(id, _)| *id as u64).collect(); states.sort_unstable(); let dag = candidates .select(&choices) @@ -487,23 +495,26 @@ fn lifecycle_timed_dag( /// Compile inputs for a timed DAG: its raw source, available at either phase. fn raw_inputs( - dag: &asap_types::post_asap::PostAsapDAG, -) -> std::collections::BTreeMap { + dag: &asap_types::ir::physical_export::PhysicalASAPDAG, +) -> std::collections::BTreeMap +{ let raw = dag .nodes .iter() .find(|node| { matches!( node.payload, - asap_types::post_asap::PostAsapOperatorPayload::Fallback { .. } + asap_types::ir::physical_export::PhysicalASAPOperatorPayload::NonASAP( + asap_types::ir::NonASAPOp::TimeRange { .. } + ) ) }) .unwrap(); std::collections::BTreeMap::from([( - u64::from(raw.id.0), - asap_physical_operators::physical_planner::InputContract::bounded(std::sync::Arc::new( - raw.output_schema.clone(), - )), + raw.id as u64, + asap_physical_operators::unified_physical_planner::InputContract::bounded( + std::sync::Arc::new(raw.output_schema.clone()), + ), )]) } @@ -527,7 +538,7 @@ fn planner_lifecycle_selection_reproduces_strategy_timing() { != SummaryMaintenanceLifecycle::Ephemeral }) })); - let strategy = asap_types::post_asap::compile_post_asap_dag(&plan.root).unwrap(); + let strategy = compile_physical_asap_dag(&plan.root).unwrap(); assert_eq!(plan.execution_timed_dag().unwrap(), strategy, "{query}"); } } @@ -538,8 +549,8 @@ fn planner_lifecycle_selection_reproduces_strategy_timing() { #[test] fn chosen_lifecycle_timing_decides_precompute_contents() { use asap_physical_operators::{ - physical_planner::{compile_candidate, frontier_from_timing}, runtime::Scope, + unified_physical_planner::{compile_candidate, frontier_from_timing}, values::{Batch, Value}, }; use asap_types::{post_asap::FieldDataType, pre_asap::DataType}; @@ -558,8 +569,7 @@ fn chosen_lifecycle_timing_decides_precompute_contents() { let (&raw_id, contract) = inputs.iter().next().unwrap(); let schema = contract.schema.clone(); let frontier = frontier_from_timing(&dag).unwrap(); - let candidate = - compile_candidate(&dag, inputs, &[u64::from(dag.root.0)], &frontier).unwrap(); + let candidate = compile_candidate(&dag, inputs, &[dag.roots[0] as u64], &frontier).unwrap(); let rows = (1..=100) .map(|value| { schema @@ -633,14 +643,14 @@ fn chosen_lifecycle_timing_decides_precompute_contents() { /// Ephemeral. Covers the KLL quantile fixture and grouped Rate→Sum. #[test] fn lifecycle_timing_cuts_one_compilation() { - use asap_physical_operators::physical_planner::{ + use asap_physical_operators::unified_physical_planner::{ compile, compile_candidate, cut_candidate, frontier_from_timing, }; for query in ["quantile(0.99, latency)", "sum by(job)(rate(m[1m]))"] { let ephemeral = SummaryMaintenanceLifecycle::Ephemeral; let (compiled_dag, _) = lifecycle_timed_dag(query, &ephemeral); let inputs = raw_inputs(&compiled_dag); - let roots = [u64::from(compiled_dag.root.0)]; + let roots = [compiled_dag.roots[0] as u64]; let compiled = compile(&compiled_dag, inputs.clone(), &roots).unwrap(); for lifecycle in [ SummaryMaintenanceLifecycle::ContinuouslyMaintained, @@ -651,7 +661,7 @@ fn lifecycle_timing_cuts_one_compilation() { // Retained states read by a query-time consumer, or the root itself. let query_time = |id: u64| { dag.nodes.iter().any(|node| { - u64::from(node.id.0) == id + node.id as u64 == id && node.output_state.timing == asap_types::post_asap::ExecutionTiming::QueryTime }) @@ -663,10 +673,9 @@ fn lifecycle_timing_cuts_one_compilation() { .iter() .copied() .filter(|state| { - *state == u64::from(dag.root.0) + *state == dag.roots[0] as u64 || dag.edges.iter().any(|edge| { - u64::from(edge.producer.0) == *state - && query_time(u64::from(edge.consumer.0)) + edge.producer as u64 == *state && query_time(edge.consumer as u64) }) }) .collect() @@ -693,23 +702,20 @@ fn chosen_population_lifecycle_decides_precompute_contents() { maintained_population::MaintainedPopulationStrategy, }; use asap_physical_operators::{ - physical_planner::{ + runtime::Scope, + unified_physical_planner::{ compile_candidate, promql_rows::{series_row, with_series_identity}, InputContract, }, - runtime::Scope, values::{Batch, Value}, }; - use asap_types::post_asap::{ - maintained_population::PopulationInput, PostAsapOperatorPayload, ValueOperation, - }; + use asap_types::post_asap::maintained_population::PopulationInput; use std::{collections::BTreeMap, sync::Arc}; let workload = quantile_workload("topk by(job)(1, m)"); - let root = Rc::new( - with_series_identity(&lower_promql_workload(&workload, 0).unwrap().remove(0)).unwrap(), - ); + let root = + with_series_identity(&lower_promql_workload(&workload, 0).unwrap().remove(0)).unwrap(); let root = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)) .candidate(&root) .unwrap(); @@ -741,9 +747,8 @@ fn chosen_population_lifecycle_decides_precompute_contents() { .execution_timed_dag() .unwrap(); let population = dag.nodes.iter().find(|node| node.id == id).unwrap(); - let PostAsapOperatorPayload::Value { - operation: ValueOperation::MaintainPopulation { population }, - } = &population.payload + let PhysicalASAPOperatorPayload::ASAP(ASAPOp::MaintainPopulation { population, .. }) = + &population.payload else { panic!("the deployment is the maintained population"); }; @@ -754,15 +759,22 @@ fn chosen_population_lifecycle_decides_precompute_contents() { let raw = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::Fallback { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP( + asap_types::ir::NonASAPOp::TimeRange { .. } + ) + ) + }) .unwrap(); - let (raw_id, schema) = (u64::from(raw.id.0), Arc::new(raw.output_schema.clone())); + let (raw_id, schema) = (raw.id as u64, Arc::new(raw.output_schema.clone())); let frontier = - asap_physical_operators::physical_planner::frontier_from_timing(&dag).unwrap(); + asap_physical_operators::unified_physical_planner::frontier_from_timing(&dag).unwrap(); let candidate = compile_candidate( &dag, BTreeMap::from([(raw_id, InputContract::bounded(schema.clone()))]), - &[u64::from(dag.root.0)], + &[dag.roots[0] as u64], &frontier, ) .unwrap(); @@ -796,7 +808,7 @@ fn chosen_population_lifecycle_decides_precompute_contents() { query_scope, ) } else { - let state = u64::from(id.0); + let state = id as u64; assert_eq!(frontier, [state]); let stored = physical_common::execute( candidate.precompute.as_ref().unwrap(), @@ -835,21 +847,20 @@ fn chosen_population_lifecycle_decides_precompute_contents() { #[test] fn grouped_rate_sum_placement_is_a_lifecycle_choice() { use asap_aware_mapping::enumerate_summary_maintenance_lifecycles; - use asap_physical_operators::physical_planner::{compile_candidate, InputContract}; - use asap_types::post_asap::{ExactKind, FieldDataType, PostAsapOperatorPayload, SummaryExpr}; + use asap_physical_operators::unified_physical_planner::{compile_candidate, InputContract}; + use asap_types::post_asap::{ExactKind, FieldDataType}; use std::{collections::BTreeMap, sync::Arc}; let workload = quantile_workload("sum by(job)(rate(m[1m]))"); - let root = Rc::new( - asap_physical_operators::physical_planner::promql_rows::with_series_identity( + let root = + asap_physical_operators::unified_physical_planner::promql_rows::with_series_identity( &lower_promql_workload(&workload, 0).unwrap().remove(0), ) - .unwrap(), - ); - let is_exact = |node: &SummaryNode, kind: ExactKind| { - matches!(&node.expr, SummaryExpr::SummaryAgg { + .unwrap(); + let is_exact = |node: &OperatorNode, kind: ExactKind| { + matches!(&node.operator, asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(k, _), .. - } if *k == kind) + }) if *k == kind) }; let inventory = asap_aware_mapping::search_workload(vec![("q", root)]) .enumerate_candidate_dags(4096) @@ -859,7 +870,7 @@ fn grouped_rate_sum_placement_is_a_lifecycle_choice() { .into_iter() .map(|mut forest| forest.remove(0).1) .filter(|candidate| { - matches!(&candidate.expr, SummaryExpr::ValueOperation { child, .. } + matches!(&candidate.operator, asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) if is_exact(child, ExactKind::Sum)) }) .collect::>(); @@ -905,25 +916,32 @@ fn grouped_rate_sum_placement_is_a_lifecycle_choice() { let raw = dag .nodes .iter() - .find(|node| matches!(node.payload, PostAsapOperatorPayload::Fallback { .. })) + .find(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP( + asap_types::ir::NonASAPOp::TimeRange { .. } + ) + ) + }) .unwrap(); let frontier = - asap_physical_operators::physical_planner::frontier_from_timing(&dag).unwrap(); + asap_physical_operators::unified_physical_planner::frontier_from_timing(&dag).unwrap(); let [boundary] = frontier.as_slice() else { panic!("one precompute output, got {frontier:?}"); }; let boundary = dag .nodes .iter() - .find(|node| u64::from(node.id.0) == *boundary) + .find(|node| node.id as u64 == *boundary) .unwrap(); let physical = compile_candidate( &dag, BTreeMap::from([( - u64::from(raw.id.0), + raw.id as u64, InputContract::bounded(Arc::new(raw.output_schema.clone())), )]), - &[u64::from(dag.root.0)], + &[dag.roots[0] as u64], &frontier, ) .unwrap(); @@ -944,10 +962,10 @@ fn grouped_rate_sum_placement_is_a_lifecycle_choice() { else { unreachable!() }; - let state = |payload: &PostAsapOperatorPayload, kind: ExactKind| { - matches!(payload, PostAsapOperatorPayload::SummaryAgg { + let state = |payload: &PhysicalASAPOperatorPayload, kind: ExactKind| { + matches!(payload, PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(k, _), .. - } if *k == kind) + }) if *k == kind) }; assert!(state(retained, ExactKind::Sum)); assert!(builds(retained_pre, "Rate") && builds(retained_pre, "Sum")); @@ -959,10 +977,10 @@ fn grouped_rate_sum_placement_is_a_lifecycle_choice() { /// The lifecycle-timed DAG Planner selects for `query` with upfront series /// typing, and whether it keeps an ingestion-time Binary. -fn typed_selection(query: &str) -> (asap_types::post_asap::PostAsapDAG, bool) { - use asap_types::post_asap::{ExecutionTiming, PostAsapOperatorPayload}; +fn typed_selection(query: &str) -> (asap_types::ir::physical_export::PhysicalASAPDAG, bool) { + use asap_types::post_asap::ExecutionTiming; let workload = quantile_workload(query); - let lowered = asap_types::pre_asap::schema::with_promql_series_identity( + let lowered = asap_types::ir::schema_support::with_promql_series_identity( &lower_promql_workload(&workload, 0).unwrap().remove(0), ) .unwrap(); @@ -970,8 +988,10 @@ fn typed_selection(query: &str) -> (asap_types::post_asap::PostAsapDAG, bool) { .execution_timed_dag() .unwrap(); let ingestion_binary = dag.nodes.iter().any(|node| { - matches!(node.payload, PostAsapOperatorPayload::Binary { .. }) - && node.output_state.timing == ExecutionTiming::IngestionTime + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP(asap_types::ir::NonASAPOp::BinaryOp { .. }) + ) && node.output_state.timing == ExecutionTiming::IngestionTime }); (dag, ingestion_binary) } @@ -979,58 +999,51 @@ fn typed_selection(query: &str) -> (asap_types::post_asap::PostAsapDAG, bool) { /// Execute a timed DAG's precompute and query DAGs over `samples` /// (`(metric, job, seconds, value)`) at 300s; returns the root's values. fn execute_timed( - dag: &asap_types::post_asap::PostAsapDAG, + dag: &asap_types::ir::physical_export::PhysicalASAPDAG, samples: &[(&str, &str, i64, f64)], ) -> Vec { use asap_physical_operators::{ - physical_planner::{ - compile_candidate, frontier_from_timing, promql_fallback, promql_rows, InputContract, - }, runtime::Scope, + unified_physical_planner::{ + compile_candidate, frontier_from_timing, promql_rows, InputContract, + }, values::{Batch, Value}, }; - use asap_types::{ - post_asap::PostAsapOperatorPayload, - pre_asap::{QueryExpr, Source}, - }; + use asap_types::{ir::physical_export::PhysicalASAPOperatorPayload, pre_asap::Source}; use std::{collections::BTreeMap, sync::Arc}; // Raw inputs: a selector Fallback is itself the input; a retained // expression reads each of its selectors through its raw-series slots. let mut raw = BTreeMap::new(); for node in &dag.nodes { - let PostAsapOperatorPayload::Fallback { expression } = &node.payload else { + if !matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP( + asap_types::ir::NonASAPOp::TimeRange { .. } + | asap_types::ir::NonASAPOp::Scan { .. } + ) + ) { continue; - }; - let metric = |selector: &QueryExpr| match selector { - QueryExpr::TimeRange { child, .. } => match child.as_ref() { - QueryExpr::Scan { - source: Source::TimeSeries { metric }, - .. - } => Some(metric.clone()), - _ => None, - }, - QueryExpr::Scan { + } + let mut id = node.id; + loop { + let n = dag.nodes.iter().find(|n| n.id == id).unwrap(); + if let PhysicalASAPOperatorPayload::NonASAP(asap_types::ir::NonASAPOp::Scan { source: Source::TimeSeries { metric }, .. - } => Some(metric.clone()), - _ => None, - }; - if let Some(name) = metric(expression) { - raw.insert( - u64::from(node.id.0), - (Arc::new(node.output_schema.clone()), name), - ); - } else { - for (i, (selector, schema)) in promql_fallback::raw_series(expression) - .unwrap() - .into_iter() - .enumerate() + }) = &n.payload { raw.insert( - promql_fallback::raw_series_input(u64::from(node.id.0), i), - (schema, metric(&selector).unwrap()), + node.id as u64, + (Arc::new(node.output_schema.clone()), metric.clone()), ); + break; } + id = dag + .edges + .iter() + .find(|e| e.consumer == id) + .unwrap() + .producer; } } let batch = |schema: &asap_physical_operators::values::SchemaRef, name: &str| { @@ -1053,15 +1066,16 @@ fn execute_timed( raw.iter() .map(|(id, (schema, _))| (*id, InputContract::bounded(schema.clone()))) .collect(), - &[u64::from(dag.root.0)], + &[dag.roots[0] as u64], &frontier, ) .unwrap(); - let raw_sources = |plan: &asap_physical_operators::physical_planner::CompiledPhysicalDAG| { - plan.input_contracts() - .filter_map(|(id, _)| raw.get(&id).map(|(schema, name)| (id, batch(schema, name)))) - .collect::>() - }; + let raw_sources = + |plan: &asap_physical_operators::unified_physical_planner::CompiledPhysicalDAG| { + plan.input_contracts() + .filter_map(|(id, _)| raw.get(&id).map(|(schema, name)| (id, batch(schema, name)))) + .collect::>() + }; let mut query_sources = raw_sources(&candidate.query); if let Some(precompute) = &candidate.precompute { let stored = physical_common::execute( @@ -1126,7 +1140,7 @@ fn maintained_arithmetic_over_different_selectors_matches_prometheus() { } /// Arithmetic over one selector keeps its maintained layout and adds each -/// series' two readouts before the quantile. +/// series' two evaluations before the quantile. #[test] fn maintained_arithmetic_over_one_selector_executes() { let (dag, ingestion_binary) = diff --git a/crates/integration-tests/tests/time_range.rs b/crates/integration-tests/tests/time_range.rs index d3ab732fe..95212ca99 100644 --- a/crates/integration-tests/tests/time_range.rs +++ b/crates/integration-tests/tests/time_range.rs @@ -1,46 +1,53 @@ -//! `QueryExpr::TimeRange` — range / streaming function tests. +//! `NonASAPOp::TimeRange` — range / streaming function tests. //! -//! All range functions lower to `Aggregate { child: TimeRange { range, child: Scan } }`. +//! All range functions lower to `Aggregate { child: TimeRange { range, kind: Range, child: Scan } }`. //! The temporal range lives on the `TimeRange` node, not in the `AggIntent`. //! `rate` / `increase` use `AggIntent::Rate` / `AggIntent::Increase` (no window field). //! `*_over_time` functions reuse the corresponding cross-series intents -//! (`Count`, `Sum`, `Quantile`, …) — the `TimeRange` child is what marks them -//! as per-series reductions. +//! (`Count`, `Sum`, `Quantile`, …) — the `Range` selector child is what marks +//! them as per-series reductions. use std::rc::Rc; use std::time::Duration; use asap_integration_tests::fixtures::lower_promql; use asap_integration_tests::fixtures::metric_schema; -use asap_types::pre_asap::{AggIntent, QueryExpr, Reduction, Source}; +use asap_types::ir::{NonASAPOp, OperatorNode, TimeRangeKind}; +use asap_types::pre_asap::{AggIntent, Reduction, Source}; use asap_types::types::AccuracyTarget; -fn lower(q: &str) -> QueryExpr { +fn lower(q: &str) -> Rc { lower_promql(q, AccuracyTarget::Exact).unwrap_or_else(|e| panic!("lower failed for {q:?}: {e}")) } -fn scan(metric: &str) -> QueryExpr { - QueryExpr::Scan { +fn node(op: NonASAPOp) -> Rc { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(op)) + .expect("fixture node derives its schema") +} + +fn scan(metric: &str) -> Rc { + node(NonASAPOp::Scan { source: Source::TimeSeries { metric: metric.into(), }, predicates: vec![], schema: metric_schema(&[]), - } + }) } -fn range_agg(range_secs: u64, intent: AggIntent, metric: &str) -> QueryExpr { - QueryExpr::Aggregate { +fn range_agg(range_secs: u64, intent: AggIntent, metric: &str) -> Rc { + node(NonASAPOp::Aggregate { reduction: Reduction::PerEntity, measures: vec![intent], output_names: vec!["".into()], filters: vec![], having: None, - child: Rc::new(QueryExpr::TimeRange { + child: node(NonASAPOp::TimeRange { range: Duration::from_secs(range_secs), - child: Rc::new(scan(metric)), + kind: TimeRangeKind::Range, + child: scan(metric), }), - } + }) } // #13 — rate: counter-reset-aware per-second rate; range on TimeRange node diff --git a/crates/metricsql-parser-vendored/src/optimizer/const_evaluator.rs b/crates/metricsql-parser-vendored/src/optimizer/const_evaluator.rs index 3ac1170e8..6befd206d 100644 --- a/crates/metricsql-parser-vendored/src/optimizer/const_evaluator.rs +++ b/crates/metricsql-parser-vendored/src/optimizer/const_evaluator.rs @@ -12,7 +12,7 @@ use crate::functions::{BuiltinFunction, TransformFunction}; use crate::parser::{parse_number, ParseError, ParseResult}; #[allow(rustdoc::private_intra_doc_links)] -/// Partially evaluate `Expr`s so constant subtrees are evaluated at plan time. +/// Partially evaluate `Expr`s so constant sub-DAGs are evaluated at plan time. /// /// Note it does not handle algebraic rewrites such as `(a or false)` /// --> `a`, which is handled by [`Simplifier`] diff --git a/crates/planner/src/lib.rs b/crates/planner/src/lib.rs index 88a085bf8..77de25378 100644 --- a/crates/planner/src/lib.rs +++ b/crates/planner/src/lib.rs @@ -11,17 +11,15 @@ //! and a catalog — skips this crate and calls //! [`asap_aware_mapping::optimize`] directly. -use std::rc::Rc; - use asap_types::parsed_workload::{ParsedWorkload, ParsedWorkloadError}; -use asap_types::pre_asap::query_expr::QueryExpr; use asap_types::workload::{PlanningWorkload, QueryLanguage, SqlDialect, WorkloadError}; -use asap_frontend_metricsql::{lower_metricsql, MetricsqlError}; -use asap_frontend_promql::{ - lower_promql_workload, lower_promql_workload_with_histograms, HistogramCatalog, PromqlError, +use asap_frontend_metricsql::unified::{lower_metricsql_query_workload, MetricsqlError}; +use asap_frontend_promql::unified::{ + lower_promql_query_workload, lower_promql_query_workload_with_histograms, HistogramCatalog, + PromqlError, }; -use asap_frontend_sql::{lower_sql_dialect, SqlCatalog, SqlError}; +use asap_frontend_sql::unified::{lower_sql_dialect, SqlCatalog, SqlError}; // The optimization stage's vocabulary is this facade's vocabulary too: a caller // configures the same models and reads the same output whether it goes through @@ -47,7 +45,11 @@ pub enum FrontendInput<'a> { now_ms: u64, histograms: Option, }, - Metricsql, + Metricsql { + /// Planning clock, as for PromQL: the ingestion-interval evidence + /// must be valid at it. + now_ms: u64, + }, } #[non_exhaustive] @@ -95,6 +97,7 @@ impl<'a> UserInput<'a> { QueryLanguage::SQL(_) | QueryLanguage::DataFusion, FrontendInput::Sql { .. } ) | (QueryLanguage::PromQL, FrontendInput::Promql { .. }) + | (QueryLanguage::MetricsQL, FrontendInput::Metricsql { .. }) ); if !matches_frontend { return Err(UserInputError::FrontendMismatch { @@ -127,7 +130,7 @@ impl FrontendInput<'_> { match self { Self::Sql { .. } => "Sql", Self::Promql { .. } => "Promql", - Self::Metricsql => "Metricsql", + Self::Metricsql { .. } => "Metricsql", } } } @@ -191,7 +194,7 @@ pub async fn e2e_plan(input: UserInput<'_>) -> Result { input.validate()?; let exprs = lower(&input).await?; - let parsed = ParsedWorkload::new(input.workload.clone(), exprs)?; + let parsed = ParsedWorkload::from_roots(input.workload.clone(), exprs)?; let fallback = MajorPass; let pass: &dyn OptimizationPass = input.pass.unwrap_or(&fallback); @@ -202,11 +205,11 @@ pub async fn e2e_plan(input: UserInput<'_>) -> Result { /// One expression per normalized entry, in `QueryWorkload::entries()` order. /// -/// The SQL and MetricsQL frontends are driven one entry at a time rather than +/// The SQL frontend is driven one entry at a time rather than /// through `lower_sql_batch`, which walks `query_batch` alone and would drop /// every repeating query — exactly the entries whose recurrence the lifecycle /// stage needs. -async fn lower(input: &UserInput<'_>) -> Result>, PlanError> { +async fn lower(input: &UserInput<'_>) -> Result, PlanError> { let entries = || input.workload.query_workload.entries(); match &input.frontend_specific { @@ -229,7 +232,7 @@ async fn lower(input: &UserInput<'_>) -> Result>, PlanError> { entry_index: Some(index), source: LoweringError::Sql(source), })?; - lowered.push(Rc::new(expr)); + lowered.push(expr.into()); } Ok(lowered) } @@ -237,30 +240,26 @@ async fn lower(input: &UserInput<'_>) -> Result>, PlanError> { now_ms, histograms, .. } => { let lowered = match histograms { - Some(histograms) => lower_promql_workload_with_histograms( + Some(histograms) => lower_promql_query_workload_with_histograms( input.workload, histograms.clone(), *now_ms, ), - None => lower_promql_workload(input.workload, *now_ms), + None => lower_promql_query_workload(input.workload, *now_ms), } .map_err(|source| PlanError::Lowering { entry_index: None, source: LoweringError::Promql(source), })?; - Ok(lowered.into_iter().map(Rc::new).collect()) - } - FrontendInput::Metricsql => { - let mut lowered = Vec::new(); - for (index, entry) in entries().enumerate() { - let expr = lower_metricsql(&entry.query.0, entry.requirements.accuracy.target()) - .map_err(|source| PlanError::Lowering { - entry_index: Some(index), - source: LoweringError::Metricsql(source), - })?; - lowered.push(Rc::new(expr)); - } Ok(lowered) } + FrontendInput::Metricsql { now_ms } => { + lower_metricsql_query_workload(input.workload, *now_ms).map_err(|source| { + PlanError::Lowering { + entry_index: None, + source: LoweringError::Metricsql(source), + } + }) + } } } diff --git a/crates/planner/tests/e2e_plan.rs b/crates/planner/tests/e2e_plan.rs index ff8a8dde0..c700871b9 100644 --- a/crates/planner/tests/e2e_plan.rs +++ b/crates/planner/tests/e2e_plan.rs @@ -10,9 +10,8 @@ use asap_aware_mapping::replacement::default_strategies_with_evidence; use asap_aware_mapping::{ search_workload_with_targets, Horizon, LifecycleInput, SummaryMaintenanceLifecycleCapabilities, }; -use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; +use asap_frontend_sql::unified::{lower_sql_dialect, SqlCatalog}; use asap_planner::{e2e_plan, FrontendInput, PlanError, UserInput, UserInputError}; -use asap_types::post_asap::SummaryExpr; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ @@ -137,7 +136,7 @@ async fn builtin_cost_model_cannot_price_lifecycles_and_falls_back_to_raw_recomp ) .await .expect("lowers"); - roots.push((index, Rc::new(expr), Some(accuracy))); + roots.push((index, expr, Some(accuracy))); } let strategies = default_strategies_with_evidence(models.cost, models.evidence); let space = search_workload_with_targets(roots, &strategies, models.accuracy); @@ -150,12 +149,12 @@ async fn builtin_cost_model_cannot_price_lifecycles_and_falls_back_to_raw_recomp .expect("assembles") .expect("root has a group"); assert!( - !matches!(cost_only.expr, SummaryExpr::KeepPreAsap(_)), + cost_only.contains_asap(), "entry {}: cost-only selection was expected to pick a summary", plan.entry_index ); assert!( - matches!(plan.plan.root.expr, SummaryExpr::KeepPreAsap(_)) + !plan.plan.root.contains_asap() && plan.plan.selected_raw_recompute && plan.plan.deployments.is_empty() && plan.plan.summary_total_cost.is_none() @@ -390,7 +389,7 @@ async fn lifecycle_decisions_ride_inside_each_plan() { assert_eq!(output.plans.len(), 1); assert_eq!(output.plans[0].entry_index, 0); let _: &Rc<_> = &output.plans[0].plan.root; - assert_eq!(output.dags().len(), 1); + assert_eq!(output.operator_roots().len(), 1); } /// Each root's lifecycle is planned against the entries that read it: a @@ -437,3 +436,54 @@ async fn each_plan_counts_only_its_own_entries_reads() { let reads: Vec<_> = output.plans.iter().map(|p| p.plan.expected_reads).collect(); assert_eq!(reads, vec![Some(60.0), Some(6.0)]); } + +/// Scalar-only and mixed workloads preserve entry bindings without wrapper nodes. +#[tokio::test] +async fn scalar_roots_survive_planning_in_workload_order() { + for queries in [ + vec!["2", "time()"], + vec!["2", "up * 2", "scalar(sum(up)) + 1"], + ] { + let workload = PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(queries.iter().map(|q| batch(q)).collect()), + repeating_queries: None, + }, + data_workload: Some(DataWorkload { + data_ingestion_interval: Evidence { + value: Some(DurationMs(1000)), + ..Default::default() + }, + ..Default::default() + }), + }; + let output = e2e_plan(UserInput::new( + &workload, + FrontendInput::Promql { + now_ms: NOW_MS, + histograms: None, + }, + PlanningModels::builtin(), + lifecycle(), + )) + .await + .unwrap(); + assert_eq!( + output.entry_indices(), + (0..queries.len()).collect::>() + ); + assert!(matches!( + output.roots()[0], + asap_types::ir::QueryRoot::Scalar(_) + )); + assert_eq!(output.roots().len(), queries.len()); + if queries.len() == 3 { + assert_eq!(output.plans[0].entry_index, 1); + let asap_types::ir::QueryRoot::Scalar(expr) = &output.roots()[2] else { + panic!() + }; + assert_eq!(expr.operator_refs().len(), 1); + } + } +} diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index 41b489e9c..809f16594 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -1,6 +1,8 @@ //! Structurally identical summary producers chosen by different queries are -//! shared after Pass 1: one `Rc` across their plans, costed once. +//! shared after Pass 1: one `Rc` across their plans, costed once. +use asap_types::ir::cse::share_common_sub_dags; +use asap_types::ir::{ASAPOp, OperatorNode}; use std::rc::Rc; use asap_aware_mapping::accuracy::{ @@ -11,25 +13,23 @@ use asap_aware_mapping::pass::{PlanOutput, PlanningModels}; use asap_aware_mapping::replacement::{default_size_params, DEFAULT_DELTA}; use asap_aware_mapping::{ global_selection_with_summary_maintenance_lifecycles, search_workload_with_targets, - ReplacementStrategy, SketchAlgorithmStrategy, WorkloadDemand, + ASAPStrategies, ReplacementStrategy, WorkloadDemand, }; use asap_aware_mapping::{ CostModel, CostRate, DefaultCostModel, Horizon, LifecycleInput, SummaryMaintenanceCapabilities, SummaryMaintenanceLifecycleCapabilities, SummaryMaintenanceLifecycleCostInputs, }; -use asap_frontend_promql::lower_promql_workload; -use asap_frontend_sql::SqlCatalog; +use asap_frontend_promql::unified::lower_promql_workload; +use asap_frontend_sql::unified::SqlCatalog; use asap_planner::{e2e_plan, FrontendInput, UserInput}; use asap_types::post_asap::{ - share_common_summary_sub_dags, AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, - ProbabilityExpr, ResultGuarantee, SketchStatistic, -}; -use asap_types::post_asap::{ - FieldDataType, SketchAlgorithm, SketchParams, SummaryExpr, SummaryNode, + AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, ProbabilityExpr, ResultGuarantee, + SketchStatistic, }; +use asap_types::post_asap::{FieldDataType, SketchAlgorithm, SketchParams}; use asap_types::pre_asap::agg_intent::default_quantile; use asap_types::pre_asap::schema::{DataType, Field, Schema}; -use asap_types::pre_asap::{AggIntent, QueryExpr}; +use asap_types::pre_asap::AggIntent; use asap_types::types::AccuracyTarget; use asap_types::workload::{ AccuracyRequirement, DataArrival, DataWorkload, DurationMs, Evidence, LatencyRequirement, @@ -58,7 +58,7 @@ impl CostModel for FixedCosts { fn summary_maintenance_lifecycle_cost_inputs( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceLifecycleCostInputs { SummaryMaintenanceLifecycleCostInputs { build_cost: Some(Cost(self.build)), @@ -71,7 +71,7 @@ impl CostModel for FixedCosts { fn summary_maintenance_capabilities( &self, - _summary: &SummaryNode, + _summary: &OperatorNode, ) -> SummaryMaintenanceCapabilities { SummaryMaintenanceCapabilities { incremental_update: true, @@ -80,7 +80,7 @@ impl CostModel for FixedCosts { } } - fn raw_query_recompute_cost(&self, _target: &QueryExpr) -> Option { + fn raw_query_recompute_cost(&self, _target: &OperatorNode) -> Option { Some(Cost(self.raw_per_read)) } } @@ -185,7 +185,7 @@ async fn plan_sql(queries: &[&str], costs: &FixedCosts) -> PlanOutput { } /// Every summary state each plan deploys. -fn states(output: &PlanOutput) -> Vec>> { +fn states(output: &PlanOutput) -> Vec>> { output .plans .iter() @@ -202,7 +202,7 @@ fn states(output: &PlanOutput) -> Vec>> { } /// Whether the two plans deploy exactly the same states, by pointer. -fn same_states(states: &[Vec>]) -> bool { +fn same_states(states: &[Vec>]) -> bool { states[0].len() == states[1].len() && states[0] .iter() @@ -212,7 +212,7 @@ fn same_states(states: &[Vec>]) -> bool { /// The deployments a consumer would run, deduplicated by pointer. fn unique_deployments(output: &PlanOutput) -> usize { - let mut seen: Vec<*const SummaryNode> = Vec::new(); + let mut seen: Vec<*const OperatorNode> = Vec::new(); for plan in &output.plans { for deployment in &plan.plan.deployments { let ptr = Rc::as_ptr(&deployment.summary); @@ -297,12 +297,12 @@ fn kll_k(plan: &asap_aware_mapping::pass::QueryLifecyclePlan) -> u32 { let [deployment] = plan.plan.deployments.as_slice() else { panic!("one state: {:?}", plan.plan.deployments.len()); }; - let SummaryExpr::SummaryAgg { + let asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - } = &deployment.summary.expr + }) = &deployment.summary.operator else { - panic!("sketch state: {:?}", deployment.summary.expr); + panic!("sketch state: {:?}", deployment.summary.operator); }; let SketchParams::Kll { k } = kind.params() else { panic!("KLL state: {kind:?}"); @@ -382,7 +382,7 @@ async fn identical_sql_percentiles_share_one_producer() { assert_eq!(unique_deployments(&output), 1); } -/// The quantile is a readout parameter: SQL p50 and p99 over one filtered +/// The quantile is a evaluation parameter: SQL p50 and p99 over one filtered /// column build one KLL, named after its input, while each query keeps its /// own output column. #[tokio::test] @@ -451,7 +451,7 @@ async fn shared_amortization_alone_can_beat_raw_recompute() { assert_eq!(unique_deployments(&output), 1); } -/// Synthetic evidence certifying UnivMon readouts; it exercises sharing, never +/// Synthetic evidence certifying UnivMon evaluations; it exercises sharing, never /// runtime accuracy. struct UnivMonEvidence; @@ -493,7 +493,7 @@ impl AccuracyModel for UnivMonEvidence { /// when the states are identical. `MajorPass` builds candidates with the /// built-in accuracy model, so this runs its pipeline with the test model. #[test] -fn certified_frequency_readouts_share_one_univmon_state() { +fn certified_frequency_evaluations_share_one_univmon_state() { let queries = [ ("distinct_over_time(m[5m])", 0.02), ("entropy_over_time(m[5m])", 0.02), @@ -505,12 +505,10 @@ fn certified_frequency_readouts_share_one_univmon_state() { .into_iter() .zip(queries) .enumerate() - .map(|(index, (expr, (_, epsilon)))| { - (index, Rc::new(expr), Some(AccuracyTarget::Epsilon(epsilon))) - }) + .map(|(index, (expr, (_, epsilon)))| (index, expr, Some(AccuracyTarget::Epsilon(epsilon)))) .collect(); let strategies: Vec> = - vec![Box::new(SketchAlgorithmStrategy::new_with_planning_inputs( + vec![Box::new(ASAPStrategies::new_with_planning_inputs( &CHEAP_SUMMARY, &UnivMonEvidence, &EqualSplitAllocator, @@ -541,15 +539,17 @@ fn certified_frequency_readouts_share_one_univmon_state() { (*index, dag) }) .collect(); - let mut states: Vec> = Vec::new(); - for (_, root) in share_common_summary_sub_dags(assembled) { - assert!(root.guarantee.is_some(), "{:?}", root.expr); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - panic!("summary readout: {:?}", root.expr); + let mut states: Vec> = Vec::new(); + for (_, root) in share_common_sub_dags(assembled) { + assert!(root.guarantee.is_some(), "{:?}", root.operator); + let asap_types::ir::Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = + &root.operator + else { + panic!("summary evaluation: {:?}", root.operator); }; assert!(matches!( - &summary_input.expr, - SummaryExpr::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } + &summary_input.operator, + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if kind.algorithm() == &SketchAlgorithm::UnivMon )); states.push(Rc::clone(summary_input)); diff --git a/crates/sql-function-catalog/src/lib.rs b/crates/sql-function-catalog/src/lib.rs index 0ae4ee5ab..be79e7a71 100644 --- a/crates/sql-function-catalog/src/lib.rs +++ b/crates/sql-function-catalog/src/lib.rs @@ -277,7 +277,7 @@ pub struct ClickHouseBuiltin { pub const CLICKHOUSE_BUILTINS: &[ClickHouseBuiltin] = &[ // Explicit time-series reducers. These deliberately survive under their // own names: the SQL frontend validates (value, timestamp, window_ms) and - // lowers the window to QueryExpr::TimeRange rather than pretending these + // lowers the window to NonASAPOp::TimeRange rather than pretending these // are ordinary tabular aggregates. ClickHouseBuiltin { name: "asap_rate", @@ -323,7 +323,7 @@ pub const CLICKHOUSE_BUILTINS: &[ClickHouseBuiltin] = &[ /// /// This list exists for one reason: `asap-frontend-sql`'s DataFusion-registry /// drift test (issue #225, item 3 -- see -/// `asap_frontend_sql::sql::catalog_drift`) walks a real `SessionContext`'s +/// `asap_frontend_sql::unified::sql::catalog_drift`) walks a real `SessionContext`'s /// resolved aggregate names and requires every one to be either in /// [`NATIVE_FUNCTIONS`], a [`CLICKHOUSE_BUILTINS`] name, or listed here. A /// name landing here is a *recorded decision*, not a silenced test failure -- diff --git a/crates/types/src/dag_export.rs b/crates/types/src/dag_export.rs index f26673ea7..9efbd66c5 100644 --- a/crates/types/src/dag_export.rs +++ b/crates/types/src/dag_export.rs @@ -1,31 +1,46 @@ -//! Export the pre-ASAP [`QueryExpr`] DAG as a generic node/edge DAG, for tools -//! that need to render or diff the IR (the `dag_export` example + the -//! `tools/dag-viewer` viewer — see issue #133) rather than walk it in Rust. +//! Export an [`OperatorNode`] DAG as a generic node/edge dag, for tools +//! that need to render or diff the IR (the `dag_export` devtools binary + +//! the `tools/dag-viewer` viewer — see issue #133) rather than walk it in +//! Rust. //! -//! `QueryExpr` already derives `Serialize`, but as a Rust-shaped tagged DAG -//! (`Rc` children nested inside each variant's own field). This module -//! flattens that into an explicit node list + child-id edges — the shape a -//! generic DAG renderer wants — and additionally tags each node with -//! [`structural_hash`](crate::pre_asap::cse::structural_hash), so a caller -//! with several exported queries can spot identical sub-DAGs (a -//! shared `Scan`, a repeated `Aggregate` shape, …) by comparing hashes -//! rather than re-implementing `QueryExpr: PartialEq` structural comparison -//! client-side. +//! `OperatorNode` already derives `Serialize`, but as a Rust-shaped tagged +//! tree (`Rc` children nested inside each variant's own field, repeated once +//! per reference). This module flattens that into an explicit node list + +//! child-id edges — one entry per unique node, deduplicated by `Rc` pointer +//! identity, so a shared sub-DAG stays one node with several parents — and +//! additionally tags each node with +//! [`structural_hash`](crate::ir::cse::structural_hash), so a caller with +//! several exported queries can spot identical sub-DAGs (a shared `Scan`, a +//! repeated `Aggregate` shape, …) by comparing hashes rather than +//! re-implementing structural comparison client-side. //! //! This is literally the same hashing -//! [`share_common_sub_dags`](crate::pre_asap::cse::share_common_sub_dags) -//! uses to bucket candidates in its `InternTable` (issue #223 stage 3) — not -//! a parallel reimplementation. `tools/dag-viewer`'s "shared sub-DAG" +//! [`share_common_sub_dags`](crate::ir::cse::share_common_sub_dags) uses to +//! bucket candidates in its `InternTable` (issue #223 stage 3) — not a +//! parallel reimplementation. `tools/dag-viewer`'s "shared sub-DAG" //! highlighting is still a *proxy* for real CSE, though: a hash match here //! only means two nodes are legal `InternTable` bucket-mates (same coarse -//! hash), the same candidate-narrowing step `structural_hash` performs -//! inside `InternTable::intern` — it does not mean `share_common_sub_dags` -//! actually ran on this data and merged them onto one `Rc` (that also -//! requires the `PartialEq` check `InternTable::intern` performs, and the +//! hash) — it does not mean `share_common_sub_dags` actually ran on this +//! data and merged them onto one `Rc` (that also requires the structural +//! equality check `InternTable::intern` performs, and the //! `Schema::has_unique_key` legality gate, neither of which this export //! step evaluates). See `tools/dag-viewer/README.md` for the up-to-date //! caveat. //! +//! There is one IR before and after ASAP optimization, so there is one +//! exporter: an ordinary operator and an ASAP summary operator are both +//! rendered by the same per-variant [`shape`] match, whichever entry point +//! ([`export`], [`export_summary`], [`export_post_asap`]) reached them. +//! +//! ## Scalar expressions +//! +//! A [`ScalarExpr`] is owned by value by an operator field (`Filter.pred`, +//! `Project.cols`, …) and is rendered into that operator's `detail`, not as +//! a node of its own. The operator nodes a scalar expression reads +//! (`scalar(v)`, `EXISTS (subquery)`, …) *are* nodes of the dag — they are +//! in [`OperatorNode::children`] — so inside `detail` each such reference is +//! rendered as `{"scalar_ref": }` rather than inlined. +//! //! ## `DAGNode::notes` — a layering seam, not a feature this module implements //! //! [`DAGNode`] also carries `notes: Vec<`[`DAGNote`]`>`, always empty coming @@ -33,13 +48,12 @@ //! `asap_types`, never the reverse — can annotate an already-exported DAG //! after the fact without this module needing to know anything about that //! layer's concepts. Concretely: `asap-aware-mapping`'s `explanation` module -//! (issue #257) computes `structural_hash` over the same `QueryExpr` -//! sub-DAGs this module does (via the identical function). The devtools -//! exporter uses that hash to narrow candidates, then compares -//! `ReplacementExplanation::target` with [`DAGNode::source_expr`] for a -//! collision-safe match before pushing a [`DAGNote`] onto the node. -//! `asap_types` itself never constructs a `DAGNote` — see [`DAGNode::notes`] -//! for the layering rule this keeps. +//! (issue #257) computes `structural_hash` over the same nodes this module +//! does (via the identical function). The devtools exporter uses that hash +//! to narrow candidates, then compares its target with +//! [`DAGNode::source_node`] for a collision-safe match before pushing a +//! [`DAGNote`] onto the node. `asap_types` itself never constructs a +//! `DAGNote` — see [`DAGNode::notes`] for the layering rule this keeps. use std::collections::HashMap; use std::rc::Rc; @@ -47,9 +61,11 @@ use std::rc::Rc; use serde::Serialize; use crate::cost::CostAnnotation; -use crate::post_asap::{AccuracyError, ResultGuarantee, SummaryExpr, SummaryNode}; -use crate::pre_asap::cse::{structural_hash, HashCache}; -use crate::pre_asap::query_expr::{QueryExpr, Source}; +use crate::ir::cse::{structural_hash, HashCache}; +use crate::ir::operator_properties::Source; +use crate::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; +use crate::post_asap::{AccuracyError, ResultGuarantee}; +use crate::pre_asap::schema::FieldDataType; /// One flattened IR node. `detail` holds this node's own scalar fields /// (predicates, aggregate funcs, schema, sort keys, …) — everything except @@ -57,56 +73,48 @@ use crate::pre_asap::query_expr::{QueryExpr, Source}; #[derive(Debug, Clone, Serialize)] pub struct DAGNode { pub id: u32, - /// The `QueryExpr` variant name (e.g. `"Aggregate"`). + /// The operator variant name — [`Operator::kind_name`] (e.g. + /// `"Aggregate"`, `"SummaryAgg"`). pub kind: &'static str, /// Short human-readable summary for a node's collapsed on-DAG label. pub label: String, pub detail: serde_json::Value, - /// Output schema carried by every exported node. Edge renderers use the - /// child node's schema as the schema flowing along child → consumer. + /// Output schema carried by every exported node ([`OperatorNode::schema`] + /// as JSON). Edge renderers use the child node's schema as the schema + /// flowing along child → consumer. #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option, - /// Child node ids, in the variant's field order (e.g. `Join` is - /// `[left, right]`). + /// Child node ids in [`OperatorNode::children`] order: the operator's + /// own inputs in field order (e.g. `Join` is `[left, right]`), then the + /// nodes referenced from its scalar expressions. pub children: Vec, /// Explicit workload-wide identity assigned by a higher-level exporter. /// Viewers use this field to union nodes and must not reconstruct a /// structural signature client-side. #[serde(skip_serializing_if = "Option::is_none")] pub workload_node_id: Option, - /// [`structural_hash`](crate::pre_asap::cse::structural_hash) of the - /// sub-DAG rooted at this node — the exact same function `cse`'s - /// `InternTable` uses to bucket CSE candidates, so two nodes hash - /// equally here iff they would land in the same `InternTable` bucket. - /// See the module doc for what a hash match here does and doesn't - /// guarantee. - /// - /// `None` for the same reason `source_expr` is `None` — a post-ASAP- - /// originated node in an [`export_post_asap`] merged DAG has no - /// `QueryExpr` to hash. Omitted from JSON entirely (rather than, say, - /// serialized as `0`) so a consumer's shared-sub-DAG-by-hash pass can - /// tell "no hash" apart from a real hash that happens to collide with a - /// placeholder — `0` is a legal `structural_hash` output, not a safe - /// sentinel. + /// [`structural_hash`](crate::ir::cse::structural_hash) of the sub-DAG + /// rooted at this node — the exact same function `cse`'s `InternTable` + /// uses to bucket CSE candidates, so two nodes hash equally here iff they + /// would land in the same `InternTable` bucket. See the module doc for + /// what a hash match here does and doesn't guarantee. Always `Some` + /// for a node this module produces; the `Option` is retained for the + /// JSON shape (`None` is omitted rather than serialized as a sentinel, + /// since `0` is a legal hash). #[serde(skip_serializing_if = "Option::is_none")] pub hash: Option, - /// Exact source expression for in-process annotation matching. It is not - /// part of the JSON format: callers first narrow by `hash`, then compare - /// this value structurally to avoid treating a hash collision as node - /// identity. - /// - /// `None` for a node with no corresponding pre-ASAP `QueryExpr` at all — - /// only possible for a post-ASAP-originated node inside a merged - /// [`export_post_asap`] DAG (a `SummaryAgg`/`SummaryJoin`/… node has no - /// single `QueryExpr` it corresponds to). Every node [`export`] itself - /// produces is pre-ASAP by construction and always carries `Some`. + /// The exported node itself, for in-process annotation matching. Not + /// part of the JSON format: callers first narrow by `hash`, then + /// compare this value (by pointer or structurally) to avoid treating a + /// hash collision as node identity. Always `Some` for a node this + /// module produces. #[serde(skip)] - pub source_expr: Option, - /// In-process identity of the source `QueryExpr`. Unlike `source_expr`'s - /// structural value, this preserves an `Rc` child reached from multiple - /// parents so post-ASAP flattening can retain true DAG sharing. + pub source_node: Option>, + /// In-process identity of `source_node` (`Rc::as_ptr` as an address): + /// the key the builder deduplicates on, so a node reached from several + /// parents is exported once. Not part of the JSON format. #[serde(skip)] - source_ptr: Option, + pub source_ptr: Option, /// Arbitrary reporting-layer annotations for this node — e.g. why a /// replacement exists here. `asap_types` never populates this itself /// (it has no notion of a "replacement" at all — see the module doc's @@ -187,7 +195,7 @@ pub struct EdgeCostAnnotation { pub cost: CostAnnotation, } -/// One query's exported DAG. `nodes[root as usize]` is the DAG's root. +/// One query's exported dag. `nodes[root as usize]` is the DAG's root. #[derive(Debug, Clone, Serialize)] pub struct ExportDAG { pub nodes: Vec, @@ -195,8 +203,7 @@ pub struct ExportDAG { /// See [`EdgeCostAnnotation`]. Always empty unless a higher layer /// explicitly populated it (same layering rule as [`DAGNode::notes`]); /// omitted from JSON entirely when empty, so every existing producer of - /// [`ExportDAG`] (every call to [`export`]/[`export_summary`]) is - /// unaffected. + /// [`ExportDAG`] is unaffected. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edge_annotations: Vec, } @@ -205,10 +212,10 @@ pub struct ExportDAG { #[derive(Debug, Clone, Serialize)] pub struct NamedDAG { pub name: String, - /// The original query text (SQL or PromQL) this DAG was lowered from, - /// for display alongside the DAG — not used by `export` itself, since - /// that only sees the already-lowered `QueryExpr`. Optional because not - /// every producer of a `NamedDAG` has the source text on hand. + /// The original query text (SQL or PromQL) this dag was lowered from, + /// for display alongside the dag — not used by `export` itself, since + /// that only sees the already-lowered DAG. Optional because not every + /// producer of a `NamedDAG` has the source text on hand. #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, pub dag: ExportDAG, @@ -228,15 +235,13 @@ pub struct NamedDAG { /// [`TargetReplacement::before`]/`::after` (small, self-contained /// before/after pairs, one per independently-discovered replacement /// site), this is a single flattened [`ExportDAG`] spanning the whole - /// query: every node that has no winning replacement renders as an - /// ordinary pre-ASAP [`DAGNode`] (same shape [`export`] itself - /// produces), and every node that does splices in its winning - /// candidate's shape instead — a rewritten [`QueryExpr`] sub-DAG, or a - /// bound `SummaryNode` sub-DAG, rendered inline in the very same node - /// list. `None` unless a higher layer explicitly built one (e.g. the - /// `dag_export` devtools binary's `--post-asap` flag); omitted from the - /// JSON entirely when absent, so every existing producer/consumer of - /// `NamedDAG` is unaffected. + /// query: every node that has no winning replacement renders as it does + /// in [`export`], and every node that does splices in its winning + /// candidate's sub-DAG instead, in the very same node list. `None` + /// unless a higher layer explicitly built one (e.g. the `dag_export` + /// devtools binary's `--post-asap` flag); omitted from the JSON entirely + /// when absent, so every existing producer/consumer of `NamedDAG` is + /// unaffected. #[serde(default, skip_serializing_if = "Option::is_none")] pub post_dag: Option, /// This query's own selected-workload cost/benefit — one of issue @@ -282,81 +287,51 @@ pub struct WorkloadDAG { pub workload_cost: Option, } -// ── Post-ASAP replacement export — a second, layering-seam-shaped feature ── +// ── Post-ASAP replacement export — a layering-seam-shaped feature ────────── // -// Everything below this point is the post-ASAP counterpart of the pre-ASAP -// flattening above: [`export_summary`] flattens a `SummaryNode` the same way -// [`export`] flattens a `QueryExpr`, and [`TargetReplacement`] is the -// generic, crate-agnostic "one replacement site, before and after" shape a -// higher layer (`asap-aware-mapping`, via the `dag_export` devtools binary's -// `--post-asap` flag) populates after running its own search — the exact -// same layering rule [`DAGNode::notes`]'s doc above already states: this -// module never runs `asap_aware_mapping::replacement::search_workload_with` -// itself, never picks a "winning" candidate, and has no opinion on what a +// [`TargetReplacement`] is the generic, crate-agnostic "one replacement +// site, before and after" shape a higher layer (`asap-aware-mapping`, via +// the `dag_export` devtools binary's `--post-asap` flag) populates after +// running its own search — the exact same layering rule [`DAGNode::notes`]'s +// doc above already states: this module never runs +// `asap_aware_mapping::replacement::search_workload_with` itself, never +// picks a "winning" candidate, and has no opinion on what a // `ReplacementProvenance` or a cost model even is. It only defines shapes // concrete and serializable enough for a higher layer to fill in, and for // `tools/dag-viewer` to render without needing to know anything about // `asap-aware-mapping`'s own vocabulary. -// -// A single whole-query "post-ASAP DAG" isn't attempted here, and isn't -// representable in the current type system either: `SummaryExpr` has no -// variant letting a `SummaryNode` be embedded back inside a plain -// `QueryExpr`'s child slot (`QueryExpr`'s own children are always -// `Rc`, never `Rc`), so there is no way to splice a -// post-ASAP binding back into its original pre-ASAP DAG in place. Inventing -// a bridge type for that is a real `asap_types`/`asap-aware-mapping` IR -// design decision, well beyond what a devtools visualization export should -// decide unilaterally. Instead, each independently-discovered replacement -// target gets its own small, self-contained `before`/`after` pair — the -// target's own pre-ASAP sub-DAG, and either the winning `SummaryNode` or the -// winning rewritten `QueryExpr`, both of which *are* fully representable -// today via [`export`]/[`export_summary`] as-is. - -/// One flattened post-ASAP node — the [`SummaryExpr`] analogue of -/// [`DAGNode`]. `detail` holds this node's own scalar fields (the summarized -/// column, the summary family, grouping strategy, sketch-query kind, …) — -/// everything except its `SummaryNode` children, which live in `children` -/// instead. -/// -/// Unlike [`DAGNode`], this carries no `hash`/`source_expr` pair: nothing in -/// this module ever needs to re-identify a particular `SummaryDAGNode` the -/// way `DAGNode::hash` lets a higher layer re-identify a pre-ASAP node (a -/// `SummaryNode` is always freshly exported for exactly one -/// [`TargetReplacementAfter::Summary`] site, never matched back against a -/// separately-exported DAG the way pre-ASAP notes are). -/// -/// Several of `SummaryExpr`'s own fields (`FieldDataType`, -/// `GroupingStrategy`, `SketchStatistic`) derive neither `Serialize` nor -/// `Deserialize` in `asap_types::post_asap` — they carry no reporting -/// obligation there, since nothing before this module ever needed to -/// serialize a post-ASAP node. Rather than adding `Serialize` impls to -/// `post_asap`'s own core types purely for this devtools-facing export (a -/// change to that module's own public API contract, out of scope for a -/// reporting concern), this module renders those particular fields into -/// `detail` via their `Debug` formatting instead — human-readable, and -/// sufficient for the display purpose `detail` exists for on every other -/// node in this file (see [`DAGNode::detail`]'s own doc), at the cost of -/// those particular fields being opaque strings rather than structured JSON -/// on the `SummaryDAGNode` side of the export. + +/// One flattened node of a [`SummaryDAG`] — the same node as a +/// [`DAGNode`], in the shape the summary-maintenance consumers read: +/// snake_case `kind`, the accuracy guarantee as its own field, no +/// hash/annotation seams. #[derive(Debug, Clone, Serialize)] pub struct SummaryDAGNode { pub id: u32, - /// The `SummaryExpr` variant name (e.g. `"SummaryAgg"`). + /// The operator variant name in snake_case (e.g. `"summary_agg"`, + /// `"scan"`) — see [`snake_case_kind`]. pub kind: &'static str, /// Short human-readable summary for a node's collapsed on-DAG label. pub label: String, pub detail: serde_json::Value, - /// Child node ids, in the variant's field order (e.g. `SummaryJoin` is - /// `[outer, inner]`). + /// [`OperatorNode::schema`] as JSON. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema: Option, + /// Child node ids in [`OperatorNode::children`] order. pub children: Vec, /// The value's machine-readable accuracy guarantee (issue #172) — - /// [`SummaryNode::guarantee`] serialized structurally (metric, symbolic + /// [`OperatorNode::guarantee`] serialized structurally (metric, symbolic /// bound, failure probability, provenance including any budget /// allocation), not as prose. Omitted when the node carries none (raw /// summary state, or a family with no error model), so every consumer /// predating this field parses the same shape it always has. #[serde(default, skip_serializing_if = "Option::is_none")] pub guarantee: Option, + /// The exported node itself, so a caller annotating the dag can find + /// a node by `Rc` pointer identity rather than by walk order. Not part + /// of the JSON format. Always `Some`. + #[serde(skip)] + pub source_node: Option>, } /// One accuracy-illegal candidate a higher layer's search refused for a @@ -378,239 +353,14 @@ pub struct TargetRejection { pub error: AccuracyError, } -/// One post-ASAP `SummaryNode` DAG, flattened the same way [`ExportDAG`] -/// flattens a pre-ASAP `QueryExpr` DAG. +/// A DAG flattened into [`SummaryDAGNode`]s — the same dag [`ExportDAG`] +/// holds, in the summary-maintenance consumers' node shape. #[derive(Debug, Clone, Serialize)] pub struct SummaryDAG { pub nodes: Vec, pub root: u32, } -/// Flatten a [`SummaryNode`] the same way [`export`] flattens a `QueryExpr` -/// — post-order, one [`SummaryDAGNode`] per [`SummaryExpr`] variant, no -/// memoization of repeated `Rc` references (a shared -/// sub-expression reachable through two parents is flattened twice, into two -/// separate node entries — the same "this is a flattened DAG view, not a -/// pointer-identity-preserving DAG" behavior [`build`] already has for -/// `QueryExpr`). -/// -/// A `KeepPreAsap(inner)` leaf embeds the *whole* pre-ASAP sub-DAG beneath it -/// as a nested [`ExportDAG`] (via [`export(inner)`](export)) inside its own -/// `detail` field (`{"pre_asap_sub_dag": }`) rather than trying to -/// flatten it into this same node list — [`DAGNode`] and [`SummaryDAGNode`] -/// are different types with different id spaces, so mixing them into one -/// `Vec` isn't type-safe; nesting is. `label` for a `KeepPreAsap` node is -/// `format!("KeepPreAsap({kind})")`, where `kind` is the inner sub-DAG's own -/// top-level `DAGNode::kind`. -pub fn export_summary(node: &SummaryNode) -> SummaryDAG { - let mut nodes = Vec::new(); - let root = build_summary(node, &mut nodes); - SummaryDAG { nodes, root } -} - -fn push_summary_node( - nodes: &mut Vec, - kind: &'static str, - label: String, - detail: serde_json::Value, - children: Vec, - guarantee: Option, -) -> u32 { - let id = nodes.len() as u32; - nodes.push(SummaryDAGNode { - id, - kind, - label, - detail, - children, - guarantee, - }); - id -} - -/// A short, human-readable label for a [`crate::post_asap::FieldDataType`] -/// (e.g. `"Sketch(Kll)"`, `"ExactAggregate(Sum)"`) — for -/// [`SummaryDAGNode::label`] text on a `SummaryAgg`/`SummaryJoin` node. Not -/// exhaustive prose (mirrors `asap_aware_mapping::replacement::describe_intent`'s -/// own "this is a label, not a decision" stance) — every variant is covered, -/// but via `Debug` for the inner kind rather than hand-written prose per -/// algorithm. -fn family_label(family: &crate::post_asap::FieldDataType) -> String { - use crate::post_asap::FieldDataType; - match family { - FieldDataType::Plain(dtype) => format!("Plain({dtype:?})"), - FieldDataType::ExactAggregate(kind, _) => format!("ExactAggregate({kind:?})"), - FieldDataType::Sketch(kind, _grouping) => format!("Sketch({:?})", kind.algorithm()), - FieldDataType::Sample(kind, _) => format!("Sample({kind:?})"), - FieldDataType::Wavelet(kind, _) => format!("Wavelet({kind:?})"), - FieldDataType::StatModel(kind, _) => format!("StatModel({kind:?})"), - } -} - -/// `(kind, label, detail)` for every [`SummaryExpr`] variant *except* -/// [`SummaryExpr::KeepPreAsap`] — that variant has no `SummaryDAGNode`/ -/// `DAGNode` of its own (see [`build_summary`]/[`build_summary_hybrid`], its -/// only two callers, both of which special-case it before ever reaching -/// this function). Factored out so [`build_summary`] (nests a `KeepPreAsap` -/// leaf's pre-ASAP sub-DAG as its own [`SummaryDAG`]) and -/// [`build_summary_hybrid`] (splices that same sub-DAG directly into a -/// shared [`ExportDAG`] node list — see [`export_post_asap`]) can't drift -/// apart on how every *other* variant's own shape is described, since -/// nothing about that description differs between the two. -macro_rules! define_summary_kind_tags { - ($($pattern:pat => $tag:literal),+ $(,)?) => { - #[cfg(test)] - const SUMMARY_KIND_TAGS: &[&str] = &[$($tag),+]; - - fn summary_kind_tag(expr: &SummaryExpr) -> &'static str { - match expr { - SummaryExpr::KeepPreAsap(_) => unreachable!( - "summary_kind_tag's callers special-case KeepPreAsap" - ), - $($pattern => $tag),+ - } - } - }; -} - -define_summary_kind_tags! { - SummaryExpr::BinaryOp { .. } => "SummaryBinaryOp", - - SummaryExpr::ValueOperation { .. } => "ValueOperation", - SummaryExpr::RelationalJoin { .. } => "RelationalJoin", - SummaryExpr::SummaryAgg { .. } => "SummaryAgg", - SummaryExpr::SummaryJoin { .. } => "SummaryJoin", - SummaryExpr::SummarySubtract { .. } => "SummarySubtract", - SummaryExpr::SummaryDelete { .. } => "SummaryDelete", - SummaryExpr::SummaryEstimate { .. } => "SummaryEstimate", - SummaryExpr::SummaryMerge { .. } => "SummaryMerge", -} - -fn summary_shape(expr: &SummaryExpr) -> (&'static str, String, serde_json::Value) { - let kind = summary_kind_tag(expr); - match expr { - SummaryExpr::KeepPreAsap(_) => { - unreachable!("summary_shape's callers special-case KeepPreAsap before calling it") - } - SummaryExpr::BinaryOp { operator, .. } => { - let label = format!("BinaryOp({:?})", operator.kind); - let detail = serde_json::json!({ - "kind": format!("{:?}", operator.kind), - "vector_match": operator.vector_match, - }); - (kind, label, detail) - } - - SummaryExpr::ValueOperation { - operation, timing, .. - } => ( - kind, - format!("ValueOperation({operation:?})"), - serde_json::json!({ - "operation": format!("{operation:?}"), - "timing": timing.as_str(), - }), - ), - SummaryExpr::RelationalJoin { - kind: join_kind, - pred, - .. - } => ( - kind, - format!("RelationalJoin({join_kind:?})"), - serde_json::json!({ "join_kind": join_kind, "predicate": pred }), - ), - SummaryExpr::SummaryAgg { - family, - input, - reduction, - grouping, - .. - } => { - let label = format!("SummaryAgg({})", family_label(family)); - let detail = serde_json::json!({ - "family": format!("{family:?}"), - "input": input, - "reduction": reduction, - "grouping": format!("{grouping:?}"), - }); - (kind, label, detail) - } - SummaryExpr::SummaryJoin { key, family, .. } => { - let label = format!("SummaryJoin({})", family_label(family)); - let detail = serde_json::json!({ - "key": key, - "family": format!("{family:?}"), - }); - (kind, label, detail) - } - SummaryExpr::SummarySubtract { .. } => { - (kind, "SummarySubtract".into(), serde_json::json!({})) - } - SummaryExpr::SummaryDelete { key, .. } => { - let detail = serde_json::json!({ "key": key }); - (kind, "SummaryDelete".into(), detail) - } - SummaryExpr::SummaryEstimate { query, .. } => { - let label = format!("SummaryEstimate({query:?})"); - let detail = serde_json::json!({ "query": format!("{query:?}") }); - (kind, label, detail) - } - SummaryExpr::SummaryMerge { children, .. } => { - let label = format!("SummaryMerge({} children)", children.len()); - (kind, label, serde_json::json!({})) - } - } -} - -/// `expr`'s own `Rc` children, in the variant's field order -/// (e.g. `SummaryJoin` is `[outer, inner]`) — empty for -/// [`SummaryExpr::KeepPreAsap`], which has no `SummaryNode` children at all -/// (only a boxed pre-ASAP `QueryExpr`). Shared by [`build_summary`] and -/// [`build_summary_hybrid`] for the same reason [`summary_shape`] is. -fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { - match expr { - SummaryExpr::KeepPreAsap(_) => vec![], - SummaryExpr::BinaryOp { lhs, rhs, .. } => vec![lhs, rhs], - - SummaryExpr::ValueOperation { child, .. } => vec![child], - SummaryExpr::RelationalJoin { left, right, .. } => vec![left, right], - SummaryExpr::SummaryAgg { child, .. } => vec![child], - SummaryExpr::SummaryJoin { outer, inner, .. } => vec![outer, inner], - SummaryExpr::SummarySubtract { left, right } => vec![left, right], - SummaryExpr::SummaryDelete { summary_input, .. } => vec![summary_input], - SummaryExpr::SummaryEstimate { summary_input, .. } => vec![summary_input], - SummaryExpr::SummaryMerge { children, .. } => children.iter().collect(), - } -} - -/// Recursively flatten `node`, appending [`SummaryDAGNode`]s to `nodes` in -/// post-order (children pushed before their parent), and return the pushed -/// root's id. Exhaustive over every [`SummaryExpr`] variant, matching this -/// file's own exhaustive style for `QueryExpr` in [`build`]. -fn build_summary(node: &SummaryNode, nodes: &mut Vec) -> u32 { - if let SummaryExpr::KeepPreAsap(inner) = &node.expr { - let pre_asap_sub_dag = export(inner); - let inner_kind = pre_asap_sub_dag.nodes[pre_asap_sub_dag.root as usize].kind; - let label = format!("KeepPreAsap({inner_kind})"); - let detail = serde_json::json!({ "pre_asap_sub_dag": pre_asap_sub_dag }); - return push_summary_node( - nodes, - "KeepPreAsap", - label, - detail, - vec![], - node.guarantee.clone(), - ); - } - let children: Vec = summary_children(&node.expr) - .into_iter() - .map(|child| build_summary(child, nodes)) - .collect(); - let (kind, label, detail) = summary_shape(&node.expr); - push_summary_node(nodes, kind, label, detail, children, node.guarantee.clone()) -} - /// One replacement site a higher layer (the `dag_export` binary) found by /// running `asap_aware_mapping::replacement::search_workload_with` + /// `CandidateLogicalASAPDAGs::cost_sorted` and picking the best-ranked candidate for one @@ -624,7 +374,7 @@ pub struct TargetReplacement { /// so renderers can explain a clicked post-ASAP node without guessing by /// label, hash, or DAG shape. pub decision_id: u32, - /// Id of the [`DAGNode`] (in this query's own `DAG.nodes`, i.e. the + /// Id of the [`DAGNode`] (in this query's own `dag.nodes`, i.e. the /// [`NamedDAG`] this `TargetReplacement` is attached to) this /// replacement's `before` sub-DAG is rooted at. pub target_pre_id: u32, @@ -648,7 +398,7 @@ pub struct TargetReplacement { /// doesn't estimate a numeric cost for this candidate shape (see that /// field's own doc upstream). pub cost: f64, - /// The target's own pre-ASAP sub-DAG, before replacement — literally + /// The target's own sub-DAG, before replacement — literally /// `export(target)` for the `TargetSubDAGCandidates`'s own `target`, reused as-is. pub before: ExportDAG, pub after: TargetReplacementAfter, @@ -668,8 +418,10 @@ pub struct TargetReplacement { } /// What a [`TargetReplacement`] became — either a genuine post-ASAP binding -/// or a still-pre-ASAP-shaped structural rewrite, mirroring -/// `asap_aware_mapping::replacement::Replacement`'s own two variants. +/// or a still-relational structural rewrite, mirroring +/// `asap_aware_mapping::replacement::Replacement`'s own two variants. Both +/// carry an ordinary [`ExportDAG`]: the unified IR renders a summary sub-DAG +/// and a rewritten relational sub-DAG through the same [`export`]. /// /// Serializes as `{"kind": "Summary"|"Rewrite", "DAG": {...}}` (serde's /// adjacently-tagged representation for a `#[serde(tag = "kind", content = @@ -680,73 +432,83 @@ pub struct TargetReplacement { #[serde(tag = "kind", content = "dag")] pub enum TargetReplacementAfter { /// A `Replacement::Summary` candidate — a genuine post-ASAP binding. - Summary(SummaryDAG), - /// A `Replacement::Rewrite` candidate — still pre-ASAP shaped (CSE + Summary(ExportDAG), + /// A `Replacement::Rewrite` candidate — still relational (CSE /// share/recompute, `AvgToSumOverCountStrategy`, and `RollupStrategy` - /// all produce this kind), so this reuses [`ExportDAG`]/[`export`] too, - /// not a new type. + /// all produce this kind). Rewrite(ExportDAG), } -/// Flatten `expr` into a [`ExportDAG`]. -pub fn export(expr: &QueryExpr) -> ExportDAG { - let mut nodes = Vec::new(); - // One cache for the whole export — persisted across every `build`/ - // `push_node` call, not reset per node, so `structural_hash` memoizes - // real work across this pass instead of re-walking an already-hashed - // shared descendant once per node that references it. - let mut cache = HashCache::new(); - // No substitution: an ordinary pre-ASAP export never splices anything - // in — see `build`'s own doc for why it always takes a `find_winner` - // callback regardless (so `export_post_asap` can share this exact - // per-variant traversal instead of duplicating it). - let root = build(expr, &mut nodes, &mut cache, &mut |_| None); - ExportDAG { - nodes, - root, - edge_annotations: Vec::new(), - } -} - -/// What a higher layer found for one specific pre-ASAP node when building a -/// merged post-ASAP DAG via [`export_post_asap`] — see that function's own -/// doc for the full design. `asap_types` has no opinion on *how* this is +/// What a higher layer found for one specific node when building a merged +/// post-ASAP dag via [`export_post_asap`] — see that function's own doc +/// for the full design. `asap_types` has no opinion on *how* this is /// decided (that's `asap_aware_mapping::replacement::search_workload_with` + /// `CandidateLogicalASAPDAGs::cost_sorted`'s job, a higher layer, exactly the layering rule /// [`DAGNode::notes`] already states); it only defines the shape a decision -/// comes back in. +/// comes back in. Both variants render identically (one IR, one builder); +/// they are kept apart so the caller's `Replacement` maps one-to-one. #[derive(Debug, Clone)] pub enum PostAsapSubstitution { /// This exact node has a winning `Replacement::Rewrite` — keep building - /// from `.0` instead of the original node. Still pre-ASAP shaped, so - /// [`build`] renders it via the same ordinary `DAGNode` path — see - /// [`build`]'s own doc for why `.0`'s own top level is rendered without - /// re-querying `find_winner` on it (its descendants still are). + /// from `replacement` instead of the original node. Rewrite { - replacement: Rc, + replacement: Rc, decision: DAGDecision, }, - /// This exact node has a winning `Replacement::Summary` — switch to - /// rendering `.0`'s bound `SummaryNode` shape from here down, via - /// [`build_summary_hybrid`]. + /// This exact node has a winning `Replacement::Summary` — keep building + /// from `replacement` (a summary-bound sub-DAG) instead of the original + /// node. Summary { - replacement: Rc, + replacement: Rc, decision: DAGDecision, }, } +/// Flatten the DAG rooted at `root` into a [`ExportDAG`]: one [`DAGNode`] +/// per unique reachable node, children pushed before their parents. +pub fn export(root: &Rc) -> ExportDAG { + let mut no_substitution = |_: &Rc| None; + let mut builder = Builder::new(&mut no_substitution); + let root = builder.build(root); + builder.finish(root) +} + +/// Flatten the DAG rooted at `node` into a [`SummaryDAG`] — the same +/// nodes [`export`] produces, in the [`SummaryDAGNode`] shape (snake_case +/// `kind`, `guarantee` as its own field). +pub fn export_summary(node: &Rc) -> SummaryDAG { + let dag = export(node); + let nodes = dag + .nodes + .into_iter() + .map(|node| { + let source = node + .source_node + .expect("every exported node carries its source"); + SummaryDAGNode { + id: node.id, + kind: snake_case_kind(&source.operator), + label: node.label, + detail: node.detail, + schema: node.schema, + children: node.children, + guarantee: source.guarantee.clone(), + source_node: Some(source), + } + }) + .collect(); + SummaryDAG { + nodes, + root: dag.root, + } +} + /// Build one merged "whole query, but post-ASAP" [`ExportDAG`] by walking -/// `root`'s ordinary pre-ASAP shape and, at every node, asking `find_winner` -/// whether *that exact node* has a winning replacement — if so, splicing -/// the replacement's own shape in at that position instead, in the very -/// same flattened node list (not a nested sub-DAG the way -/// [`TargetReplacement::before`]/`::after` — small, independent, per-site -/// before/after pairs — already do; see this file's "Post-ASAP replacement -/// export" section doc for why *that* design doesn't attempt a single -/// whole-query composite, and why this one can: this is a synthetic -/// id/edge list, the same kind of thing [`ExportDAG`] already is for the -/// pre-ASAP side, not a real `QueryExpr`/`SummaryNode` value with a type -/// system to satisfy). +/// `root` and, at every node, asking `find_winner` whether *that exact +/// node* has a winning replacement — if so, splicing the replacement's own +/// sub-DAG in at that position instead, in the very same flattened node +/// list (not a nested sub-dag the way [`TargetReplacement::before`]/ +/// `::after` — small, independent, per-site before/after pairs — do). /// /// `find_winner` is the whole layering seam: `asap_types` never runs /// `asap_aware_mapping::replacement::search_workload_with` or @@ -758,7 +520,7 @@ pub enum PostAsapSubstitution { /// for [`TargetReplacement`] discovery, and passes it in here unchanged. /// /// `find_winner` is deliberately consulted only once per node, at the -/// moment [`build`] first reaches it — **not** re-consulted on a +/// moment the builder first reaches it — **not** re-consulted on a /// substitution's own immediate top level (only on that substitution's /// *descendants*, which get an ordinary fresh call same as any other node). /// This matters for correctness, not just efficiency: @@ -770,225 +532,189 @@ pub enum PostAsapSubstitution { /// re-query at exactly that one level is what makes this termination-safe /// for every registered strategy, not just the ones that happen not to /// return the target itself as a candidate. +/// +/// Every node a substitution introduced carries the substitution's +/// [`DAGDecision`] (`role = "replacement_root"` on the spliced-in root, +/// `"replacement_region"` on its newly exported descendants); a descendant +/// that was already exported before the splice (a shared input the +/// replacement reuses) keeps whatever it already had. pub fn export_post_asap( - root: &QueryExpr, - find_winner: &mut dyn FnMut(&QueryExpr) -> Option, + root: &Rc, + find_winner: &mut dyn FnMut(&Rc) -> Option, ) -> ExportDAG { - let mut nodes = Vec::new(); - let mut cache = HashCache::new(); - let root_id = build(root, &mut nodes, &mut cache, find_winner); - deduplicate_pointer_shared_nodes(nodes, root_id) + let mut builder = Builder::new(find_winner); + let root = builder.build(root); + builder.finish(root) } -fn deduplicate_pointer_shared_nodes(nodes: Vec, root: u32) -> ExportDAG { - let mut by_source_ptr = HashMap::::new(); - let mut old_to_new = vec![0_u32; nodes.len()]; - let mut deduplicated = Vec::with_capacity(nodes.len()); - for mut node in nodes { - node.children = node - .children - .into_iter() - .map(|child| old_to_new[child as usize]) - .collect(); - if let Some(existing) = node - .source_ptr - .and_then(|source_ptr| by_source_ptr.get(&source_ptr).copied()) - { - old_to_new[node.id as usize] = existing; - continue; - } - let old_id = node.id; - let new_id = deduplicated.len() as u32; - node.id = new_id; - if let Some(source_ptr) = node.source_ptr { - by_source_ptr.insert(source_ptr, new_id); +/// The one flattening pass behind every entry point. Nodes are memoized by +/// `Rc` pointer identity: a node reached from several parents (an operator +/// input shared with a scalar reference, say) is exported once. +struct Builder<'a> { + nodes: Vec, + /// `Rc::as_ptr` of every node already exported (or substituted) → its id. + ids: HashMap<*const OperatorNode, u32>, + /// One cache for the whole export — persisted across every node, not + /// reset per node, so `structural_hash` memoizes real work across this + /// pass instead of re-walking an already-hashed shared descendant once + /// per node that references it. + cache: HashCache, + find_winner: &'a mut dyn FnMut(&Rc) -> Option, +} + +impl<'a> Builder<'a> { + fn new( + find_winner: &'a mut dyn FnMut(&Rc) -> Option, + ) -> Self { + Self { + nodes: Vec::new(), + ids: HashMap::new(), + cache: HashCache::new(), + find_winner, } - old_to_new[old_id as usize] = new_id; - deduplicated.push(node); } - ExportDAG { - nodes: deduplicated, - root: old_to_new[root as usize], - edge_annotations: Vec::new(), + fn finish(self, root: u32) -> ExportDAG { + ExportDAG { + nodes: self.nodes, + root, + edge_annotations: Vec::new(), + } } -} -macro_rules! define_query_kind_tags { - ($($pattern:pat => $tag:literal),+ $(,)?) => { - #[cfg(test)] - const QUERY_KIND_TAGS: &[&str] = &[$($tag),+]; - - fn kind_tag(expr: &QueryExpr) -> &'static str { - match expr { - $($pattern => $tag),+, - other @ (QueryExpr::Column(_) - | QueryExpr::Literal(_) - | QueryExpr::Compare { .. } - | QueryExpr::BoolAnd(_) - | QueryExpr::BoolOr(_) - | QueryExpr::Not(_) - | QueryExpr::IsNull(_) - | QueryExpr::IsNotNull(_) - | QueryExpr::Cast { .. } - | QueryExpr::InList { .. } - | QueryExpr::FunctionCall { .. } - | QueryExpr::Arithmetic { .. } - | QueryExpr::Case { .. }) => unreachable!( - "kind_tag reached a scalar QueryExpr variant directly: {other:?}" - ), + /// Export `node` (or, when `find_winner` has a substitution for it, the + /// substitution's sub-DAG in its place) and return its id. + fn build(&mut self, node: &Rc) -> u32 { + let ptr = Rc::as_ptr(node); + if let Some(&id) = self.ids.get(&ptr) { + return id; + } + let (replacement, decision) = match (self.find_winner)(node) { + None => return self.build_node(node), + Some(PostAsapSubstitution::Rewrite { + replacement, + decision, + }) + | Some(PostAsapSubstitution::Summary { + replacement, + decision, + }) => (replacement, decision), + }; + let first = self.nodes.len(); + let root = self.build_node(&replacement); + for exported in &mut self.nodes[first..] { + if exported.decision.is_none() { + let mut node_decision = decision.clone(); + node_decision.role = if exported.id == root { + "replacement_root" + } else { + "replacement_region" + }; + exported.decision = Some(node_decision); } } - }; -} - -define_query_kind_tags! { - QueryExpr::Scan { .. } => "Scan", - QueryExpr::PromqlScalarBridge(_) => "PromqlScalarBridge", - QueryExpr::EvalTimestamp => "EvalTimestamp", - QueryExpr::CurrentTimestamp => "CurrentTimestamp", - QueryExpr::PromqlVectorFromScalar(_) => "PromqlVectorFromScalar", - QueryExpr::PromqlScalarFromVector(_) => "PromqlScalarFromVector", - QueryExpr::PromqlRelabel { .. } => "PromqlRelabel", - QueryExpr::PromqlInfoEnrich { .. } => "PromqlInfoEnrich", - QueryExpr::PromqlSeriesSample { .. } => "PromqlSeriesSample", - QueryExpr::Filter { .. } => "Filter", - QueryExpr::Project { .. } => "Project", - QueryExpr::Aggregate { .. } => "Aggregate", - QueryExpr::Dedup { .. } => "Dedup", - QueryExpr::Concat { .. } => "Concat", - QueryExpr::Join { .. } => "Join", - QueryExpr::SetOp { .. } => "SetOp", - QueryExpr::Sort { .. } => "Sort", - QueryExpr::Limit { .. } => "Limit", - QueryExpr::PromqlSubquery { .. } => "PromqlSubquery", - QueryExpr::TimeRange { .. } => "TimeRange", - QueryExpr::TimeShift { .. } => "TimeShift", - QueryExpr::SQLWindowFunc { .. } => "SQLWindowFunc", - QueryExpr::BinaryOp { .. } => "BinaryOp", -} - -/// Push one flattened node for `expr`. `expr` is the *whole* sub-DAG this -/// node represents (not just its own fields) — `hash` is -/// [`structural_hash(expr)`](structural_hash), the identical function and -/// the identical input `InternTable::intern` would hash for this same -/// sub-DAG, so this node's `hash` matches what `cse::share_common_sub_dags` -/// would bucket it under. `kind` is [`kind_tag(expr)`](kind_tag), not a -/// caller-supplied argument — see that function's doc for why. -fn push_node( - nodes: &mut Vec, - expr: &QueryExpr, - cache: &mut HashCache, - label: String, - detail: serde_json::Value, - children: Vec, -) -> u32 { - let id = nodes.len() as u32; - let hash = Some(structural_hash(expr, cache)); - nodes.push(DAGNode { - id, - kind: kind_tag(expr), - label, - detail, - schema: expr - .output_schema() - .ok() - .and_then(|schema| serde_json::to_value(schema).ok()), - children, - workload_node_id: None, - hash, - source_expr: Some(expr.clone()), - source_ptr: Some(expr as *const QueryExpr as usize), - notes: Vec::new(), - decision: None, - }); - id -} + // The original node now resolves to the substitution: another + // parent of the same `Rc` reuses the spliced-in sub-DAG. + self.ids.insert(ptr, root); + root + } -/// Push one flattened node with no corresponding pre-ASAP `QueryExpr` at -/// all — a post-ASAP-originated node inside [`export_post_asap`]'s merged -/// DAG (a `SummaryAgg`/`SummaryJoin`/… node, via [`build_summary_hybrid`]). -/// `hash`/`source_expr`-based re-identification (see [`DAGNode::hash`]'s own -/// doc) has no meaning for a node with no `QueryExpr` behind it, so this -/// pushes a fixed placeholder hash (`0`) and `source_expr: None` rather than -/// inventing a hash over `SummaryExpr` (which, unlike `QueryExpr`, has no -/// [`structural_hash`]-equivalent function at all — see [`SummaryDAGNode`]'s -/// own doc on why `SummaryExpr`'s fields don't even derive `Hash`/`PartialEq` -/// consistently enough to build one). -fn push_summary_originated_node( - nodes: &mut Vec, - kind: &'static str, - label: String, - detail: serde_json::Value, - children: Vec, -) -> u32 { - let id = nodes.len() as u32; - nodes.push(DAGNode { - id, - kind, - label, - detail, - schema: None, - children, - workload_node_id: None, - hash: None, - source_expr: None, - source_ptr: None, - notes: Vec::new(), - decision: None, - }); - id + /// Export `node` itself (no substitution check at this level; children + /// still go through [`Self::build`]) and return its id. + fn build_node(&mut self, node: &Rc) -> u32 { + let ptr = Rc::as_ptr(node); + if let Some(&id) = self.ids.get(&ptr) { + return id; + } + let children: Vec = node.children().into_iter().map(|c| self.build(c)).collect(); + let (label, mut detail) = shape(node, &self.ids); + if let serde_json::Value::Object(map) = &mut detail { + if let Some(timing) = node.timing { + map.insert("timing".into(), serde_json::json!(timing.as_str())); + } + if let Some(guarantee) = &node.guarantee { + if let Ok(value) = serde_json::to_value(guarantee) { + map.insert("guarantee".into(), value); + } + } + } + let hash = structural_hash(node, &mut self.cache); + self.cache.insert(ptr, hash); + let id = self.nodes.len() as u32; + self.nodes.push(DAGNode { + id, + kind: node.operator.kind_name(), + label, + detail, + schema: serde_json::to_value(&node.schema).ok(), + children, + workload_node_id: None, + hash: Some(hash), + source_node: Some(Rc::clone(node)), + source_ptr: Some(ptr as usize), + notes: Vec::new(), + decision: None, + }); + self.ids.insert(ptr, id); + id + } } -/// The [`build_summary`]/[`build_summary_hybrid`] counterpart of [`build`] -/// for a bound [`SummaryNode`] reached while building -/// [`export_post_asap`]'s merged DAG: appends into the *same* `nodes: -/// Vec` list `build` itself is filling, instead of a separate -/// [`SummaryDAG`]. A `KeepPreAsap(inner)` leaf recurses back into -/// [`build`] on `inner` (the general pre-ASAP entry, `find_winner` included) -/// rather than nesting a `{"pre_asap_sub_dag": ...}` blob the way -/// [`build_summary`] does — so the merged DAG reads as one seamless DAG -/// with no dead ends, and so a target reachable underneath a `KeepPreAsap` -/// wrapper (a nested aggregate a strategy independently found a -/// replacement for, say) still gets spliced in correctly. -fn build_summary_hybrid( - node: &SummaryNode, - nodes: &mut Vec, - cache: &mut HashCache, - find_winner: &mut dyn FnMut(&QueryExpr) -> Option, -) -> u32 { - if let SummaryExpr::KeepPreAsap(inner) = &node.expr { - return build(inner, nodes, cache, find_winner); - } - let children: Vec = summary_children(&node.expr) - .into_iter() - .map(|child| build_summary_hybrid(child, nodes, cache, find_winner)) - .collect(); - let (kind, label, mut detail) = summary_shape(&node.expr); - // The merged DAG's `DAGNode` has no dedicated guarantee field (it is - // the pre-ASAP node shape); the guarantee rides in `detail` under the - // same key/shape `SummaryDAGNode::guarantee` uses, additively. - if let Some(guarantee) = &node.guarantee { - if let (serde_json::Value::Object(map), Ok(value)) = - (&mut detail, serde_json::to_value(guarantee)) - { - map.insert("guarantee".into(), value); - } +/// [`Operator::kind_name`] in snake_case, for [`SummaryDAGNode::kind`]. +/// Exhaustive so a new operator variant fails to compile here until it is +/// named. +fn snake_case_kind(operator: &Operator) -> &'static str { + match operator { + Operator::NonASAP(op) => match op { + NonASAPOp::Scan { .. } => "scan", + NonASAPOp::Values { .. } => "values", + NonASAPOp::Filter { .. } => "filter", + NonASAPOp::Project { .. } => "project", + NonASAPOp::Aggregate { .. } => "aggregate", + NonASAPOp::Join { .. } => "join", + NonASAPOp::SetOp { .. } => "set_op", + NonASAPOp::Concat { .. } => "concat", + NonASAPOp::Dedup { .. } => "dedup", + NonASAPOp::Sort { .. } => "sort", + NonASAPOp::Limit { .. } => "limit", + NonASAPOp::BinaryOp { .. } => "binary_op", + NonASAPOp::SQLWindowFunc { .. } => "sql_window_func", + NonASAPOp::TimeRange { .. } => "time_range", + NonASAPOp::TimeShift { .. } => "time_shift", + NonASAPOp::PromqlVectorFromScalar(_) => "promql_vector_from_scalar", + NonASAPOp::PromqlRelabel { .. } => "promql_relabel", + NonASAPOp::PromqlInfoEnrich { .. } => "promql_info_enrich", + NonASAPOp::PromqlSeriesSample { .. } => "promql_series_sample", + NonASAPOp::PromqlSubquery { .. } => "promql_subquery", + }, + Operator::ASAP(op) => match op { + ASAPOp::SummaryAgg { .. } => "summary_agg", + ASAPOp::SummaryEstimate { .. } => "summary_estimate", + ASAPOp::FinalizeExactAccumulator { .. } => "finalize_exact_accumulator", + ASAPOp::MaintainPopulation { .. } => "maintain_population", + ASAPOp::EvaluatePopulation { .. } => "read_population", + ASAPOp::SummaryMerge { .. } => "summary_merge", + ASAPOp::SummarySubtract { .. } => "summary_subtract", + ASAPOp::SummaryDelete { .. } => "summary_delete", + ASAPOp::SummaryJoin { .. } => "summary_join", + ASAPOp::Extension { .. } => "extension", + }, } - let id = push_summary_originated_node(nodes, kind, label, detail, children); - nodes[id as usize].schema = Some(summary_schema_json(&node.schema)); - id } -fn summary_schema_json(schema: &crate::post_asap::Schema) -> serde_json::Value { - serde_json::json!({ - "fields": schema.fields.iter().map(|field| serde_json::json!({ - "name": field.name, - "dtype": format!("{:?}", field.dtype), - "nullable": field.nullable, - })).collect::>(), - "time_index": schema.time_index, - }) +/// A short, human-readable label for a [`FieldDataType`] (e.g. +/// `"Sketch(Kll)"`, `"ExactAggregate(Sum)"`) — for the label text on a +/// `SummaryAgg`/`SummaryJoin` node. Every variant is covered, via `Debug` +/// for the inner kind rather than hand-written prose per algorithm. +fn family_label(family: &FieldDataType) -> String { + match family { + FieldDataType::Plain(dtype) => format!("Plain({dtype:?})"), + FieldDataType::ExactAggregate(kind, _) => format!("ExactAggregate({kind:?})"), + FieldDataType::Sketch(kind, _grouping) => format!("Sketch({:?})", kind.algorithm()), + FieldDataType::Sample(kind, _) => format!("Sample({kind:?})"), + FieldDataType::Wavelet(kind, _) => format!("Wavelet({kind:?})"), + FieldDataType::StatModel(kind, _) => format!("StatModel({kind:?})"), + } } fn source_label(source: &Source) -> String { @@ -998,407 +724,336 @@ fn source_label(source: &Source) -> String { } } -/// Recursively flatten `expr`, appending nodes to `nodes` in post-order -/// (children pushed before their parent), and return the id of the pushed -/// root node. Exhaustive over every **operator** `QueryExpr` variant — a new -/// one fails to compile here until this match is extended, matching the rest -/// of the IR's exhaustive-match style (e.g. `output_schema`). The scalar -/// variants (issue #205) are never passed to `build` directly: every operator -/// arm that carries one (`Filter.pred`, `Project.cols`, `Aggregate.having`, …) -/// serializes it as opaque `detail` JSON via `Predicate`/`ProjectItem`/ -/// `AggIntent`'s own `Serialize` impl, same as before the merge — a scalar -/// sub-DAG was never a separate DAG node, so this doesn't change that. -/// -/// `find_winner` is [`export_post_asap`]'s substitution seam, threaded -/// through every recursive call (including [`export`]'s own, which always -/// passes a closure that returns `None`) so both entry points share this -/// exact traversal instead of maintaining two copies of it. `build` itself -/// only ever calls `find_winner` once, right here at the top, before -/// dispatching into the ordinary per-variant match below — see -/// [`export_post_asap`]'s own doc for why a substitution's own immediate -/// result is rendered via that match directly (recursing into its children -/// through `build` again, so *they* still get a fresh `find_winner` call) -/// rather than by looping back through this check a second time. -fn build( - expr: &QueryExpr, - nodes: &mut Vec, - cache: &mut HashCache, - find_winner: &mut dyn FnMut(&QueryExpr) -> Option, -) -> u32 { - match find_winner(expr) { - Some(PostAsapSubstitution::Rewrite { - replacement, - decision, - }) => { - let first = nodes.len(); - let root = build_no_recheck(&replacement, nodes, cache, find_winner); - for node in &mut nodes[first..] { - if node.decision.is_none() { - let mut node_decision = decision.clone(); - node_decision.role = if node.id == root { - "replacement_root" - } else { - "replacement_region" - }; - node.decision = Some(node_decision); - } +/// `(label, detail)` for one node: its own fields, never its children. +/// Exhaustive over every operator variant — a new one fails to compile +/// here until this match is extended, matching the rest of the IR's +/// exhaustive-match style. Scalar expressions are rendered through +/// [`scalar_json`] with `ids` resolving their operator references. +fn shape( + node: &OperatorNode, + ids: &HashMap<*const OperatorNode, u32>, +) -> (String, serde_json::Value) { + let scalar = |expr: &ScalarExpr| scalar_json(expr, ids); + let scalars = + |exprs: &[ScalarExpr]| -> Vec { exprs.iter().map(scalar).collect() }; + let predicate = |pred: &crate::ir::Predicate| scalar(&pred.0); + let sort_keys = |keys: &[crate::ir::SortKey]| -> Vec { + keys.iter() + .map(|key| { + serde_json::json!({ + "expr": scalar(&key.expr), + "ascending": key.ascending, + "nulls_first": key.nulls_first, + }) + }) + .collect() + }; + match &node.operator { + Operator::NonASAP(op) => match op { + NonASAPOp::Scan { + source, + predicates, + schema, + } => ( + format!("Scan({})", source_label(source)), + serde_json::json!({ + "source": source, + "predicates": predicates.iter().map(predicate).collect::>(), + "schema": schema, + }), + ), + NonASAPOp::Values { rows, schema } => ( + format!("Values({} rows)", rows.len()), + serde_json::json!({ + "rows": rows.iter().map(|row| scalars(row)).collect::>(), + "schema": schema, + }), + ), + NonASAPOp::Filter { pred, .. } => ( + "Filter".into(), + serde_json::json!({ "pred": predicate(pred) }), + ), + NonASAPOp::Project { + cols, qualifier, .. + } => ( + format!("Project({} cols)", cols.len()), + serde_json::json!({ + "cols": cols.iter().map(|item| serde_json::json!({ + "alias": item.alias, + "expr": scalar(&item.expr), + })).collect::>(), + "qualifier": qualifier, + }), + ), + NonASAPOp::Aggregate { + reduction, + measures, + output_names, + having, + .. + } => ( + format!("Aggregate({} measures)", measures.len()), + serde_json::json!({ + "reduction": reduction, + "measures": measures, + "output_names": output_names, + "having": having.as_ref().map(predicate), + }), + ), + NonASAPOp::Join { kind, pred, .. } => ( + format!("Join({kind:?})"), + serde_json::json!({ "kind": kind, "pred": predicate(pred) }), + ), + NonASAPOp::SetOp { kind, all, .. } => ( + format!("SetOp({kind:?})"), + serde_json::json!({ "kind": kind, "all": all }), + ), + NonASAPOp::Concat { + children, + discriminator_unique_key, + } => ( + format!("Concat({} branches)", children.len()), + serde_json::json!({ "discriminator_unique_key": discriminator_unique_key }), + ), + NonASAPOp::Dedup { cols, .. } => ( + format!("Dedup({} cols)", cols.len()), + serde_json::json!({ "cols": cols }), + ), + NonASAPOp::Sort { + keys, partition_by, .. + } => ( + format!("Sort({} keys)", keys.len()), + serde_json::json!({ "keys": sort_keys(keys), "partition_by": partition_by }), + ), + NonASAPOp::Limit { + n, + offset, + partition_by, + .. + } => ( + match n { + Some(n) => format!("Limit({n})"), + None => format!("Limit(offset {offset})"), + }, + serde_json::json!({ "n": n, "offset": offset, "partition_by": partition_by }), + ), + NonASAPOp::BinaryOp { + operator, + return_bool, + .. + } => ( + format!("BinaryOp({})", operator.kind), + serde_json::json!({ + "op": operator.kind.to_string(), + "vector_match": operator.vector_match, + "checked_relative_division": operator.checked_relative_division, + "checked_finite_division": operator.checked_finite_division, + "return_bool": return_bool, + }), + ), + NonASAPOp::SQLWindowFunc { + func, + args, + partition_by, + order_by, + frame, + output_name, + .. + } => ( + format!("SQLWindowFunc({func:?})"), + serde_json::json!({ + "func": func, + "args": scalars(args), + "partition_by": partition_by, + "order_by": sort_keys(order_by), + "frame": frame, + "output_name": output_name, + }), + ), + NonASAPOp::TimeRange { range, kind, .. } => ( + format!("TimeRange({kind:?}, {range:?})"), + serde_json::json!({ "range": range, "kind": kind }), + ), + NonASAPOp::TimeShift { shift, .. } => { + ("TimeShift".into(), serde_json::json!({ "shift": shift })) } - return root; - } - Some(PostAsapSubstitution::Summary { - replacement, - decision, - }) => { - let first = nodes.len(); - let root = build_summary_hybrid(&replacement, nodes, cache, find_winner); - for node in &mut nodes[first..] { - if node.decision.is_none() { - let mut node_decision = decision.clone(); - node_decision.role = if node.id == root { - "replacement_root" - } else { - "replacement_region" - }; - node.decision = Some(node_decision); - } + NonASAPOp::PromqlVectorFromScalar(value) => ( + "vector()".into(), + serde_json::json!({ "value": scalar(value) }), + ), + NonASAPOp::PromqlRelabel { dst, value, .. } => ( + format!("PromqlRelabel(dst={dst})"), + serde_json::json!({ "dst": dst, "value": scalar(value) }), + ), + NonASAPOp::PromqlInfoEnrich { selector, .. } => ( + "PromqlInfoEnrich".into(), + serde_json::json!({ "selector": selector }), + ), + NonASAPOp::PromqlSeriesSample { by, kind, .. } => ( + format!("PromqlSeriesSample({kind:?})"), + serde_json::json!({ "by": by, "kind": kind }), + ), + NonASAPOp::PromqlSubquery { + range, resolution, .. + } => ( + "PromqlSubquery".into(), + serde_json::json!({ "range": range, "resolution": resolution }), + ), + }, + Operator::ASAP(op) => match op { + ASAPOp::SummaryAgg { + family, + input, + reduction, + grouping, + .. + } => ( + format!("SummaryAgg({})", family_label(family)), + serde_json::json!({ + "family": format!("{family:?}"), + "input": input, + "reduction": reduction, + "grouping": format!("{grouping:?}"), + }), + ), + ASAPOp::SummaryEstimate { query, .. } => ( + format!("SummaryEstimate({query:?})"), + serde_json::json!({ "query": format!("{query:?}") }), + ), + ASAPOp::FinalizeExactAccumulator { .. } => { + ("FinalizeExactAccumulator".into(), serde_json::json!({})) } - return root; - } - None => {} + ASAPOp::MaintainPopulation { population, .. } => ( + format!("MaintainPopulation(max_k={})", population.max_k), + serde_json::json!({ "population": population }), + ), + ASAPOp::EvaluatePopulation { evaluation, .. } => ( + format!("EvaluatePopulation({evaluation:?})"), + serde_json::json!({ "evaluation": evaluation }), + ), + ASAPOp::SummaryMerge { children } => ( + format!("SummaryMerge({} children)", children.len()), + serde_json::json!({}), + ), + ASAPOp::SummarySubtract { .. } => ("SummarySubtract".into(), serde_json::json!({})), + ASAPOp::SummaryDelete { key, .. } => { + ("SummaryDelete".into(), serde_json::json!({ "key": key })) + } + ASAPOp::SummaryJoin { key, family, .. } => ( + format!("SummaryJoin({})", family_label(family)), + serde_json::json!({ "key": key, "family": format!("{family:?}") }), + ), + ASAPOp::Extension { name, .. } => ( + format!("Extension({name})"), + serde_json::json!({ "name": name }), + ), + }, } - build_no_recheck(expr, nodes, cache, find_winner) } -/// The actual per-variant match [`build`] dispatches to once it has decided -/// (by consulting `find_winner` exactly once) which `QueryExpr` value to -/// render at this position — either `expr` itself (unchanged), or a winning -/// `Replacement::Rewrite`'s own target. Every recursive call here goes back -/// through [`build`] (not this function), so every child gets its own fresh -/// `find_winner` query. -fn build_no_recheck( - expr: &QueryExpr, - nodes: &mut Vec, - cache: &mut HashCache, - find_winner: &mut dyn FnMut(&QueryExpr) -> Option, -) -> u32 { +/// `{"scalar_ref": }` for an operator node a scalar expression reads. +/// The node is one of the owning operator's children, so it has already +/// been exported by the time its parent's `detail` is built. +fn scalar_ref( + node: &Rc, + ids: &HashMap<*const OperatorNode, u32>, +) -> serde_json::Value { + serde_json::json!({ "scalar_ref": ids.get(&Rc::as_ptr(node)).copied() }) +} + +/// `expr` as JSON in `ScalarExpr`'s own serde shape (externally tagged +/// variants), except that every operator reference is rendered via +/// [`scalar_ref`] instead of inlining the referenced sub-DAG. Exhaustive so +/// a new variant fails to compile here until it is rendered. +fn scalar_json(expr: &ScalarExpr, ids: &HashMap<*const OperatorNode, u32>) -> serde_json::Value { + let sub = |e: &ScalarExpr| scalar_json(e, ids); + let list = |es: &[ScalarExpr]| -> Vec { es.iter().map(sub).collect() }; match expr { - QueryExpr::Scan { - source, - predicates, - schema, - } => { - let label = format!("Scan({})", source_label(source)); - let detail = serde_json::json!({ - "source": source, - "predicates": predicates, - "schema": schema, - }); - push_node(nodes, expr, cache, label, detail, vec![]) - } - // The bridged child is a scalar-sub-language node (issue #220), not - // an operator node `build` can recurse into — serialize it as opaque - // `detail` JSON, same as every other scalar-typed field - // (`Filter.pred`, `Project.cols`, …) rather than pushing it as a - // separate DAG node. - QueryExpr::PromqlScalarBridge(inner) => { - let detail = serde_json::json!({ "value": inner }); - push_node( - nodes, - expr, - cache, - format!("PromqlScalarBridge({inner:?})"), - detail, - vec![], - ) - } - QueryExpr::EvalTimestamp => push_node( - nodes, - expr, - cache, - "EvalTimestamp".into(), - serde_json::json!({}), - vec![], - ), - QueryExpr::CurrentTimestamp => push_node( - nodes, - expr, - cache, - "CurrentTimestamp".into(), - serde_json::json!({}), - vec![], - ), - QueryExpr::PromqlVectorFromScalar(child) => { - let c = build(child, nodes, cache, find_winner); - push_node( - nodes, - expr, - cache, - "vector()".into(), - serde_json::json!({}), - vec![c], - ) - } - QueryExpr::PromqlScalarFromVector(child) => { - let c = build(child, nodes, cache, find_winner); - push_node( - nodes, - expr, - cache, - "scalar()".into(), - serde_json::json!({}), - vec![c], - ) - } - QueryExpr::PromqlRelabel { dst, value, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "dst": dst, "value": value }); - push_node( - nodes, - expr, - cache, - format!("PromqlRelabel(dst={dst})"), - detail, - vec![c], - ) - } - QueryExpr::PromqlInfoEnrich { selector, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "selector": selector }); - push_node( - nodes, - expr, - cache, - "PromqlInfoEnrich".into(), - detail, - vec![c], - ) - } - QueryExpr::PromqlSeriesSample { by, kind, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "by": by, "kind": kind }); - push_node( - nodes, - expr, - cache, - format!("PromqlSeriesSample({kind:?})"), - detail, - vec![c], - ) - } - QueryExpr::Filter { pred, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "pred": pred }); - push_node(nodes, expr, cache, "Filter".into(), detail, vec![c]) - } - QueryExpr::Project { - cols, - qualifier, - child, - } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "cols": cols, "qualifier": qualifier }); - push_node( - nodes, - expr, - cache, - format!("Project({} cols)", cols.len()), - detail, - vec![c], - ) - } - QueryExpr::Aggregate { - reduction, - measures, - output_names, - filters, - having, - child, - } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ - "reduction": reduction, - "measures": measures, - "output_names": output_names, - "filters": filters, - "having": having, - }); - push_node( - nodes, - expr, - cache, - format!("Aggregate({} measures)", measures.len()), - detail, - vec![c], - ) - } - QueryExpr::Dedup { cols, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "cols": cols }); - push_node( - nodes, - expr, - cache, - format!("Dedup({} cols)", cols.len()), - detail, - vec![c], - ) - } - QueryExpr::Concat { - children, - discriminator_unique_key, - } => { - let ids: Vec = children - .iter() - .map(|c| build(c, nodes, cache, find_winner)) - .collect(); - let label = format!("Concat({} branches)", ids.len()); - let detail = - serde_json::json!({ "discriminator_unique_key": discriminator_unique_key }); - push_node(nodes, expr, cache, label, detail, ids) - } - QueryExpr::Join { - kind, - pred, + ScalarExpr::Column(id) => serde_json::json!({ "Column": id }), + ScalarExpr::Literal(value) => serde_json::json!({ "Literal": value }), + ScalarExpr::Negative { expr, semantics } => serde_json::json!({ + "Negative": { "expr": sub(expr), "semantics": semantics } + }), + ScalarExpr::Compare { left, + op, right, - } => { - let l = build(left, nodes, cache, find_winner); - let r = build(right, nodes, cache, find_winner); - let detail = serde_json::json!({ "kind": kind, "pred": pred }); - push_node( - nodes, - expr, - cache, - format!("Join({kind:?})"), - detail, - vec![l, r], - ) - } - QueryExpr::SetOp { - kind, - all, + semantics, + } => serde_json::json!({ + "Compare": { + "left": sub(left), + "op": op, + "right": sub(right), + "semantics": semantics, + } + }), + ScalarExpr::BoolAnd(parts) => serde_json::json!({ "BoolAnd": list(parts) }), + ScalarExpr::BoolOr(parts) => serde_json::json!({ "BoolOr": list(parts) }), + ScalarExpr::Not(e) => serde_json::json!({ "Not": sub(e) }), + ScalarExpr::IsNull(e) => serde_json::json!({ "IsNull": sub(e) }), + ScalarExpr::IsNotNull(e) => serde_json::json!({ "IsNotNull": sub(e) }), + ScalarExpr::Cast { expr, to, try_cast } => serde_json::json!({ + "Cast": { "expr": sub(expr), "to": to, "try_cast": try_cast } + }), + ScalarExpr::InList { + expr, + list: items, + negated, + } => serde_json::json!({ + "InList": { "expr": sub(expr), "list": list(items), "negated": negated } + }), + ScalarExpr::FunctionCall { name, args } => serde_json::json!({ + "FunctionCall": { "name": name, "args": list(args) } + }), + ScalarExpr::Arithmetic { + op, left, right, - } => { - let l = build(left, nodes, cache, find_winner); - let r = build(right, nodes, cache, find_winner); - let detail = serde_json::json!({ "kind": kind, "all": all }); - push_node( - nodes, - expr, - cache, - format!("SetOp({kind:?})"), - detail, - vec![l, r], - ) - } - QueryExpr::Sort { - keys, - partition_by, - child, - } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "keys": keys, "partition_by": partition_by }); - push_node( - nodes, - expr, - cache, - format!("Sort({} keys)", keys.len()), - detail, - vec![c], - ) - } - QueryExpr::Limit { n, offset, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "n": n, "offset": offset }); - push_node(nodes, expr, cache, format!("Limit({n})"), detail, vec![c]) - } - QueryExpr::PromqlSubquery { - range, - resolution, - child, - } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "range": range, "resolution": resolution }); - push_node(nodes, expr, cache, "PromqlSubquery".into(), detail, vec![c]) - } - QueryExpr::TimeRange { range, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "range": range }); - push_node( - nodes, - expr, - cache, - format!("TimeRange({range:?})"), - detail, - vec![c], - ) - } - QueryExpr::TimeShift { shift, child } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ "shift": shift }); - push_node(nodes, expr, cache, "TimeShift".into(), detail, vec![c]) - } - QueryExpr::SQLWindowFunc { - func, - args, - partition_by, - order_by, - frame, - output_name, - child, - } => { - let c = build(child, nodes, cache, find_winner); - let detail = serde_json::json!({ - "func": func, - "args": args, - "partition_by": partition_by, - "order_by": order_by, - "frame": frame, - "output_name": output_name, - }); - push_node( - nodes, - expr, - cache, - format!("SQLWindowFunc({func:?})"), - detail, - vec![c], - ) - } - QueryExpr::BinaryOp { - op, - lhs, - rhs, - vector_match, - } => { - let l = build(lhs, nodes, cache, find_winner); - let r = build(rhs, nodes, cache, find_winner); - let detail = serde_json::json!({ "op": op.to_string(), "vector_match": vector_match }); - push_node( - nodes, - expr, - cache, - format!("BinaryOp({op})"), - detail, - vec![l, r], - ) - } - other @ (QueryExpr::Column(_) - | QueryExpr::Literal(_) - | QueryExpr::Compare { .. } - | QueryExpr::BoolAnd(_) - | QueryExpr::BoolOr(_) - | QueryExpr::Not(_) - | QueryExpr::IsNull(_) - | QueryExpr::IsNotNull(_) - | QueryExpr::Cast { .. } - | QueryExpr::InList { .. } - | QueryExpr::FunctionCall { .. } - | QueryExpr::Arithmetic { .. } - | QueryExpr::Case { .. }) => { - unreachable!("dag_export::build reached a scalar QueryExpr variant directly: {other:?}") - } + semantics, + } => serde_json::json!({ + "Arithmetic": { + "op": op, + "left": sub(left), + "right": sub(right), + "semantics": semantics, + } + }), + ScalarExpr::Case { + operand, + branches, + else_expr, + } => serde_json::json!({ + "Case": { + "operand": operand.as_deref().map(sub), + "branches": branches + .iter() + .map(|(when, then)| serde_json::json!([sub(when), sub(then)])) + .collect::>(), + "else_expr": else_expr.as_deref().map(sub), + } + }), + ScalarExpr::CurrentTimestamp => serde_json::json!("CurrentTimestamp"), + ScalarExpr::EvalTimestamp => serde_json::json!("EvalTimestamp"), + ScalarExpr::PromqlScalarFromVector(node) => serde_json::json!({ + "PromqlScalarFromVector": scalar_ref(node, ids) + }), + ScalarExpr::ScalarSubquery(node) => serde_json::json!({ + "ScalarSubquery": scalar_ref(node, ids) + }), + ScalarExpr::Exists { subquery, negated } => serde_json::json!({ + "Exists": { "subquery": scalar_ref(subquery, ids), "negated": negated } + }), + ScalarExpr::InSubquery { + expr, + subquery, + negated, + } => serde_json::json!({ + "InSubquery": { + "expr": sub(expr), + "subquery": scalar_ref(subquery, ids), + "negated": negated, + } + }), } } @@ -1407,14 +1062,20 @@ mod tests { use std::rc::Rc; use super::*; + use crate::ir::operator_properties::{GroupKeys, JoinKind, Reduction}; + use crate::ir::Predicate; + use crate::post_asap::{ + BoundExpr, CompositionOperator, ErrorMetric, GroupingStrategy, GuaranteeSource, + ProbabilityExpr, SketchAlgorithm, SketchKind, SketchParams, SketchStatistic, SummaryUpdate, + }; use crate::pre_asap::agg_intent::AggIntent; - use crate::pre_asap::expr_ir::ScalarValue; - use crate::pre_asap::query_expr::{GroupKeys, Predicate, Reduction}; + use crate::pre_asap::expr_ir::{ColumnRef, ScalarValue}; use crate::pre_asap::schema::{DataType, Field, Schema}; + use crate::types::AccuracyTarget; - fn scan(table: &str, columns: Vec) -> QueryExpr { - QueryExpr::Scan { + fn scan(table: &str, columns: Vec) -> Rc { + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Scan { source: Source::Table { table_ref: table.into(), }, @@ -1425,25 +1086,95 @@ mod tests { unique_keys: vec![], closed: true, }, - } + })) + .unwrap() } fn value_col() -> Vec { vec![Field::plain("value", DataType::Float64, false)] } + fn true_pred() -> Predicate { + Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))) + } + + fn count_agg(child: Rc) -> Rc { + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::Reduce(GroupKeys::none()), + measures: vec![AggIntent::Count { + accuracy: AccuracyTarget::Exact, + }], + output_names: vec![], + filters: vec![], + having: None, + child, + })) + .unwrap() + } + + fn join(left: Rc, right: Rc) -> Rc { + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Join { + kind: JoinKind::Inner, + pred: true_pred(), + left, + right, + })) + .unwrap() + } + + /// A KLL `SummaryAgg` over `leaf`'s `v` column, read out as a quantile. + fn quantile_evaluation( + leaf: Rc, + guarantee: Option, + ) -> (Rc, Rc) { + let family = FieldDataType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 40 }), + GroupingStrategy::default(), + ); + let agg = std::rc::Rc::new( + OperatorNode::with_schema( + crate::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child: leaf, + family: family.clone(), + input: SummaryUpdate::column(ColumnRef::Named("v".into())), + reduction: Reduction::by(vec![]), + grouping: GroupingStrategy::default(), + filter: None, + }), + Schema::lifted(vec![Field::new("state", family, false)], None), + ) + .with_guarantee(None), + ); + let evaluation = std::rc::Rc::new( + OperatorNode::with_schema( + crate::ir::Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: Rc::clone(&agg), + query: SketchStatistic::Quantile { q: 0.99 }, + }), + Schema::lifted( + vec![Field::plain("quantile", DataType::Float64, false)], + None, + ), + ) + .with_guarantee(guarantee), + ); + (agg, evaluation) + } + #[test] fn leaf_scan_is_a_single_node() { let dag = export(&scan("metrics", value_col())); assert_eq!(dag.nodes.len(), 1); assert_eq!(dag.root, 0); assert_eq!(dag.nodes[0].kind, "Scan"); + assert_eq!(dag.nodes[0].label, "Scan(metrics)"); assert!(dag.nodes[0].children.is_empty()); + assert!(dag.nodes[0].source_node.is_some()); } /// `export` itself never populates higher-layer annotations. Empty - /// annotations must not appear in serialized JSON, so ordinary (non-ASAP) - /// exports retain their existing shape. + /// annotations must not appear in serialized JSON, so ordinary exports + /// retain their existing shape. #[test] fn export_omits_empty_higher_layer_annotations() { let dag = export(&scan("metrics", value_col())); @@ -1460,6 +1191,10 @@ mod tests { !json.contains("decision"), "empty `decision` must be skipped, not serialized as `null`: {json}" ); + assert!( + !json.contains("source_node"), + "`source_node` is in-process only: {json}" + ); let dag_json = serde_json::to_string(&dag).unwrap(); assert!( !dag_json.contains("edge_annotations"), @@ -1473,22 +1208,29 @@ mod tests { // single child slot) share the exact same `Rc` Scan — // `export_post_asap` must merge them onto one node id. Sharing alone // is not physical cost evidence, so no edge cost may be fabricated. - let shared_scan = Rc::new(scan("metrics", value_col())); - let left_branch = QueryExpr::Dedup { - cols: vec![0], - child: Rc::clone(&shared_scan), - }; - let right_branch = QueryExpr::Limit { - n: 5, - offset: 0, - child: Rc::clone(&shared_scan), - }; - let root = QueryExpr::Concat { + let shared_scan = scan("metrics", value_col()); + let left_branch = + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Dedup { + cols: vec![0], + child: Rc::clone(&shared_scan), + })) + .unwrap(); + let right_branch = + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(5), + offset: 0, + partition_by: GroupKeys::none(), + child: Rc::clone(&shared_scan), + })) + .unwrap(); + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Concat { children: vec![left_branch, right_branch], discriminator_unique_key: None, - }; + })) + .unwrap(); let dag = export_post_asap(&root, &mut |_| None); + assert_eq!(dag.nodes.len(), 4, "Scan, Dedup, Limit, Concat"); assert_eq!( dag.nodes.iter().filter(|n| n.kind == "Scan").count(), 1, @@ -1497,20 +1239,15 @@ mod tests { assert!(dag.edge_annotations.is_empty()); } - /// Regression test: a single parent referencing the same shared child - /// from two of its own operand slots at once (a `Join` whose left and - /// right sides are the exact same `Rc`, post pointer-dedup) is *one* - /// downstream consumer, not two — this must not inflate - /// produce an edge-cost annotation without explicit physical evidence. + /// A single parent referencing the same shared child from two of its + /// own operand slots at once (a `Join` whose left and right sides are + /// the exact same `Rc`) is *one* downstream consumer, not two — this + /// must not produce an edge-cost annotation without explicit physical + /// evidence. #[test] fn a_single_parent_referencing_a_shared_child_twice_is_one_consumer_not_two() { - let shared_scan = Rc::new(scan("metrics", value_col())); - let root = QueryExpr::Join { - kind: crate::pre_asap::query_expr::JoinKind::Inner, - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - left: Rc::clone(&shared_scan), - right: Rc::clone(&shared_scan), - }; + let shared_scan = scan("metrics", value_col()); + let root = join(Rc::clone(&shared_scan), Rc::clone(&shared_scan)); let dag = export_post_asap(&root, &mut |_| None); assert_eq!( @@ -1518,6 +1255,7 @@ mod tests { 1, "the shared Scan must be merged onto one node, not duplicated" ); + assert_eq!(dag.nodes[dag.root as usize].children, vec![0, 0]); assert!( dag.edge_annotations.is_empty(), "a single parent referencing the same child twice is one consumer, not a genuine \ @@ -1527,38 +1265,33 @@ mod tests { } #[test] - fn export_never_produces_edge_annotations_since_it_never_shares_nodes() { - // Plain `export` (no `export_post_asap`) never deduplicates by `Rc` - // pointer identity — even a workload-level shared sub-DAG renders as - // two independent DAG nodes here, so there is nothing to annotate. - let shared_scan = Rc::new(scan("metrics", value_col())); - let root = QueryExpr::Join { - kind: crate::pre_asap::query_expr::JoinKind::Inner, - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - left: Rc::clone(&shared_scan), - right: Rc::clone(&shared_scan), - }; - let dag = export(&root); - assert_eq!(dag.nodes.iter().filter(|n| n.kind == "Scan").count(), 2); + fn export_merges_pointer_shared_nodes_but_not_equal_copies() { + // Plain `export` deduplicates by `Rc` pointer identity: the same + // `Rc` reached twice is one node ... + let shared_scan = scan("metrics", value_col()); + let dag = export(&join(Rc::clone(&shared_scan), Rc::clone(&shared_scan))); + assert_eq!(dag.nodes.iter().filter(|n| n.kind == "Scan").count(), 1); assert!(dag.edge_annotations.is_empty()); + + // ... while two structurally equal but distinct `Rc`s stay two + // nodes (with equal hashes — that is CSE's job, not the export's). + let dag = export(&join( + scan("metrics", value_col()), + scan("metrics", value_col()), + )); + let scans: Vec<_> = dag.nodes.iter().filter(|n| n.kind == "Scan").collect(); + assert_eq!(scans.len(), 2); + assert_eq!(scans[0].hash, scans[1].hash); } #[test] fn chain_preserves_shape_and_child_links() { - let expr = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - child: Rc::new(QueryExpr::Aggregate { - reduction: Reduction::Reduce(GroupKeys::none()), - measures: vec![AggIntent::Count { - accuracy: AccuracyTarget::Exact, - }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(scan("metrics", value_col())), - }), - }; - let dag = export(&expr); + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: true_pred(), + child: count_agg(scan("metrics", value_col())), + })) + .unwrap(); + let dag = export(&root); assert_eq!(dag.nodes.len(), 3, "Filter -> Aggregate -> Scan"); let filter = &dag.nodes[dag.root as usize]; @@ -1567,6 +1300,7 @@ mod tests { let agg = &dag.nodes[filter.children[0] as usize]; assert_eq!(agg.kind, "Aggregate"); + assert_eq!(agg.label, "Aggregate(1 measures)"); assert_eq!(agg.children.len(), 1); let leaf = &dag.nodes[agg.children[0] as usize]; @@ -1576,18 +1310,76 @@ mod tests { #[test] fn merge_keeps_every_branch_as_a_child() { - let expr = QueryExpr::concat(vec![ - scan("a", value_col()), - scan("b", value_col()), - scan("c", value_col()), - ]); - let dag = export(&expr); + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Concat { + children: vec![ + scan("a", value_col()), + scan("b", value_col()), + scan("c", value_col()), + ], + discriminator_unique_key: None, + })) + .unwrap(); + let dag = export(&root); assert_eq!(dag.nodes.len(), 4, "3 branches + the Concat node"); let merge = &dag.nodes[dag.root as usize]; assert_eq!(merge.kind, "Concat"); assert_eq!(merge.children.len(), 3); } + /// An operator node read from a scalar expression is a child of the + /// owning operator (after its operator inputs), and the expression's + /// `detail` points at it by id instead of inlining it. + #[test] + fn scalar_operator_references_are_children_rendered_as_scalar_refs() { + let subquery = scan("other", value_col()); + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Exists { + subquery: Rc::clone(&subquery), + negated: false, + }), + child: scan("metrics", value_col()), + })) + .unwrap(); + let dag = export(&root); + assert_eq!(dag.nodes.len(), 3); + let filter = &dag.nodes[dag.root as usize]; + assert_eq!( + filter.children.len(), + 2, + "operator input, then the scalar reference" + ); + let input = &dag.nodes[filter.children[0] as usize]; + let referenced = &dag.nodes[filter.children[1] as usize]; + assert_eq!(input.label, "Scan(metrics)"); + assert_eq!(referenced.label, "Scan(other)"); + assert_eq!( + filter.detail["pred"]["Exists"]["subquery"]["scalar_ref"], + serde_json::json!(referenced.id) + ); + assert_eq!(filter.detail["pred"]["Exists"]["negated"], false); + let json = serde_json::to_string(&filter.detail).unwrap(); + assert!( + !json.contains("other"), + "the referenced sub_dag must not be inlined into detail: {json}" + ); + } + + #[test] + fn limit_without_n_is_offset_only() { + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Limit { + n: None, + offset: 3, + partition_by: GroupKeys::none(), + child: scan("metrics", value_col()), + })) + .unwrap(); + let dag = export(&root); + let limit = &dag.nodes[dag.root as usize]; + assert_eq!(limit.label, "Limit(offset 3)"); + assert_eq!(limit.detail["n"], serde_json::Value::Null); + assert_eq!(limit.detail["offset"], 3); + } + #[test] fn identical_sub_dags_hash_equal_and_differing_ones_dont() { let left = scan("metrics", value_col()); @@ -1615,17 +1407,18 @@ mod tests { // Two roots that each wrap the *same* Scan shape in a different outer // node — the exported hash should still flag the shared Scan even // though it's embedded at different depths / under different parents. - let shared_shape = || scan("metrics", value_col()); - - let q1 = QueryExpr::Limit { - n: 10, + let q1 = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Limit { + n: Some(10), offset: 0, - child: Rc::new(shared_shape()), - }; - let q2 = QueryExpr::Dedup { + partition_by: GroupKeys::none(), + child: scan("metrics", value_col()), + })) + .unwrap(); + let q2 = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Dedup { cols: vec![0], - child: Rc::new(shared_shape()), - }; + child: scan("metrics", value_col()), + })) + .unwrap(); let g1 = export(&q1); let g2 = export(&q2); @@ -1647,9 +1440,9 @@ mod tests { fn root_hash_matches_cse_structural_hash_for_the_same_node() { // Not just "hashes equal for equal inputs" (any two consistent hash // functions would do that) — the exported root's `hash` must be the - // literal `u64` `crate::pre_asap::cse::structural_hash` produces for - // this exact node, because it's the same function call, not a - // parallel reimplementation that happens to agree. + // literal `u64` `crate::ir::cse::structural_hash` produces for this + // exact node, because it's the same function call, not a parallel + // reimplementation that happens to agree. let leaf = scan("metrics", value_col()); let dag = export(&leaf); assert_eq!( @@ -1661,24 +1454,15 @@ mod tests { #[test] fn every_node_hash_matches_cse_structural_hash_on_its_own_sub_dag() { - // A multi-level DAG: check the parity holds at every depth, not - // just the root — each `DAGNode::hash` must equal - // `structural_hash` applied to the actual `QueryExpr` sub-DAG that - // node represents. - let agg = QueryExpr::Aggregate { - reduction: Reduction::Reduce(GroupKeys::none()), - measures: vec![AggIntent::Count { - accuracy: AccuracyTarget::Exact, - }], - output_names: vec![], - filters: vec![], - having: None, - child: Rc::new(scan("metrics", value_col())), - }; - let root = QueryExpr::Filter { - pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), - child: Rc::new(agg.clone()), - }; + // A multi-level tree: check the parity holds at every depth, not + // just the root — each `DAGNode::hash` must equal `structural_hash` + // applied to the actual node it represents. + let agg = count_agg(scan("metrics", value_col())); + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: true_pred(), + child: Rc::clone(&agg), + })) + .unwrap(); let dag = export(&root); assert_eq!( @@ -1697,39 +1481,23 @@ mod tests { ); } - /// Issue #172: a readout's guarantee is exported structurally — metric, + /// Issue #172: a evaluation's guarantee is exported structurally — metric, /// symbolic bound, failure probability, provenance (allocation - /// included) — and a rejection carries its typed reason. + /// included) — and a rejection carries its typed reason. A relational + /// node below a summary is its own node, in the same dag. #[test] fn export_carries_guarantee_allocation_and_rejection_reason() { - use crate::post_asap::{ - BoundExpr, CompositionOperator, ErrorMetric, FieldDataType, GroupingStrategy, - GuaranteeSource, ProbabilityExpr, Schema, SketchAlgorithm, SketchKind, SketchParams, - SketchStatistic, - }; - let leaf = Rc::new(scan("t", vec![Field::plain("v", DataType::Float64, false)])); - let kept = Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::clone(&leaf)), - schema: Schema::lifted(vec![], None), - guarantee: Some(ResultGuarantee::exact("KeepPreAsap")), - }); - let agg = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: kept, - family: FieldDataType::Sketch( - SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 40 }), - GroupingStrategy::default(), - ), - input: crate::post_asap::SummaryUpdate::column( - crate::pre_asap::expr_ir::ColumnRef::Named("v".into()), - ), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }, - schema: Schema::lifted(vec![], None), - guarantee: None, - }); + let leaf = Rc::new( + OperatorNode::new(Operator::NonASAP(NonASAPOp::Scan { + source: Source::Table { + table_ref: "t".into(), + }, + predicates: vec![], + schema: Schema::lifted(vec![Field::plain("v", DataType::Float64, false)], None), + })) + .unwrap() + .with_guarantee(Some(ResultGuarantee::exact("Scan"))), + ); let guarantee = ResultGuarantee { metric: ErrorMetric::Rank, bound: BoundExpr::Sum { @@ -1755,15 +1523,15 @@ mod tests { }, ], }; - let root = SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input: agg, - query: SketchStatistic::Quantile { q: 0.99 }, - }, - schema: Schema::lifted(vec![], None), - guarantee: Some(guarantee), - }; + let (_, root) = quantile_evaluation(Rc::clone(&leaf), Some(guarantee)); let dag = export_summary(&root); + assert_eq!( + dag.nodes.iter().map(|n| n.kind).collect::>(), + ["scan", "summary_agg", "summary_estimate"] + ); + assert_eq!(dag.nodes[1].label, "SummaryAgg(Sketch(Kll))"); + assert!(dag.nodes[2].label.starts_with("SummaryEstimate(Quantile")); + assert!(dag.nodes.iter().all(|n| n.schema.is_some())); let json = serde_json::to_value(&dag).unwrap(); let root_json = &json["nodes"][dag.root as usize]; assert_eq!(root_json["guarantee"]["metric"], "rank"); @@ -1779,10 +1547,18 @@ mod tests { assert!(provenance.iter().any(|s| s["kind"] == "composition_step")); // Raw sketch state carries none; the exact leaf carries zero error. let state = &json["nodes"][1]; - assert_eq!(state["kind"], "SummaryAgg"); assert!(state.get("guarantee").is_none()); assert_eq!(json["nodes"][0]["guarantee"]["bound"]["op"], "zero"); + // The `DAGNode` shape carries the same guarantee inside `detail`. + let dag = export(&root); + assert_eq!( + dag.nodes.iter().map(|n| n.kind).collect::>(), + ["Scan", "SummaryAgg", "SummaryEstimate"] + ); + assert_eq!(dag.nodes[2].detail["guarantee"]["metric"], "rank"); + assert!(dag.nodes[1].detail.get("guarantee").is_none()); + let named = NamedDAG { name: "q".into(), source: None, @@ -1792,7 +1568,7 @@ mod tests { workload_cost: None, rejections: vec![TargetRejection { target_pre_id: 0, - strategy: "SketchAlgorithmStrategy".into(), + strategy: "ASAPStrategies".into(), description: "quantile over quantile".into(), error: AccuracyError::UnsupportedComposition { operator: CompositionOperator::ApproximateAggregate, @@ -1819,38 +1595,166 @@ mod tests { .is_none()); } - fn viewer_kind_categories() -> std::collections::BTreeMap { - const START: &str = "const KIND_CATEGORY_JSON = `"; - let source = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../tools/dag-viewer/node-style.js" - )); - let json = source - .split_once(START) - .expect("node-style.js must declare KIND_CATEGORY_JSON") - .1 - .split_once("`;") - .expect("KIND_CATEGORY_JSON must be a template literal") - .0; - serde_json::from_str(json).expect("KIND_CATEGORY_JSON must be valid JSON") + /// `export_post_asap` splices a winning summary in place of its target, + /// tags every node the splice introduced with the decision, and leaves + /// the rest of the query — including an input the summary reuses that + /// was already exported — untagged and shared. + #[test] + fn export_post_asap_splices_a_summary_substitution_in_place() { + let leaf = scan("t", vec![Field::plain("v", DataType::Float64, false)]); + let target = count_agg(Rc::clone(&leaf)); + // `leaf` is exported through the Join's left side before the target + // (its right side) is reached and substituted. + let root = join(Rc::clone(&leaf), Rc::clone(&target)); + let (_, evaluation) = quantile_evaluation(Rc::clone(&leaf), None); + let decision = DAGDecision { + id: 7, + strategy: "Sketch".into(), + rationale: "quantile via KLL".into(), + rank: 0, + cost: 1.0, + role: "", + baseline_cost: None, + selected_cost: None, + benefit: None, + }; + let mut calls = Vec::new(); + let dag = export_post_asap(&root, &mut |node| { + calls.push(node.operator.kind_name()); + Rc::ptr_eq(node, &target).then(|| PostAsapSubstitution::Summary { + replacement: Rc::clone(&evaluation), + decision: decision.clone(), + }) + }); + + let kinds: Vec<_> = dag.nodes.iter().map(|n| n.kind).collect(); + assert_eq!(kinds, ["Scan", "SummaryAgg", "SummaryEstimate", "Join"]); + assert!(!kinds.contains(&"Aggregate"), "the target itself is gone"); + let join_node = &dag.nodes[dag.root as usize]; + assert_eq!(join_node.children, vec![0, 2]); + assert!(join_node.decision.is_none()); + let estimate = &dag.nodes[2]; + assert_eq!(estimate.kind, "SummaryEstimate"); + assert_eq!( + estimate.decision.as_ref().map(|d| (d.id, d.role)), + Some((7, "replacement_root")) + ); + let agg = &dag.nodes[estimate.children[0] as usize]; + assert_eq!( + agg.decision.as_ref().map(|d| (d.id, d.role)), + Some((7, "replacement_region")) + ); + let scan_node = &dag.nodes[agg.children[0] as usize]; + assert_eq!( + scan_node.id, 0, + "the summary reuses the already-exported input" + ); + assert!( + scan_node.decision.is_none(), + "a node exported before the splice is not tagged by it" + ); + assert_eq!( + calls, + ["Join", "Scan", "Aggregate", "SummaryAgg"], + "the substitution's own top level (SummaryEstimate) is never re-queried; its \ + descendants are, except the input already exported" + ); } + /// A `SharedSubDAGStrategy`-shaped substitution returns the target + /// itself as its replacement; the walk must still terminate and render + /// the target once. #[test] - fn viewer_categorizes_exactly_the_exported_node_kinds() { - let expected: std::collections::BTreeSet<_> = QUERY_KIND_TAGS - .iter() - .chain(SUMMARY_KIND_TAGS) - .copied() - .chain(std::iter::once("KeepPreAsap")) - .collect(); + fn export_post_asap_terminates_when_the_replacement_is_the_target() { + let target = count_agg(scan("t", value_col())); + let decision = DAGDecision { + id: 1, + strategy: "SharedSubDAG".into(), + rationale: "share".into(), + rank: 0, + cost: f64::NAN, + role: "", + baseline_cost: None, + selected_cost: None, + benefit: None, + }; + let dag = export_post_asap(&target, &mut |node| { + Rc::ptr_eq(node, &target).then(|| PostAsapSubstitution::Rewrite { + replacement: Rc::clone(&target), + decision: decision.clone(), + }) + }); + assert_eq!(dag.nodes.len(), 2); + assert_eq!(dag.nodes[dag.root as usize].kind, "Aggregate"); assert_eq!( - expected.len(), - QUERY_KIND_TAGS.len() + SUMMARY_KIND_TAGS.len() + 1, - "exported kind tags must be unique" + dag.nodes[dag.root as usize] + .decision + .as_ref() + .map(|d| d.role), + Some("replacement_root") ); - let categories = viewer_kind_categories(); - let actual: std::collections::BTreeSet<_> = categories.keys().map(String::as_str).collect(); + } - assert_eq!(actual, expected); + /// The snake_case `kind` table is exactly `kind_name` re-cased, for + /// every variant: a `SummaryDAGNode` and a `DAGNode` for the same node + /// never disagree on what it is. + #[test] + fn summary_kind_is_the_operator_kind_name_in_snake_case() { + fn to_snake(name: &str) -> String { + let mut out = String::new(); + let chars: Vec = name.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c.is_ascii_uppercase() { + let prev_lower = i > 0 && !chars[i - 1].is_ascii_uppercase(); + let next_lower = chars.get(i + 1).is_some_and(|n| n.is_ascii_lowercase()); + if i > 0 && (prev_lower || next_lower) { + out.push('_'); + } + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out + } + let leaf = scan("t", vec![Field::plain("v", DataType::Float64, false)]); + let (_, evaluation) = quantile_evaluation(Rc::clone(&leaf), None); + let finalize = std::rc::Rc::new( + OperatorNode::with_schema( + crate::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: evaluation }), + Schema::lifted(vec![], None), + ) + .with_guarantee(None), + ); + let root = + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::SQLWindowFunc { + func: crate::ir::operator_properties::WindowFuncKind::RowNumber, + args: vec![], + partition_by: GroupKeys::none(), + order_by: vec![], + frame: None, + output_name: "rn".into(), + child: finalize, + })) + .unwrap(); + let dag = export(&root); + let summary = export_summary(&root); + assert_eq!(dag.nodes.len(), summary.nodes.len()); + for (a, b) in dag.nodes.iter().zip(&summary.nodes) { + assert_eq!(a.id, b.id); + assert_eq!(b.kind, to_snake(a.kind), "{}", a.kind); + assert_eq!(a.children, b.children); + assert_eq!(a.label, b.label); + } + assert_eq!( + summary.nodes.iter().map(|n| n.kind).collect::>(), + [ + "scan", + "summary_agg", + "summary_estimate", + "finalize_exact_accumulator", + "sql_window_func", + ] + ); } } diff --git a/crates/types/src/parsed_workload.rs b/crates/types/src/parsed_workload.rs index ff955e6a7..9dcf21b83 100644 --- a/crates/types/src/parsed_workload.rs +++ b/crates/types/src/parsed_workload.rs @@ -1,5 +1,5 @@ //! [`ParsedWorkload`] — a [`PlanningWorkload`] whose queries have been lowered -//! to pre-ASAP IR. +//! to the operator IR. //! //! This is the boundary between the frontend stage and the optimization stage //! (issues #429, #430). Everything downstream of lowering consumes this type @@ -10,7 +10,7 @@ use std::rc::Rc; -use crate::pre_asap::query_expr::QueryExpr; +use crate::ir::{OperatorNode, QueryRoot, ScalarExpr}; use crate::workload::{ DataWorkload, PlanningWorkload, QueryWorkload, QueryWorkloadEntry, WorkloadError, }; @@ -33,7 +33,9 @@ pub enum ParsedWorkloadError { #[derive(Debug, Clone)] pub struct ParsedWorkload { workload: PlanningWorkload, - exprs: Vec>, + exprs: Vec>, + operator_indices: Vec, + scalars: Vec<(usize, ScalarExpr)>, } impl ParsedWorkload { @@ -41,16 +43,43 @@ impl ParsedWorkload { /// `i`-th entry. pub fn new( workload: PlanningWorkload, - exprs: Vec>, + exprs: Vec>, + ) -> Result { + Self::from_roots( + workload, + exprs.into_iter().map(QueryRoot::Operator).collect(), + ) + } + + pub fn from_roots( + workload: PlanningWorkload, + roots: Vec, ) -> Result { let entries = workload.query_workload.entries().count(); - if entries != exprs.len() { + if entries != roots.len() { return Err(ParsedWorkloadError::LengthMismatch { entries, - lowered: exprs.len(), + lowered: roots.len(), }); } - Ok(Self { workload, exprs }) + let mut exprs = Vec::new(); + let mut operator_indices = Vec::new(); + let mut scalars = Vec::new(); + for (index, root) in roots.into_iter().enumerate() { + match root { + QueryRoot::Operator(node) => { + operator_indices.push(index); + exprs.push(node); + } + QueryRoot::Scalar(expr) => scalars.push((index, expr)), + } + } + Ok(Self { + workload, + exprs, + operator_indices, + scalars, + }) } pub fn planning_workload(&self) -> &PlanningWorkload { @@ -65,26 +94,37 @@ impl ParsedWorkload { self.workload.data_workload.as_ref() } - pub fn exprs(&self) -> &[Rc] { + pub fn exprs(&self) -> &[Rc] { &self.exprs } pub fn len(&self) -> usize { - self.exprs.len() + self.exprs.len() + self.scalars.len() } pub fn is_empty(&self) -> bool { - self.exprs.is_empty() + self.len() == 0 } /// Normalized entries paired with their lowered expression. - pub fn entries(&self) -> impl Iterator)> + '_ { + pub fn entries(&self) -> impl Iterator)> + '_ { self.workload .query_workload .entries() + .enumerate() + .filter(|(index, _)| self.operator_indices.binary_search(index).is_ok()) + .map(|(_, entry)| entry) .zip(self.exprs.iter()) } + pub fn operator_indices(&self) -> &[usize] { + &self.operator_indices + } + + pub fn scalar_roots(&self) -> &[(usize, ScalarExpr)] { + &self.scalars + } + /// The retained workload's own validation — entry legality and data-workload /// consistency. The PromQL-specific checks it also runs were already a /// precondition of the lowering that produced `self`. diff --git a/crates/types/src/post_asap/execution_data_state.rs b/crates/types/src/post_asap/execution_data_state.rs index fa60fbb8d..629465354 100644 --- a/crates/types/src/post_asap/execution_data_state.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -820,6 +820,8 @@ pub enum ExactOperationSchemaError { NonPlainInput, #[error("schema derivation failed: {0}")] Schema(#[from] QueryExprError), + #[error("schema derivation failed: {0}")] + Derivation(#[from] crate::ir::SchemaDerivationError), } #[cfg(test)] diff --git a/crates/types/src/post_asap/expr.rs b/crates/types/src/post_asap/expr.rs index bd45748aa..732a09db5 100644 --- a/crates/types/src/post_asap/expr.rs +++ b/crates/types/src/post_asap/expr.rs @@ -33,7 +33,7 @@ pub enum ValueOperation { /// Maintain the full declared population, including membership changes, /// so removing a TopK member can promote another. MaintainPopulation { - population: super::maintained_population::MaintainedPopulation, + population: super::maintained_population::MaintainedPopulation, }, /// Read an aggregate or TopK prefix from the maintained population. ReadPopulation { diff --git a/crates/types/src/post_asap/guarantee.rs b/crates/types/src/post_asap/guarantee.rs index cfc6f3f01..68cd7cea9 100644 --- a/crates/types/src/post_asap/guarantee.rs +++ b/crates/types/src/post_asap/guarantee.rs @@ -16,9 +16,9 @@ //! ## What a guarantee says //! //! [`ResultGuarantee`] is attached to a finalized, caller-visible value — -//! [`super::SummaryNode::guarantee`] on a `SummaryEstimate` readout, an +//! [`crate::ir::OperatorNode::guarantee`] on a `SummaryEstimate` evaluation, an //! exact accumulator, or a kept pre-ASAP sub-DAG — never to raw summary -//! state (a `SummaryAgg` sketch node carries `None`; its readout carries the +//! state (a `SummaryAgg` sketch node carries `None`; its evaluation carries the //! guarantee). Its statement is: //! //! ```text @@ -325,13 +325,13 @@ pub enum GuaranteeSource { /// Deterministic exact computation — zero error by construction. Exact { /// What made it exact (e.g. `"ExactAggregate(Sum)"`, - /// `"KeepPreAsap"`). + /// `"RetainedExact"`). reason: String, }, - /// The target this readout's sketch was sized against. + /// The target this evaluation's sketch was sized against. AccuracyTarget { target: AccuracyTarget }, - /// The concrete sketch a readout's local guarantee was derived from. - SketchReadout { + /// The concrete sketch a evaluation's local guarantee was derived from. + SketchEvaluation { algorithm: String, /// Stable estimator/analysis contract used to derive this guarantee. #[serde(default)] @@ -421,8 +421,8 @@ impl ResultGuarantee { self.bound.is_zero() && self.failure_probability.is_zero() } - /// How many approximate sketch readouts contributed to this value — - /// `1` for a plain readout, `0` for an exact value, and the transitive + /// How many approximate sketch evaluations contributed to this value — + /// `1` for a plain evaluation, `0` for an exact value, and the transitive /// count through every [`GuaranteeSource::ChildGuarantee`] for a /// composition. An `AccuracyBudgetAllocator` uses this as the number /// of layers a budget must be split across. @@ -430,7 +430,7 @@ impl ResultGuarantee { self.provenance .iter() .map(|source| match source { - GuaranteeSource::SketchReadout { .. } => 1, + GuaranteeSource::SketchEvaluation { .. } => 1, GuaranteeSource::ChildGuarantee { guarantee, .. } => { guarantee.approximate_layer_count() } diff --git a/crates/types/src/post_asap/maintained_population.rs b/crates/types/src/post_asap/maintained_population.rs index 73ddd38ba..577544bd7 100644 --- a/crates/types/src/post_asap/maintained_population.rs +++ b/crates/types/src/post_asap/maintained_population.rs @@ -114,7 +114,7 @@ impl CurrentSeriesInput { /// Membership is part of state identity. Table rows must never acquire implicit /// latest-per-series selection, stale markers, or a PromQL lookback. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum PopulationInput { +pub enum PopulationInput { CurrentSeries(CurrentSeriesInput), Rows { input: std::rc::Rc, @@ -124,13 +124,13 @@ pub enum PopulationInput { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MaintainedPopulation { +pub struct MaintainedPopulation { pub input: PopulationInput, pub max_k: usize, pub quantiles: bool, } -impl MaintainedPopulation { +impl MaintainedPopulation { pub fn matches_input(&self, input: &crate::pre_asap::QueryExpr) -> bool { match &self.input { PopulationInput::CurrentSeries(spec) => spec.matches_input(input), diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index f71aaca0f..b1a7da67a 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -27,6 +27,7 @@ //! alongside `reduction` and on sketch-valued edge types //! — see `asap_aware_mapping::grouping`'s module docs for why. +// Legacy summary IR: no longer re-exported; removed by the cleanup PR. pub mod cse; pub mod execution_data_state; pub mod expr; @@ -40,21 +41,29 @@ pub mod summary_maintenance_lifecycle; pub mod summary_window; pub use crate::pre_asap::schema::{Field, FieldDataType, Schema}; -pub use cse::share_common_summary_sub_dags; pub use execution_data_state::{ - assigned_child_data_state, exact_operation_output_schema, produced_data_state, - validate_execution_data_states, validate_execution_data_states_at, DataPrimitive, - ExactOperationSchemaError, ExecutionDataState, ExecutionDataStateAssignment, + lift_plain, DataPrimitive, ExactOperationSchemaError, ExecutionDataState, ExecutionDataStateError, ExecutionTiming, }; -pub use expr::{ +// Legacy summary IR names, kept for the legacy modules above only. +#[allow(unused_imports)] +pub(crate) use cse::share_common_summary_sub_dags; +#[allow(unused_imports)] +pub(crate) use execution_data_state::{ + assigned_child_data_state, exact_operation_output_schema, produced_data_state, + validate_execution_data_states, validate_execution_data_states_at, + ExecutionDataStateAssignment, +}; +#[allow(unused_imports)] +pub(crate) use expr::{ BinaryOperator, CandidateCompleteness, ExactOperation, SummaryExpr, SummaryNode, ValueOperation, }; pub use guarantee::{ AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, GuaranteeSource, ProbabilityExpr, ResultGuarantee, }; -pub use post_asap_dag::{ +#[allow(unused_imports)] +pub(crate) use post_asap_dag::{ compile_post_asap_dag, compile_post_asap_dag_with_node_ids, EdgeRole, GroupingEdgeCompatibility, PostAsapDAG, PostAsapDAGCompilation, PostAsapDAGDocument, PostAsapDAGEdge, PostAsapDAGNode, PostAsapDAGValidationError, PostAsapNodeId, diff --git a/crates/types/src/post_asap/query_time/error_estimation.rs b/crates/types/src/post_asap/query_time/error_estimation.rs index 663d2da12..f2bf229da 100644 --- a/crates/types/src/post_asap/query_time/error_estimation.rs +++ b/crates/types/src/post_asap/query_time/error_estimation.rs @@ -41,7 +41,7 @@ //! //! ## What this is *not* — no runtime sketch exists yet to wire this into //! -//! This issue names two possible integration points: (1) runtime/readout-time +//! This issue names two possible integration points: (1) runtime/evaluation-time //! accuracy reporting from a sketch's *actual* counters, and (2) tighter //! plan-time sizing. As of this module landing, **this repository has no //! vendored CMS/CountSketch/CU-Sketch runtime and no counter-array data @@ -52,12 +52,12 @@ //! planning-time sizing metadata. There is no `A[row][col]` counter matrix //! anywhere in the workspace for these functions to be handed at query //! time. So integration point (1) — reporting an *actual* query's posterior -//! error from real counters at readout — has nothing to wire into today. +//! error from real counters at evaluation — has nothing to wire into today. //! //! The functions here are deliberately **sketch-object-agnostic**: they take //! plain counter slices (`&[u64]` / `&[i64]`) and numeric parameters, not a //! concrete sketch type, specifically so that the moment a real CMS/ -//! Count-Sketch/CU-Sketch runtime lands in this workspace, its readout path +//! Count-Sketch/CU-Sketch runtime lands in this workspace, its evaluation path //! can call these functions directly on its real counter arrays with zero //! changes needed here. That wiring is out of scope for this module — see //! issue #239. @@ -255,8 +255,8 @@ fn posterior_rank(w: usize, rows: u32, delta: f64) -> Option { /// The `k`-th largest value in `values` (1-indexed: `k=1` is the max). /// `select_nth_unstable_by` partitions in O(w) average instead of fully /// sorting in O(w log w) — this only ever needs one rank, not a total -/// order, and both call sites (this module's per-query readout math) are -/// documented as meant to run on a future runtime's hot readout path. +/// order, and both call sites (this module's per-query evaluation math) are +/// documented as meant to run on a future runtime's hot evaluation path. fn kth_largest(values: &[u64], k: usize) -> u64 { let mut buf: Vec = values.to_vec(); let idx = k - 1; diff --git a/crates/types/src/post_asap/sketch.rs b/crates/types/src/post_asap/sketch.rs index a5e1edcc1..65e78d24f 100644 --- a/crates/types/src/post_asap/sketch.rs +++ b/crates/types/src/post_asap/sketch.rs @@ -6,7 +6,7 @@ use crate::pre_asap::ColumnRef; /// An exact, mergeable accumulator family — zero approximation error. The /// partial state built for one of these *is* the answer; no -/// `SummaryEstimate` readout is needed to get a value out of it. +/// `SummaryEstimate` evaluation is needed to get a value out of it. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum ExactKind { /// Exact sum accumulator (mergeable by addition). @@ -48,7 +48,7 @@ pub enum ExactParams { /// [`SketchKind::new`] is where that classification is made. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum SketchAlgorithm { - /// Universal frequency-vector summary with shared statistic readouts. + /// Universal frequency-vector summary with shared statistic evaluations. UnivMon, /// KLL quantile sketch (mergeable, ε-accurate rank queries). Kll, @@ -345,7 +345,7 @@ pub enum StatModelParams { /// really the universal-sketch composition (L layers of Count-Sketch plus a /// heavy-hitter heap, Theorems 1+2 combined) estimating entropy/L1-norm/ /// L2-norm/cardinality/frequency-moments as one instance. Standalone UnivMon -/// and its frequency readouts are represented here, but sharing a Hydra +/// and its frequency evaluations are represented here, but sharing a Hydra /// grid across populations still needs its own collision/error contract; /// standalone support does not establish that contract. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] @@ -500,7 +500,7 @@ pub fn default_hydra_params( /// enums themselves, for exactly the reason explained in this section's /// module docs above. /// -/// Carried both on `SummaryExpr::SummaryAgg` (where planning consults it) +/// Carried both on `ASAPOp::SummaryAgg` (where planning consults it) /// and on sketch-valued `FieldDataType` edges (where it prevents /// incompatible shared and independent physical states from type-checking /// as merge-compatible). @@ -590,7 +590,8 @@ pub enum SummaryInputExpr { EntityIdentity(EntityIdentity), } -/// What to extract from a built summary. Carried by `SummaryEstimate`. +/// The statistic computed from summary state by `SummaryEstimate`, for example +/// `Quantile { q: 0.99 }`. This is a result operation, not a workload query. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SketchStatistic { /// sqrt(sum_v frequency(v)^2), not the norm of numeric input values. @@ -605,8 +606,8 @@ pub enum SketchStatistic { /// `value: Some(v)` is a per-item point lookup (e.g. /// `count(cms_metric{item="checkout"})` — `key` is `item`, `value` is /// `"checkout"`). `value` is carried here rather than resolved by the - /// `SummaryExecutor` from a `Filter` predicate because `readout`'s - /// trait signature has no DAG access — see `CostModel::readout_extension`. + /// `SummaryExecutor` from a `Filter` predicate because `evaluation`'s + /// trait signature has no dag access — see `CostModel::evaluation_extension`. PointCount { key: ColumnRef, value: Option, @@ -622,7 +623,7 @@ mod tests { use super::*; #[test] - fn keyed_summary_input_and_topk_readout_round_trip() { + fn keyed_summary_input_and_topk_evaluation_round_trip() { for input in [ SummaryUpdate { item: Some(SummaryInputExpr::EntityIdentity( diff --git a/crates/types/src/post_asap/summary_maintenance.rs b/crates/types/src/post_asap/summary_maintenance.rs index d1e50d7e5..4e7eaf205 100644 --- a/crates/types/src/post_asap/summary_maintenance.rs +++ b/crates/types/src/post_asap/summary_maintenance.rs @@ -1,6 +1,6 @@ //! Planner-level construction mode for a materialized summary. //! -//! A [`super::SummaryNode`] is a logical summary expression and deliberately +//! An ASAP [`crate::ir::OperatorNode`] is a logical summary expression and deliberately //! does not carry this choice: the same candidate may be built directly for //! one workload or maintained incrementally for another. Planner search //! attaches the selected mode to its lifecycle guarantee; downstream physical diff --git a/crates/types/src/post_asap/summary_window.rs b/crates/types/src/post_asap/summary_window.rs index 0e344c1ed..6d45649b4 100644 --- a/crates/types/src/post_asap/summary_window.rs +++ b/crates/types/src/post_asap/summary_window.rs @@ -57,7 +57,7 @@ pub enum PaneCoverageError { }, } -/// Validate that a pane-only readout covers a query exactly. A mismatched +/// Validate that a pane-only evaluation covers a query exactly. A mismatched /// phase is sound only when the physical plan explicitly supplies an exact /// residual for the partial edge panes. pub fn validate_pane_coverage( @@ -147,7 +147,7 @@ mod tests { } #[test] - fn pane_only_readout_rejects_source_and_query_phase_mismatch() { + fn pane_only_evaluation_rejects_source_and_query_phase_mismatch() { let layout = PaneLayout { pane_width_ms: 60_000, pane_origin_ms: Some(26_000), diff --git a/crates/types/tests/planner_vocabulary.rs b/crates/types/tests/planner_vocabulary.rs index f14a56ee8..f3d53cf59 100644 --- a/crates/types/tests/planner_vocabulary.rs +++ b/crates/types/tests/planner_vocabulary.rs @@ -1,6 +1,5 @@ -use asap_types::post_asap::{ - validate_pane_coverage, PaneLayout, WindowEdgeCompatibility, WindowEdgeCoverage, -}; +use asap_types::ir::physical_export::WindowEdgeCompatibility; +use asap_types::post_asap::{validate_pane_coverage, PaneLayout, WindowEdgeCoverage}; use asap_types::pre_asap::{SchemaResolver, Source, UnresolvedQueryExpr}; use asap_types::resources::{PhysicalHandoffBytes, PhysicalHandoffKind}; From f0afd898debc03f00806abe4ad6d3b51371cf257 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:14:48 +0000 Subject: [PATCH 02/18] feat(planner): plan and export standalone scalar queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workload entry whose root is a standalone scalar (`scalar(quantile(0.99, rate(x[5m])))`, `scalar(sum(up)) + 1`) was copied through unplanned. Per #511 §2.3 it has no query node and reads operator sub-DAGs by reference; per operator-sharing §3 those sub-DAGs get the same validation and accuracy assessment as operator roots. MajorPass now searches, selects, assembles and lifecycle-plans each distinct operator sub-DAG a scalar root reads, against the scalar's entry and accuracy target, alongside the operator roots, and rebuilds the scalar over the selected sub-DAGs. PlanOutput::scalar_plans holds their lifecycle plans. PhysicalASAPDAG gains `scalar_roots`: each standalone scalar query as a `ScalarExpr` keyed by its position in the batch, so query i is the scalar root keyed i, or else the next of `roots`. Validation checks positions and treats scalar references as roots for existence, cycles and reachability. Wire version 9. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/pass/major.rs | 68 ++++++++++++++---- crates/asap-aware-mapping/src/pass/mod.rs | 12 +++- .../tests/physical_dag.rs | 3 + .../tests/precompute_population.rs | 2 + .../tests/promql_binary.rs | 1 + .../asap-physical-operators/tests/raw_scan.rs | 1 + .../tests/summary_projection.rs | 1 + crates/planner/tests/e2e_plan.rs | 4 ++ crates/planner/tests/summary_sharing.rs | 39 ++++++++++ crates/types/src/ir/physical_export.rs | 72 ++++++++++++++----- crates/types/tests/physical_export.rs | 58 ++++++++++++++- 11 files changed, 225 insertions(+), 36 deletions(-) diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index 7be53e0f0..8699886df 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -39,25 +39,47 @@ impl OptimizationPass for MajorPass { // search result carries the workload binding the lifecycle stage and // the output both need. CSE may make two identical queries share one // `Rc`, but it never drops or reorders a root, so this stays aligned. - let roots: Vec<(usize, Rc, Option)> = workload + let targets: Vec = workload + .query_workload() .entries() + .map(|entry| entry.requirements.accuracy.target()) + .collect(); + let mut roots: Vec<(usize, Rc, Option)> = workload + .exprs() + .iter() .zip(workload.operator_indices().iter().copied()) - .map(|((entry, expr), index)| { - ( - index, - Rc::clone(expr), - Some(entry.requirements.accuracy.target()), - ) + .map(|(expr, index)| (index, Rc::clone(expr), Some(targets[index].clone()))) + .collect(); + // A standalone scalar query is not a node; the operator sub-DAGs it + // reads are planned like operator roots, against its entry (#511 + // §2.3, operator-sharing §3). They follow the operator roots, each + // scalar's distinct references in `operator_refs` order. + let scalar_refs: Vec>> = workload + .scalar_roots() + .iter() + .map(|(_, expr)| { + let mut refs: Vec> = Vec::new(); + for node in expr.operator_refs() { + if !refs.iter().any(|r| Rc::ptr_eq(r, node)) { + refs.push(Rc::clone(node)); + } + } + refs }) .collect(); + for ((index, _), refs) in workload.scalar_roots().iter().zip(&scalar_refs) { + for node in refs { + roots.push((*index, Rc::clone(node), Some(targets[*index].clone()))); + } + } + + // One index per root, in `CandidateLogicalASAPDAGs::roots` order — which is the order + // the roots went in. + let entry_indices: Vec = roots.iter().map(|(index, _, _)| *index).collect(); let space = search_workload_with_targets(roots, &strategies, models.accuracy); let lifecycle = input.lifecycle; - - // One index per root, in `CandidateLogicalASAPDAGs::roots` order — which is the order - // the roots went in, which is `entries()` order. - let entry_indices = workload.operator_indices().to_vec(); let demand = WorkloadDemand { workload: workload.query_workload(), data_workload: workload.data_workload(), @@ -142,7 +164,7 @@ impl OptimizationPass for MajorPass { } } - let mut plans = Vec::with_capacity(space.roots.len()); + let mut plans: Vec = Vec::with_capacity(space.roots.len()); for (position, (entry_index, root)) in space.roots.iter().enumerate() { let mut entries = bindings[&Rc::as_ptr(root)].clone(); let mut dag = Rc::clone(&interned[position].1); @@ -182,8 +204,28 @@ impl OptimizationPass for MajorPass { plan, }); } + // Rebuild each scalar root over its planned sub-DAGs. + let scalar_plans = plans.split_off(workload.exprs().len()); + let mut planned = scalar_plans.iter(); + let scalar_roots = workload + .scalar_roots() + .iter() + .zip(&scalar_refs) + .map(|((index, expr), refs)| { + let selected: Vec<_> = planned.by_ref().take(refs.len()).collect(); + let expr = expr.map_operator_refs(&mut |node| { + let position = refs + .iter() + .position(|r| Rc::ptr_eq(r, node)) + .expect("every reference was planned"); + Rc::clone(&selected[position].plan.root) + }); + (*index, expr) + }) + .collect(); let mut output = PlanOutput::new(plans); - output.scalar_roots = workload.scalar_roots().to_vec(); + output.scalar_roots = scalar_roots; + output.scalar_plans = scalar_plans; Ok(output) } } diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index f9dd0ac2d..c7a7e3666 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -164,8 +164,9 @@ pub enum OptimizationInputError { // ── Output ─────────────────────────────────────────────────────────────── -/// One query's selected post-ASAP DAG plus the maintenance decisions taken -/// for it. The DAG is `plan.root`. +/// One query's selected post-ASAP DAG, or one operator sub-DAG a standalone +/// scalar query reads, plus the maintenance decisions taken for it. The DAG +/// is `plan.root`. #[derive(Debug, Clone)] pub struct QueryLifecyclePlan { /// Index into `QueryWorkload::entries()`. @@ -184,8 +185,12 @@ pub struct QueryLifecyclePlan { #[non_exhaustive] pub struct PlanOutput { pub plans: Vec, - /// Exact scalar expressions, keyed by workload entry; embedded plan reads remain visible. + /// Standalone scalar queries, keyed by workload entry. Each operator + /// reference is the root of one of `scalar_plans`. pub scalar_roots: Vec<(usize, asap_types::ir::ScalarExpr)>, + /// One plan per distinct operator sub-DAG a scalar root reads, keyed by + /// that scalar's entry, in `scalar_roots` order. + pub scalar_plans: Vec, } impl PlanOutput { @@ -193,6 +198,7 @@ impl PlanOutput { Self { plans, scalar_roots: Vec::new(), + scalar_plans: Vec::new(), } } diff --git a/crates/asap-physical-operators/tests/physical_dag.rs b/crates/asap-physical-operators/tests/physical_dag.rs index ff63a6f6e..f65e9e64b 100644 --- a/crates/asap-physical-operators/tests/physical_dag.rs +++ b/crates/asap-physical-operators/tests/physical_dag.rs @@ -547,6 +547,7 @@ fn bind_post_asap_before_execution() { window: WindowEdgeCompatibility::NotApplicable, }], roots: vec![1], + scalar_roots: vec![], }; let sources = || -> BTreeMap> { BTreeMap::from([( @@ -655,6 +656,7 @@ fn source_batches_must_match_the_bound_schema() { }], edges: vec![], roots: vec![0], + scalar_roots: vec![], }; let source = Box::new(WrongSource { schema: expected, @@ -813,6 +815,7 @@ fn planner_semijoin_sort_limit_contract_at_both_phases() { edge(3, 4, EdgeRole::Input, &rows_schema), ], roots: vec![4], + scalar_roots: vec![], }; let text = |v: &str| Value::Utf8(v.into()); for (phase, scope) in [ diff --git a/crates/asap-physical-operators/tests/precompute_population.rs b/crates/asap-physical-operators/tests/precompute_population.rs index 746a37fc9..5f15e7ef2 100644 --- a/crates/asap-physical-operators/tests/precompute_population.rs +++ b/crates/asap-physical-operators/tests/precompute_population.rs @@ -131,6 +131,7 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { nodes, edges, roots: vec![3], + scalar_roots: vec![], }; // Identity metadata must remain one non-null Utf8 column. for mutation in 0..3 { @@ -308,6 +309,7 @@ fn state_dag( nodes, edges, roots: vec![root], + scalar_roots: vec![], }, &[0], &[root as u64], diff --git a/crates/asap-physical-operators/tests/promql_binary.rs b/crates/asap-physical-operators/tests/promql_binary.rs index 3fd9c19a0..3aad33e15 100644 --- a/crates/asap-physical-operators/tests/promql_binary.rs +++ b/crates/asap-physical-operators/tests/promql_binary.rs @@ -427,6 +427,7 @@ fn stored_series_evaluations_support_filters_and_sets() { nodes, edges, roots: vec![4], + scalar_roots: vec![], }; let physical_dag = compile( &dag, diff --git a/crates/asap-physical-operators/tests/raw_scan.rs b/crates/asap-physical-operators/tests/raw_scan.rs index 005b45fc3..2c8585ac7 100644 --- a/crates/asap-physical-operators/tests/raw_scan.rs +++ b/crates/asap-physical-operators/tests/raw_scan.rs @@ -117,6 +117,7 @@ fn plan( ], edges: vec![edge(0, 1), edge(1, 2)], roots: vec![2], + scalar_roots: vec![], } } fn registry(source: Arc) -> DataSources { diff --git a/crates/asap-physical-operators/tests/summary_projection.rs b/crates/asap-physical-operators/tests/summary_projection.rs index c78c5c4ab..2fe0fb864 100644 --- a/crates/asap-physical-operators/tests/summary_projection.rs +++ b/crates/asap-physical-operators/tests/summary_projection.rs @@ -95,6 +95,7 @@ fn post_asap_summary_projection_survives_recovery() { window: WindowEdgeCompatibility::NotApplicable, }], roots: vec![1], + scalar_roots: vec![], }; let program = compile( &dag, diff --git a/crates/planner/tests/e2e_plan.rs b/crates/planner/tests/e2e_plan.rs index c700871b9..b55077289 100644 --- a/crates/planner/tests/e2e_plan.rs +++ b/crates/planner/tests/e2e_plan.rs @@ -484,6 +484,10 @@ async fn scalar_roots_survive_planning_in_workload_order() { panic!() }; assert_eq!(expr.operator_refs().len(), 1); + assert!(Rc::ptr_eq( + expr.operator_refs()[0], + &output.scalar_plans[0].plan.root + )); } } } diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index 809f16594..eafb2cd18 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -557,3 +557,42 @@ fn certified_frequency_evaluations_share_one_univmon_state() { assert_eq!(states.len(), 3); assert!(states.iter().all(|state| Rc::ptr_eq(state, &states[0]))); } + +/// The operator sub-DAG a standalone scalar query reads is planned like an +/// operator root (#511 §2.3): under an approximate target the quantile inside +/// `scalar(...)` reads a summary, and the scalar root is rebuilt over the +/// selected sub-DAG. The same quantile as an operator query shares that +/// state, and entries keep their order around a literal scalar query. +#[tokio::test] +async fn scalar_queries_read_planned_summaries() { + let output = plan_promql( + &[ + ("1", 0.01), + ("scalar(quantile(0.99, rate(x[5m])))", 0.01), + ("quantile(0.99, rate(x[5m]))", 0.01), + ], + &CHEAP_SUMMARY, + ) + .await; + assert_eq!(output.entry_indices(), vec![0, 1, 2]); + let scalar_entries: Vec<_> = output.scalar_roots.iter().map(|(i, _)| *i).collect(); + assert_eq!(scalar_entries, vec![0, 1]); + assert_eq!(output.plans.len(), 1); + assert_eq!(output.plans[0].entry_index, 2); + + assert_eq!(output.scalar_plans.len(), 1); + let read = &output.scalar_plans[0]; + assert_eq!(read.entry_index, 1); + assert!(!read.plan.selected_raw_recompute, "{:?}", read.plan.root); + let read_states: Vec<_> = read + .plan + .deployments + .iter() + .map(|deployment| Rc::clone(&deployment.summary)) + .collect(); + assert!(!read_states.is_empty()); + assert!(same_states(&[read_states, states(&output).remove(0)])); + let refs = output.scalar_roots[1].1.operator_refs(); + assert_eq!(refs.len(), 1); + assert!(Rc::ptr_eq(refs[0], &read.plan.root)); +} diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index 1682ddd36..48ca4c3c9 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -17,6 +17,7 @@ use super::node::{Operator, OperatorNode}; use super::non_asap::NonASAPOp; use super::operator_properties::Reduction; use super::query::QueryRoot; +use super::scalar::ScalarExpr; use super::timing::data_state; use crate::post_asap::execution_data_state::{ ExecutionDataState, ExecutionDataStateError, ExecutionTiming, @@ -24,7 +25,7 @@ use crate::post_asap::execution_data_state::{ use crate::post_asap::guarantee::ResultGuarantee; use crate::pre_asap::schema::{FieldDataType, Schema}; -pub const PHYSICAL_ASAP_DAG_WIRE_VERSION: u32 = 8; +pub const PHYSICAL_ASAP_DAG_WIRE_VERSION: u32 = 9; /// A node's operator, with each child replaced by its node id. pub type PhysicalASAPOperatorPayload = Operator; @@ -86,11 +87,16 @@ pub struct PhysicalASAPDAGEdge { pub struct PhysicalASAPDAG { pub nodes: Vec, pub edges: Vec, - /// Semantic workload root. Physical query/precompute sinks are selected + /// Semantic workload roots. Physical query/precompute sinks are selected /// downstream by the control plane. - /// One root per query of the batch, in workload order. Scalar query roots - /// are not physical nodes yet. + /// One root per operator query of the batch, in workload order. pub roots: Vec, + /// One expression per standalone scalar query, keyed by the query's + /// position in the batch, in increasing order. Its operator references + /// name nodes of this DAG. A standalone scalar query is not a node (#511 + /// §2.3), so query `i` of the batch is the scalar root keyed `i` when + /// there is one, and otherwise the next entry of `roots`. + pub scalar_roots: Vec<(usize, ScalarExpr)>, } /// Versioned transport envelope for a physical ASAP DAG. @@ -134,6 +140,8 @@ pub enum PhysicalASAPDAGValidationError { EdgeRoleMismatch { consumer: PhysicalASAPNodeId }, #[error("physical ASAP DAG has no query roots")] NoRoots, + #[error("scalar root keyed {0} is out of order or outside the batch")] + ScalarRootPosition(usize), #[error("physical ASAP DAG contains a cycle")] Cycle, #[error("physical ASAP node {0:?} is not reachable from the root")] @@ -222,10 +230,31 @@ impl PhysicalASAPDAG { } } } - if self.roots.is_empty() { + if self.roots.is_empty() && self.scalar_roots.is_empty() { return Err(PhysicalASAPDAGValidationError::NoRoots); } - for root in &self.roots { + let queries = self.roots.len() + self.scalar_roots.len(); + let mut next = 0; + for (position, _) in &self.scalar_roots { + if *position < next || *position >= queries { + return Err(PhysicalASAPDAGValidationError::ScalarRootPosition( + *position, + )); + } + next = position + 1; + } + // Operator roots and the nodes scalar roots read. + let root_ids: Vec = self + .roots + .iter() + .copied() + .chain( + self.scalar_roots + .iter() + .flat_map(|(_, expr)| expr.operator_refs().into_iter().copied()), + ) + .collect(); + for root in &root_ids { if !nodes.contains_key(root) { return Err(PhysicalASAPDAGValidationError::MissingRoot(*root)); } @@ -315,7 +344,7 @@ impl PhysicalASAPDAG { true } let mut visited = HashSet::new(); - for root in &self.roots { + for root in &root_ids { if !visit(*root, &children, &mut HashSet::new(), &mut visited) { return Err(PhysicalASAPDAGValidationError::Cycle); } @@ -333,7 +362,7 @@ impl PhysicalASAPDAG { } } let mut reachable = HashSet::new(); - for root in &self.roots { + for root in &root_ids { mark(*root, &children, &mut reachable); } if let Some(id) = nodes.keys().find(|id| !reachable.contains(id)) { @@ -383,27 +412,33 @@ pub fn compile_physical_asap_dag( pub fn compile_physical_asap_dag_with_node_ids( root: &Rc, ) -> Result { - compile_physical_asap_workload_with_node_ids(std::slice::from_ref(root)) + compile_physical_asap_workload_with_node_ids(&[QueryRoot::Operator(Rc::clone(root))]) } -/// Export a timed batch as one DAG with one root per query. +/// Export a timed batch as one DAG: operator queries become `roots` and +/// standalone scalar queries `scalar_roots`, keyed by their position in the +/// batch. pub fn compile_physical_asap_workload( - roots: &[Rc], + roots: &[QueryRoot], ) -> Result { Ok(compile_physical_asap_workload_with_node_ids(roots)?.dag) } pub fn compile_physical_asap_workload_with_node_ids( - roots: &[Rc], + roots: &[QueryRoot], ) -> Result { - let roots: Vec = roots.iter().cloned().map(QueryRoot::Operator).collect(); - let (flat, nodes_by_id) = flatten(&roots); + let (flat, nodes_by_id) = flatten(roots); + let mut scalar_roots = Vec::new(); let roots = flat .roots - .iter() - .map(|root| match root { - QueryRoot::Operator(id) => *id, - QueryRoot::Scalar(_) => unreachable!("only operator roots are flattened"), + .into_iter() + .enumerate() + .filter_map(|(position, root)| match root { + QueryRoot::Operator(id) => Some(id), + QueryRoot::Scalar(expr) => { + scalar_roots.push((position, expr)); + None + } }) .collect(); let mut nodes: Vec = Vec::with_capacity(flat.nodes.len()); @@ -444,6 +479,7 @@ pub fn compile_physical_asap_workload_with_node_ids( nodes, edges, roots, + scalar_roots, }; dag.validate()?; Ok(PhysicalASAPDAGCompilation { diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index 9182a99d3..a5b9b8266 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -5,7 +5,7 @@ use asap_types::ir::physical_export::{ }; use asap_types::ir::{ apply_lifecycle_timings, ASAPOp, LifecycleAssignment, NonASAPOp, Operator, OperatorNode, - TimingMemo, + QueryRoot, TimingMemo, }; use asap_types::post_asap::{ ExactKind, ExactParams, ExecutionDataStateError, ExecutionTiming, SummaryUpdate, @@ -111,12 +111,66 @@ fn batch_shares_the_summary_and_keeps_one_root_per_query() { .iter() .map(|root| apply_lifecycle_timings(root, &assignment, &mut memo).unwrap()) .collect(); - let dag = compile_physical_asap_workload(&timed).unwrap(); + let roots: Vec = timed.into_iter().map(QueryRoot::Operator).collect(); + let dag = compile_physical_asap_workload(&roots).unwrap(); dag.validate().unwrap(); assert_eq!(dag.roots.len(), 2); assert_eq!(dag.nodes.len(), 4, "scan and summary are exported once"); } +/// A standalone scalar query exports as a scalar root keyed by its position +/// in the batch, reading the shared nodes by id; operator queries keep `roots`. +#[test] +fn scalar_roots_export_in_batch_order_and_reference_nodes() { + use asap_types::ir::{ExprSemantics, ScalarExpr}; + use asap_types::pre_asap::ArithmeticOpKind; + let timed = apply_lifecycle_timings( + &plan(), + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .unwrap(); + let scalar = ScalarExpr::Arithmetic { + op: ArithmeticOpKind::Add, + left: Box::new(ScalarExpr::PromqlScalarFromVector(Rc::clone(&timed))), + right: Box::new(ScalarExpr::literal_f64(1.0)), + semantics: ExprSemantics::Promql, + }; + let dag = compile_physical_asap_workload(&[ + QueryRoot::Scalar(scalar), + QueryRoot::Operator(Rc::clone(&timed)), + ]) + .unwrap(); + assert_eq!(dag.nodes.len(), 3, "the read plan is exported once"); + assert_eq!(dag.roots.len(), 1); + assert_eq!(dag.scalar_roots.len(), 1); + let (position, expr) = &dag.scalar_roots[0]; + assert_eq!(*position, 0); + assert_eq!(expr.operator_refs(), vec![&dag.roots[0]]); + let document = PhysicalASAPDAGDocument::new(dag.clone()); + let decoded: PhysicalASAPDAGDocument = + serde_json::from_str(&serde_json::to_string(&document).unwrap()).unwrap(); + assert_eq!(decoded, document); + + // A scalar-only batch is valid: its referenced nodes are reachable. + let mut scalar_only = dag.clone(); + scalar_only.roots.clear(); + scalar_only.validate().unwrap(); + + let mut missing = dag.clone(); + missing.scalar_roots[0].1 = ScalarExpr::PromqlScalarFromVector(99); + assert_eq!( + missing.validate(), + Err(PhysicalASAPDAGValidationError::MissingRoot(99)) + ); + let mut outside = dag.clone(); + outside.scalar_roots[0].0 = 2; + assert_eq!( + outside.validate(), + Err(PhysicalASAPDAGValidationError::ScalarRootPosition(2)) + ); +} + /// The roles of a binary operator's edges must match its inputs: swapping /// `Left` and `Right` would reorder the operands of `a - b`. #[test] From c44ecda90e8eface08794053db7305ac02995a7b Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:57:49 +0000 Subject: [PATCH 03/18] fix(planner): plan non-pass-through scalar references exact A standalone scalar hands its entry's accuracy target to its operator reference only when it passes that one reference through (`scalar(v)`, or arithmetic/comparisons with literals). Otherwise each reference is planned exact: independently approximate references leave a combination such as a difference of two quantiles unbounded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/pass/major.rs | 31 +++++++++++++++++++-- crates/planner/tests/summary_sharing.rs | 31 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index 8699886df..ee1e10c6f 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -9,7 +9,7 @@ use asap_types::ir::cse::share_common_sub_dags; use std::rc::Rc; -use asap_types::ir::OperatorNode; +use asap_types::ir::{OperatorNode, ScalarExpr}; use asap_types::types::AccuracyTarget; use super::{OptimizationInput, OptimizationPass, OptimizeError, PlanOutput, QueryLifecyclePlan}; @@ -67,9 +67,18 @@ impl OptimizationPass for MajorPass { refs }) .collect(); - for ((index, _), refs) in workload.scalar_roots().iter().zip(&scalar_refs) { + // The entry's target bounds the scalar's value, so only a pass-through + // scalar hands it to its one reference; otherwise each reference is + // planned exact, since independent approximate references leave the + // combined value unbounded (e.g. a difference of two quantiles). + for ((index, expr), refs) in workload.scalar_roots().iter().zip(&scalar_refs) { + let target = if is_pass_through(expr) { + targets[*index].clone() + } else { + AccuracyTarget::Exact + }; for node in refs { - roots.push((*index, Rc::clone(node), Some(targets[*index].clone()))); + roots.push((*index, Rc::clone(node), Some(target.clone()))); } } @@ -243,3 +252,19 @@ impl MajorPass { } } } + +/// Whether a standalone scalar passes one operator reference through: `v` +/// alone, or arithmetic and comparisons between that one reference and +/// literals. +fn is_pass_through(expr: &ScalarExpr) -> bool { + fn literal_ops(expr: &ScalarExpr) -> bool { + match expr { + ScalarExpr::Literal(_) | ScalarExpr::PromqlScalarFromVector(_) => true, + ScalarExpr::Negative { expr, .. } => literal_ops(expr), + ScalarExpr::Arithmetic { left, right, .. } + | ScalarExpr::Compare { left, right, .. } => literal_ops(left) && literal_ops(right), + _ => false, + } + } + expr.operator_refs().len() == 1 && literal_ops(expr) +} diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index eafb2cd18..a3825eb4b 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -596,3 +596,34 @@ async fn scalar_queries_read_planned_summaries() { assert_eq!(refs.len(), 1); assert!(Rc::ptr_eq(refs[0], &read.plan.root)); } + +/// A scalar combining several references is not a pass-through: the entry's +/// target does not bound each reference on its own, so every reference is +/// planned exact and reads no sketch. +#[tokio::test] +async fn combined_scalar_references_plan_exact() { + let output = plan_promql( + &[( + "scalar(quantile(0.99, rate(x[5m]))) - scalar(quantile(0.5, rate(x[5m])))", + 0.01, + )], + &CHEAP_SUMMARY, + ) + .await; + assert_eq!(output.scalar_plans.len(), 2); + for read in &output.scalar_plans { + let sketches = OperatorNode::reachable(&read.plan.root) + .into_iter() + .filter(|node| { + matches!( + node.asap(), + Some(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(..), + .. + }) + ) + }) + .count(); + assert_eq!(sketches, 0, "{:#?}", read.plan.root); + } +} From 4af7fb65749e51dbe0fb81a2911d737ec44986d0 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:00:40 +0000 Subject: [PATCH 04/18] fix(planner): check scalar plans in the contract; remove dead code check_contract now verifies that `scalar_plans` holds one plan per distinct reference of each scalar root, in order and keyed by its entry. The scalar_roots doc says keys are batch positions, equal to entry indices because the contract requires dense indices. A validation test covers duplicate and descending scalar-root keys. The never-constructed VectorScalar/ScalarVector operand modes and the unused ParsedWorkload::new/entries are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../asap-aware-mapping/src/analytical_cost.rs | 27 -------- crates/asap-aware-mapping/src/pass/mod.rs | 62 ++++++++++++++++++- crates/types/src/parsed_workload.rs | 35 ++--------- crates/types/tests/physical_export.rs | 17 +++++ 4 files changed, 83 insertions(+), 58 deletions(-) diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index 8256d6f10..fa7e2f598 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -335,8 +335,6 @@ pub enum PhysicalOperator { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PromqlBinaryOperandMode { VectorVector, - VectorScalar, - ScalarVector, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1282,9 +1280,6 @@ pub fn estimate_operator( let promql = require_promql_binary(edges)?; let right = input(1)?; let matching_bytes = match operand_mode { - PromqlBinaryOperandMode::VectorScalar | PromqlBinaryOperandMode::ScalarVector => { - per_row_width(output.rows, output.bytes)? - } PromqlBinaryOperandMode::VectorVector => { let build_series = match build_side.ok_or( AnalyticalCostError::MissingOrStale("vector_match_build_side"), @@ -2034,28 +2029,6 @@ fn validate_promql_binary( return invalid("PromQL binary evaluation steps or output kind are invalid"); } let (row_bound, series_bound) = match operand_mode { - PromqlBinaryOperandMode::VectorScalar => { - if promql.inputs[0].value_kind != PromqlValueKind::Vector - || promql.inputs[1].value_kind != PromqlValueKind::Scalar - || cardinality != PromqlVectorCardinality::OneToOne - || build_side.is_some() - || matching_key_bytes != 0 - { - return invalid("vector/scalar binary configuration is invalid"); - } - (edges.inputs[0].rows, promql.inputs[0].series) - } - PromqlBinaryOperandMode::ScalarVector => { - if promql.inputs[0].value_kind != PromqlValueKind::Scalar - || promql.inputs[1].value_kind != PromqlValueKind::Vector - || cardinality != PromqlVectorCardinality::OneToOne - || build_side.is_some() - || matching_key_bytes != 0 - { - return invalid("scalar/vector binary configuration is invalid"); - } - (edges.inputs[1].rows, promql.inputs[1].series) - } PromqlBinaryOperandMode::VectorVector => { if promql .inputs diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index c7a7e3666..d3b7bc780 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -185,8 +185,10 @@ pub struct QueryLifecyclePlan { #[non_exhaustive] pub struct PlanOutput { pub plans: Vec, - /// Standalone scalar queries, keyed by workload entry. Each operator - /// reference is the root of one of `scalar_plans`. + /// Standalone scalar queries, keyed by position in the batch, which is + /// the entry index because [`check_contract`] requires dense entry + /// indices. Each operator reference is the root of one of + /// `scalar_plans`. pub scalar_roots: Vec<(usize, asap_types::ir::ScalarExpr)>, /// One plan per distinct operator sub-DAG a scalar root reads, keyed by /// that scalar's entry, in `scalar_roots` order. @@ -350,6 +352,33 @@ fn check_contract( ))); } } + // `scalar_plans` holds one plan per distinct reference of each scalar + // root, in `scalar_roots` order, keyed by that scalar's entry. + let mut scalar_plans = output.scalar_plans.iter(); + for (entry_index, expr) in &output.scalar_roots { + let mut refs: Vec<&Rc> = Vec::new(); + for node in expr.operator_refs() { + if !refs.iter().any(|r| Rc::ptr_eq(r, node)) { + refs.push(node); + } + } + for node in refs { + match scalar_plans.next() { + Some(plan) + if plan.entry_index == *entry_index && Rc::ptr_eq(&plan.plan.root, node) => {} + _ => { + return Err(violation(format!( + "scalar root {entry_index} reads an operator without its scalar plan" + ))) + } + } + } + } + if scalar_plans.next().is_some() { + return Err(violation( + "a scalar plan is read by no scalar root".to_string(), + )); + } Ok(()) } @@ -440,6 +469,35 @@ mod tests { )); } + /// A scalar root's operator reference must be the root of its scalar + /// plan; a scalar root without one is rejected. + #[test] + fn contract_rejects_a_scalar_reference_without_its_plan() { + use asap_types::ir::non_asap::NonASAPOp; + use asap_types::ir::operator_properties::Source; + use asap_types::ir::{Operator, ScalarExpr}; + use asap_types::pre_asap::schema::{DataType, Field, Schema}; + let scan = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric: "m".into() }, + predicates: vec![], + schema: Schema::with_time_index( + vec![Field::plain("ts", DataType::Timestamp, false)], + 0, + vec![], + ), + })) + .unwrap(); + let mut output = PlanOutput::new(Vec::new()); + output + .scalar_roots + .push((0, ScalarExpr::PromqlScalarFromVector(scan))); + let err = check_contract(&output, "stub", 1).unwrap_err(); + assert!(matches!( + err, + OptimizeError::ContractViolation { pass: "stub", .. } + )); + } + /// The matching count passes. #[test] fn contract_accepts_an_empty_workload() { diff --git a/crates/types/src/parsed_workload.rs b/crates/types/src/parsed_workload.rs index 9dcf21b83..bed0a18a8 100644 --- a/crates/types/src/parsed_workload.rs +++ b/crates/types/src/parsed_workload.rs @@ -11,9 +11,7 @@ use std::rc::Rc; use crate::ir::{OperatorNode, QueryRoot, ScalarExpr}; -use crate::workload::{ - DataWorkload, PlanningWorkload, QueryWorkload, QueryWorkloadEntry, WorkloadError, -}; +use crate::workload::{DataWorkload, PlanningWorkload, QueryWorkload, WorkloadError}; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ParsedWorkloadError { @@ -26,10 +24,10 @@ pub enum ParsedWorkloadError { /// One lowered expression per normalized workload entry, in /// [`QueryWorkload::entries`] order (batch first, then repeating). /// -/// Positional correspondence between `exprs` and the workload's entries is an -/// invariant, not a convention: the fields are private and [`Self::new`] is the -/// only constructor, so a caller cannot hand the optimizer a root bound to the -/// wrong entry's recurrence. +/// Positional correspondence between the lowered roots and the workload's +/// entries is an invariant, not a convention: the fields are private and +/// [`Self::from_roots`] is the only constructor, so a caller cannot hand the +/// optimizer a root bound to the wrong entry's recurrence. #[derive(Debug, Clone)] pub struct ParsedWorkload { workload: PlanningWorkload, @@ -39,18 +37,8 @@ pub struct ParsedWorkload { } impl ParsedWorkload { - /// `exprs[i]` must be the lowering of `workload.query_workload.entries()`'s + /// `roots[i]` must be the lowering of `workload.query_workload.entries()`'s /// `i`-th entry. - pub fn new( - workload: PlanningWorkload, - exprs: Vec>, - ) -> Result { - Self::from_roots( - workload, - exprs.into_iter().map(QueryRoot::Operator).collect(), - ) - } - pub fn from_roots( workload: PlanningWorkload, roots: Vec, @@ -106,17 +94,6 @@ impl ParsedWorkload { self.len() == 0 } - /// Normalized entries paired with their lowered expression. - pub fn entries(&self) -> impl Iterator)> + '_ { - self.workload - .query_workload - .entries() - .enumerate() - .filter(|(index, _)| self.operator_indices.binary_search(index).is_ok()) - .map(|(_, entry)| entry) - .zip(self.exprs.iter()) - } - pub fn operator_indices(&self) -> &[usize] { &self.operator_indices } diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index a5b9b8266..91179ed41 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -169,6 +169,23 @@ fn scalar_roots_export_in_batch_order_and_reference_nodes() { outside.validate(), Err(PhysicalASAPDAGValidationError::ScalarRootPosition(2)) ); + + // Keys must strictly ascend: a duplicate or a descending key is rejected. + let literal: ScalarExpr = + ScalarExpr::Literal(asap_types::pre_asap::ScalarValue::Float64(2.0)); + let mut duplicate = scalar_only.clone(); + duplicate.scalar_roots.push((0, literal.clone())); + assert_eq!( + duplicate.validate(), + Err(PhysicalASAPDAGValidationError::ScalarRootPosition(0)) + ); + let mut descending = scalar_only.clone(); + descending.scalar_roots[0].0 = 1; + descending.scalar_roots.push((0, literal)); + assert_eq!( + descending.validate(), + Err(PhysicalASAPDAGValidationError::ScalarRootPosition(0)) + ); } /// The roles of a binary operator's edges must match its inputs: swapping From 033a3dc9e0790369eed7304eb73f0ce30105fe48 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:26:27 +0000 Subject: [PATCH 05/18] fix(planner): lower scalar(v) in Project/Filter expressions as scalar inputs `a / scalar(b)` and `a > scalar(b)` failed costing with UnsupportedQueryOperator. A Project or Filter now takes one PromqlVectorToScalar input per distinct operator reference its expressions read, after its row input; its statistics carry one `scalar_inputs` edge per such input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../asap-aware-mapping/src/analytical_cost.rs | 20 +- .../src/physical_operator_statistics.rs | 51 ++++- .../src/query_physical_lowering.rs | 216 +++++++++++++++++- 3 files changed, 271 insertions(+), 16 deletions(-) diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index fa7e2f598..d705d323f 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -921,9 +921,12 @@ fn expected_input_arity( statistics_inputs: 1, dag_children: 0, }, - PhysicalOperator::Filter { .. } - | PhysicalOperator::Project { .. } - | PhysicalOperator::HashAggregate { .. } + // The row input, then one input per scalar the expressions read. + PhysicalOperator::Filter { .. } | PhysicalOperator::Project { .. } => OperatorInputArity { + statistics_inputs: variadic_child_count.max(1), + dag_children: variadic_child_count.max(1), + }, + PhysicalOperator::HashAggregate { .. } | PhysicalOperator::InMemoryComparisonSort { .. } | PhysicalOperator::TopK { .. } | PhysicalOperator::HashDeduplicate { .. } @@ -2483,6 +2486,7 @@ mod tests { "filter".into(), OperatorStatistics::Filter { edges: unary_edges(edge, edge), + scalar_inputs: vec![], }, ), ]); @@ -2579,6 +2583,7 @@ mod tests { filter_operator(), OperatorStatistics::Filter { edges: unary_row_edges(10, 11), + scalar_inputs: vec![], }, ), Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) @@ -2588,6 +2593,7 @@ mod tests { project_operator(), OperatorStatistics::Project { edges: unary_row_edges(10, 9), + scalar_inputs: vec![], }, ), Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) @@ -2675,6 +2681,7 @@ mod tests { }, OperatorStatistics::Filter { edges: unary_row_edges(1_000, 100), + scalar_inputs: vec![], }, ); assert_eq!( @@ -2841,12 +2848,14 @@ mod tests { "left".into(), OperatorStatistics::Filter { edges: unary_edges(scan_edge, branch_edge), + scalar_inputs: vec![], }, ), ( "right".into(), OperatorStatistics::Filter { edges: unary_edges(scan_edge, branch_edge), + scalar_inputs: vec![], }, ), ( @@ -3084,6 +3093,7 @@ mod tests { bytes: 400, }, ), + scalar_inputs: vec![], }, ); assert_eq!( @@ -3151,6 +3161,7 @@ mod tests { bytes: 400, }, ), + scalar_inputs: vec![], }, ), ]); @@ -3200,6 +3211,7 @@ mod tests { "filter".into(), OperatorStatistics::Filter { edges: unary_edges(input, EdgeStatistics { rows: 0, bytes: 0 }), + scalar_inputs: vec![], }, ), ]); @@ -3450,6 +3462,7 @@ mod tests { }; let filter = OperatorStatistics::Filter { edges: unary_edges(edge, edge), + scalar_inputs: vec![], }; assert_eq!( @@ -3523,6 +3536,7 @@ mod tests { }; let filter = OperatorStatistics::Filter { edges: unary_edges(edge, edge), + scalar_inputs: vec![], }; assert_eq!( estimate_operator( diff --git a/crates/asap-aware-mapping/src/physical_operator_statistics.rs b/crates/asap-aware-mapping/src/physical_operator_statistics.rs index 1d2b9ebb0..b4ab0680a 100644 --- a/crates/asap-aware-mapping/src/physical_operator_statistics.rs +++ b/crates/asap-aware-mapping/src/physical_operator_statistics.rs @@ -221,6 +221,16 @@ pub struct UnaryEdgeStatistics { pub promql: Option, } +/// Evidence for one scalar input a `Filter` or `Project` expression reads +/// beside its row input: the edge from a `PromqlVectorToScalar` child. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScalarInputStatistics { + pub edge: EdgeStatistics, + #[serde(default)] + pub promql: Option, +} + /// Input and output evidence for a binary physical operator. The input order /// is the physical operator's left/right order and must match its DAG children. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -299,9 +309,17 @@ pub enum OperatorStatistics { }, Filter { edges: UnaryEdgeStatistics, + /// One input per operator reference (`scalar(v)`) the predicate + /// reads, after the row input, in DAG child order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + scalar_inputs: Vec, }, Project { edges: UnaryEdgeStatistics, + /// One input per operator reference (`scalar(v)`) the expressions + /// read, after the row input, in DAG child order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + scalar_inputs: Vec, }, HashAggregate { edges: UnaryEdgeStatistics, @@ -386,9 +404,10 @@ pub enum OperatorStatistics { impl OperatorStatistics { pub fn input_count(&self) -> usize { match self { + Self::Filter { scalar_inputs, .. } | Self::Project { scalar_inputs, .. } => { + 1 + scalar_inputs.len() + } Self::Scan { .. } - | Self::Filter { .. } - | Self::Project { .. } | Self::HashAggregate { .. } | Self::InMemoryComparisonSort { .. } | Self::TopK { .. } @@ -415,9 +434,18 @@ impl OperatorStatistics { Self::Scan { edges, .. } | Self::HashAggregate { edges, .. } | Self::HashDeduplicate { edges, .. } => (index == 0).then_some(edges.input), - Self::Filter { edges } - | Self::Project { edges } - | Self::InMemoryComparisonSort { edges, .. } + Self::Filter { + edges, + scalar_inputs, + } + | Self::Project { + edges, + scalar_inputs, + } => match index { + 0 => Some(edges.input), + _ => scalar_inputs.get(index - 1).map(|input| input.edge), + }, + Self::InMemoryComparisonSort { edges, .. } | Self::TopK { edges } | Self::InMemoryAnalyticWindow { edges, .. } | Self::Limit { edges } @@ -443,8 +471,8 @@ impl OperatorStatistics { Self::Scan { edges, .. } | Self::HashAggregate { edges, .. } | Self::HashDeduplicate { edges, .. } => edges.output, - Self::Filter { edges } - | Self::Project { edges } + Self::Filter { edges, .. } + | Self::Project { edges, .. } | Self::InMemoryComparisonSort { edges, .. } | Self::TopK { edges } | Self::InMemoryAnalyticWindow { edges, .. } @@ -476,6 +504,11 @@ impl OperatorStatistics { Self::HashJoin { edges } | Self::PromqlBinary { edges, .. } | Self::PromqlInfoEnrich { edges, .. } => edges.promql?.inputs.get(index).copied(), + Self::Filter { scalar_inputs, .. } | Self::Project { scalar_inputs, .. } + if index > 0 => + { + scalar_inputs.get(index - 1)?.promql + } _ => self.unary_promql().and_then( |edges| { if index == 0 { @@ -505,8 +538,8 @@ impl OperatorStatistics { pub(crate) fn unary_promql(&self) -> Option { match self { Self::Scan { edges, .. } - | Self::Filter { edges } - | Self::Project { edges } + | Self::Filter { edges, .. } + | Self::Project { edges, .. } | Self::HashAggregate { edges, .. } | Self::InMemoryComparisonSort { edges, .. } | Self::TopK { edges } diff --git a/crates/asap-aware-mapping/src/query_physical_lowering.rs b/crates/asap-aware-mapping/src/query_physical_lowering.rs index 055d19c79..90fac1842 100644 --- a/crates/asap-aware-mapping/src/query_physical_lowering.rs +++ b/crates/asap-aware-mapping/src/query_physical_lowering.rs @@ -162,6 +162,52 @@ pub fn lower_query_physical_dag( self.push(evidence, operator, children, None) } + /// `operator` over its row input `child`, plus one + /// `PromqlVectorToScalar` input per distinct operator reference + /// (`scalar(v)`) its expressions read, in reference order. + fn lower_with_scalar_inputs<'e>( + &mut self, + query: &OperatorNode, + occurrence: usize, + operator: PhysicalOperator, + child: &OperatorNode, + exprs: impl IntoIterator, + ) -> Result { + let mut refs: Vec<&Rc> = Vec::new(); + for expr in exprs { + for node in expr.operator_refs() { + if !refs.iter().any(|r| Rc::ptr_eq(r, node)) { + refs.push(node); + } + } + } + if refs.is_empty() { + return self.lower_unary(query, occurrence, operator, child); + } + let mut children = vec![self.lower(child)?]; + for node in refs { + let scalar = ScalarExpr::PromqlScalarFromVector(Rc::clone(node)); + let scalar_occurrence = self.next_id; + self.next_id += 1; + children.push(self.lower_scalar_operand(query, scalar_occurrence, &scalar)?); + } + let evidence = self.resolve(query, operator, occurrence, false, &children, None)?; + require_statistics_shape(&evidence.physical_id, &evidence.statistics, children.len())?; + for (index, child_id) in children.iter().enumerate() { + let child_statistics = self.node_statistics(child_id)?; + if evidence.statistics.input(index) != Some(child_statistics.output()) { + return Err(AnalyticalCostError::ConflictingEdgeStatistics { + parent: evidence.physical_id.clone(), + child: child_id.clone(), + input_index: index, + }); + } + require_promql_edge(&evidence.statistics, index, child_statistics)?; + } + require_operator_statistics(operator, &evidence.statistics)?; + self.push(evidence, operator, children, None) + } + fn lower_promql_unary( &mut self, query: &OperatorNode, @@ -294,7 +340,7 @@ pub fn lower_query_physical_dag( let operator = PhysicalOperator::Filter { predicate_operations_per_row: scalar_operation_count(&pred.0)?.max(1), }; - self.lower_unary(query, occurrence, operator, child) + self.lower_with_scalar_inputs(query, occurrence, operator, child, [&pred.0]) } NonASAPOp::Project { cols, child, .. } => { let expression_operations_per_row = cols @@ -309,13 +355,14 @@ pub fn lower_query_physical_dag( .ok_or(AnalyticalCostError::Overflow) })? .max(1); - self.lower_unary( + self.lower_with_scalar_inputs( query, occurrence, PhysicalOperator::Project { expression_operations_per_row, }, child, + cols.iter().map(|item| &item.expr), ) } NonASAPOp::Aggregate { @@ -1133,6 +1180,8 @@ fn scalar_operation_count(expr: &ScalarExpr) -> Result | ScalarExpr::Literal(_) | ScalarExpr::EvalTimestamp | ScalarExpr::CurrentTimestamp => Ok(0), + // Read from a scalar input of the containing operator. + ScalarExpr::PromqlScalarFromVector(_) => Ok(0), ScalarExpr::Compare { left, right, .. } | ScalarExpr::Arithmetic { left, right, .. } => { with_local(&[left, right]) } @@ -1325,8 +1374,14 @@ mod tests { ) -> OperatorStatistics { let edges = unary_edges(input, output); match operator { - PhysicalOperator::Filter { .. } => OperatorStatistics::Filter { edges }, - PhysicalOperator::Project { .. } => OperatorStatistics::Project { edges }, + PhysicalOperator::Filter { .. } => OperatorStatistics::Filter { + edges, + scalar_inputs: vec![], + }, + PhysicalOperator::Project { .. } => OperatorStatistics::Project { + edges, + scalar_inputs: vec![], + }, PhysicalOperator::InMemoryComparisonSort { .. } => { OperatorStatistics::InMemoryComparisonSort { edges, @@ -2709,4 +2764,157 @@ mod tests { )); assert!(estimate_physical_dag(&dag.nodes, &dag.root, &query_scope, &dag.evidence).is_ok()); } + + /// `a / scalar(b)` and `a > scalar(b)` read `b` through a + /// `PromqlVectorToScalar` input of the `Project` or `Filter`, after its + /// row input, so the queries lower and cost. + #[test] + fn scalar_reference_in_an_expression_is_a_scalar_input() { + for query in [ + "rate(a[5m]) / scalar(rate(b[5m]))", + "rate(a[5m]) > scalar(rate(b[5m]))", + ] { + assert_scalar_reference_is_a_scalar_input(query); + } + } + + fn assert_scalar_reference_is_a_scalar_input(query: &str) { + use crate::physical_operator_statistics::ScalarInputStatistics; + use asap_types::types::AccuracyTarget; + use asap_types::workload::{ + AccuracyRequirement, BatchEntry, DataWorkload, Evidence, PlanningWorkload, + Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, + }; + use std::cell::RefCell; + + let workload = PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(vec![BatchEntry { + query: Query(query.into()), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(AccuracyTarget::Exact), + ..Default::default() + }, + predictability: Predictability::Unknown, + invocations: 1, + execute_at: None, + time_selection: TimeSelection::default(), + }]), + repeating_queries: None, + }, + data_workload: Some(DataWorkload { + data_ingestion_interval: Evidence { + value: Some(DurationMs(1_000)), + ..Default::default() + }, + ..Default::default() + }), + }; + let root = asap_frontend_promql::unified::lower_promql_workload(&workload, 0) + .unwrap() + .remove(0); + + let vector = |series| promql_edge(series, 10, PromqlValueKind::Vector); + let produced: RefCell> = RefCell::default(); + let provider = |request: PhysicalNodeRequest<'_>| { + let produced_now = produced.borrow(); + let inputs: Vec<&OperatorStatistics> = request + .children + .iter() + .map(|id| &produced_now[id]) + .collect(); + let input = || inputs[0].output(); + let promql_input = || inputs[0].promql_output().unwrap(); + let scalar_inputs = || { + inputs[1..] + .iter() + .map(|scalar| ScalarInputStatistics { + edge: scalar.output(), + promql: scalar.promql_output(), + }) + .collect() + }; + let statistics = match request.operator { + PhysicalOperator::Scan => OperatorStatistics::Scan { + edges: promql_unary_edges( + edge(100, 1_600), + edge(100, 1_600), + vector(10), + vector(10), + ), + source_read_bytes: 1_600, + }, + PhysicalOperator::PromqlRange { .. } => OperatorStatistics::PromqlRange { + edges: promql_unary_edges( + input(), + input(), + promql_input(), + promql_edge(10, 10, PromqlValueKind::RangeVector), + ), + max_window_samples_per_series: 10, + }, + PhysicalOperator::PromqlPerSeries { .. } => OperatorStatistics::PromqlPerSeries { + edges: promql_unary_edges(input(), edge(10, 160), promql_input(), vector(10)), + accumulator_bytes_per_series: 8, + }, + PhysicalOperator::PromqlVectorToScalar => { + OperatorStatistics::PromqlVectorToScalar { + edges: promql_unary_edges( + input(), + edge(10, 160), + promql_input(), + promql_edge(0, 10, PromqlValueKind::Scalar), + ), + } + } + PhysicalOperator::Project { .. } => OperatorStatistics::Project { + edges: promql_unary_edges(input(), input(), promql_input(), promql_input()), + scalar_inputs: scalar_inputs(), + }, + PhysicalOperator::Filter { .. } => OperatorStatistics::Filter { + edges: promql_unary_edges(input(), input(), promql_input(), promql_input()), + scalar_inputs: scalar_inputs(), + }, + other => panic!("unexpected physical operator {other:?}"), + }; + drop(produced_now); + let physical_id = format!("query-{}", request.occurrence); + produced + .borrow_mut() + .insert(physical_id.clone(), statistics.clone()); + Ok(PhysicalNodeEvidence { + physical_id, + ..evidence(statistics) + }) + }; + let query_scope = scope(vec![ + coverage( + asap_types::pre_asap::Source::TimeSeries { metric: "a".into() }, + vec![], + ), + coverage( + asap_types::pre_asap::Source::TimeSeries { metric: "b".into() }, + vec![], + ), + ]); + let dag = lower_query_physical_dag(&root, &query_scope, &provider).unwrap(); + let root_node = dag.nodes.iter().find(|node| node.id == dag.root).unwrap(); + assert!( + matches!( + root_node.operator, + PhysicalOperator::Project { .. } | PhysicalOperator::Filter { .. } + ), + "{query}: {:?}", + root_node.operator + ); + assert_eq!(root_node.children.len(), 2, "{query}"); + let scalar = dag + .nodes + .iter() + .find(|node| node.id == root_node.children[1]) + .unwrap(); + assert_eq!(scalar.operator, PhysicalOperator::PromqlVectorToScalar); + estimate_physical_dag(&dag.nodes, &dag.root, &query_scope, &dag.evidence).unwrap(); + } } From 1e915553187e048ba718b2ebb0778f845b06be1d Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:27:35 +0000 Subject: [PATCH 06/18] fix(planner): reuse a top-k only over the same partitions TopKLimitReuseStrategy offered a global top-10 as the source of a per-job top-5. It now requires equal `partition_by`, and the maintained population recognizer requires the limit to partition like its sort. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../src/maintained_population.rs | 7 +++- crates/asap-aware-mapping/src/topk_reuse.rs | 42 +++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index cb1236fd7..97317c1a7 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -78,8 +78,8 @@ fn recognize( NonASAPOp::Limit { n: Some(n), offset: 0, + partition_by: limit_partition_by, child, - .. } => { let Some(NonASAPOp::Sort { child, @@ -89,6 +89,11 @@ fn recognize( else { return None; }; + // The limit must keep k rows per the sort's partitions; a global + // limit over a partitioned sort (or the reverse) is another query. + if limit_partition_by != partition_by { + return None; + } let [key] = keys.as_slice() else { return None; }; diff --git a/crates/asap-aware-mapping/src/topk_reuse.rs b/crates/asap-aware-mapping/src/topk_reuse.rs index aec95292a..dbf750599 100644 --- a/crates/asap-aware-mapping/src/topk_reuse.rs +++ b/crates/asap-aware-mapping/src/topk_reuse.rs @@ -2,7 +2,8 @@ //! //! If two queries rank the same input and request top-k results with //! `small_k < large_k`, the smaller result is exactly the first `small_k` -//! rows of the larger result. This strategy therefore replaces +//! rows of the larger result when both limit over the same partitions. +//! This strategy therefore replaces //! `Limit(small_k, Sort(X))` with `Limit(small_k, Limit(large_k, Sort(X)))`. use std::rc::Rc; @@ -29,8 +30,8 @@ impl TopKLimitReuseStrategy { let Some(NonASAPOp::Limit { n: Some(target_n), offset: 0, + partition_by: target_partition_by, child: target_child, - .. }) = target.root.non_asap() else { return Vec::new(); @@ -46,13 +47,15 @@ impl TopKLimitReuseStrategy { let Some(NonASAPOp::Limit { n: Some(n), offset: 0, + partition_by, child, - .. }) = candidate.non_asap() else { return false; }; + // A global top-k is not a per-group one (or the reverse). n > target_n + && partition_by == target_partition_by && (Rc::ptr_eq(child, target_child) || child.as_ref() == target_child.as_ref()) }) .collect(); @@ -121,15 +124,46 @@ mod tests { } fn limit(n: usize, offset: usize, child: Rc) -> Rc { + partitioned_limit(n, offset, GroupKeys::none(), child) + } + + fn partitioned_limit( + n: usize, + offset: usize, + partition_by: GroupKeys, + child: Rc, + ) -> Rc { OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Limit { n: Some(n), offset, - partition_by: GroupKeys::none(), + partition_by, child, })) .unwrap() } + /// A global top-10 is not the per-job top-5's source: its rows are the + /// ten best overall, not the five best of each job. + #[test] + fn global_limit_is_not_reused_for_a_per_group_limit() { + use asap_types::pre_asap::{DataType, Field}; + let child = scan( + "m", + Schema::with_time_index( + vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("job", DataType::Utf8, false), + ], + 0, + vec![], + ), + ); + let per_job = partitioned_limit(5, 0, GroupKeys::by(vec![1]), Rc::clone(&child)); + let global = limit(10, 0, child); + let strategy = TopKLimitReuseStrategy::new(&[Rc::clone(&per_job), global]); + assert!(!strategy.matches(&TargetSubDAG::new(&per_job))); + } + #[test] fn smaller_limit_reuses_larger_compatible_limit() { let child = scan_named("m"); From aed3f186e11fcebc25f9bcba306ede9300f31a32 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:38:34 +0000 Subject: [PATCH 07/18] refactor(planner): tell logical rewrites apart by provenance `is_logical_rewrite` inferred a logical rewrite from the replacement's shape (no guarantee, no ASAP operator). It is now `ReplacementSubDAG::is_logical_rewrite`, read from the candidate's provenance (LogicalRewrite, CseShare, CseRecompute, AccuracyReconciliation); the free function is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/lib.rs | 13 ++-- crates/asap-aware-mapping/src/replacement.rs | 67 ++++++++++++------- crates/devtools/src/bin/dag_export.rs | 24 ++++--- crates/integration-tests/tests/cse.rs | 10 +-- .../tests/promql_numeric_regressions.rs | 3 +- .../tests/promql_to_post_asap.rs | 7 +- 6 files changed, 72 insertions(+), 52 deletions(-) diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 16e6e6118..36a7d384b 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -207,13 +207,12 @@ pub use recurrence::{ UpdateRate, }; pub use replacement::{ - default_strategies, default_strategies_with, is_logical_rewrite, search_workload, - search_workload_with, search_workload_with_targets, summary_candidates, ASAPStrategies, - CandidateLogicalASAPDAGs, CompositionDecision, GlobalSelection, Matcher, Proposals, - RankedTargetSubDAGCandidates, Realization, RealizationError, RecurrenceProfileMap, - RejectedCandidate, Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, - SharedSubDAGStrategy, TargetSubDAG, TargetSubDAGCandidates, TargetSubDAGSelection, - MAX_SEARCH_ITERATIONS, + default_strategies, default_strategies_with, search_workload, search_workload_with, + search_workload_with_targets, summary_candidates, ASAPStrategies, CandidateLogicalASAPDAGs, + CompositionDecision, GlobalSelection, Matcher, Proposals, RankedTargetSubDAGCandidates, + Realization, RealizationError, RecurrenceProfileMap, RejectedCandidate, Replacement, + ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, SharedSubDAGStrategy, + TargetSubDAG, TargetSubDAGCandidates, TargetSubDAGSelection, MAX_SEARCH_ITERATIONS, }; pub use rewrite::{AvgToSumOverCountStrategy, SemanticEquivalentRewriteStrategy}; pub use summary_maintenance_dag_export::{ diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index a3ff4dbd4..eaa7c56e2 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -491,7 +491,8 @@ pub enum Replacement { /// realization of the target) or a pre-ASAP rewrite (a logical sub-DAG /// with no ASAP operator, structurally different from the target's own /// `root` — e.g. sharing vs. not sharing a sub-DAG — but semantically - /// equivalent to it). [`is_logical_rewrite`] tells the two apart. + /// equivalent to it). [`ReplacementSubDAG::is_logical_rewrite`] tells the + /// two apart. SubDAG(Rc), /// An exact operator composed over another target's *own* selected /// decision across an explicit update/evaluation boundary (issue #171): @@ -504,13 +505,13 @@ pub enum Replacement { ExactComposition(ExactComposition), } -/// Whether a [`Replacement::SubDAG`] is a pure logical rewrite: a sub-DAG -/// with no ASAP operator and no guarantee established yet (the shape every -/// front end emits and every rewrite strategy builds). A bound summary -/// decision contains an ASAP operator, or is a kept pre-ASAP sub-DAG that -/// already carries its exact guarantee. -pub fn is_logical_rewrite(node: &OperatorNode) -> bool { - node.guarantee.is_none() && !node.contains_asap() +impl ReplacementSubDAG { + /// Whether this candidate is a pure logical rewrite of its target rather + /// than a bound summary decision, as its [`ReplacementProvenance`] + /// records. + pub fn is_logical_rewrite(&self) -> bool { + matches!(self.replacement, Replacement::SubDAG(_)) && self.provenance.is_logical_rewrite() + } } /// One candidate replacement for a [`TargetSubDAG`], plus a human-readable @@ -540,7 +541,7 @@ impl ReplacementSubDAG { pub fn has_missing_accuracy_evidence(&self) -> bool { matches!( &self.replacement, - Replacement::SubDAG(node) if !is_logical_rewrite(node) && has_missing_accuracy_evidence(node) + Replacement::SubDAG(node) if !self.is_logical_rewrite() && has_missing_accuracy_evidence(node) ) } @@ -554,7 +555,7 @@ impl ReplacementSubDAG { } // Any summary decision, including one rooted in a relational // operator above its evaluations, asks the deployment for support. - Replacement::SubDAG(node) if !is_logical_rewrite(node) => { + Replacement::SubDAG(node) if !self.is_logical_rewrite() => { cost_model.summary_support_evidence(node) } Replacement::SubDAG(_) => Some(true), @@ -596,6 +597,21 @@ pub enum ReplacementProvenance { RootPhysicalRealization, } +impl ReplacementProvenance { + /// Whether a candidate of this origin is a pure logical rewrite of its + /// target (a structurally different, semantically equivalent sub-DAG + /// with no summary decision bound yet). + pub fn is_logical_rewrite(self) -> bool { + matches!( + self, + Self::LogicalRewrite + | Self::CseShare + | Self::CseRecompute + | Self::AccuracyReconciliation + ) + } +} + /// A candidate a strategy considered for a target but refused to propose on /// accuracy-legality grounds (issue #172) — kept alongside the group's /// legal candidates in [`TargetSubDAGCandidates::rejected`] so a rejection is as @@ -4004,7 +4020,7 @@ impl TargetSubDAGCandidates { let is_duplicate = self.candidates.iter().any(|existing| { match (&existing.replacement, &candidate.replacement) { (Replacement::SubDAG(existing_rc), Replacement::SubDAG(rc)) - if is_logical_rewrite(existing_rc) && is_logical_rewrite(rc) => + if existing.is_logical_rewrite() && candidate.is_logical_rewrite() => { is_duplicate_rewrite(existing_rc, rc, &self.target) } @@ -4144,7 +4160,7 @@ impl CandidateLogicalASAPDAGs { .flat_map(|g| &g.candidates) .filter_map(|c| match &c.replacement { Replacement::SubDAG(child) - if !is_logical_rewrite(child) && operation.accepts_child(child) => + if !c.is_logical_rewrite() && operation.accepts_child(child) => { Some(Rc::clone(child)) } @@ -4246,7 +4262,7 @@ impl CandidateLogicalASAPDAGs { if let Some(group) = self.groups.get(&ptr) { for candidate in &group.candidates { if let Replacement::SubDAG(rewritten) = &candidate.replacement { - if is_logical_rewrite(rewritten) { + if candidate.is_logical_rewrite() { walk(rewritten, &mut reachable, &mut nodes, &mut counts); } } @@ -5665,7 +5681,7 @@ fn composition_options<'a>( let Replacement::SubDAG(summary) = &child_candidate.replacement else { continue; }; - if is_logical_rewrite(summary) || !composition.accepts_child(summary) { + if child_candidate.is_logical_rewrite() || !composition.accepts_child(summary) { continue; } let Some(prepared) = plans.iter().find(|p| { @@ -6027,13 +6043,14 @@ impl CandidateLogicalASAPDAGs { *effective_uses.entry(Rc::as_ptr(source)).or_insert(0) += outgoing_multiplier; } _ => { - let selected_rewrite = match chosen.map(|candidate| &candidate.replacement) { - Some(Replacement::SubDAG(rewrite)) if is_logical_rewrite(rewrite) => { - rewrite - } - Some(Replacement::SubDAG(_) | Replacement::ExactComposition(_)) | None => { - &group.target - } + let selected_rewrite = match chosen { + Some( + candidate @ ReplacementSubDAG { + replacement: Replacement::SubDAG(rewrite), + .. + }, + ) if candidate.is_logical_rewrite() => rewrite, + _ => &group.target, }; for (child, edge_count) in direct_child_counts(selected_rewrite) { *effective_uses.entry(child).or_insert(0) += @@ -6251,7 +6268,7 @@ fn reference_dag(space: &CandidateLogicalASAPDAGs) -> ReferenceDAG { record_possible_edges(*ptr, &group.target, &mut dag); for candidate in &group.candidates { if let Replacement::SubDAG(rewrite) = &candidate.replacement { - if !is_logical_rewrite(rewrite) { + if !candidate.is_logical_rewrite() { continue; } if candidate.provenance == ReplacementProvenance::AccuracyReconciliation { @@ -6571,7 +6588,7 @@ pub fn search_workload_with_targets<'s, Id>( .candidates .drain(..) .partition(|candidate| match &candidate.replacement { - Replacement::SubDAG(node) if is_logical_rewrite(node) => true, + Replacement::SubDAG(_) if candidate.is_logical_rewrite() => true, Replacement::SubDAG(node) => node.guarantee.as_ref().map_or_else( || !matches!(target, AccuracyTarget::Exact), |g| accuracy_model.satisfies(&g.optimistic_floor(), &target), @@ -6813,7 +6830,7 @@ fn search_cse_workload_with<'s, Id>( for candidate in &proposed { if let Replacement::SubDAG(rc) = &candidate.replacement { - if is_logical_rewrite(rc) { + if candidate.is_logical_rewrite() { discover_new_descendant_targets(rc, &mut order, &mut nodes, &mut counts); } } @@ -6870,7 +6887,7 @@ fn add_effective_count_cse_candidates( } for candidate in &group.candidates { if let Replacement::SubDAG(rewrite) = &candidate.replacement { - if !is_logical_rewrite(rewrite) { + if !candidate.is_logical_rewrite() { continue; } for (child, _) in direct_child_counts(rewrite) { diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index 5f2be890a..d65fa67c9 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -88,8 +88,8 @@ use asap_aware_mapping::physical_plan_cost_model::{ }; use asap_aware_mapping::query_physical_lowering::PhysicalNodeRequest; use asap_aware_mapping::replacement::{ - default_strategies_with_evidence, is_logical_rewrite, search_workload, search_workload_with, - Replacement, ReplacementSubDAG, + default_strategies_with_evidence, search_workload, search_workload_with, Replacement, + ReplacementSubDAG, }; use asap_aware_mapping::{AccuracyEvidenceProvider, PropagationStats}; use asap_types::cost::{BaselineRef, CostAnnotation, CostInput, CostSource, CostUnit}; @@ -226,10 +226,12 @@ impl CandidatePhysicalEvidence { fn matches(&self, candidate: &ReplacementSubDAG) -> bool { let actual = match (self, &candidate.replacement) { - (Self::Summary { .. }, Replacement::SubDAG(node)) if !is_logical_rewrite(node) => { + (Self::Summary { .. }, Replacement::SubDAG(node)) + if !candidate.is_logical_rewrite() => + { serde_json::to_value(dag_export::export(node)) } - (Self::Rewrite { .. }, Replacement::SubDAG(node)) if is_logical_rewrite(node) => { + (Self::Rewrite { .. }, Replacement::SubDAG(node)) if candidate.is_logical_rewrite() => { serde_json::to_value(dag_export::export(node)) } _ => return false, @@ -1099,7 +1101,7 @@ fn target_replacement( let strategy = winner.candidate.strategy.to_string(); let before = dag_export::export(winner.target); let after = match &winner.candidate.replacement { - Replacement::SubDAG(node) if is_logical_rewrite(node) => { + Replacement::SubDAG(node) if winner.candidate.is_logical_rewrite() => { TargetReplacementAfter::Rewrite(dag_export::export(node)) } Replacement::SubDAG(node) => TargetReplacementAfter::Summary(dag_export::export(node)), @@ -1338,10 +1340,12 @@ fn run_post_asap_with_progress( benefit: Some(benefit), }; Some(match &winners[i].candidate.replacement { - Replacement::SubDAG(rc) if is_logical_rewrite(rc) => PostAsapSubstitution::Rewrite { - replacement: Rc::clone(rc), - decision, - }, + Replacement::SubDAG(rc) if winners[i].candidate.is_logical_rewrite() => { + PostAsapSubstitution::Rewrite { + replacement: Rc::clone(rc), + decision, + } + } Replacement::SubDAG(rc) => PostAsapSubstitution::Summary { replacement: Rc::clone(rc), decision, @@ -2326,7 +2330,7 @@ mod tests { target: query.clone(), scope: test_scope(), candidates: vec![match &candidate.replacement { - Replacement::SubDAG(node) if !is_logical_rewrite(node) => { + Replacement::SubDAG(_) if !candidate.is_logical_rewrite() => { CandidatePhysicalEvidence::Summary { plan, query_nodes: query_evidence(&query), diff --git a/crates/integration-tests/tests/cse.rs b/crates/integration-tests/tests/cse.rs index 56a0e657b..4551aa9c5 100644 --- a/crates/integration-tests/tests/cse.rs +++ b/crates/integration-tests/tests/cse.rs @@ -24,7 +24,7 @@ use std::rc::Rc; -use asap_aware_mapping::{is_logical_rewrite, search_workload, Replacement}; +use asap_aware_mapping::{search_workload, Replacement}; use asap_integration_tests::fixtures::lower_promql; use asap_types::ir::NonASAPOp; use asap_types::types::AccuracyTarget; @@ -84,7 +84,7 @@ fn duplicate_workload_queries_collapse_onto_one_memo_group() { ); // A bound summary is a `Subtree` with an ASAP operator in it; a logical - // rewrite is a `Subtree` with none (`is_logical_rewrite`). + // rewrite is a `Subtree` with none (`ReplacementSubDAG::is_logical_rewrite`). let summary_count = group .candidates .iter() @@ -93,7 +93,7 @@ fn duplicate_workload_queries_collapse_onto_one_memo_group() { let rewrite_count = group .candidates .iter() - .filter(|c| matches!(&c.replacement, Replacement::SubDAG(n) if is_logical_rewrite(n))) + .filter(|c| c.is_logical_rewrite()) .count(); assert_eq!(summary_count, 1); assert_eq!(rewrite_count, 2); @@ -104,11 +104,11 @@ fn duplicate_workload_queries_collapse_onto_one_memo_group() { // structurally-identical but independently-built `Rc`. let one_is_the_target = group.candidates.iter().any(|c| { matches!(&c.replacement, Replacement::SubDAG(rc) - if is_logical_rewrite(rc) && Rc::ptr_eq(rc, &group.target)) + if c.is_logical_rewrite() && Rc::ptr_eq(rc, &group.target)) }); let one_is_not = group.candidates.iter().any(|c| { matches!(&c.replacement, Replacement::SubDAG(rc) - if is_logical_rewrite(rc) && !Rc::ptr_eq(rc, &group.target)) + if c.is_logical_rewrite() && !Rc::ptr_eq(rc, &group.target)) }); assert!(one_is_the_target && one_is_not); } diff --git a/crates/integration-tests/tests/promql_numeric_regressions.rs b/crates/integration-tests/tests/promql_numeric_regressions.rs index 3ec4d8eac..309a5bb3d 100644 --- a/crates/integration-tests/tests/promql_numeric_regressions.rs +++ b/crates/integration-tests/tests/promql_numeric_regressions.rs @@ -1,6 +1,5 @@ //! Numeric regression fixtures: actual PromQL lowering plus numeric update/evaluation checks. //! The count/sum interpreter below verifies planner update semantics, not a deployed backend. -use asap_aware_mapping::replacement::is_logical_rewrite; use asap_aware_mapping::{ASAPStrategies, Replacement, ReplacementStrategy, TargetSubDAG}; use asap_integration_tests::fixtures::lower_promql; use asap_integration_tests::post_asap::post_asap_dag; @@ -17,7 +16,7 @@ fn plan(query: &str, accuracy: AccuracyTarget) -> Rc { .into_iter() .find_map(|r| match r.replacement { // A bound decision: a summary DAG or a kept (exact) sub-DAG. - Replacement::SubDAG(n) if !is_logical_rewrite(&n) => Some(n), + Replacement::SubDAG(n) if !r.provenance.is_logical_rewrite() => Some(n), _ => None, }) .unwrap_or_else(|| asap_aware_mapping::replacement::retain_exact(&pre).unwrap()) diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index b999720fa..f41667686 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -13,7 +13,7 @@ use asap_aware_mapping::accuracy::{ QuantileInputDomain, }; use asap_aware_mapping::cost_model::DefaultCostModel; -use asap_aware_mapping::replacement::{is_logical_rewrite, retain_exact, RealizationError}; +use asap_aware_mapping::replacement::{retain_exact, RealizationError}; use asap_aware_mapping::{ search_workload, search_workload_with_targets, ASAPStrategies, AccuracyModel, Replacement, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, @@ -48,8 +48,9 @@ fn realize(root: &Rc) -> Result, RealizationError // is not a binding, so it falls back to keeping the target. Some(ReplacementSubDAG { replacement: Replacement::SubDAG(node), + provenance, .. - }) if !is_logical_rewrite(&node) => Ok(node), + }) if !provenance.is_logical_rewrite() => Ok(node), _ => retain_exact(root), } .inspect(|node| { @@ -1017,7 +1018,7 @@ fn promql_sum_of_count_over_time_is_composed_by_default_search() { let Replacement::SubDAG(rewritten) = &candidate.replacement else { panic!("expected logical rewrite") }; - assert!(is_logical_rewrite(rewritten), "expected logical rewrite"); + assert!(candidate.is_logical_rewrite(), "expected logical rewrite"); assert_eq!(rewritten.schema, original_schema); let Some(NonASAPOp::Project { child, .. }) = rewritten.non_asap() else { From 84553316a8e5962d21d0314e1d297cd432ed4879 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:40:43 +0000 Subject: [PATCH 08/18] fix(planner): return an error when search input already holds ASAP operators The search entry points asserted that no workload root contains an ASAP operator. They now return `SearchError::AsapInput`; MajorPass reports it as `OptimizeError::Search`, and the explanation entry points pass it on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../src/accuracy/reconciliation.rs | 12 +- crates/asap-aware-mapping/src/explanation.rs | 35 ++-- crates/asap-aware-mapping/src/grouping.rs | 3 +- crates/asap-aware-mapping/src/lib.rs | 5 +- .../src/maintained_population.rs | 3 +- crates/asap-aware-mapping/src/pass/major.rs | 3 +- crates/asap-aware-mapping/src/pass/mod.rs | 2 + .../src/physical_plan_cost_model.rs | 15 +- crates/asap-aware-mapping/src/recurrence.rs | 14 +- crates/asap-aware-mapping/src/replacement.rs | 155 +++++++++++------- crates/asap-aware-mapping/src/rewrite.rs | 4 +- crates/asap-aware-mapping/src/rollup.rs | 6 +- .../src/summary_maintenance_cost/model.rs | 2 +- .../src/summary_maintenance_lifecycle.rs | 21 ++- .../unified_physical_planner/candidates.rs | 2 +- .../tests/deployment_computation.rs | 2 +- .../tests/planspace_series_identity_heap.rs | 7 +- .../tests/precompute_candidates.rs | 2 +- crates/devtools/src/bin/dag_export.rs | 16 +- crates/devtools/src/bin/sketch_coverage.rs | 2 +- .../frontend-promql/tests/count_planning.rs | 3 +- .../tests/univmon_candidates.rs | 3 +- crates/integration-tests/tests/cse.rs | 6 +- .../tests/exact_composition.rs | 11 +- .../tests/operator_sharing.rs | 2 +- .../tests/precompute_raw_samples.rs | 2 +- .../tests/promql_to_post_asap.rs | 17 +- .../tests/sql_to_physical.rs | 2 +- .../tests/sql_to_post_asap.rs | 16 +- .../summary_maintenance_lifecycle_e2e.rs | 3 +- crates/planner/tests/e2e_plan.rs | 2 +- crates/planner/tests/summary_sharing.rs | 2 +- 32 files changed, 231 insertions(+), 149 deletions(-) diff --git a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs index 36a219d4e..62a82ec26 100644 --- a/crates/asap-aware-mapping/src/accuracy/reconciliation.rs +++ b/crates/asap-aware-mapping/src/accuracy/reconciliation.rs @@ -578,7 +578,8 @@ mod tests { let tight = quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan); let loose = quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan); - let space = crate::replacement::search_workload(vec![("tight", tight), ("loose", loose)]); + let space = + crate::replacement::search_workload(vec![("tight", tight), ("loose", loose)]).unwrap(); let loose_root = &space.roots[1].1; let loose_group = space @@ -807,7 +808,8 @@ mod tests { let scan = metric_scan(); let tight = quantile(0.99, AccuracyTarget::Epsilon(0.01), &scan); let loose = quantile(0.99, AccuracyTarget::Epsilon(0.05), &scan); - let space = crate::replacement::search_workload(vec![("tight", tight), ("loose", loose)]); + let space = + crate::replacement::search_workload(vec![("tight", tight), ("loose", loose)]).unwrap(); let loose_root = &space.roots[1].1; let cost_model = DefaultCostModel; @@ -868,7 +870,8 @@ mod tests { ("loose_a", Rc::new(loose_a)), ("loose_b", Rc::new(loose_b)), ("tight", Rc::new(tight)), - ]); + ]) + .unwrap(); // Fixture sanity: the two loose roots really did merge onto one Rc. assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); @@ -959,7 +962,8 @@ mod tests { let space = crate::replacement::search_workload(vec![ ("tight", Rc::clone(&tight)), ("loose", Rc::clone(&loose)), - ]); + ]) + .unwrap(); let selected = space.global_selection(&PreferReconciliation); let tight_root = &space.roots[0].1; diff --git a/crates/asap-aware-mapping/src/explanation.rs b/crates/asap-aware-mapping/src/explanation.rs index bc67be96f..ab343915e 100644 --- a/crates/asap-aware-mapping/src/explanation.rs +++ b/crates/asap-aware-mapping/src/explanation.rs @@ -265,7 +265,7 @@ pub struct ReplacementExplanation { /// "The reframing" section for what that translation actually checks. pub fn explain_replacements( roots: Vec<(Id, Rc)>, -) -> Vec { +) -> Result, replacement::SearchError> { explain_replacements_with(roots, &replacement::default_strategies()) } @@ -280,13 +280,13 @@ pub fn explain_replacements( pub fn explain_replacements_with<'s, Id: Display>( roots: Vec<(Id, Rc)>, strategies: &[Box], -) -> Vec { +) -> Result, replacement::SearchError> { let ided: Vec<(String, Rc)> = roots .into_iter() .map(|(id, expr)| (id.to_string(), expr)) .collect(); - let space = replacement::search_workload_with(ided, strategies); - findings_from_candidate_logical_asap_dags(&space) + let space = replacement::search_workload_with(ided, strategies)?; + Ok(findings_from_candidate_logical_asap_dags(&space)) } /// Translate every discovered [`TargetSubDAGCandidates`] in `space` into zero, one, or two @@ -571,7 +571,7 @@ mod tests { #[test] fn approximate_quantile_is_a_sketch_applicability_finding() { let q = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let findings = explain_replacements(vec![("dashboard_p99", q)]); + let findings = explain_replacements(vec![("dashboard_p99", q)]).unwrap(); let sketch: Vec<_> = findings .iter() .filter(|f| f.kind == ExplanationKind::SketchApproximation) @@ -596,7 +596,7 @@ mod tests { let dag = asap_types::dag_export::export(&q); let expected_hash = dag.nodes[dag.root as usize].hash; - let findings = explain_replacements(vec![("dashboard_p99", q)]); + let findings = explain_replacements(vec![("dashboard_p99", q)]).unwrap(); let sketch = findings .iter() .find(|f| f.kind == ExplanationKind::SketchApproximation) @@ -620,7 +620,7 @@ mod tests { }, metric_scan(&["job"]), ); - let findings = explain_replacements(vec![("exact_p99", q)]); + let findings = explain_replacements(vec![("exact_p99", q)]).unwrap(); assert!( findings .iter() @@ -638,7 +638,7 @@ mod tests { // nested_aggregates_bind_per_node test uses. let inner = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let outer = agg(vec![], default_quantile(0.9), inner); - let findings = explain_replacements(vec![("q", outer)]); + let findings = explain_replacements(vec![("q", outer)]).unwrap(); let sketch_count = findings .iter() .filter(|f| f.kind == ExplanationKind::SketchApproximation) @@ -652,7 +652,7 @@ mod tests { #[test] fn pass_through_intent_reports_no_sketch_finding() { let q = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); - let findings = explain_replacements(vec![("avg_latency", q)]); + let findings = explain_replacements(vec![("avg_latency", q)]).unwrap(); assert!(findings .iter() .all(|f| f.kind != ExplanationKind::SketchApproximation)); @@ -672,7 +672,7 @@ mod tests { Rc::clone(&quantile), quantile, ); - let findings = explain_replacements(vec![("ratio", root)]); + let findings = explain_replacements(vec![("ratio", root)]).unwrap(); let sketch: Vec<_> = findings .iter() .filter(|f| f.kind == ExplanationKind::SketchApproximation) @@ -694,7 +694,7 @@ mod tests { // gate — and identical across both roots, so it is shareable. let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let findings = explain_replacements(vec![("dash_a", a), ("dash_b", b)]); + let findings = explain_replacements(vec![("dash_a", a), ("dash_b", b)]).unwrap(); let reuse: Vec<_> = findings .iter() .filter(|f| f.kind == ExplanationKind::CommonSubexpressionReuse) @@ -712,7 +712,8 @@ mod tests { fn descendant_of_a_shared_root_keeps_every_root_breadcrumb() { let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let outer = agg(vec![0], AggIntent::Sum { col: None }, inner); - let findings = explain_replacements(vec![("dash_a", Rc::clone(&outer)), ("dash_b", outer)]); + let findings = + explain_replacements(vec![("dash_a", Rc::clone(&outer)), ("dash_b", outer)]).unwrap(); let inner_sketch = findings .iter() .find(|f| { @@ -731,7 +732,7 @@ mod tests { AggIntent::Sum { col: None }, metric_scan(&["route"]), ); - let findings = explain_replacements(vec![("dash_a", a), ("dash_b", b)]); + let findings = explain_replacements(vec![("dash_a", a), ("dash_b", b)]).unwrap(); assert!( findings .iter() @@ -747,7 +748,7 @@ mod tests { // must not report a finding either. let a = agg(vec![], AggIntent::Sum { col: None }, metric_scan(&["job"])); let b = agg(vec![], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let findings = explain_replacements(vec![("a", a), ("b", b)]); + let findings = explain_replacements(vec![("a", a), ("b", b)]).unwrap(); assert!(findings .iter() .all(|f| f.kind != ExplanationKind::CommonSubexpressionReuse)); @@ -763,7 +764,7 @@ mod tests { Rc::clone(&branch), branch, ); - let findings = explain_replacements(vec![("ratio", q)]); + let findings = explain_replacements(vec![("ratio", q)]).unwrap(); let reuse: Vec<_> = findings .iter() .filter(|f| f.kind == ExplanationKind::CommonSubexpressionReuse) @@ -801,7 +802,7 @@ mod tests { child: shared, })) .unwrap(); - let findings = explain_replacements(vec![("a", root_a), ("b", root_b)]); + let findings = explain_replacements(vec![("a", root_a), ("b", root_b)]).unwrap(); let reuse: Vec<_> = findings .iter() .filter(|f| f.kind == ExplanationKind::CommonSubexpressionReuse) @@ -843,7 +844,7 @@ mod tests { let strategies: Vec> = vec![Box::new( crate::replacement::ASAPStrategies::new(&custom_model), )]; - let findings = explain_replacements_with(vec![("q", q)], &strategies); + let findings = explain_replacements_with(vec![("q", q)], &strategies).unwrap(); assert_eq!(findings.len(), 1); assert!(findings[0].reason.to_lowercase().contains("ddsketch")); } diff --git a/crates/asap-aware-mapping/src/grouping.rs b/crates/asap-aware-mapping/src/grouping.rs index bbbaad37f..497bfa783 100644 --- a/crates/asap-aware-mapping/src/grouping.rs +++ b/crates/asap-aware-mapping/src/grouping.rs @@ -711,7 +711,8 @@ mod tests { let space = crate::replacement::search_workload_with( vec![("q", Rc::clone(&q))], &[Box::new(strategy)], - ); + ) + .unwrap(); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); assert!(group.candidates.is_empty()); assert_eq!(group.rejected.len(), 2); diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 36a7d384b..7e91ff5a9 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -211,8 +211,9 @@ pub use replacement::{ search_workload_with_targets, summary_candidates, ASAPStrategies, CandidateLogicalASAPDAGs, CompositionDecision, GlobalSelection, Matcher, Proposals, RankedTargetSubDAGCandidates, Realization, RealizationError, RecurrenceProfileMap, RejectedCandidate, Replacement, - ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, SharedSubDAGStrategy, - TargetSubDAG, TargetSubDAGCandidates, TargetSubDAGSelection, MAX_SEARCH_ITERATIONS, + ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, SearchError, + SharedSubDAGStrategy, TargetSubDAG, TargetSubDAGCandidates, TargetSubDAGSelection, + MAX_SEARCH_ITERATIONS, }; pub use rewrite::{AvgToSumOverCountStrategy, SemanticEquivalentRewriteStrategy}; pub use summary_maintenance_dag_export::{ diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index 97317c1a7..da637687f 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -397,7 +397,8 @@ mod tests { .map(|(i, r)| (i, Rc::clone(r))) .collect(), &[Box::new(MaintainedPopulationStrategy::new(&roots))], - ); + ) + .unwrap(); assert!(space .target_subdag_candidates() .flat_map(|g| &g.candidates) diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index ee1e10c6f..9876a795e 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -86,7 +86,8 @@ impl OptimizationPass for MajorPass { // the roots went in. let entry_indices: Vec = roots.iter().map(|(index, _, _)| *index).collect(); - let space = search_workload_with_targets(roots, &strategies, models.accuracy); + let space = search_workload_with_targets(roots, &strategies, models.accuracy) + .map_err(OptimizeError::Search)?; let lifecycle = input.lifecycle; let demand = WorkloadDemand { diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index d3b7bc780..7c4ee1ad2 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -286,6 +286,8 @@ pub enum OptimizeError { entry_index: usize, source: RealizationError, }, + #[error("search: {0}")] + Search(crate::replacement::SearchError), #[error("summary-maintenance-lifecycle selection: {0}")] LifecycleSelection(SummaryMaintenanceLifecycleSelectionError), #[error("entry {entry_index}: {source}")] diff --git a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs index d846f907b..4670c5511 100644 --- a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs +++ b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs @@ -820,7 +820,8 @@ mod tests { let space = crate::replacement::search_workload_with( vec![("q", Rc::clone(&root))], &crate::replacement::default_strategies(), - ); + ) + .unwrap(); let model = PhysicalPlanCostModel::new(&provider, zero_base.clone()).unwrap(); let selected = space.global_selection(&model); assert!(selected @@ -860,7 +861,8 @@ mod tests { let space = crate::replacement::search_workload_with( vec![("q", Rc::clone(&root))], &crate::replacement::default_strategies(), - ); + ) + .unwrap(); let planned_root = Rc::clone(&space.roots[0].1); let provider = TestProvider::new(true, 800); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); @@ -926,7 +928,8 @@ mod tests { let space = crate::replacement::search_workload_with( vec![("q", query())], &crate::replacement::default_strategies(), - ); + ) + .unwrap(); let provider = AlmostResident(TestProvider::new(true, WORKING_SET)); let model = PhysicalPlanCostModel::new( &provider, @@ -971,7 +974,8 @@ mod tests { let space = crate::replacement::search_workload_with( vec![("q", Rc::clone(&root))], &crate::replacement::default_strategies(), - ); + ) + .unwrap(); let planned_root = Rc::clone(&space.roots[0].1); let provider = TestProvider::new(false, 800); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); @@ -1080,7 +1084,8 @@ mod tests { let space = crate::replacement::search_workload_with( vec![("q", Rc::clone(&root))], &crate::replacement::default_strategies(), - ); + ) + .unwrap(); let planned_root = Rc::clone(&space.roots[0].1); let provider = TestProvider::new(true, 100_000); let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); diff --git a/crates/asap-aware-mapping/src/recurrence.rs b/crates/asap-aware-mapping/src/recurrence.rs index 835a35a65..c9b677c86 100644 --- a/crates/asap-aware-mapping/src/recurrence.rs +++ b/crates/asap-aware-mapping/src/recurrence.rs @@ -1207,7 +1207,7 @@ mod tests { ("root_b", filtered_root(2)), ("root_c", filtered_root(3)), ]; - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); // Fixture sanity: the three roots stayed distinct (different // literal predicates), but their `sum_agg()` children merged onto @@ -1268,7 +1268,7 @@ mod tests { #[test] fn plan_selection_uses_recurrence_profiles_for_cse_choices() { let roots = vec![("a", filtered_root(1)), ("b", filtered_root(2))]; - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); let shared = space .target_subdag_candidates() .find(|group| matches!(group.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) @@ -1331,7 +1331,7 @@ mod tests { fn recurrence_profiles_rejects_an_invalid_evaluation_rate() { let root = scan(); let roots: Vec<(&str, Rc)> = vec![("only", root)]; - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); let err = space .recurrence_profiles(&[RootRecurrence::Repeating(EvaluationRate(f64::NAN))], None) .unwrap_err(); @@ -1345,7 +1345,7 @@ mod tests { fn recurrence_profiles_reports_a_root_count_mismatch_as_an_error_not_a_panic() { let root = scan(); let roots: Vec<(&str, Rc)> = vec![("only", root)]; - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); let err = space.recurrence_profiles(&[], None).unwrap_err(); assert_eq!( err, @@ -1360,7 +1360,7 @@ mod tests { fn recurrence_profiles_rejects_an_invalid_update_rate() { let root = scan(); let roots: Vec<(&str, Rc)> = vec![("only", root)]; - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); let err = space .recurrence_profiles( &[RootRecurrence::OneShotCount(1)], @@ -1398,7 +1398,7 @@ mod tests { })) .unwrap(); let roots: Vec<(&str, Rc)> = vec![("q", avg_root)]; - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); let count_group = space .target_subdag_candidates() @@ -1459,7 +1459,7 @@ mod tests { rhs: sum_agg(), })) .unwrap(); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let shared_group = space .target_subdag_candidates() diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index eaa7c56e2..c26aa76f3 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -6481,10 +6481,24 @@ pub fn default_strategies_with_evidence<'a>( /// `sorted_by(cost_model)` step. Use [`search_workload_with`] to plug in a /// custom strategy set (e.g. built via [`default_strategies_with`] for a /// deployment-specific [`CostModel`]). -pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogicalASAPDAGs { +pub fn search_workload( + roots: Vec<(Id, Rc)>, +) -> Result, SearchError> { search_workload_with(roots, &default_strategies()) } +/// Why [`search_workload`] could not search a workload. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum SearchError { + /// Replacement search takes the front end's pre-ASAP DAG only. + #[error( + "a workload root already contains an ASAP operator ({0}); replacement search \ + takes the front end's pre-ASAP DAG only" + )] + AsapInput(&'static str), +} + /// Like [`search_workload`], but with an explicit set of context-free /// `strategies` (see [`default_strategies_with`] to plug in a /// deployment-specific [`CostModel`]). The workload-dependent @@ -6506,10 +6520,10 @@ pub fn search_workload(roots: Vec<(Id, Rc)>) -> CandidateLogic pub fn search_workload_with<'s, Id>( roots: Vec<(Id, Rc)>, strategies: &[Box], -) -> CandidateLogicalASAPDAGs { - let mut space = search_cse_workload_with(cse_workload(roots), strategies); +) -> Result, SearchError> { + let mut space = search_cse_workload_with(cse_workload(roots), strategies)?; space.prepare_compositions(&DefaultAccuracyModel, &HashMap::new()); - space + Ok(space) } /// [`search_workload_with`] plus a per-root end-to-end `AccuracyTarget` @@ -6535,7 +6549,7 @@ pub fn search_workload_with_targets<'s, Id>( roots: Vec<(Id, Rc, Option)>, strategies: &[Box], accuracy_model: &dyn AccuracyModel, -) -> CandidateLogicalASAPDAGs { +) -> Result, SearchError> { let mut targets = Vec::with_capacity(roots.len()); let roots = roots .into_iter() @@ -6544,7 +6558,7 @@ pub fn search_workload_with_targets<'s, Id>( (id, root) }) .collect(); - let mut space = search_cse_workload_with(cse_workload(roots), strategies); + let mut space = search_cse_workload_with(cse_workload(roots), strategies)?; // `cse_workload` preserves root order, so targets zip by position. let root_ptrs: Vec<(*const OperatorNode, AccuracyTarget)> = space .roots @@ -6634,7 +6648,7 @@ pub fn search_workload_with_targets<'s, Id>( })); } space.prepare_compositions(accuracy_model, &composition_targets); - space + Ok(space) } /// The strictest accuracy among `siblings` that read the same summary input @@ -6712,14 +6726,9 @@ fn cse_workload(roots: Vec<(Id, Rc)>) -> Vec<(Id, Rc( cse_roots: Vec<(Id, Rc)>, strategies: &[Box], -) -> CandidateLogicalASAPDAGs { - for (_, root) in &cse_roots { - assert!( - !root.contains_asap(), - "search_workload: a workload root already contains an ASAP operator \ - ({}); replacement search takes the front end's pre-ASAP DAG only", - root.operator.kind_name() - ); +) -> Result, SearchError> { + if let Some((_, root)) = cse_roots.iter().find(|(_, root)| root.contains_asap()) { + return Err(SearchError::AsapInput(root.operator.kind_name())); } let mut order = Vec::new(); let mut nodes = HashMap::new(); @@ -6859,12 +6868,12 @@ fn search_cse_workload_with<'s, Id>( add_effective_count_cse_candidates(&order, &mut groups); - CandidateLogicalASAPDAGs { + Ok(CandidateLogicalASAPDAGs { roots: cse_roots, groups, order, composition_plans: Vec::new(), - } + }) } /// Materialize share/recompute alternatives for descendants whose raw edge @@ -7030,6 +7039,29 @@ mod tests { use asap_types::types::AccuracyTarget; use std::collections::HashMap; + /// Search takes the front end's pre-ASAP DAG: a root that already holds + /// an ASAP operator is an error, not a panic. + #[test] + fn search_rejects_a_root_with_an_asap_operator() { + let root = lower_promql( + "quantile_over_time(0.99, m[1m])", + AccuracyTarget::Epsilon(0.01), + ); + let space = search_workload(vec![("q", root)]).unwrap(); + let summary = space + .target_subdag_candidates() + .flat_map(|group| &group.candidates) + .find_map(|candidate| match &candidate.replacement { + Replacement::SubDAG(node) if node.contains_asap() => Some(Rc::clone(node)), + _ => None, + }) + .expect("a summary candidate"); + assert!(matches!( + search_workload(vec![("q", summary)]), + Err(SearchError::AsapInput(_)) + )); + } + // Candidate shape without execution timing: what is computed, not where. fn timing_free_shape(node: &Rc) -> serde_json::Value { fn strip(value: &mut serde_json::Value) { @@ -7059,6 +7091,7 @@ mod tests { ] { let root = lower_promql(query, accuracy); let inventory = search_workload(vec![(0usize, root)]) + .unwrap() .enumerate_candidate_dags(4096) .unwrap(); let shapes = inventory @@ -7078,6 +7111,7 @@ mod tests { fn grouped_rate_sum_inventory_keeps_sum_state_for_lifecycle_placement() { let root = lower_promql("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact); let inventory = search_workload(vec![(0usize, root)]) + .unwrap() .enumerate_candidate_dags(4096) .unwrap(); let is_exact = |node: &OperatorNode, kind: ExactKind| { @@ -7106,7 +7140,7 @@ mod tests { "sum_over_time(m[1m])", ] { let root = lower_promql(query, AccuracyTarget::Exact); - let space = search_workload(vec![(0usize, root.clone())]); + let space = search_workload(vec![(0usize, root.clone())]).unwrap(); let inventory = space.enumerate_candidate_dags(4096).unwrap(); assert!(!inventory.candidates.is_empty()); let strategy = ASAPStrategies::new(&DefaultCostModel); @@ -7149,7 +7183,7 @@ mod tests { #[test] fn unpriced_inventory_retains_quantile_families_and_raw_execution() { let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); - let space = search_workload(vec![(0usize, query)]); + let space = search_workload(vec![(0usize, query)]).unwrap(); let inventory = space.enumerate_candidate_dags(4096).unwrap(); let roots = inventory .candidates @@ -7179,7 +7213,7 @@ mod tests { ) }) .collect(); - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); assert!(space.enumerate_candidate_dags(4096).is_err()); for id in 0..24 { let inventory = space.enumerate_candidate_dags_for_root(&id, 4096).unwrap(); @@ -7218,7 +7252,7 @@ mod tests { ) }) .collect(); - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); let full = space.enumerate_candidate_dags(4096).unwrap(); for id in 0..2 { let inventory = space.enumerate_candidate_dags_for_root(&id, 4096).unwrap(); @@ -7237,7 +7271,7 @@ mod tests { #[test] fn inventory_budget_never_returns_a_silent_partial_search() { let query = agg(vec![2], default_quantile(0.9), metric_scan(&["job"])); - let space = search_workload(vec![(0usize, query)]); + let space = search_workload(vec![(0usize, query)]).unwrap(); assert!(space.enumerate_candidate_dags(0).is_err()); } @@ -7449,7 +7483,7 @@ mod tests { false, ), ] { - let space = search_workload(vec![(0usize, Rc::clone(&root))]); + let space = search_workload(vec![(0usize, Rc::clone(&root))]).unwrap(); let assembled = space .global_selection(&DefaultCostModel) .assemble_selected_query(&space.roots[0].1) @@ -8272,7 +8306,8 @@ mod tests { let space = search_workload_with( vec![("q", Rc::clone(&outer))], &default_strategies_with(&PreferDDSketchViaCostModel), - ); + ) + .unwrap(); let Some(NonASAPOp::Aggregate { child, .. }) = space.roots[0].1.non_asap() else { unreachable!() }; @@ -8442,7 +8477,7 @@ mod tests { }, }; let root = agg(vec![2], intent, metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); // One group for the Aggregate, one for its Scan child. assert_eq!(space.len(), 2); @@ -8516,7 +8551,7 @@ mod tests { #[test] fn cardinality_group_keeps_all_four_candidates() { let root = agg(vec![2], default_cardinality(), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let agg_group = space .target_subdag_candidates() .find(|g| matches!(g.target.non_asap(), Some(NonASAPOp::Aggregate { .. }))) @@ -8540,7 +8575,8 @@ mod tests { )], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let target = &targeted.roots[0].1; assert!(targeted .candidates_for_target(target) @@ -8567,7 +8603,8 @@ mod tests { )], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); assert!(exact_target .candidates_for_target(&exact_target.roots[0].1) .unwrap() @@ -8585,7 +8622,7 @@ mod tests { // SharedSubDAGStrategy's share-vs-recompute pair. let a = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let b = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); - let space = search_workload(vec![("a", a), ("b", b)]); + let space = search_workload(vec![("a", a), ("b", b)]).unwrap(); // roots[0] and roots[1] must have merged onto the same Rc. assert!(Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); @@ -8653,7 +8690,7 @@ mod tests { })) .unwrap(); - let space = search_workload(vec![("a", root_a), ("b", root_b)]); + let space = search_workload(vec![("a", root_a), ("b", root_b)]).unwrap(); assert_eq!( space.len(), 4, @@ -8804,7 +8841,7 @@ mod tests { for i in 0..20 { roots.push((i, Rc::new((*shared).clone()))); } - let space = search_workload(roots); + let space = search_workload(roots).unwrap(); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); assert_eq!(group.consumer_count, 20); @@ -8853,7 +8890,7 @@ mod tests { } let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let ranked = space.cost_sorted(&PreferDDSketch); let agg_group = ranked .iter() @@ -8895,7 +8932,8 @@ mod tests { }; let root = agg(vec![2, 3], intent, metric_scan(&["tenant_id", "endpoint"])); let strategies = default_strategies_with(&model); - let space = search_workload_with(vec![("tenant_endpoint_count", root)], &strategies); + let space = + search_workload_with(vec![("tenant_endpoint_count", root)], &strategies).unwrap(); let ranked = space.cost_sorted(&model); let aggregate = ranked .iter() @@ -8926,7 +8964,7 @@ mod tests { #[test] fn cost_sorted_pairs_each_candidate_with_its_own_estimate_cost() { let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let ranked = space.cost_sorted(&DefaultCostModel); let agg_group = ranked .iter() @@ -9010,7 +9048,8 @@ mod tests { } let aggregate = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("left", Rc::clone(&aggregate)), ("right", aggregate)]); + let space = + search_workload(vec![("left", Rc::clone(&aggregate)), ("right", aggregate)]).unwrap(); let root = &space.roots[0].1; assert!(cse_candidate_pair(space.candidates_for_target(root).unwrap()).is_some()); let selected = space.global_selection(&MixedCost); @@ -9025,7 +9064,7 @@ mod tests { // candidate must be cost_sorted's top pick, for both the sketch // group and its child Scan. let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let ranked = space.cost_sorted(&DefaultCostModel); let selected = space.global_selection(&DefaultCostModel); @@ -9052,7 +9091,7 @@ mod tests { // gets a group with an empty candidate list (see TargetSubDAGCandidates's own // doc) — global_selection must not invent a candidate for it. let root = metric_scan(&["job"]); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let selected = space.global_selection(&DefaultCostModel); let scan_group = selected .target_selections() @@ -9090,7 +9129,7 @@ mod tests { } let root = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let selected = space.global_selection(&PreferDDSketch); let agg_group = selected .target_selections() @@ -9218,7 +9257,7 @@ mod tests { .unwrap() }; - let space = search_workload(vec![("root1", a()), ("root2", a()), ("root3", c())]); + let space = search_workload(vec![("root1", a()), ("root2", a()), ("root3", c())]).unwrap(); // Fixture sanity: root1/root2 merged onto one shared `a`, and `c` // (root1/root2's shared child, and root3 itself) merged onto one @@ -9341,7 +9380,8 @@ mod tests { let space = search_workload(vec![ ("left", Rc::clone(&shared)), ("right", Rc::clone(&shared)), - ]); + ]) + .unwrap(); let planned = &space.roots[0].1; let selected = space.global_selection(&CompletePlanCost); @@ -9367,7 +9407,7 @@ mod tests { })) .unwrap() }; - let space = search_workload(vec![("root1", a()), ("root2", a())]); + let space = search_workload(vec![("root1", a()), ("root2", a())]).unwrap(); let a_rc = &space.roots[0].1; let Some(NonASAPOp::Filter { child: c_rc, .. }) = a_rc.non_asap() else { panic!("expected Filter root"); @@ -9420,7 +9460,7 @@ mod tests { })) .unwrap() }; - let space = search_workload(vec![("root1", parent()), ("root2", parent())]); + let space = search_workload(vec![("root1", parent()), ("root2", parent())]).unwrap(); let parent_rc = &space.roots[0].1; let Some(NonASAPOp::Filter { child: child_rc, .. @@ -9482,7 +9522,7 @@ mod tests { })) .unwrap(); let strategies: Vec> = vec![Box::new(ReplaceFilterChild)]; - let space = search_workload_with(vec![("q", root)], &strategies); + let space = search_workload_with(vec![("q", root)], &strategies).unwrap(); let root = &space.roots[0].1; let selected = space.global_selection(&DefaultCostModel); let Replacement::SubDAG(rewrite) = &selected @@ -9545,7 +9585,7 @@ mod tests { } } let root = lower_promql("sum_over_time(a[1m])", AccuracyTarget::Exact); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let selected = space.global_selection(&Unsupported); assert!(selected .for_target(&space.roots[0].1) @@ -9593,7 +9633,7 @@ mod tests { )) } } - let space = search_workload(vec![("q", root.clone())]); + let space = search_workload(vec![("q", root.clone())]).unwrap(); let selected = space.global_selection(&PreferComposed); let node = selected.assemble_target(&space.roots[0].1).unwrap(); assert!(matches!(&node.operator, @@ -9628,7 +9668,7 @@ mod tests { } } let root = lower_promql("sum by(job)(sum_over_time(a[1m]))", AccuracyTarget::Exact); - let space = search_workload(vec![("q", root)]); + let space = search_workload(vec![("q", root)]).unwrap(); let selection = space.global_selection(&ExplicitCosts); let selected = selection .for_target(&space.roots[0].1) @@ -9665,7 +9705,7 @@ mod tests { let a = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); let b = agg(vec![2], AggIntent::Avg { col: None }, metric_scan(&["job"])); - let space = search_workload(vec![("a", a), ("b", b)]); + let space = search_workload(vec![("a", a), ("b", b)]).unwrap(); let root = &space.roots[0].1; let selected = space.global_selection(&PreferLogicalRewrite); @@ -9765,7 +9805,7 @@ mod tests { // module docs — so this always converges in exactly 2 passes). let a = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); let b = agg(vec![2], default_quantile(0.99), metric_scan(&["job"])); - let space = search_workload(vec![("a", a), ("b", b)]); + let space = search_workload(vec![("a", a), ("b", b)]).unwrap(); assert!(!space.is_empty()); } @@ -9821,7 +9861,7 @@ mod tests { let strategies: Vec> = vec![Box::new(AlwaysGrowingStrategy { next: std::cell::Cell::new(0), })]; - let _ = search_workload_with(vec![("q", root)], &strategies); + let _ = search_workload_with(vec![("q", root)], &strategies).unwrap(); } // ── realize_child / retain_exact: end-to-end single-target realization ── // @@ -11023,7 +11063,7 @@ mod tests { &RankAdditiveModel, &EqualSplitAllocator, ))]; - let space = search_workload_with(vec![("q", Rc::clone(&outer))], &strategies); + let space = search_workload_with(vec![("q", Rc::clone(&outer))], &strategies).unwrap(); let root = &space.roots[0].1; let group = space.candidates_for_target(root).unwrap(); assert!(!group.rejected.is_empty()); @@ -11067,7 +11107,8 @@ mod tests { vec![("q", Rc::clone(&q), Some(AccuracyTarget::Epsilon(0.001)))], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let root = &space.roots[0].1; let group = space.candidates_for_target(root).unwrap(); assert!(group @@ -11087,7 +11128,8 @@ mod tests { vec![("q", Rc::clone(&q), Some(AccuracyTarget::Epsilon(0.01)))], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); assert!(group .candidates @@ -11099,7 +11141,8 @@ mod tests { vec![("q", Rc::clone(&q), Some(AccuracyTarget::Exact))], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); assert!(group.candidates.iter().all(|c| match &c.replacement { Replacement::SubDAG(node) if node.contains_asap() => node @@ -11132,7 +11175,8 @@ mod tests { vec![("q", Rc::clone(&q), Some(AccuracyTarget::Epsilon(0.01)))], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let group = space.candidates_for_target(&space.roots[0].1).unwrap(); assert!(group.candidates.iter().any(|candidate| matches!( @@ -11295,7 +11339,8 @@ mod tests { vec![("q", root.clone(), Some(AccuracyTarget::Exact))], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let selected = space.global_selection(&DefaultCostModel); // CSE re-interns the workload, so the space's root/child `Rc`s are not // the fixture's. Assembly only assembles children that are discovered diff --git a/crates/asap-aware-mapping/src/rewrite.rs b/crates/asap-aware-mapping/src/rewrite.rs index ca752a971..999529364 100644 --- a/crates/asap-aware-mapping/src/rewrite.rs +++ b/crates/asap-aware-mapping/src/rewrite.rs @@ -690,7 +690,7 @@ mod tests { #[test] fn default_search_discovers_bindable_sum_and_count_targets() { let root = avg_agg(vec![2], None, metric_scan(&["job"])); - let space = crate::replacement::search_workload(vec![("avg", Rc::clone(&root))]); + let space = crate::replacement::search_workload(vec![("avg", Rc::clone(&root))]).unwrap(); let avg_group = space .candidates_for_target(&space.roots[0].1) @@ -908,7 +908,7 @@ mod tests { accuracy: AccuracyTarget::Exact, }, ); - let space = crate::replacement::search_workload(vec![("sum-count", root)]); + let space = crate::replacement::search_workload(vec![("sum-count", root)]).unwrap(); let root = &space.roots[0].1; let group = space.candidates_for_target(root).expect("root memo group"); let candidate = group diff --git a/crates/asap-aware-mapping/src/rollup.rs b/crates/asap-aware-mapping/src/rollup.rs index de96383c5..b39dab6ca 100644 --- a/crates/asap-aware-mapping/src/rollup.rs +++ b/crates/asap-aware-mapping/src/rollup.rs @@ -683,7 +683,8 @@ mod tests { let fine = agg(vec![2, 3], AggIntent::Sum { col: Some(1) }, &fine_scan); let coarse = agg(vec![2], AggIntent::Sum { col: Some(1) }, &coarse_scan); - let space = crate::replacement::search_workload(vec![("fine", fine), ("coarse", coarse)]); + let space = + crate::replacement::search_workload(vec![("fine", fine), ("coarse", coarse)]).unwrap(); let coarse_group = space .target_subdag_candidates() .find(|group| { @@ -723,7 +724,8 @@ mod tests { }; let fine = agg(vec![2, 3], intent.clone(), &metric_scan()); let coarse = agg(vec![2], intent, &metric_scan()); - let space = crate::replacement::search_workload(vec![("fine", fine), ("coarse", coarse)]); + let space = + crate::replacement::search_workload(vec![("fine", fine), ("coarse", coarse)]).unwrap(); let coarse_group = space .target_subdag_candidates() .find(|group| { diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs index a7f386a8b..4eb41339a 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs @@ -1404,7 +1404,7 @@ mod tests { #[test] fn global_selection_compares_streaming_summary_and_raw_over_one_horizon() { let target = streaming_sum_query(); - let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]); + let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]).unwrap(); let workload = streaming_workload(); let mut model = streaming_model(); for group in space.target_subdag_candidates() { diff --git a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs index 12f38694b..3c0836812 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs @@ -1823,7 +1823,7 @@ mod tests { #[test] fn rejects_invalid_parallel_data_evidence() { let query = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let space = crate::replacement::search_workload(vec![("q", quantile_query())]); + let space = crate::replacement::search_workload(vec![("q", quantile_query())]).unwrap(); for rate in [1.0, -1.0, f64::NAN, f64::INFINITY] { let mut data = at_rest(); data.ingestion_rate.value = Some(Rate(rate)); @@ -2655,7 +2655,7 @@ mod tests { #[test] fn lifecycle_cost_can_fall_back_to_raw_recomputation() { let target = sum_query(); - let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]); + let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]).unwrap(); let selection = space.global_selection(&RawCheaper); let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( @@ -2785,7 +2785,7 @@ mod tests { #[test] fn materialization_falls_back_to_raw_when_raw_cost_is_unavailable() { let target = quantile_query(); - let space = crate::replacement::search_workload(vec![("q", target)]); + let space = crate::replacement::search_workload(vec![("q", target)]).unwrap(); let selection = space.global_selection(&UnitCosts); let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( @@ -2811,7 +2811,7 @@ mod tests { #[test] fn unmatched_target_is_reported_as_raw_recomputation() { let target = query_root(); - let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]); + let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]).unwrap(); let selection = space.global_selection(&RawCheaper); let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( @@ -2837,7 +2837,7 @@ mod tests { #[test] fn lifecycle_cost_reorders_semantic_summary_candidates_before_materialization() { let target = quantile_query(); - let space = crate::replacement::search_workload(vec![("q", target)]); + let space = crate::replacement::search_workload(vec![("q", target)]).unwrap(); let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); let selection = global_selection_with_summary_maintenance_lifecycles( @@ -3001,8 +3001,9 @@ mod tests { let space = crate::replacement::search_workload(vec![ ("p50", quantile(0.5)), ("p99", quantile(0.99)), - ]); - let alone = crate::replacement::search_workload(vec![("p99", quantile(0.99))]); + ]) + .unwrap(); + let alone = crate::replacement::search_workload(vec![("p99", quantile(0.99))]).unwrap(); assert_eq!(summaries(&space, &[0, 1]), vec![false, false]); assert_eq!(summaries(&alone, &[1]), vec![false]); } @@ -3010,7 +3011,8 @@ mod tests { #[test] fn normalized_workload_drives_candidate_logical_asap_dags_recurrence_profiles() { let root = query_root(); - let space = crate::replacement::search_workload(vec![("dashboard", Rc::clone(&root))]); + let space = + crate::replacement::search_workload(vec![("dashboard", Rc::clone(&root))]).unwrap(); let workload = workload(vec![], vec![repeating()], continuous(1_000, 60_000)); let profiles = space .recurrence_profiles_from_workload( @@ -3036,7 +3038,8 @@ mod tests { let space = crate::replacement::search_workload(vec![ ("dashboard", repeating_root), ("batch", batch_root), - ]); + ]) + .unwrap(); let workload = workload( vec![batch(Predictability::AdHoc)], vec![repeating()], diff --git a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs index f90b1416a..0798e4d68 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs @@ -364,7 +364,7 @@ mod tests { .unwrap() .remove(0); let root = promql_rows::with_series_identity(&root).unwrap(); - let space = asap_aware_mapping::search_workload(vec![("q", root)]); + let space = asap_aware_mapping::search_workload(vec![("q", root)]).unwrap(); let selected = space .global_selection(&asap_aware_mapping::cost_model::DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) diff --git a/crates/asap-physical-operators/tests/deployment_computation.rs b/crates/asap-physical-operators/tests/deployment_computation.rs index ff049f4fa..f9a8b4d82 100644 --- a/crates/asap-physical-operators/tests/deployment_computation.rs +++ b/crates/asap-physical-operators/tests/deployment_computation.rs @@ -53,7 +53,7 @@ fn lower_with(query: &str, accuracy: AccuracyTarget) -> Rc PhysicalASAPDAG { let expression = lower(query); let root = promql_rows::with_series_identity(&expression).unwrap_or(expression); - let space = asap_aware_mapping::search_workload(vec![("q", root)]); + let space = asap_aware_mapping::search_workload(vec![("q", root)]).unwrap(); let selected = space .global_selection(&asap_aware_mapping::DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) diff --git a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs index 467b7c0c1..6c0f25d6c 100644 --- a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs +++ b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs @@ -119,6 +119,7 @@ fn inventories(query: &str, accuracy: AccuracyTarget) -> (Vec, Vec .collect(); let enumerate = |strategies: &[Box]| { search_workload_with_targets(roots.clone(), strategies, &DefaultAccuracyModel) + .unwrap() .enumerate_candidate_dags_for_root(&1, 65_536) .unwrap() .candidates @@ -218,7 +219,8 @@ fn global_selection_never_commits_a_series_identity_heap() { vec![(0, root, Some(accuracy))], &strategies, &DefaultAccuracyModel, - ); + ) + .unwrap(); let selected = space .global_selection(&DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) @@ -242,7 +244,8 @@ fn repeated_roots_do_not_duplicate_alternatives() { ) }) .collect(); - let space = search_workload_with_targets(roots, &strategies, &DefaultAccuracyModel); + let space = + search_workload_with_targets(roots, &strategies, &DefaultAccuracyModel).unwrap(); space .candidates_for_target(&space.roots[0].1) .unwrap() diff --git a/crates/asap-physical-operators/tests/precompute_candidates.rs b/crates/asap-physical-operators/tests/precompute_candidates.rs index f3b2490ba..5472d35ec 100644 --- a/crates/asap-physical-operators/tests/precompute_candidates.rs +++ b/crates/asap-physical-operators/tests/precompute_candidates.rs @@ -51,7 +51,7 @@ fn grouped_rate_space() -> asap_aware_mapping::CandidateLogicalASAPDAGs<&'static let root = asap_physical_operators::unified_physical_planner::promql_rows::with_series_identity(&root) .unwrap(); - search_workload(vec![("grouped-rate", root)]) + search_workload(vec![("grouped-rate", root)]).unwrap() } fn grouped_rate() -> PhysicalASAPDAG { diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index d65fa67c9..268e2f60e 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -1241,7 +1241,8 @@ fn run_post_asap_with_progress( search_workload_with(roots, &strategies) } else { search_workload(roots) - }; + } + .expect("front ends emit pre-ASAP DAGs"); let selection = space.global_selection(cost_model); // A group's top candidate can be `retain_exact`'s own conservative @@ -1537,7 +1538,8 @@ async fn main() { .iter() .map(|(name, _, qe)| (name.clone(), qe.clone())) .collect(), - ); + ) + .expect("front ends emit pre-ASAP DAGs"); let mut matched = vec![false; explanations.len()]; let mut queries = Vec::new(); for (name, source, qe) in &lowered_queries { @@ -2304,7 +2306,7 @@ mod tests { fn cost_fixture() -> (Rc, ReplacementSubDAG, PlannerCostDocument) { let query = non_topk_query(); let root = Rc::clone(&query); - let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]); + let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]).unwrap(); let group = space .target_subdag_candidates() .find(|group| group.target == query) @@ -2602,7 +2604,7 @@ mod tests { fn global_selection_uses_the_cheapest_complete_physical_candidate() { let query = non_topk_query(); let root = Rc::clone(&query); - let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]); + let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]).unwrap(); let group = space .target_subdag_candidates() .find(|group| group.target == query) @@ -2836,7 +2838,8 @@ mod tests { let a = lower_promql(query, AccuracyTarget::Exact).unwrap(); let b = lower_promql(query, AccuracyTarget::Exact).unwrap(); let explanations = - asap_aware_mapping::explain_replacements(vec![("a", a.clone()), ("b", b.clone())]); + asap_aware_mapping::explain_replacements(vec![("a", a.clone()), ("b", b.clone())]) + .unwrap(); assert!(explanations .iter() .any(|e| { e.kind == asap_aware_mapping::ExplanationKind::CommonSubexpressionReuse })); @@ -2857,7 +2860,8 @@ mod tests { AccuracyTarget::Epsilon(0.01), ) .unwrap(); - let explanations = asap_aware_mapping::explain_replacements(vec![("target", target)]); + let explanations = + asap_aware_mapping::explain_replacements(vec![("target", target)]).unwrap(); let explanation = explanations .iter() .find(|e| e.kind == asap_aware_mapping::ExplanationKind::SketchApproximation) diff --git a/crates/devtools/src/bin/sketch_coverage.rs b/crates/devtools/src/bin/sketch_coverage.rs index b91afdbfd..54dffc17b 100644 --- a/crates/devtools/src/bin/sketch_coverage.rs +++ b/crates/devtools/src/bin/sketch_coverage.rs @@ -163,7 +163,7 @@ fn analyze_corpus( ) -> CorpusCoverage { let lowered = roots.len(); let labels: Vec = roots.iter().map(|(id, _)| root_label(id)).collect(); - let explanations = explain_replacements(roots); + let explanations = explain_replacements(roots).expect("front ends emit pre-ASAP DAGs"); let mut sketch_covered: BTreeSet = BTreeSet::new(); let mut cse_covered: BTreeSet = BTreeSet::new(); diff --git a/crates/frontend-promql/tests/count_planning.rs b/crates/frontend-promql/tests/count_planning.rs index c5e5831f1..b401d1e5c 100644 --- a/crates/frontend-promql/tests/count_planning.rs +++ b/crates/frontend-promql/tests/count_planning.rs @@ -27,7 +27,8 @@ fn grouped_count_keeps_uncertified_hydra_candidates_for_backend_review() { vec![("count", root, Some(target))], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let planned = &space.roots[0].1; let hydra: Vec<_> = space .candidates_for_target(planned) diff --git a/crates/frontend-promql/tests/univmon_candidates.rs b/crates/frontend-promql/tests/univmon_candidates.rs index 02a85ee9d..eaf12e2e2 100644 --- a/crates/frontend-promql/tests/univmon_candidates.rs +++ b/crates/frontend-promql/tests/univmon_candidates.rs @@ -159,7 +159,8 @@ fn uncalibrated_frequency_evaluations_do_not_bypass_accuracy_targets() { vec![("q", Rc::clone(&root), Some(target))], &default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); assert!(space .candidates_for_target(&space.roots[0].1) .unwrap() diff --git a/crates/integration-tests/tests/cse.rs b/crates/integration-tests/tests/cse.rs index 4551aa9c5..cebe4c95a 100644 --- a/crates/integration-tests/tests/cse.rs +++ b/crates/integration-tests/tests/cse.rs @@ -57,7 +57,7 @@ fn duplicate_workload_queries_collapse_onto_one_memo_group() { "fixture sanity: identical query text lowers identically" ); - let space = search_workload(vec![("a", a), ("b", b)]); + let space = search_workload(vec![("a", a), ("b", b)]).unwrap(); // roots[0] and roots[1] must have merged onto the same Rc — the // `share_common_sub_dags` pass `search_workload` runs internally. @@ -125,7 +125,7 @@ fn distinct_workload_queries_get_independent_memo_groups() { .expect("query b failed to lower"); assert_ne!(a, b, "fixture sanity: the two queries differ"); - let space = search_workload(vec![("a", a), ("b", b)]); + let space = search_workload(vec![("a", a), ("b", b)]).unwrap(); assert!(!Rc::ptr_eq(&space.roots[0].1, &space.roots[1].1)); let group_a = space @@ -152,7 +152,7 @@ fn single_query_repeated_subexpression_shares_one_memo_group() { let query = "sum by (job) (http_requests_total) / sum by (job) (http_requests_total)"; let expr = lower_promql(query, AccuracyTarget::Exact).expect("query failed to lower"); - let space = search_workload(vec![("q", expr)]); + let space = search_workload(vec![("q", expr)]).unwrap(); let [(_, root)] = space.roots.as_slice() else { panic!("expected 1 root"); }; diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index 3193026ab..4a8df4771 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -139,7 +139,8 @@ fn custom_accuracy_rule_survives_root_target_and_materialization() { vec![("q", root, Some(AccuracyTarget::Exact))], &default_strategies_with(&StatsModel), &Model, - ); + ) + .unwrap(); let selection = space.global_selection(&StatsModel); assert!(selection .for_target(&space.roots[0].1) @@ -163,7 +164,8 @@ fn root_target_rejects_unproven_composition() { vec![("q", root, Some(AccuracyTarget::Exact))], &default_strategies_with(&StatsModel), &asap_aware_mapping::DefaultAccuracyModel, - ); + ) + .unwrap(); let selection = space.global_selection(&StatsModel); assert!(selection .for_target(&space.roots[0].1) @@ -291,7 +293,7 @@ fn plan( roots: Vec<(&'static str, Rc)>, cost_model: &dyn CostModel, ) -> asap_aware_mapping::CandidateLogicalASAPDAGs<&'static str> { - search_workload_with(roots, &default_strategies_with(cost_model)) + search_workload_with(roots, &default_strategies_with(cost_model)).unwrap() } fn is_plain(node: &OperatorNode) -> bool { @@ -772,7 +774,8 @@ fn missing_cost_statistics_preserve_the_conservative_retain_exact() { let node = selection.assemble_selected_dag(&root).unwrap().unwrap(); assert!(!is_query_time_fold(&node)); - let explanations = asap_aware_mapping::explain_replacements(vec![("q", Rc::clone(&root))]); + let explanations = + asap_aware_mapping::explain_replacements(vec![("q", Rc::clone(&root))]).unwrap(); assert!(explanations .iter() .any(|e| e.kind == ExplanationKind::ExactComposition)); diff --git a/crates/integration-tests/tests/operator_sharing.rs b/crates/integration-tests/tests/operator_sharing.rs index 2bce6841f..fa9d33f0e 100644 --- a/crates/integration-tests/tests/operator_sharing.rs +++ b/crates/integration-tests/tests/operator_sharing.rs @@ -52,7 +52,7 @@ async fn plan(sql: &str, accuracy: AccuracyTarget) -> Rc { let pre = lower_sql(sql, &catalog(), accuracy) .await .unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}")); - let space = search_workload(vec![("query", pre)]); + let space = search_workload(vec![("query", pre)]).unwrap(); let selection = space.global_selection(&DefaultCostModel); selection .assemble_selected_dag(&space.roots[0].1) diff --git a/crates/integration-tests/tests/precompute_raw_samples.rs b/crates/integration-tests/tests/precompute_raw_samples.rs index daf841142..4dd7ca00c 100644 --- a/crates/integration-tests/tests/precompute_raw_samples.rs +++ b/crates/integration-tests/tests/precompute_raw_samples.rs @@ -68,7 +68,7 @@ fn candidates(query: &str, accuracy: AccuracyTarget) -> Vec> { _ => None, }) .collect::>(); - let space = search_workload(vec![("query", root)]); + let space = search_workload(vec![("query", root)]).unwrap(); if let Ok(Some(selected)) = space .global_selection(&DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index f41667686..3bbc58c0e 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -85,7 +85,7 @@ fn distinct_over_time_offers_hll_cardinality_evaluation() { fn lower_search_and_materialize(query: &str) -> Rc { let pre = lower_promql(query, AccuracyTarget::Exact).expect("lowering failed"); - let space = search_workload(vec![("query", pre)]); + let space = search_workload(vec![("query", pre)]).unwrap(); let selection = space.global_selection(&DefaultCostModel); selection .assemble_selected_dag(&space.roots[0].1) @@ -579,7 +579,8 @@ fn ddsketch_quantile_ratio_meets_the_shared_relative_error_target() { &evidence, ), &DefaultAccuracyModel, - ); + ) + .unwrap(); let root = &space.roots[0].1; let selected = space.global_selection(&DefaultCostModel); let chosen = selected @@ -1007,7 +1008,7 @@ fn promql_sum_of_count_over_time_is_composed_by_default_search() { ) .expect("lowering failed"); let original_schema = original.schema.clone(); - let space = search_workload(vec![("query", original)]); + let space = search_workload(vec![("query", original)]).unwrap(); let root = &space.roots[0].1; let group = space.candidates_for_target(root).expect("root memo group"); let candidate = group @@ -1056,7 +1057,7 @@ fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { AccuracyTarget::Epsilon(0.05), ) .unwrap(); - let space = search_workload(vec![("query", pre)]); + let space = search_workload(vec![("query", pre)]).unwrap(); let selected = space.global_selection(&DefaultCostModel); let plan = selected .assemble_selected_dag(&space.roots[0].1) @@ -1116,7 +1117,7 @@ fn physical_node_owns_phase_independently_of_binary_payload() { ), ] { let input = lower_promql(query, AccuracyTarget::Epsilon(0.05)).unwrap(); - let search = search_workload(vec![("q", input)]); + let search = search_workload(vec![("q", input)]).unwrap(); let choice = search.global_selection(&DefaultCostModel); let plan = choice .assemble_selected_dag(&search.roots[0].1) @@ -1159,7 +1160,8 @@ fn ddsketch_ratio_without_domain_proof_is_uncertified() { vec![("unproven", pre, Some(AccuracyTarget::Epsilon(0.01)))], &asap_aware_mapping::default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let root_group = space .target_subdag_candidates() .find(|group| Rc::ptr_eq(&group.target, &space.roots[0].1)) @@ -1391,7 +1393,8 @@ fn without_aggregation_candidates_export_valid_dags() { vec![(0, root, Some(accuracy.clone()))], &asap_aware_mapping::default_strategies(), &DefaultAccuracyModel, - ); + ) + .unwrap(); let inventory = space.enumerate_candidate_dags_for_root(&0, 65_536).unwrap(); assert!(!inventory.candidates.is_empty(), "{query}"); for (_, node) in inventory.candidates.iter().flatten() { diff --git a/crates/integration-tests/tests/sql_to_physical.rs b/crates/integration-tests/tests/sql_to_physical.rs index c8457e85a..4f3215bcd 100644 --- a/crates/integration-tests/tests/sql_to_physical.rs +++ b/crates/integration-tests/tests/sql_to_physical.rs @@ -36,7 +36,7 @@ async fn sql_filter_grouped_sum_executes_and_rebinds() { let logical = lower_sql(query, &catalog, AccuracyTarget::Exact) .await .unwrap(); - let space = search_workload(vec![("sql", logical)]); + let space = search_workload(vec![("sql", logical)]).unwrap(); let selected = space .global_selection(&DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) diff --git a/crates/integration-tests/tests/sql_to_post_asap.rs b/crates/integration-tests/tests/sql_to_post_asap.rs index d01dfae59..651b6f4e5 100644 --- a/crates/integration-tests/tests/sql_to_post_asap.rs +++ b/crates/integration-tests/tests/sql_to_post_asap.rs @@ -194,7 +194,7 @@ async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { ) .await .expect("nested temporal SQL must lower"); - let space = search_workload(vec![("nested", Rc::clone(&pre_asap))]); + let space = search_workload(vec![("nested", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) @@ -255,7 +255,7 @@ async fn sql_full_query_retains_project_and_binds_inner_aggregate() { else { panic!("sanity: a SQL root is a Project, unlike lower_promql's bare Aggregate"); }; - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); + let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) @@ -301,7 +301,7 @@ async fn sql_join_recursively_binds_both_temporal_aggregate_children() { ) .await .expect("two-subquery rate ratio must lower"); - let space = search_workload(vec![("ratio", Rc::clone(&pre_asap))]); + let space = search_workload(vec![("ratio", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) @@ -417,7 +417,7 @@ async fn unsupported_sql_join_shapes_remain_fail_closed() { ) .await .unwrap_or_else(|error| panic!("join must lower before fail-closed mapping: {error}")); - let space = search_workload(vec![("unsupported-join", Rc::clone(&pre_asap))]); + let space = search_workload(vec![("unsupported-join", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) @@ -446,7 +446,7 @@ async fn sql_relational_parents_retain_summary_bound_aggregate() { AccuracyTarget::Epsilon(0.01), ) .await; - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); + let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) @@ -537,7 +537,7 @@ async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { }; assert_eq!(expected_source_predicates.len(), 1, "fixture source WHERE"); - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); + let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) @@ -593,7 +593,7 @@ async fn sql_filter_preserves_local_fallback_boundary_for_unsupported_child() { AccuracyTarget::Exact, ) .await; - let space = search_workload(vec![("query", Rc::clone(&pre_asap))]); + let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection .assemble_selected_dag(&space.roots[0].1) @@ -828,7 +828,7 @@ async fn map_projection_export_preserves_unsupported_child_boundary() { "SELECT map('job', t.service) AS labels, t.avg_bytes FROM (SELECT service, AVG(bytes) AS avg_bytes FROM metrics GROUP BY service) t WHERE t.avg_bytes > 100", &catalog(), SqlDialect::ClickhouseSQL, AccuracyTarget::Exact, ).await.unwrap(); - let space = search_workload(vec![("map_query", pre)]); + let space = search_workload(vec![("map_query", pre)]).unwrap(); let root = space .global_selection(&DefaultCostModel) .assemble_selected_dag(&space.roots[0].1) diff --git a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs b/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs index eb7594480..8d24c3d23 100644 --- a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs +++ b/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs @@ -227,7 +227,7 @@ fn selected_plan_for_lowered( ) -> asap_aware_mapping::SummaryMaintenanceLifecyclePlan { let root = lowered; let strategies = asap_aware_mapping::default_strategies_with(model); - let space = search_workload_with(vec![("dashboard", Rc::clone(&root))], &strategies); + let space = search_workload_with(vec![("dashboard", Rc::clone(&root))], &strategies).unwrap(); let target = Rc::clone(&space.roots[0].1); let capabilities = SummaryMaintenanceLifecycleCapabilities { supports_ephemeral: true, @@ -863,6 +863,7 @@ fn grouped_rate_sum_placement_is_a_lifecycle_choice() { }) if *k == kind) }; let inventory = asap_aware_mapping::search_workload(vec![("q", root)]) + .unwrap() .enumerate_candidate_dags(4096) .unwrap(); let candidates = inventory diff --git a/crates/planner/tests/e2e_plan.rs b/crates/planner/tests/e2e_plan.rs index b55077289..740f9d75d 100644 --- a/crates/planner/tests/e2e_plan.rs +++ b/crates/planner/tests/e2e_plan.rs @@ -139,7 +139,7 @@ async fn builtin_cost_model_cannot_price_lifecycles_and_falls_back_to_raw_recomp roots.push((index, expr, Some(accuracy))); } let strategies = default_strategies_with_evidence(models.cost, models.evidence); - let space = search_workload_with_targets(roots, &strategies, models.accuracy); + let space = search_workload_with_targets(roots, &strategies, models.accuracy).unwrap(); let selection = space.global_selection(models.cost); assert_eq!(output.plans.len(), space.roots.len()); diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index a3825eb4b..089809829 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -513,7 +513,7 @@ fn certified_frequency_evaluations_share_one_univmon_state() { &UnivMonEvidence, &EqualSplitAllocator, ))]; - let space = search_workload_with_targets(roots, &strategies, &UnivMonEvidence); + let space = search_workload_with_targets(roots, &strategies, &UnivMonEvidence).unwrap(); let entry_indices: Vec = (0..queries.len()).collect(); let selection = global_selection_with_summary_maintenance_lifecycles( &space, From 1d6bb7ccee2875381c6657c2fbecd5ef10cd93a1 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:42:56 +0000 Subject: [PATCH 09/18] fix(planner): walk operator nodes read by scalar expressions The explanation report located only relational children, so a target read through `scalar(...)` in a predicate or projection had no breadcrumb; it now visits every operator input, as target discovery does. `frontier_from_timing` also puts an ingestion-time node a standalone scalar root reads on the frontier, like an operator root. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/explanation.rs | 60 +++++++++++++++---- .../unified_physical_planner/candidates.rs | 27 ++++++++- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/crates/asap-aware-mapping/src/explanation.rs b/crates/asap-aware-mapping/src/explanation.rs index ab343915e..810c7c3e7 100644 --- a/crates/asap-aware-mapping/src/explanation.rs +++ b/crates/asap-aware-mapping/src/explanation.rs @@ -458,9 +458,9 @@ fn visit( visit_children(node, &label, locations); } -/// `node`'s own **relational-skeleton** operator children — the same scope -/// `crate::replacement`'s own target-discovery `walk_children` (and -/// `asap_types::ir::cse::share_common_sub_dags`) use. Exhaustive over every +/// `node`'s operator inputs, including the operator nodes its scalar +/// expressions read — the same scope `crate::replacement`'s own +/// target-discovery `walk_children` uses. Exhaustive over every /// `NonASAPOp` variant: a new variant fails to compile here until this match /// is extended too. An ASAP node never occurs in a workload root. fn visit_children( @@ -472,10 +472,11 @@ fn visit_children( let Operator::NonASAP(op) = &node.operator else { return; }; + let mut inputs: Vec<(&Rc, String)> = Vec::new(); match op { Scan { .. } | Values { .. } => {} PromqlVectorFromScalar(ScalarExpr::PromqlScalarFromVector(c)) => { - visit(c, format!("{label} > child"), locations) + inputs.push((c, format!("{label} > child"))) } PromqlVectorFromScalar(_) => {} PromqlRelabel { child, .. } @@ -490,21 +491,31 @@ fn visit_children( | TimeShift { child, .. } | SQLWindowFunc { child, .. } | Sort { child, .. } - | Limit { child, .. } => visit(child, format!("{label} > child"), locations), + | Limit { child, .. } => inputs.push((child, format!("{label} > child"))), Concat { children, .. } => { for (i, c) in children.iter().enumerate() { - visit(c, format!("{label} > concat[{i}]"), locations); + inputs.push((c, format!("{label} > concat[{i}]"))); } } Join { left, right, .. } | SetOp { left, right, .. } => { - visit(left, format!("{label} > left"), locations); - visit(right, format!("{label} > right"), locations); + inputs.push((left, format!("{label} > left"))); + inputs.push((right, format!("{label} > right"))); } BinaryOp { lhs, rhs, .. } => { - visit(lhs, format!("{label} > lhs"), locations); - visit(rhs, format!("{label} > rhs"), locations); + inputs.push((lhs, format!("{label} > lhs"))); + inputs.push((rhs, format!("{label} > rhs"))); } } + // Operator nodes read from this node's scalar expressions (`scalar(v)` + // in a predicate or projection, subqueries) are inputs too. + for input in node.children() { + if !inputs.iter().any(|(visited, _)| Rc::ptr_eq(visited, input)) { + inputs.push((input, format!("{label} > scalar"))); + } + } + for (input, path) in inputs { + visit(input, path, locations); + } } #[cfg(test)] @@ -585,6 +596,35 @@ mod tests { assert!(sketch[0].reason.to_lowercase().contains("kll")); } + /// A quantile read through `scalar(...)` in a predicate is located under + /// the node whose expression reads it. + #[test] + fn a_quantile_read_by_a_scalar_expression_is_located() { + use asap_types::ir::ExprSemantics; + use asap_types::pre_asap::CompareOpKind; + let q = agg(vec![], default_quantile(0.99), metric_scan(&["job"])); + let root = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Filter { + pred: Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(1)), + op: CompareOpKind::Gt, + right: Box::new(ScalarExpr::PromqlScalarFromVector(q)), + semantics: ExprSemantics::Promql, + }), + child: metric_scan(&["job"]), + })) + .unwrap(); + let findings = explain_replacements(vec![("alert", root)]).unwrap(); + let sketch = findings + .iter() + .find(|f| f.kind == ExplanationKind::SketchApproximation) + .unwrap_or_else(|| panic!("expected a sketch finding, got {findings:?}")); + assert!( + sketch.location.contains("root \"alert\" > scalar"), + "{}", + sketch.location + ); + } + /// `node_hash` must be the literal `structural_hash` a downstream /// consumer would compute over the *same* `OperatorNode` sub-DAG via /// `asap_types::dag_export::export` — the whole point of carrying it is diff --git a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs index 0798e4d68..def860bbe 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs @@ -87,7 +87,8 @@ pub fn cut_candidate( } /// Materialization frontier implied by lifecycle-assigned timing: ingestion-time -/// nodes read by a query-time node, plus the root when it is ingestion-timed. +/// nodes read by a query-time node, plus each root, and each node a scalar +/// root reads, that is ingestion-timed. /// `cut_candidate` of one [`compile`] result with this frontier realizes the /// assignment, so different assignments are different cuts of one lowering. /// That holds while timing-dependent lowering (an ingestion-time `Binary` @@ -101,7 +102,14 @@ pub fn frontier_from_timing(dag: &PhysicalASAPDAG) -> Result, Error> .map(|node| (node.id, node.output_state.timing)) .collect::>(); let mut frontier = BTreeSet::new(); - for root in &dag.roots { + // Operator roots, and the nodes standalone scalar roots read: both are + // read at query time. + let roots = dag.roots.iter().chain( + dag.scalar_roots + .iter() + .flat_map(|(_, expr)| expr.operator_refs()), + ); + for root in roots { if timing.get(root) == Some(&IngestionTime) { frontier.insert(*root as u64); } @@ -479,6 +487,21 @@ mod tests { assert_eq!(frontier_from_timing(&timed).unwrap(), [root]); } + /// A node a standalone scalar root reads is read at query time, so an + /// ingestion-time one is on the frontier like an operator root. + #[test] + fn frontier_from_timing_includes_ingestion_scalar_reference() { + use planner_types::ir::ScalarExpr; + use planner_types::post_asap::ExecutionTiming::IngestionTime; + let (dag, _, root) = grouped_rate(); + let mut timed = with_timing(&dag, |_| IngestionTime); + let read = timed.roots.remove(0); + timed + .scalar_roots + .push((0, ScalarExpr::PromqlScalarFromVector(read))); + assert_eq!(frontier_from_timing(&timed).unwrap(), [root]); + } + /// A query-time node feeding an ingestion-time node is rejected. #[test] fn frontier_from_timing_rejects_query_time_input_to_ingestion() { From a3dffa732e08dd8cf58f6e8399c2a0668f6d2861 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:49:10 +0000 Subject: [PATCH 10/18] fix(ir): do not promote a filtered count ranking to a heavy-hitter TopK The IR canonicalize pass ignored measure filters, so `COUNT(*) FILTER (WHERE ...)` or `COUNT(col)` over a nullable column ranked by ORDER BY ... LIMIT became a heap sketch over every row. It now keeps Sort + Limit for a filtered measure (the legacy #466 guard), and the keyed heap-sketch input rule refuses a filtered inner aggregate. The legacy does_not_promote_a_filtered_count_ranking test is ported to the IR and through the SQL front end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/replacement.rs | 6 +++ .../tests/unified_sql_lowering.rs | 38 +++++++++++++++++++ crates/types/src/ir/canonicalize.rs | 37 ++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index c26aa76f3..5b63cff0f 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -3584,6 +3584,7 @@ fn realize_keyed_additive_summary_input( let Some(NonASAPOp::Aggregate { reduction, measures, + filters, having: None, child: raw_child, .. @@ -3591,6 +3592,11 @@ fn realize_keyed_additive_summary_input( else { return PhysicalSummaryInputRuleResult::NotApplicable; }; + // The heap sketch consumes every raw row; a filtered measure counts only + // some of them (#466). + if asap_types::ir::non_asap::any_measure_filtered(filters) { + return PhysicalSummaryInputRuleResult::NotApplicable; + } let counter_input = matches!(measures.as_slice(), [AggIntent::Sum { .. }]) && matches!(raw_child.non_asap(), Some(NonASAPOp::Aggregate { measures, .. }) if matches!(measures.as_slice(), [AggIntent::Rate | AggIntent::Increase])); diff --git a/crates/frontend-sql/tests/unified_sql_lowering.rs b/crates/frontend-sql/tests/unified_sql_lowering.rs index dd5377d79..2a99c5660 100644 --- a/crates/frontend-sql/tests/unified_sql_lowering.rs +++ b/crates/frontend-sql/tests/unified_sql_lowering.rs @@ -345,6 +345,44 @@ async fn count_ranked_topk_via_alias_is_also_heavy_hitter() { ); } +/// A heavy-hitter sketch ranks every row; a count over only some rows +/// (#466) stays a generic Sort + Limit: an explicit `FILTER`, and the +/// `IsNotNull` filter `COUNT(col)` gets over a nullable column. +#[tokio::test] +async fn filtered_count_ranking_is_not_a_heavy_hitter() { + let catalog = SqlCatalog::new().with_table( + "metrics", + Schema::with_time_index( + vec![ + col("ts", DataType::Timestamp), + col("service", DataType::Utf8), + col("latency", DataType::Float64), + Field::plain("bytes", DataType::Int64, true), + ], + 0, + vec![vec![0, 1]], + ), + ); + for sql in [ + "SELECT service, COUNT(*) FILTER (WHERE latency > 1) AS c FROM metrics \ + GROUP BY service ORDER BY c DESC LIMIT 10", + "SELECT service, COUNT(bytes) AS c FROM metrics GROUP BY service ORDER BY c DESC LIMIT 10", + ] { + let qe = lower_sql(sql, &catalog, AccuracyTarget::Exact) + .await + .unwrap_or_else(|e| panic!("lower failed for {sql:?}: {e}")); + let (_, measures) = find_aggregate(&qe).expect("expected an Aggregate"); + assert!( + matches!(measures.as_slice(), [AggIntent::Count { .. }]), + "{sql}: the filtered Count must stay, got {measures:?}" + ); + let NonASAPOp::Aggregate { filters, .. } = op(find_aggregate_node(&qe).unwrap()) else { + unreachable!() + }; + assert!(filters[0].is_some(), "{sql}: the measure is filtered"); + } +} + #[tokio::test] async fn non_count_ranked_limit_keeps_the_aggregate() { // Ranking by AVG (not a count) must NOT become a frequency heavy-hitter — diff --git a/crates/types/src/ir/canonicalize.rs b/crates/types/src/ir/canonicalize.rs index 691ba6610..e3fdac9d9 100644 --- a/crates/types/src/ir/canonicalize.rs +++ b/crates/types/src/ir/canonicalize.rs @@ -207,6 +207,7 @@ fn try_promote_additive_top_ranking( let Some(NonASAPOp::Aggregate { reduction, measures, + filters, child: aggregate_child, .. }) = agg_node.non_asap() @@ -216,6 +217,11 @@ fn try_promote_additive_top_ranking( let Reduction::Reduce(by) = reduction else { return Ok(None); }; + // A heavy-hitter sketch ranks the raw update stream; a filtered measure + // counts only part of it, and no binding rule applies the filter (#466). + if crate::ir::non_asap::any_measure_filtered(filters) { + return Ok(None); + } let [ranked_agg] = measures.as_slice() else { return Ok(None); }; @@ -501,6 +507,37 @@ mod tests { assert!(is_topk_over_count(&canonicalize(q).unwrap())); } + // A heavy-hitter sketch ranks every row; a count that only counts some + // rows (#466) is not that, so the generic Sort + Limit stays. Covers an + // explicit `FILTER (WHERE latency > 1)` and the `IsNotNull` filter of + // `COUNT(col)` over a nullable column. + #[test] + fn does_not_promote_a_filtered_count_ranking() { + use crate::ir::Predicate; + use crate::pre_asap::{CompareOpKind, ScalarValue}; + let greater = Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(2)), + op: CompareOpKind::Gt, + right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(1.0))), + semantics: crate::ir::ExprSemantics::Sql, + }); + let not_null = Predicate(ScalarExpr::IsNotNull(Box::new(ScalarExpr::Column(2)))); + for filter in [greater, not_null] { + let filtered = node(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![1]), + measures: vec![count()], + output_names: vec![], + filters: vec![Some(filter)], + having: None, + child: scan(), + }); + let q = limit(5, 0, sort(desc(1), filtered)); + let canonical = canonicalize(Rc::clone(&q)).unwrap(); + assert!(!is_topk_over_count(&canonical)); + assert!(Rc::ptr_eq(&canonical, &q)); + } + } + #[test] fn promotes_through_a_passthrough_projection() { // …with a `SELECT service, count` projection between the Sort and the Agg. From 8c194e072158b1a45e0c6f694d2ea4021740904a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:35:49 +0000 Subject: [PATCH 11/18] Revert "fix(planner): plan non-pass-through scalar references exact" Combinations of approximate values may read summaries: each scalar reference is planned against the entry's accuracy target again. Composing the guarantee of such a combination is left to #575 and Stage 3's accuracy model. This reverts commit c44ecda90e8eface08794053db7305ac02995a7b. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/pass/major.rs | 35 +++++---------------- crates/planner/tests/summary_sharing.rs | 31 ------------------ 2 files changed, 7 insertions(+), 59 deletions(-) diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index 9876a795e..edfdfba3e 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -9,7 +9,7 @@ use asap_types::ir::cse::share_common_sub_dags; use std::rc::Rc; -use asap_types::ir::{OperatorNode, ScalarExpr}; +use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; use super::{OptimizationInput, OptimizationPass, OptimizeError, PlanOutput, QueryLifecyclePlan}; @@ -67,18 +67,13 @@ impl OptimizationPass for MajorPass { refs }) .collect(); - // The entry's target bounds the scalar's value, so only a pass-through - // scalar hands it to its one reference; otherwise each reference is - // planned exact, since independent approximate references leave the - // combined value unbounded (e.g. a difference of two quantiles). - for ((index, expr), refs) in workload.scalar_roots().iter().zip(&scalar_refs) { - let target = if is_pass_through(expr) { - targets[*index].clone() - } else { - AccuracyTarget::Exact - }; + // Each reference is planned against the entry's accuracy target. The + // guarantee of a combination of approximate values (e.g. a difference + // of two quantiles) is not composed here: guarantee composition is + // #575's, and Stage 3's accuracy model decides. + for ((index, _), refs) in workload.scalar_roots().iter().zip(&scalar_refs) { for node in refs { - roots.push((*index, Rc::clone(node), Some(target.clone()))); + roots.push((*index, Rc::clone(node), Some(targets[*index].clone()))); } } @@ -253,19 +248,3 @@ impl MajorPass { } } } - -/// Whether a standalone scalar passes one operator reference through: `v` -/// alone, or arithmetic and comparisons between that one reference and -/// literals. -fn is_pass_through(expr: &ScalarExpr) -> bool { - fn literal_ops(expr: &ScalarExpr) -> bool { - match expr { - ScalarExpr::Literal(_) | ScalarExpr::PromqlScalarFromVector(_) => true, - ScalarExpr::Negative { expr, .. } => literal_ops(expr), - ScalarExpr::Arithmetic { left, right, .. } - | ScalarExpr::Compare { left, right, .. } => literal_ops(left) && literal_ops(right), - _ => false, - } - } - expr.operator_refs().len() == 1 && literal_ops(expr) -} diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index 089809829..68e7ae3dc 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -596,34 +596,3 @@ async fn scalar_queries_read_planned_summaries() { assert_eq!(refs.len(), 1); assert!(Rc::ptr_eq(refs[0], &read.plan.root)); } - -/// A scalar combining several references is not a pass-through: the entry's -/// target does not bound each reference on its own, so every reference is -/// planned exact and reads no sketch. -#[tokio::test] -async fn combined_scalar_references_plan_exact() { - let output = plan_promql( - &[( - "scalar(quantile(0.99, rate(x[5m]))) - scalar(quantile(0.5, rate(x[5m])))", - 0.01, - )], - &CHEAP_SUMMARY, - ) - .await; - assert_eq!(output.scalar_plans.len(), 2); - for read in &output.scalar_plans { - let sketches = OperatorNode::reachable(&read.plan.root) - .into_iter() - .filter(|node| { - matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - }) - ) - }) - .count(); - assert_eq!(sketches, 0, "{:#?}", read.plan.root); - } -} From 12c1b24239914ef6f0d1865caa2c8f00ba1e611e Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:25:06 +0000 Subject: [PATCH 12/18] fix(planner): rank top-k only when the Limit partitions like its Sort A global Limit over a partitioned Sort keeps k rows overall, not k per group, so neither the heavy-hitter promotion nor the current-series heap candidates apply to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/replacement.rs | 7 ++- .../tests/weighted_topk_binding.rs | 43 +++++++++++++++++++ crates/types/src/ir/canonicalize.rs | 37 ++++++++++++++-- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 5b63cff0f..69590fdc8 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -1356,8 +1356,8 @@ impl<'a> ASAPStrategies<'a> { let Some(NonASAPOp::Limit { n: Some(n), offset: 0, + partition_by: limit_partition, child, - .. }) = root.non_asap() else { return Proposals::default(); @@ -1370,6 +1370,11 @@ impl<'a> ASAPStrategies<'a> { else { return Proposals::default(); }; + // The Limit keeps k rows per its own partition; only when that is the + // Sort's partition is the result the top k of each ranked group. + if limit_partition != partition_by { + return Proposals::default(); + } let [key] = keys.as_slice() else { return Proposals::default(); }; diff --git a/crates/asap-physical-operators/tests/weighted_topk_binding.rs b/crates/asap-physical-operators/tests/weighted_topk_binding.rs index ed1089fa3..97f7361d7 100644 --- a/crates/asap-physical-operators/tests/weighted_topk_binding.rs +++ b/crates/asap-physical-operators/tests/weighted_topk_binding.rs @@ -756,6 +756,49 @@ fn spatial_topk_exposes_signed_heap_candidate_over_complete_snapshot() { } } +// A Limit keeps k rows per its own partition, so a heap ranking per the +// Sort's groups is only the same result when both partition alike. +#[test] +fn spatial_topk_requires_the_limit_and_sort_partitions_to_agree() { + use asap_physical_operators::unified_physical_planner::promql_rows::with_series_identity; + use planner_types::ir::{NonASAPOp, Operator as IrOperator, OperatorNode}; + use planner_types::pre_asap::GroupKeys; + let logical = lower_promql("topk by(job)(1, m)", AccuracyTarget::Epsilon(0.1)).unwrap(); + let root = Rc::new(with_series_identity(&logical).unwrap()); + let Some(NonASAPOp::Limit { + n, + offset, + partition_by, + child, + }) = root.non_asap() + else { + panic!("expected a Limit root, got {root:?}"); + }; + assert!(!partition_by.is_empty()); + let global_limit = Rc::new(OperatorNode::with_schema( + IrOperator::NonASAP(NonASAPOp::Limit { + n: *n, + offset: *offset, + partition_by: GroupKeys::none(), + child: Rc::clone(child), + }), + root.schema.clone(), + )); + let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( + &DefaultCostModel, + &DefaultAccuracyModel, + &EqualSplitAllocator, + &Evidence, + ); + let candidates = |root: &Rc| { + strategy + .current_series_topk_candidates(root, &AccuracyTarget::Epsilon(0.1)) + .candidates + }; + assert!(!candidates(&root).is_empty()); + assert!(candidates(&global_limit).is_empty()); +} + /// Deployment-side lifecycle choice: every summary state of `candidate` is /// continuously maintained, and the chosen lifecycles set execution timing. fn continuously_maintained_dag(candidate: &Rc) -> PhysicalASAPDAG { diff --git a/crates/types/src/ir/canonicalize.rs b/crates/types/src/ir/canonicalize.rs index e3fdac9d9..f19c79dc8 100644 --- a/crates/types/src/ir/canonicalize.rs +++ b/crates/types/src/ir/canonicalize.rs @@ -170,9 +170,10 @@ fn try_promote_additive_top_ranking( else { return Ok(None); }; - // A per-group `Limit` must agree with its `Sort`'s partition: the - // ranking's partition is what the outer `TopK` groups by. - if !limit_partition.is_empty() && limit_partition != partition_by { + // The `Limit` must keep k rows per the `Sort`'s partition: the ranking's + // partition is what the outer `TopK` groups by. A global `Limit` over a + // partitioned `Sort` keeps k rows overall, not k per group. + if limit_partition != partition_by { return Ok(None); } let [SortKey { @@ -697,6 +698,36 @@ mod tests { assert!(!is_topk_over_count(&canonicalize(q).unwrap())); } + #[test] + fn does_not_promote_a_global_limit_over_a_partitioned_sort() { + // A global Limit keeps k rows overall, not k per Sort partition. + let q = limit( + 5, + 0, + node(NonASAPOp::Sort { + keys: desc(1), + partition_by: GroupKeys::by(vec![0]), + child: count_by_service(), + }), + ); + assert!(!is_topk_over_count(&canonicalize(q).unwrap())); + } + + #[test] + fn promotes_a_limit_partitioned_like_its_sort() { + let q = node(NonASAPOp::Limit { + n: Some(5), + offset: 0, + partition_by: GroupKeys::by(vec![0]), + child: node(NonASAPOp::Sort { + keys: desc(1), + partition_by: GroupKeys::by(vec![0]), + child: count_by_service(), + }), + }); + assert!(is_topk_over_count(&canonicalize(q).unwrap())); + } + #[test] fn promotes_sum_ranked_limit_sort_as_weighted_heavy_hitter() { let sum = aggregate(Reduction::by(vec![1]), AggIntent::Sum { col: None }, scan()); From 46127fada20219ae9f1580947c3ce3d7d11eaf8d Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:29:08 +0000 Subject: [PATCH 13/18] fix(ir): export one ScalarRef edge per distinct referenced node Cost lowering reads a node referenced several times from one operator's expressions once; the export now matches, deduping by node id (one id per pointer). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/types/src/ir/physical_export.rs | 24 +++++++------ crates/types/tests/physical_export.rs | 50 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index 48ca4c3c9..ba5a01626 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -490,7 +490,11 @@ pub fn compile_physical_asap_workload_with_node_ids( /// Each child of `operator` with its edge role: the operator inputs, then the /// nodes read by its scalar expressions (the order of [`Operator::children`]). -fn edge_roles(operator: &Operator) -> Vec<(C, EdgeRole)> { +/// Each input of `operator` with its role. A node referenced several times +/// from the operator's scalar expressions is one `ScalarRef` input, as cost +/// lowering reads it once; ids are per node pointer, so this dedupes by +/// pointer. +fn edge_roles(operator: &Operator) -> Vec<(C, EdgeRole)> { use EdgeRole::*; let inputs: Vec = match operator { Operator::NonASAP(op) => match op { @@ -510,16 +514,14 @@ fn edge_roles(operator: &Operator) -> Vec<(C, EdgeRole)> { }, }; let children = operator.children(); - let scalar_refs = children.len() - inputs.len(); - children - .into_iter() - .copied() - .zip( - inputs - .into_iter() - .chain(std::iter::repeat_n(ScalarRef, scalar_refs)), - ) - .collect() + let (direct, scalar_refs) = children.split_at(inputs.len()); + let mut edges: Vec<(C, EdgeRole)> = direct.iter().map(|c| **c).zip(inputs).collect(); + for producer in scalar_refs { + if !edges.contains(&(**producer, ScalarRef)) { + edges.push((**producer, ScalarRef)); + } + } + edges } fn grouping_compatibility( diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index 91179ed41..e396fbaa8 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -188,6 +188,56 @@ fn scalar_roots_export_in_batch_order_and_reference_nodes() { ); } +/// A `Project` reading the same node twice from its expressions has one +/// `ScalarRef` edge to it, as cost lowering reads it once. +#[test] +fn a_reference_read_twice_is_one_scalar_ref_edge() { + use asap_types::ir::physical_export::EdgeRole; + use asap_types::ir::{ExprSemantics, ProjectItem, ScalarExpr}; + use asap_types::pre_asap::ArithmeticOpKind; + let series = |metric: &str| { + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: metric.into(), + }, + predicates: vec![], + schema: Schema::new(vec![Field::plain("value", DataType::Float64, false)]), + })) + .unwrap() + }; + let read = series("b"); + let scalar = || ScalarExpr::PromqlScalarFromVector(Rc::clone(&read)); + let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { + cols: vec![ProjectItem { + alias: Some("value".into()), + expr: ScalarExpr::Arithmetic { + op: ArithmeticOpKind::Mul, + left: Box::new(scalar()), + right: Box::new(scalar()), + semantics: ExprSemantics::Promql, + }, + }], + qualifier: None, + child: series("a"), + })) + .unwrap(); + let timed = apply_lifecycle_timings( + &project, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .unwrap(); + let dag = compile_physical_asap_dag(&timed).unwrap(); + dag.validate().unwrap(); + let roles: Vec<_> = dag + .edges + .iter() + .filter(|edge| edge.consumer == dag.roots[0]) + .map(|edge| edge.role) + .collect(); + assert_eq!(roles, vec![EdgeRole::Input, EdgeRole::ScalarRef]); +} + /// The roles of a binary operator's edges must match its inputs: swapping /// `Left` and `Right` would reorder the operands of `a - b`. #[test] From bf1b5c7b137fe48b2a44c9a2f2e4ca23bcd27f0b Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:34:00 +0000 Subject: [PATCH 14/18] test(planner): cover scalar inputs, top-k partitions, scalar plan contract, provenance Project/Filter with two scalar references and one read twice lower and cost; the maintained population requires the Limit to partition like its Sort; the keyed heap input refuses a filtered count; check_contract rejects scalar plans out of order, under another entry, or extra; a guarantee-less summary realization is not a logical rewrite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../src/maintained_population.rs | 45 ++++++++++ crates/asap-aware-mapping/src/pass/mod.rs | 88 +++++++++++++++++++ .../src/query_physical_lowering.rs | 68 ++++++++++---- crates/asap-aware-mapping/src/replacement.rs | 82 +++++++++++++++++ 4 files changed, 264 insertions(+), 19 deletions(-) diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index da637687f..a0c0a3817 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -471,6 +471,51 @@ mod tests { assert_eq!(p.grouping, ["instance"]); assert_eq!(p.matchers[0].operation, CurrentSeriesMatch::Regex); } + /// `root`, a `Limit`, with its partition replaced by `partition_by`. + fn with_limit_partition( + root: &Rc, + partition_by: asap_types::pre_asap::GroupKeys, + ) -> Rc { + let Some(NonASAPOp::Limit { + n, offset, child, .. + }) = root.non_asap() + else { + panic!("expected a Limit root, got {root:?}"); + }; + Rc::new(OperatorNode::with_schema( + Operator::NonASAP(NonASAPOp::Limit { + n: *n, + offset: *offset, + partition_by, + child: Rc::clone(child), + }), + root.schema.clone(), + )) + } + + // A Limit keeps k rows per its own partition: over a Sort partitioned + // otherwise it is not the population's top-k. + #[test] + fn topk_limit_must_partition_like_its_sort() { + let per_job = lower("topk by(job)(5,a)"); + let global = lower("topk(5,a)"); + assert!(recognize(&per_job).is_some()); + assert!(recognize(&global).is_some()); + let Some(NonASAPOp::Limit { + partition_by: by_job, + .. + }) = per_job.non_asap() + else { + panic!("expected a Limit root, got {per_job:?}"); + }; + assert!(!by_job.is_empty()); + let global_limit_over_per_job_sort = + with_limit_partition(&per_job, asap_types::pre_asap::GroupKeys::none()); + let per_job_limit_over_global_sort = with_limit_partition(&global, by_job.clone()); + assert!(recognize(&global_limit_over_per_job_sort).is_none()); + assert!(recognize(&per_job_limit_over_global_sort).is_none()); + } + // Population timing is a lifecycle choice: a retained or rebuilt // population both validate, while its evaluation must stay at query time. #[test] diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index 7c4ee1ad2..e26998ace 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -500,6 +500,94 @@ mod tests { )); } + /// A scalar plan read from `root`, keyed by `entry_index`. + fn scalar_plan(entry_index: usize, root: &Rc) -> QueryLifecyclePlan { + QueryLifecyclePlan { + entry_index, + plan: crate::SummaryMaintenanceLifecyclePlan { + root: Rc::clone(root), + deployments: vec![], + horizon: None, + evaluation_rate: None, + update_rate: None, + expected_reads: None, + selected_raw_recompute: true, + selected_window_implementation_id: None, + summary_total_cost: None, + window_accuracy_guarantee: None, + raw_recompute_total_cost: None, + }, + } + } + + /// `scalar_plans` holds one plan per distinct reference of each scalar + /// root, in order and keyed by that root's entry: a plan out of order, + /// under another entry, or read by no scalar root is rejected. + #[test] + fn contract_checks_scalar_plan_order_entry_and_count() { + use asap_types::ir::non_asap::NonASAPOp; + use asap_types::ir::operator_properties::Source; + use asap_types::ir::{ExprSemantics, Operator, ScalarExpr}; + use asap_types::pre_asap::schema::{DataType, Field, Schema}; + use asap_types::pre_asap::ArithmeticOpKind; + let series = |metric: &str| { + OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { + metric: metric.into(), + }, + predicates: vec![], + schema: Schema::with_time_index( + vec![Field::plain("ts", DataType::Timestamp, false)], + 0, + vec![], + ), + })) + .unwrap() + }; + let (a, b) = (series("a"), series("b")); + let read = |node: &Rc| ScalarExpr::PromqlScalarFromVector(Rc::clone(node)); + let combine = |left, right| ScalarExpr::Arithmetic { + op: ArithmeticOpKind::Sub, + left: Box::new(left), + right: Box::new(right), + semantics: ExprSemantics::Promql, + }; + let with_plans = |plans: Vec| { + let mut output = PlanOutput::new(Vec::new()); + // `a - b`, then `a - a`: one plan per distinct reference. + output.scalar_roots = vec![ + (0, combine(read(&a), read(&b))), + (1, combine(read(&a), read(&a))), + ]; + output.scalar_plans = plans; + check_contract(&output, "stub", 2) + }; + with_plans(vec![ + scalar_plan(0, &a), + scalar_plan(0, &b), + scalar_plan(1, &a), + ]) + .unwrap(); + for plans in [ + // Wrong order. + vec![scalar_plan(0, &b), scalar_plan(0, &a), scalar_plan(1, &a)], + // Wrong entry key. + vec![scalar_plan(0, &a), scalar_plan(1, &b), scalar_plan(1, &a)], + // An extra plan. + vec![ + scalar_plan(0, &a), + scalar_plan(0, &b), + scalar_plan(1, &a), + scalar_plan(1, &a), + ], + ] { + assert!(matches!( + with_plans(plans), + Err(OptimizeError::ContractViolation { pass: "stub", .. }) + )); + } + } + /// The matching count passes. #[test] fn contract_accepts_an_empty_workload() { diff --git a/crates/asap-aware-mapping/src/query_physical_lowering.rs b/crates/asap-aware-mapping/src/query_physical_lowering.rs index 90fac1842..31ccb479a 100644 --- a/crates/asap-aware-mapping/src/query_physical_lowering.rs +++ b/crates/asap-aware-mapping/src/query_physical_lowering.rs @@ -2774,11 +2774,28 @@ mod tests { "rate(a[5m]) / scalar(rate(b[5m]))", "rate(a[5m]) > scalar(rate(b[5m]))", ] { - assert_scalar_reference_is_a_scalar_input(query); + assert_scalar_reference_is_a_scalar_input(query, false, 1); } } - fn assert_scalar_reference_is_a_scalar_input(query: &str) { + /// Each distinct reference is one scalar input of the `Project` or + /// `Filter`; one node read twice is one input. + #[test] + fn scalar_references_are_one_input_per_distinct_reference() { + for query in [ + "rate(a[5m]) / (scalar(rate(b[5m])) + scalar(rate(c[5m])))", + "rate(a[5m]) > (scalar(rate(b[5m])) + scalar(rate(c[5m])))", + ] { + assert_scalar_reference_is_a_scalar_input(query, false, 2); + assert_scalar_reference_is_a_scalar_input(query, true, 1); + } + } + + fn assert_scalar_reference_is_a_scalar_input( + query: &str, + same_reference: bool, + scalar_inputs: usize, + ) { use crate::physical_operator_statistics::ScalarInputStatistics; use asap_types::types::AccuracyTarget; use asap_types::workload::{ @@ -2814,6 +2831,22 @@ mod tests { let root = asap_frontend_promql::unified::lower_promql_workload(&workload, 0) .unwrap() .remove(0); + // Read one node through every reference (as a planner that shares + // the identical references would), so the input dedupe is tested. + let root = if same_reference { + let children = root.children(); + let (row, first) = (Rc::clone(children[0]), Rc::clone(children[1])); + OperatorNode::new_shared(root.operator.map_children(|child| { + if Rc::ptr_eq(child, &row) { + Rc::clone(&row) + } else { + Rc::clone(&first) + } + })) + .unwrap() + } else { + root + }; let vector = |series| promql_edge(series, 10, PromqlValueKind::Vector); let produced: RefCell> = RefCell::default(); @@ -2888,16 +2921,15 @@ mod tests { ..evidence(statistics) }) }; - let query_scope = scope(vec![ - coverage( - asap_types::pre_asap::Source::TimeSeries { metric: "a".into() }, - vec![], - ), - coverage( - asap_types::pre_asap::Source::TimeSeries { metric: "b".into() }, - vec![], - ), - ]); + let query_scope = scope( + OperatorNode::reachable(&root) + .iter() + .filter_map(|node| match node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => Some(coverage(source.clone(), vec![])), + _ => None, + }) + .collect(), + ); let dag = lower_query_physical_dag(&root, &query_scope, &provider).unwrap(); let root_node = dag.nodes.iter().find(|node| node.id == dag.root).unwrap(); assert!( @@ -2908,13 +2940,11 @@ mod tests { "{query}: {:?}", root_node.operator ); - assert_eq!(root_node.children.len(), 2, "{query}"); - let scalar = dag - .nodes - .iter() - .find(|node| node.id == root_node.children[1]) - .unwrap(); - assert_eq!(scalar.operator, PhysicalOperator::PromqlVectorToScalar); + assert_eq!(root_node.children.len(), 1 + scalar_inputs, "{query}"); + for child in &root_node.children[1..] { + let scalar = dag.nodes.iter().find(|node| node.id == *child).unwrap(); + assert_eq!(scalar.operator, PhysicalOperator::PromqlVectorToScalar); + } estimate_physical_dag(&dag.nodes, &dag.root, &query_scope, &dag.evidence).unwrap(); } } diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 69590fdc8..be564e4cc 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -7050,6 +7050,88 @@ mod tests { use asap_types::types::AccuracyTarget; use std::collections::HashMap; + /// A logical rewrite is told apart by provenance, not shape: a summary + /// realization with no guarantee and no ASAP operator is still a bound + /// summary decision. + #[test] + fn logical_rewrite_follows_provenance_not_shape() { + let node = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); + assert!(node.guarantee.is_none() && !node.contains_asap()); + let candidate = |provenance| ReplacementSubDAG { + replacement: Replacement::SubDAG(Rc::clone(&node)), + strategy: "test", + provenance, + rationale: String::new(), + }; + let realization = candidate(ReplacementProvenance::SummaryRealization); + assert!(!realization.is_logical_rewrite()); + assert!(realization.has_missing_accuracy_evidence()); + for provenance in [ + ReplacementProvenance::LogicalRewrite, + ReplacementProvenance::CseShare, + ReplacementProvenance::CseRecompute, + ReplacementProvenance::AccuracyReconciliation, + ] { + assert!(candidate(provenance).is_logical_rewrite(), "{provenance:?}"); + } + } + + /// A heap sketch consumes every raw row, so the keyed input rule refuses + /// a TopK over a filtered count (#466) even when handed one directly. + #[test] + fn keyed_heap_input_refuses_a_filtered_count() { + use asap_types::ir::{ExprSemantics, Predicate}; + use asap_types::pre_asap::{CompareOpKind, ScalarValue}; + let count_by_job = |filter: Option| { + OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: ReductionTy::by(vec![2]), + measures: vec![AggIntent::Count { + accuracy: AccuracyTarget::Exact, + }], + output_names: vec![], + filters: vec![filter], + having: None, + child: metric_scan(&["job"]), + })) + .unwrap() + }; + let intent = AggIntent::TopK { + k: 5, + accuracy: AccuracyTarget::Epsilon(0.01), + }; + let family = FieldDataType::Sketch( + SketchKind::new( + SketchAlgorithm::CmsWithHeap, + SketchParams::CmsWithHeap { + width: 64, + depth: 4, + heap_size: 5, + }, + ), + GroupingStrategy::default(), + ); + let output = ReductionTy::Reduce(asap_types::ir::operator_properties::GroupKeys::none()); + assert!(matches!( + realize_keyed_additive_summary_input(&intent, &family, &output, &count_by_job(None)), + PhysicalSummaryInputRuleResult::Realized(_) + )); + let value_above_one = Predicate(ScalarExpr::Compare { + left: Box::new(ScalarExpr::Column(1)), + op: CompareOpKind::Gt, + right: Box::new(ScalarExpr::Literal(ScalarValue::Float64(1.0))), + semantics: ExprSemantics::Promql, + }); + assert!(matches!( + realize_keyed_additive_summary_input( + &intent, + &family, + &output, + &count_by_job(Some(value_above_one)) + ), + PhysicalSummaryInputRuleResult::NotApplicable + )); + } + /// Search takes the front end's pre-ASAP DAG: a root that already holds /// an ASAP operator is an error, not a panic. #[test] From 0a81ed0e66523d6792e59e5a01297b4a8925f7c3 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:36:40 +0000 Subject: [PATCH 15/18] test(planner): a difference of two scalar quantiles reads summaries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/planner/tests/summary_sharing.rs | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index 68e7ae3dc..b1d6f1be6 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -596,3 +596,34 @@ async fn scalar_queries_read_planned_summaries() { assert_eq!(refs.len(), 1); assert!(Rc::ptr_eq(refs[0], &read.plan.root)); } + +/// A combination of approximate values may read summaries: each reference of +/// `scalar(q99) - scalar(q50)` is planned against the entry's target, so +/// under a cost model that prefers summaries both read a sketch. +#[tokio::test] +async fn combined_scalar_references_read_summaries() { + let output = plan_promql( + &[( + "scalar(quantile(0.99, rate(x[5m]))) - scalar(quantile(0.5, rate(x[5m])))", + 0.01, + )], + &CHEAP_SUMMARY, + ) + .await; + assert_eq!(output.scalar_plans.len(), 2); + for read in &output.scalar_plans { + let sketches = OperatorNode::reachable(&read.plan.root) + .into_iter() + .filter(|node| { + matches!( + node.asap(), + Some(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(..), + .. + }) + ) + }) + .count(); + assert!(sketches > 0, "{:#?}", read.plan.root); + } +} From c5e92705ea4bbc2a1a31ad478af7f2141237cd92 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:14:14 +0000 Subject: [PATCH 16/18] fix(planner): plan structurally identical scalar references once Front ends build identical references as separate nodes and planning merges them, so pointer-distinct references left the rebuilt scalar with one reference but two scalar plans, failing the contract. References are now distinct by structure. Document that each reference keeps its summary candidates and that the combined guarantee is composed later (#669, #575). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/asap-aware-mapping/src/pass/major.rs | 25 +++++++++++++----- crates/asap-aware-mapping/src/replacement.rs | 5 +++- crates/planner/tests/summary_sharing.rs | 27 ++++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index edfdfba3e..cde0081dd 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -53,24 +53,30 @@ impl OptimizationPass for MajorPass { // A standalone scalar query is not a node; the operator sub-DAGs it // reads are planned like operator roots, against its entry (#511 // §2.3, operator-sharing §3). They follow the operator roots, each - // scalar's distinct references in `operator_refs` order. + // scalar's distinct references in `operator_refs` order. References + // are distinct by structure, not pointer: front ends build identical + // references as separate nodes, and planning merges them into one. let scalar_refs: Vec>> = workload .scalar_roots() .iter() .map(|(_, expr)| { let mut refs: Vec> = Vec::new(); for node in expr.operator_refs() { - if !refs.iter().any(|r| Rc::ptr_eq(r, node)) { + if !refs.iter().any(|r| same_reference(r, node)) { refs.push(Rc::clone(node)); } } refs }) .collect(); - // Each reference is planned against the entry's accuracy target. The - // guarantee of a combination of approximate values (e.g. a difference - // of two quantiles) is not composed here: guarantee composition is - // #575's, and Stage 3's accuracy model decides. + // Each reference is planned against the entry's accuracy target, so a + // combination of approximate values (e.g. a difference of two + // quantiles) keeps its summary candidates. The combined guarantee is + // not composed in this pass (it is effectively `None`): composition is + // designed in #669 and implemented in #575, Stage 3's accuracy model + // checks it, and in the MVP a rank-interval guarantee counts as + // `None`. The same holds for scalar references inside Project/Filter + // expressions, which are planned as interior sites. for ((index, _), refs) in workload.scalar_roots().iter().zip(&scalar_refs) { for node in refs { roots.push((*index, Rc::clone(node), Some(targets[*index].clone()))); @@ -221,7 +227,7 @@ impl OptimizationPass for MajorPass { let expr = expr.map_operator_refs(&mut |node| { let position = refs .iter() - .position(|r| Rc::ptr_eq(r, node)) + .position(|r| same_reference(r, node)) .expect("every reference was planned"); Rc::clone(&selected[position].plan.root) }); @@ -248,3 +254,8 @@ impl MajorPass { } } } + +/// Whether two scalar references read the same operator sub-DAG. +fn same_reference(a: &Rc, b: &Rc) -> bool { + Rc::ptr_eq(a, b) || a.as_ref() == b.as_ref() +} diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index be564e4cc..d61bca3c2 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -7016,7 +7016,10 @@ fn walk( /// inputs plus the operator nodes its scalar expressions read), the same /// scope `asap_types::ir::cse::share_common_sub_dags` itself uses and /// `tests::count_consumers` mirrors for its own fixtures. `Concat` is -/// transparent: its branches are walked in place of it. +/// transparent: its branches are walked in place of it. A `scalar(v)` read +/// by a Project/Filter expression is planned here as an interior site and +/// keeps its summary candidates; a combination's guarantee is not composed +/// in this pass (#669). fn walk_children( node: &OperatorNode, order: &mut Vec<*const OperatorNode>, diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index b1d6f1be6..28844f90c 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -627,3 +627,30 @@ async fn combined_scalar_references_read_summaries() { assert!(sketches > 0, "{:#?}", read.plan.root); } } + +/// Front ends build structurally identical references as separate nodes and +/// planning merges them: such a scalar plans each distinct reference once and +/// satisfies the plan contract. +#[tokio::test] +async fn identical_scalar_references_plan_once() { + for (query, distinct) in [ + ("scalar(rate(x[5m])) - scalar(rate(x[5m]))", 1), + ( + "scalar(sum(rate(x[5m]))) / scalar(sum(rate(x[5m]))) + scalar(count(x))", + 2, + ), + ] { + let output = plan_promql(&[(query, 0.01)], &CHEAP_SUMMARY).await; + assert_eq!(output.scalar_plans.len(), distinct, "{query}"); + let (_, expr) = &output.scalar_roots[0]; + for node in expr.operator_refs() { + assert!( + output + .scalar_plans + .iter() + .any(|read| Rc::ptr_eq(&read.plan.root, node)), + "{query}" + ); + } + } +} From 353f8138e93e3aab9700f5dd484d449744f77f78 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:14:14 +0000 Subject: [PATCH 17/18] test(planner): reject a per-job Limit over a global Sort; doc cleanups Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../src/maintained_population.rs | 1 + .../tests/weighted_topk_binding.rs | 55 ++++++++++++------- crates/types/src/ir/physical_export.rs | 9 ++- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index a0c0a3817..7c22bba81 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -471,6 +471,7 @@ mod tests { assert_eq!(p.grouping, ["instance"]); assert_eq!(p.matchers[0].operation, CurrentSeriesMatch::Regex); } + /// `root`, a `Limit`, with its partition replaced by `partition_by`. fn with_limit_partition( root: &Rc, diff --git a/crates/asap-physical-operators/tests/weighted_topk_binding.rs b/crates/asap-physical-operators/tests/weighted_topk_binding.rs index 97f7361d7..6e50c7f61 100644 --- a/crates/asap-physical-operators/tests/weighted_topk_binding.rs +++ b/crates/asap-physical-operators/tests/weighted_topk_binding.rs @@ -763,27 +763,38 @@ fn spatial_topk_requires_the_limit_and_sort_partitions_to_agree() { use asap_physical_operators::unified_physical_planner::promql_rows::with_series_identity; use planner_types::ir::{NonASAPOp, Operator as IrOperator, OperatorNode}; use planner_types::pre_asap::GroupKeys; - let logical = lower_promql("topk by(job)(1, m)", AccuracyTarget::Epsilon(0.1)).unwrap(); - let root = Rc::new(with_series_identity(&logical).unwrap()); + let series_root = |query: &str| { + let logical = lower_promql(query, AccuracyTarget::Epsilon(0.1)).unwrap(); + Rc::new(with_series_identity(&logical).unwrap()) + }; + // `root`, a `Limit`, with its partition replaced by `partition_by`. + let with_limit_partition = |root: &Rc, partition_by: GroupKeys| { + let Some(NonASAPOp::Limit { + n, offset, child, .. + }) = root.non_asap() + else { + panic!("expected a Limit root, got {root:?}"); + }; + Rc::new(OperatorNode::with_schema( + IrOperator::NonASAP(NonASAPOp::Limit { + n: *n, + offset: *offset, + partition_by, + child: Rc::clone(child), + }), + root.schema.clone(), + )) + }; + let per_job = series_root("topk by(job)(1, m)"); + let global = series_root("topk(1, m)"); let Some(NonASAPOp::Limit { - n, - offset, - partition_by, - child, - }) = root.non_asap() + partition_by: by_job, + .. + }) = per_job.non_asap() else { - panic!("expected a Limit root, got {root:?}"); + panic!("expected a Limit root, got {per_job:?}"); }; - assert!(!partition_by.is_empty()); - let global_limit = Rc::new(OperatorNode::with_schema( - IrOperator::NonASAP(NonASAPOp::Limit { - n: *n, - offset: *offset, - partition_by: GroupKeys::none(), - child: Rc::clone(child), - }), - root.schema.clone(), - )); + assert!(!by_job.is_empty()); let strategy = ASAPStrategies::new_with_planning_inputs_and_evidence( &DefaultCostModel, &DefaultAccuracyModel, @@ -795,8 +806,12 @@ fn spatial_topk_requires_the_limit_and_sort_partitions_to_agree() { .current_series_topk_candidates(root, &AccuracyTarget::Epsilon(0.1)) .candidates }; - assert!(!candidates(&root).is_empty()); - assert!(candidates(&global_limit).is_empty()); + assert!(!candidates(&per_job).is_empty()); + assert!(!candidates(&global).is_empty()); + // A global Limit over a per-job Sort, and a per-job Limit over a global + // Sort. + assert!(candidates(&with_limit_partition(&per_job, GroupKeys::none())).is_empty()); + assert!(candidates(&with_limit_partition(&global, by_job.clone())).is_empty()); } /// Deployment-side lifecycle choice: every summary state of `candidate` is diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index ba5a01626..ba322e0a7 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -488,12 +488,11 @@ pub fn compile_physical_asap_workload_with_node_ids( }) } -/// Each child of `operator` with its edge role: the operator inputs, then the +/// Each input of `operator` with its edge role: the operator inputs, then the /// nodes read by its scalar expressions (the order of [`Operator::children`]). -/// Each input of `operator` with its role. A node referenced several times -/// from the operator's scalar expressions is one `ScalarRef` input, as cost -/// lowering reads it once; ids are per node pointer, so this dedupes by -/// pointer. +/// A node referenced several times from the scalar expressions is one +/// `ScalarRef` input, as cost lowering reads it once; ids are per node +/// pointer, so this dedupes by pointer. fn edge_roles(operator: &Operator) -> Vec<(C, EdgeRole)> { use EdgeRole::*; let inputs: Vec = match operator { From 8837667eafead5fa16360bafaf0890b47c9182b1 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:27:06 +0000 Subject: [PATCH 18/18] fix(planner): read exact-state query roots through their finalize MajorPass assembled roots with assemble_selected_dag, so a root whose selected candidate is an exact state came out as a bare SummaryAgg. The lifecycle plan now finalizes its root after costing (the finalize wraps the shared state, so sharing and cost evidence stay), scalar references included since scalar(v) reads values. Query-root callers and tests use assemble_selected_query. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../src/exact_composition.rs | 2 +- crates/asap-aware-mapping/src/lib.rs | 2 +- crates/asap-aware-mapping/src/pass/major.rs | 3 ++ crates/asap-aware-mapping/src/replacement.rs | 43 +++++++++++++++++-- .../src/summary_maintenance_lifecycle.rs | 6 ++- .../unified_physical_planner/candidates.rs | 2 +- .../tests/deployment_computation.rs | 2 +- .../tests/planspace_series_identity_heap.rs | 2 +- .../tests/exact_composition.rs | 22 +++++----- .../tests/operator_sharing.rs | 2 +- .../tests/precompute_raw_samples.rs | 2 +- .../tests/promql_to_post_asap.rs | 8 ++-- .../tests/sql_to_physical.rs | 2 +- .../tests/sql_to_post_asap.rs | 16 +++---- crates/planner/tests/e2e_plan.rs | 2 +- crates/planner/tests/summary_sharing.rs | 39 ++++++++++++++++- 16 files changed, 118 insertions(+), 37 deletions(-) diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index f48bc08b5..253006a93 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -27,7 +27,7 @@ //! cost-model ranking, workload-wide effective consumer count, and shared //! `Rc` identity (one inner summary serving two outer folds) all stay //! correct, and a child that is also shared by an unrelated consumer is -//! maintained exactly once. `GlobalSelection::assemble_selected_dag` then links the +//! maintained exactly once. `GlobalSelection::assemble_selected_query` then links the //! committed pair into one validated post-ASAP DAG. //! //! ## Proposal conditions diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 7e91ff5a9..0b84000a2 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -40,7 +40,7 @@ //! - Inspect the candidate space, optionally using [`CandidateLogicalASAPDAGs::cost_sorted`] //! to obtain ranked views, and perform selection downstream. //! - Call [`CandidateLogicalASAPDAGs::global_selection`] once for the workload, then -//! [`GlobalSelection::assemble_selected_dag`] for each query root. This +//! [`GlobalSelection::assemble_selected_query`] for each query root. This //! coordinates logical choices and preserves shared nodes, but makes no //! summary-maintenance lifecycle decision. //! - When Planner owns maintenance-versus-recomputation decisions, use diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index cde0081dd..4d95b18b9 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -116,6 +116,9 @@ impl OptimizationPass for MajorPass { // Assemble every root, then intern structurally identical summary // producers across them once, so two queries that selected the same // `SummaryAgg` reach one `Rc` (consumers dedupe states by pointer). + // `plan_assembled_dag` then reads an exact-state root through its + // finalize, scalar references included: `scalar(v)` reads an instant + // vector's value, not accumulator state. let mut assembled = Vec::with_capacity(space.roots.len()); for (entry_index, root) in &space.roots { let dag = selection diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index d61bca3c2..b3c7fdd8d 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -55,7 +55,7 @@ //! A caller may inspect local replacements, but taking the first candidate //! does not establish a compatible workload plan or physical deployability. //! For Planner-owned logical selection, call [`CandidateLogicalASAPDAGs::global_selection`] -//! once and [`GlobalSelection::assemble_selected_dag`] for each wanted query +//! once and [`GlobalSelection::assemble_selected_query`] for each wanted query //! root. Alternatively, use the summary-maintenance-lifecycle-aware helpers //! when Planner should also compare maintenance against raw recomputation. //! Physical binding, deployment, and execution remain downstream. @@ -5286,7 +5286,9 @@ impl<'a> GlobalSelection<'a> { /// as it is (exact); an unmatched site keeps its own operator with each /// child assembled independently ([`Self::assemble_residual`]). /// Memoized by target identity, so a shared inner summary is one `Rc` - /// no matter how many roots reach it. + /// no matter how many roots reach it. An exact-state root comes out as + /// its bare state; query results go through + /// [`Self::assemble_selected_query`]. pub fn assemble_selected_dag( &self, target: &Rc, @@ -7053,6 +7055,41 @@ mod tests { use asap_types::types::AccuracyTarget; use std::collections::HashMap; + /// A query result reads an exact state through its finalize; any other + /// selected root is returned as assembled. + #[test] + fn assemble_selected_query_finalizes_only_an_exact_state_root() { + let assemble = |query: &str, accuracy: AccuracyTarget| { + let space = search_workload(vec![("q", lower_promql(query, accuracy))]).unwrap(); + let selection = space.global_selection(&DefaultCostModel); + let root = &space.roots[0].1; + ( + selection.assemble_selected_dag(root).unwrap().unwrap(), + selection.assemble_selected_query(root).unwrap().unwrap(), + ) + }; + let (state, query) = assemble("sum_over_time(m[1m])", AccuracyTarget::Exact); + assert!(matches!( + state.asap(), + Some(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(..), + .. + }) + )); + let Some(ASAPOp::FinalizeExactAccumulator { child }) = query.asap() else { + panic!("expected a finalized exact state, got {query:#?}"); + }; + assert!(Rc::ptr_eq(child, &state)); + assert!(query.schema.fields.iter().all(|field| field.is_plain())); + + let (estimate, query) = assemble( + "quantile_over_time(0.99, m[1m])", + AccuracyTarget::Epsilon(0.01), + ); + assert!(estimate.contains_asap()); + assert!(Rc::ptr_eq(&estimate, &query)); + } + /// A logical rewrite is told apart by provenance, not shape: a summary /// realization with no guarantee and no ASAP operator is still a bound /// summary decision. @@ -11299,7 +11336,7 @@ mod tests { .chosen .is_some_and(ReplacementSubDAG::has_missing_accuracy_evidence)); assert!(selected - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .is_some()); } diff --git a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs index 3c0836812..e0bbfbeff 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs @@ -1036,6 +1036,10 @@ pub(crate) fn plan_assembled_dag( plan.summary_total_cost = None; plan.window_accuracy_guarantee = None; } + // The plan is a query result: an exact state is read through its + // finalize. Costing above saw the bare state, which is what the cost + // model's evidence is bound to; the finalize wraps the same `Rc`. + plan.root = crate::replacement::finalize_query_candidate(plan.root, target)?; Ok(plan) } @@ -2850,7 +2854,7 @@ mod tests { ) .unwrap(); let materialized = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); diff --git a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs index def860bbe..3a72061ea 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs @@ -375,7 +375,7 @@ mod tests { let space = asap_aware_mapping::search_workload(vec![("q", root)]).unwrap(); let selected = space .global_selection(&asap_aware_mapping::cost_model::DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); let selected = planner_types::ir::apply_lifecycle_timings( diff --git a/crates/asap-physical-operators/tests/deployment_computation.rs b/crates/asap-physical-operators/tests/deployment_computation.rs index f9a8b4d82..fbb23f049 100644 --- a/crates/asap-physical-operators/tests/deployment_computation.rs +++ b/crates/asap-physical-operators/tests/deployment_computation.rs @@ -56,7 +56,7 @@ fn exact_dag(query: &str) -> PhysicalASAPDAG { let space = asap_aware_mapping::search_workload(vec![("q", root)]).unwrap(); let selected = space .global_selection(&asap_aware_mapping::DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); compile_physical_asap_dag(&selected).unwrap() diff --git a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs index 6c0f25d6c..e3281a28e 100644 --- a/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs +++ b/crates/asap-physical-operators/tests/planspace_series_identity_heap.rs @@ -223,7 +223,7 @@ fn global_selection_never_commits_a_series_identity_heap() { .unwrap(); let selected = space .global_selection(&DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); assert!(!carries_identity(&vec![(0, selected)])); diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index 4a8df4771..05a0616ad 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -148,7 +148,7 @@ fn custom_accuracy_rule_survives_root_target_and_materialization() { .composition .is_some()); let node = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); let guarantee = node.guarantee.as_ref().unwrap(); @@ -284,7 +284,7 @@ fn unknown_runtime_capability_keeps_candidate_but_prevents_selection() { .composition .is_none()); assert!(selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .is_some()); } @@ -475,7 +475,7 @@ fn max_and_avg_over_quantile_compose_at_query_time_with_statistics() { child_candidate )); - let composed = selection.assemble_selected_dag(&root).unwrap().unwrap(); + let composed = selection.assemble_selected_query(&root).unwrap().unwrap(); let Some(NonASAPOp::Aggregate { child, .. }) = composed.non_asap() else { panic!( "{intent:?}: expected ValueOperationAtQueryTime root, got {:?}", @@ -542,7 +542,7 @@ fn identity_and_genuine_multi_row_folds_both_compose() { let root = &space.roots[0].1; let composed = space .global_selection(&StatsModel) - .assemble_selected_dag(root) + .assemble_selected_query(root) .unwrap() .unwrap(); assert!( @@ -610,7 +610,7 @@ fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { let composed: Vec<_> = roots .iter() - .map(|r| selection.assemble_selected_dag(r).unwrap().unwrap()) + .map(|r| selection.assemble_selected_query(r).unwrap().unwrap()) .collect(); let child_of = |n: &Rc| match n.non_asap() { Some(NonASAPOp::Aggregate { child, .. }) @@ -669,7 +669,7 @@ fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { assert!(decision.child_candidate.is_none(), "function input is raw"); assert!(decision.cost_rate < decision.baseline_rate); - let composed = selection.assemble_selected_dag(&root).unwrap().unwrap(); + let composed = selection.assemble_selected_query(&root).unwrap().unwrap(); // Walk the timed copy: timing is written by the lifecycle assignment. let composed = timed(&composed); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &composed.operator else { @@ -701,7 +701,7 @@ fn summary_construction_follows_its_value_input_phase() { let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); let post = space .global_selection(&StatsModel) - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); let illegal = std::rc::Rc::new( @@ -740,7 +740,7 @@ fn a_runtime_without_mixed_execution_gets_no_composition_candidates() { .all(|c| !matches!(c.replacement, Replacement::ExactComposition(_)))); let selection = space.global_selection(&NoCapabilityModel); assert!(selection.for_target(&root).unwrap().composition.is_none()); - let node = selection.assemble_selected_dag(&root).unwrap().unwrap(); + let node = selection.assemble_selected_query(&root).unwrap().unwrap(); assert!(!is_query_time_fold(&node)); // The inner quantile is still independently selectable. let Some(NonASAPOp::Aggregate { child, .. }) = root.non_asap() else { @@ -771,7 +771,7 @@ fn missing_cost_statistics_preserve_the_conservative_retain_exact() { selected.chosen.map(|c| &c.replacement), Some(Replacement::ExactComposition(_)) )); - let node = selection.assemble_selected_dag(&root).unwrap().unwrap(); + let node = selection.assemble_selected_query(&root).unwrap().unwrap(); assert!(!is_query_time_fold(&node)); let explanations = @@ -790,7 +790,7 @@ fn dag_export_carries_explicit_stage_and_plain_schema_for_a_composed_plan() { let root = &space.roots[0].1; let composed = space .global_selection(&StatsModel) - .assemble_selected_dag(root) + .assemble_selected_query(root) .unwrap() .unwrap(); let dag = dag_export::export_summary(&composed); @@ -843,7 +843,7 @@ fn promql_max_by_zone_over_quantile_over_time_composes() { .map(|c| (c.strategy, c.provenance)) .collect::>() ); - let composed = selection.assemble_selected_dag(root).unwrap().unwrap(); + let composed = selection.assemble_selected_query(root).unwrap().unwrap(); assert!(is_query_time_fold(&composed), "{:?}", composed.operator); assert_eq!(timed(&composed).timing, Some(ExecutionTiming::QueryTime)); assert_eq!( diff --git a/crates/integration-tests/tests/operator_sharing.rs b/crates/integration-tests/tests/operator_sharing.rs index fa9d33f0e..0e926fe03 100644 --- a/crates/integration-tests/tests/operator_sharing.rs +++ b/crates/integration-tests/tests/operator_sharing.rs @@ -55,7 +55,7 @@ async fn plan(sql: &str, accuracy: AccuracyTarget) -> Rc { let space = search_workload(vec![("query", pre)]).unwrap(); let selection = space.global_selection(&DefaultCostModel); selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered") } diff --git a/crates/integration-tests/tests/precompute_raw_samples.rs b/crates/integration-tests/tests/precompute_raw_samples.rs index 4dd7ca00c..d1befc4b2 100644 --- a/crates/integration-tests/tests/precompute_raw_samples.rs +++ b/crates/integration-tests/tests/precompute_raw_samples.rs @@ -71,7 +71,7 @@ fn candidates(query: &str, accuracy: AccuracyTarget) -> Vec> { let space = search_workload(vec![("query", root)]).unwrap(); if let Ok(Some(selected)) = space .global_selection(&DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) { result.push(selected); } diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index 3bbc58c0e..50f8da056 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -88,7 +88,7 @@ fn lower_search_and_materialize(query: &str) -> Rc { let space = search_workload(vec![("query", pre)]).unwrap(); let selection = space.global_selection(&DefaultCostModel); selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered") } @@ -1060,7 +1060,7 @@ fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { let space = search_workload(vec![("query", pre)]).unwrap(); let selected = space.global_selection(&DefaultCostModel); let plan = selected - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); // Stored timings are gone: time the plan and read the timed copy. @@ -1120,7 +1120,7 @@ fn physical_node_owns_phase_independently_of_binary_payload() { let search = search_workload(vec![("q", input)]).unwrap(); let choice = search.global_selection(&DefaultCostModel); let plan = choice - .assemble_selected_dag(&search.roots[0].1) + .assemble_selected_query(&search.roots[0].1) .unwrap() .unwrap(); let dag = post_asap_dag(&plan); @@ -1188,7 +1188,7 @@ fn ddsketch_ratio_without_domain_proof_is_uncertified() { "Planner must not automatically select an uncertified ratio" ); let materialized = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .expect("materialized root"); assert!(!materialized.contains_asap()); diff --git a/crates/integration-tests/tests/sql_to_physical.rs b/crates/integration-tests/tests/sql_to_physical.rs index 4f3215bcd..f24ad5a32 100644 --- a/crates/integration-tests/tests/sql_to_physical.rs +++ b/crates/integration-tests/tests/sql_to_physical.rs @@ -39,7 +39,7 @@ async fn sql_filter_grouped_sum_executes_and_rebinds() { let space = search_workload(vec![("sql", logical)]).unwrap(); let selected = space .global_selection(&DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); let dag = compile_physical_asap_dag(&selected).unwrap(); diff --git a/crates/integration-tests/tests/sql_to_post_asap.rs b/crates/integration-tests/tests/sql_to_post_asap.rs index 651b6f4e5..531a24623 100644 --- a/crates/integration-tests/tests/sql_to_post_asap.rs +++ b/crates/integration-tests/tests/sql_to_post_asap.rs @@ -197,7 +197,7 @@ async fn clickhouse_outer_sum_recursively_binds_inner_temporal_aggregate() { let space = search_workload(vec![("nested", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); @@ -258,7 +258,7 @@ async fn sql_full_query_retains_project_and_binds_inner_aggregate() { let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); let Some(NonASAPOp::Project { @@ -304,7 +304,7 @@ async fn sql_join_recursively_binds_both_temporal_aggregate_children() { let space = search_workload(vec![("ratio", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); let Some(NonASAPOp::Project { @@ -420,7 +420,7 @@ async fn unsupported_sql_join_shapes_remain_fail_closed() { let space = search_workload(vec![("unsupported-join", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); let Some(NonASAPOp::Project { child, .. }) = root.non_asap() else { @@ -449,7 +449,7 @@ async fn sql_relational_parents_retain_summary_bound_aggregate() { let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); @@ -540,7 +540,7 @@ async fn sql_filter_keeps_read_predicate_and_summary_population_selection() { let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); @@ -596,7 +596,7 @@ async fn sql_filter_preserves_local_fallback_boundary_for_unsupported_child() { let space = search_workload(vec![("query", Rc::clone(&pre_asap))]).unwrap(); let selection = space.global_selection(&DefaultCostModel); let root = selection - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .expect("materialization failed") .expect("root must be discovered"); @@ -831,7 +831,7 @@ async fn map_projection_export_preserves_unsupported_child_boundary() { let space = search_workload(vec![("map_query", pre)]).unwrap(); let root = space .global_selection(&DefaultCostModel) - .assemble_selected_dag(&space.roots[0].1) + .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); let dag = post_asap_dag(&root); diff --git a/crates/planner/tests/e2e_plan.rs b/crates/planner/tests/e2e_plan.rs index 740f9d75d..5231c341d 100644 --- a/crates/planner/tests/e2e_plan.rs +++ b/crates/planner/tests/e2e_plan.rs @@ -145,7 +145,7 @@ async fn builtin_cost_model_cannot_price_lifecycles_and_falls_back_to_raw_recomp assert_eq!(output.plans.len(), space.roots.len()); for (plan, (_, root)) in output.plans.iter().zip(&space.roots) { let cost_only = selection - .assemble_selected_dag(root) + .assemble_selected_query(root) .expect("assembles") .expect("root has a group"); assert!( diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index 28844f90c..2ce32f0d7 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -533,7 +533,7 @@ fn certified_frequency_evaluations_share_one_univmon_state() { .iter() .map(|(index, root)| { let dag = selection - .assemble_selected_dag(root) + .assemble_selected_query(root) .expect("assembles") .expect("root has a group"); (*index, dag) @@ -654,3 +654,40 @@ async fn identical_scalar_references_plan_once() { } } } + +/// A root whose selected candidate is an exact state is a query result: it +/// is read through `FinalizeExactAccumulator`, so its output is plain values, +/// not accumulator state. +#[tokio::test] +async fn exact_state_roots_are_finalized() { + for query in [ + "sum_over_time(m[1m])", + "sum(rate(x[5m]))", + "sum(sum_over_time(m[1m]) + sum_over_time(n[1m]))", + ] { + let output = plan_promql(&[(query, 0.01)], &CHEAP_SUMMARY).await; + let plan = &output.plans[0].plan; + assert!( + matches!( + plan.root.asap(), + Some(ASAPOp::FinalizeExactAccumulator { .. }) + ), + "{query}: {:#?}", + plan.root + ); + let dag = plan.execution_timed_dag().unwrap(); + let root = dag + .nodes + .iter() + .find(|node| node.id == dag.roots[0]) + .unwrap(); + assert!( + root.output_schema + .fields + .iter() + .all(|field| field.is_plain()), + "{query}: {:?}", + root.output_schema + ); + } +}