diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index 15e8df9b..4b766082 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -5,11 +5,22 @@ //! [`ResourceCalibration`]; without that calibration the dimensional //! estimate is still useful for explanations, but is not silently comparable. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use asap_types::post_asap::{SketchAlgorithm, SketchParams}; +use asap_types::workload::DataArrival; use serde::{Deserialize, Serialize}; -pub const ANALYTICAL_MODEL_VERSION: &str = "analytical-resource-at-rest-v1"; +use crate::physical_operator_statistics::{ + validate_comparison_scopes, ComparisonScope, EdgeStatistics, OperatorStatistics, + OperatorStatisticsProvider, PromqlEdgeStatistics, PromqlValueKind, SourceCoverage, +}; + +/// Version of the analytical formulas applied to evidenced physical plans. +/// +/// This identifies the estimation method, not a physical executor or runtime +/// implementation version. +pub const ANALYTICAL_COST_MODEL_VERSION: &str = "analytical-cost-v1"; /// Conversion from physical dimensions to one deployment-specific objective. /// Memory's coefficient means cost units per retained byte over this model's @@ -55,37 +66,119 @@ pub struct ResourceEstimate { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PhysicalOperator { Scan, - Filter, - Project, - HashAggregate, - Sort, - TopK, - HashJoin, - Deduplicate, + Filter { + /// Scalar predicate operations evaluated for each input row. + predicate_operations_per_row: u64, + }, + Project { + /// Scalar expression/copy operations evaluated for each input row. + expression_operations_per_row: u64, + }, + HashAggregate { + grouping_key_count: u64, + accumulator_count: u64, + }, + InMemoryComparisonSort { + ordering_key_count: u64, + partitioned: bool, + }, + /// Heap-based bounded ordering. The heap retains `limit + offset` rows + /// while the operator returns at most `limit` rows after skipping offset. + TopK { + limit: u64, + offset: u64, + ordering_key_count: u64, + }, + HashJoin { + build_side: HashJoinBuildSide, + equality_key_count: u64, + }, + HashDeduplicate { + key_count: u64, + }, Concat, - Window, - Limit, + /// SQL analytic window evaluated over ordered in-memory partitions. This + /// is unrelated to streaming tumbling/sliding/EH window layouts. + InMemoryAnalyticWindow { + partition_key_count: u64, + ordering_key_count: u64, + function_operations_per_row: u64, + }, + Limit { + limit: u64, + offset: u64, + }, PassThrough, + PromqlRange { + range_millis: u64, + }, + PromqlSubquery { + range_millis: u64, + resolution_millis: Option, + }, + PromqlBinary { + operation: PromqlBinaryOperation, + operand_mode: PromqlBinaryOperandMode, + cardinality: PromqlVectorCardinality, + build_side: Option, + }, + PromqlRelabel { + expression_operations_per_row: u64, + }, + PromqlInfoEnrich { + matcher_operations_per_info_row: u64, + }, + PromqlSeriesSample { + kind: PromqlSeriesSampleKind, + grouping_key_count: u64, + }, + PromqlScalarToVector, + PromqlVectorToScalar, + PromqlScalarLeaf, + PromqlPerSeries { + operations_per_row: u64, + accumulator_count: u64, + }, + PromqlPresence { + kind: PromqlPresenceKind, + operations_per_row: u64, + }, } -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct OperatorInputs { - pub input_rows: u64, - pub input_bytes: u64, - pub output_rows: u64, - pub output_bytes: u64, - pub group_count: Option, - pub key_bytes: Option, - /// Bytes of aggregate accumulator state retained per group. Required for - /// hash aggregation because one 8-byte value is not universal. - pub aggregate_value_bytes: Option, - pub k: Option, - /// Rows actually consumed by a physical Limit, including rows skipped by - /// OFFSET. This is distinct from `output_rows`. - pub limit_rows_consumed: Option, - pub right_rows: Option, - pub right_bytes: Option, - pub hash_join_build_side: Option, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PromqlBinaryOperandMode { + VectorVector, + VectorScalar, + ScalarVector, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PromqlBinaryOperation { + ArithmeticOrComparison, + And, + Or, + Unless, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PromqlVectorCardinality { + OneToOne, + ManyToOne, + OneToMany, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PromqlSeriesSampleKind { + LimitK { k: u64 }, + LimitRatio { ratio_bits: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PromqlPresenceKind { + /// `absent` and `absent_over_time`: synthesize at most one output series. + Absent, + /// `present_over_time`: emit independently for each input series. + PresentPerSeries, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -101,8 +194,11 @@ pub enum HashJoinBuildSide { pub struct PhysicalDagNode { pub id: String, pub operator: PhysicalOperator, - pub inputs: OperatorInputs, pub children: Vec, + /// Exact comparison-scope coverage consumed by a scan. Non-scan nodes + /// leave this empty. Reusing `SourceCoverage` prevents a physical plan + /// from naming a source independently of its snapshot and predicates. + pub source_coverage: Option, /// Maximum transient edge buffer, distinct from logical `output_bytes`. pub output_buffer_bytes: u64, /// State that remains live after this node finishes (zero for ordinary @@ -111,25 +207,115 @@ pub struct PhysicalDagNode { pub execution: ExecutionMultiplicity, } +/// Authoritative statistics and allocation evidence for one physical node. +/// The identity is repeated so a map entry cannot be accidentally rebound to +/// a different node while an estimate is in progress. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhysicalNodeEvidence { + pub physical_id: String, + pub statistics: OperatorStatistics, + pub output_buffer_bytes: u64, +} + +/// A complete physical DAG together with the evidence used to cost it. +/// +/// This representation is shared by query lowering and summary-maintenance +/// lowering. Keeping the root and evidence beside the nodes prevents callers +/// from estimating a valid node list with a different entry point or snapshot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EvidenceBackedPhysicalDag { + pub nodes: Vec, + pub root: String, + pub evidence: HashMap, +} + +impl OperatorStatisticsProvider for HashMap { + fn statistics(&self, node_id: &str) -> Result { + let evidence = self + .get(node_id) + .ok_or_else(|| AnalyticalCostError::MissingOperatorStatistics(node_id.into()))?; + if evidence.physical_id != node_id { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "evidence map key differs from embedded physical identity", + )); + } + Ok(evidence.statistics.clone()) + } +} + +impl OperatorStatisticsProvider for EvidenceBackedPhysicalDag { + fn statistics(&self, node_id: &str) -> Result { + let evidence = self + .evidence + .get(node_id) + .ok_or_else(|| AnalyticalCostError::MissingOperatorStatistics(node_id.into()))?; + let node = self.nodes.iter().find(|node| node.id == node_id).ok_or( + AnalyticalCostError::InvalidPhysicalDag("evidence has no matching physical node"), + )?; + if evidence.physical_id != node_id { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "evidence map key differs from embedded physical identity", + )); + } + if node.output_buffer_bytes != evidence.output_buffer_bytes { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "physical node buffer differs from evidence snapshot", + )); + } + Ok(evidence.statistics.clone()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ExecutionMultiplicity { Once, PerEvaluation, } +/// Borrowed inputs for one physical-DAG estimate. This remains available +/// independently for diagnostics; plan selection should use +/// [`estimate_physical_dag_comparison`] so scope equality is mandatory. +pub struct PhysicalDagEstimateRequest<'a> { + pub nodes: &'a [PhysicalDagNode], + pub root: &'a str, + pub scope: &'a ComparisonScope, + pub statistics: &'a dyn OperatorStatisticsProvider, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct PhysicalDagComparisonEstimate { + pub raw: ResourceEstimate, + pub candidate: ResourceEstimate, +} + +/// Estimate two plans only after proving that their source, snapshot, +/// predicate, event-time, recurrence, and horizon scopes are identical. +pub fn estimate_physical_dag_comparison( + raw: PhysicalDagEstimateRequest<'_>, + candidate: PhysicalDagEstimateRequest<'_>, +) -> Result { + validate_comparison_scopes(raw.scope, candidate.scope)?; + Ok(PhysicalDagComparisonEstimate { + raw: estimate_physical_dag(raw.nodes, raw.root, raw.scope, raw.statistics)?, + candidate: estimate_physical_dag( + candidate.nodes, + candidate.root, + candidate.scope, + candidate.statistics, + )?, + }) +} + /// Compose local operator estimates once per physical identity. CPU and disk /// are additive; peak memory is simulated over a child-before-parent schedule /// and releases transient child outputs after their last consumer. pub fn estimate_physical_dag( nodes: &[PhysicalDagNode], root: &str, - evaluation_count: u64, + scope: &ComparisonScope, + statistics: &(impl OperatorStatisticsProvider + ?Sized), ) -> Result { - use std::collections::HashMap; - - if evaluation_count == 0 { - return Err(AnalyticalCostError::MissingOrZero("evaluation_count")); - } + let evaluation_count = scope.validate()?; let by_id: HashMap<&str, &PhysicalDagNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect(); if by_id.len() != nodes.len() { return Err(AnalyticalCostError::InvalidPhysicalDag("duplicate node id")); @@ -163,8 +349,39 @@ pub fn estimate_physical_dag( } visit(root, &by_id, &mut visiting, &mut visited, &mut order)?; + // Resolve each reachable node exactly once. A provider may be backed by a + // live catalog; one estimate must not mix observations from two refreshes. + let resolved_statistics: HashMap<&str, OperatorStatistics> = order + .iter() + .map(|id| statistics.statistics(id).map(|value| (*id, value))) + .collect::>()?; + + let mut consumed_sources = Vec::new(); for id in &order { let node = by_id[id]; + let node_statistics = &resolved_statistics[id]; + match node.operator { + PhysicalOperator::Scan => { + let coverage = node.source_coverage.as_ref().ok_or_else(|| { + AnalyticalCostError::MissingScanSourceCoverage(node.id.clone()) + })?; + if !scope.sources.contains(coverage) { + return Err(AnalyticalCostError::ScanOutsideComparisonScope( + node.id.clone(), + )); + } + if !consumed_sources.contains(&coverage) { + consumed_sources.push(coverage); + } + } + _ if node.source_coverage.is_some() => { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "only scan nodes may declare source coverage", + )); + } + _ => {} + } + validate_operator_statistics(node, node_statistics, &by_id, &resolved_statistics)?; if node.retained_bytes > 0 && matches!(node.execution, ExecutionMultiplicity::PerEvaluation) { return Err(AnalyticalCostError::InvalidPhysicalDag( @@ -173,6 +390,13 @@ pub fn estimate_physical_dag( } for child in &node.children { let child = by_id[child.as_str()]; + if matches!(node.execution, ExecutionMultiplicity::Once) + && matches!(child.execution, ExecutionMultiplicity::PerEvaluation) + { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "build-once node cannot consume a per-evaluation child", + )); + } if matches!(node.execution, ExecutionMultiplicity::PerEvaluation) && matches!(child.execution, ExecutionMultiplicity::Once) && child.retained_bytes == 0 @@ -183,6 +407,15 @@ pub fn estimate_physical_dag( } } } + if scope + .sources + .iter() + .any(|expected| !consumed_sources.contains(&expected)) + { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "physical scans omit a comparison-scope source", + )); + } let mut remaining_consumers: HashMap<&str, usize> = HashMap::new(); for id in &order { @@ -197,7 +430,7 @@ pub fn estimate_physical_dag( let mut live_outputs: HashMap<&str, u64> = HashMap::new(); for id in order { let node = by_id[id]; - let local = estimate_operator(node.operator, node.inputs)?; + let local = estimate_operator(node.operator, resolved_statistics[id].clone())?; let executions = match node.execution { ExecutionMultiplicity::Once => 1, ExecutionMultiplicity::PerEvaluation => evaluation_count, @@ -214,6 +447,7 @@ pub fn estimate_physical_dag( peak_memory_bytes = peak_memory_bytes.max( live_bytes .checked_add(local.peak_memory_bytes) + .and_then(|bytes| bytes.checked_add(node.output_buffer_bytes)) .ok_or(AnalyticalCostError::Overflow)?, ); if node.retained_bytes > 0 { @@ -254,132 +488,696 @@ pub fn estimate_physical_dag( }) } +fn validate_operator_statistics( + node: &PhysicalDagNode, + node_statistics: &OperatorStatistics, + nodes: &HashMap<&str, &PhysicalDagNode>, + statistics: &HashMap<&str, OperatorStatistics>, +) -> Result<(), AnalyticalCostError> { + let arity = expected_input_arity(node.operator, node.children.len()); + if node_statistics.input_count() != arity.statistics_inputs { + return Err(AnalyticalCostError::InvalidOperatorStatistics { + node: node.id.clone(), + reason: "operator-statistics input count does not match physical arity", + }); + } + if node.children.len() != arity.dag_children { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "operator child count does not match physical arity", + )); + } + if !statistics_match_operator(node.operator, node_statistics) { + return Err(AnalyticalCostError::InvalidOperatorStatistics { + node: node.id.clone(), + reason: "statistics variant does not match physical operator", + }); + } + for edge in (0..node_statistics.input_count()) + .filter_map(|index| node_statistics.input(index)) + .chain(std::iter::once(node_statistics.output())) + { + if !edge.is_consistent() { + return Err(AnalyticalCostError::InvalidOperatorStatistics { + node: node.id.clone(), + reason: "edge rows and logical bytes are inconsistent", + }); + } + } + for (input_index, child_id) in node.children.iter().enumerate() { + let child = nodes + .get(child_id.as_str()) + .ok_or(AnalyticalCostError::InvalidPhysicalDag("missing node"))?; + let child_statistics = &statistics[child.id.as_str()]; + if node_statistics.input(input_index) != Some(child_statistics.output()) { + return Err(AnalyticalCostError::ConflictingEdgeStatistics { + parent: node.id.clone(), + child: child.id.clone(), + input_index, + }); + } + } + validate_operator_semantics(node.operator, node_statistics)?; + validate_promql_child_edges(node, node_statistics, statistics)?; + Ok(()) +} + +fn validate_promql_child_edges( + node: &PhysicalDagNode, + node_statistics: &OperatorStatistics, + statistics: &HashMap<&str, OperatorStatistics>, +) -> Result<(), AnalyticalCostError> { + let output = node_statistics.promql_output(); + let child_has_promql = node + .children + .iter() + .any(|child_id| statistics[child_id.as_str()].promql_output().is_some()); + if output.is_none() && child_has_promql { + return Err(AnalyticalCostError::InvalidOperatorStatistics { + node: node.id.clone(), + reason: "operator drops child PromQL edge statistics", + }); + } + let Some(output) = output else { return Ok(()) }; + validate_promql_edge(output)?; + for (index, child_id) in node.children.iter().enumerate() { + let input = + node_statistics + .promql_input(index) + .ok_or(AnalyticalCostError::MissingOrStale( + "promql_input_edge_statistics", + ))?; + let child = statistics[child_id.as_str()].promql_output().ok_or( + AnalyticalCostError::MissingOrStale("promql_child_output_statistics"), + )?; + validate_promql_edge(input)?; + if input != child { + return Err(AnalyticalCostError::InvalidOperatorStatistics { + node: node.id.clone(), + reason: "PromQL parent input does not match child output", + }); + } + } + Ok(()) +} + +fn validate_promql_edge(edge: PromqlEdgeStatistics) -> Result<(), AnalyticalCostError> { + if edge.evaluation_steps == 0 { + return Err(AnalyticalCostError::MissingOrZero("evaluation_steps")); + } + if matches!(edge.value_kind, PromqlValueKind::Scalar) && edge.series != 0 { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL scalar edge cannot carry series", + )); + } + Ok(()) +} + +fn statistics_match_operator(operator: PhysicalOperator, statistics: &OperatorStatistics) -> bool { + match operator { + PhysicalOperator::Scan => matches!(statistics, OperatorStatistics::Scan { .. }), + PhysicalOperator::Filter { .. } => matches!(statistics, OperatorStatistics::Filter { .. }), + PhysicalOperator::Project { .. } => { + matches!(statistics, OperatorStatistics::Project { .. }) + } + PhysicalOperator::HashAggregate { .. } => { + matches!(statistics, OperatorStatistics::HashAggregate { .. }) + } + PhysicalOperator::InMemoryComparisonSort { .. } => { + matches!( + statistics, + OperatorStatistics::InMemoryComparisonSort { .. } + ) + } + PhysicalOperator::TopK { .. } => matches!(statistics, OperatorStatistics::TopK { .. }), + PhysicalOperator::HashJoin { .. } => { + matches!(statistics, OperatorStatistics::HashJoin { .. }) + } + PhysicalOperator::HashDeduplicate { .. } => { + matches!(statistics, OperatorStatistics::HashDeduplicate { .. }) + } + PhysicalOperator::Concat => matches!(statistics, OperatorStatistics::Concat { .. }), + PhysicalOperator::InMemoryAnalyticWindow { .. } => { + matches!( + statistics, + OperatorStatistics::InMemoryAnalyticWindow { .. } + ) + } + PhysicalOperator::Limit { .. } => matches!(statistics, OperatorStatistics::Limit { .. }), + PhysicalOperator::PassThrough => { + matches!(statistics, OperatorStatistics::PassThrough { .. }) + } + PhysicalOperator::PromqlRange { .. } => { + matches!(statistics, OperatorStatistics::PromqlRange { .. }) + } + PhysicalOperator::PromqlSubquery { .. } => { + matches!(statistics, OperatorStatistics::PromqlSubquery { .. }) + } + PhysicalOperator::PromqlBinary { .. } => { + matches!(statistics, OperatorStatistics::PromqlBinary { .. }) + } + PhysicalOperator::PromqlRelabel { .. } => { + matches!(statistics, OperatorStatistics::PromqlRelabel { .. }) + } + PhysicalOperator::PromqlInfoEnrich { .. } => { + matches!(statistics, OperatorStatistics::PromqlInfoEnrich { .. }) + } + PhysicalOperator::PromqlSeriesSample { .. } => { + matches!(statistics, OperatorStatistics::PromqlSeriesSample { .. }) + } + PhysicalOperator::PromqlScalarToVector => { + matches!(statistics, OperatorStatistics::PromqlScalarToVector { .. }) + } + PhysicalOperator::PromqlVectorToScalar => { + matches!(statistics, OperatorStatistics::PromqlVectorToScalar { .. }) + } + PhysicalOperator::PromqlScalarLeaf => { + matches!(statistics, OperatorStatistics::PromqlScalarLeaf { .. }) + } + PhysicalOperator::PromqlPerSeries { .. } => { + matches!(statistics, OperatorStatistics::PromqlPerSeries { .. }) + } + PhysicalOperator::PromqlPresence { .. } => { + matches!(statistics, OperatorStatistics::PromqlPresence { .. }) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct OperatorInputArity { + /// Number of logical input-edge records required in `OperatorStatistics`. + statistics_inputs: usize, + /// Number of upstream physical nodes required in the DAG. + dag_children: usize, +} + +/// Declare both notions of operator input explicitly. A scan has one external +/// source-input statistics record but no upstream DAG node. Every other +/// operator's statistics inputs correspond one-to-one with its DAG children. +/// +/// Keep this match exhaustive: adding a physical operator must also define its +/// statistics and DAG arity instead of silently inheriting unary behavior. +fn expected_input_arity( + operator: PhysicalOperator, + variadic_child_count: usize, +) -> OperatorInputArity { + let unary = OperatorInputArity { + statistics_inputs: 1, + dag_children: 1, + }; + match operator { + PhysicalOperator::Scan => OperatorInputArity { + statistics_inputs: 1, + dag_children: 0, + }, + PhysicalOperator::Filter { .. } + | PhysicalOperator::Project { .. } + | PhysicalOperator::HashAggregate { .. } + | PhysicalOperator::InMemoryComparisonSort { .. } + | PhysicalOperator::TopK { .. } + | PhysicalOperator::HashDeduplicate { .. } + | PhysicalOperator::InMemoryAnalyticWindow { .. } + | PhysicalOperator::Limit { .. } + | PhysicalOperator::PassThrough + | PhysicalOperator::PromqlRange { .. } + | PhysicalOperator::PromqlSubquery { .. } + | PhysicalOperator::PromqlRelabel { .. } + | PhysicalOperator::PromqlSeriesSample { .. } + | PhysicalOperator::PromqlScalarToVector + | PhysicalOperator::PromqlVectorToScalar + | PhysicalOperator::PromqlPerSeries { .. } + | PhysicalOperator::PromqlPresence { .. } => unary, + PhysicalOperator::HashJoin { .. } + | PhysicalOperator::PromqlBinary { .. } + | PhysicalOperator::PromqlInfoEnrich { .. } => OperatorInputArity { + statistics_inputs: 2, + dag_children: 2, + }, + PhysicalOperator::PromqlScalarLeaf => OperatorInputArity { + statistics_inputs: 0, + dag_children: 0, + }, + PhysicalOperator::Concat => OperatorInputArity { + statistics_inputs: variadic_child_count, + dag_children: variadic_child_count, + }, + } +} + +fn checked_cpu_product(rows: u64, operations_per_row: u64) -> Result { + Ok(rows + .checked_mul(operations_per_row) + .ok_or(AnalyticalCostError::Overflow)? as f64) +} + +fn partitioned_order_estimate( + partitioning: &crate::physical_operator_statistics::PartitionStatistics, + comparison_operations: u64, + row_operations: u64, +) -> Result { + let mut cpu_ops = 0.0; + let mut peak_memory_bytes = 0; + for partition in &partitioning.partitions { + let comparisons = partition.rows as f64 + * (partition.rows.max(2) as f64).log2().ceil() + * comparison_operations as f64; + cpu_ops += comparisons + checked_cpu_product(partition.rows, row_operations)?; + peak_memory_bytes = peak_memory_bytes.max(partition.bytes); + } + if !cpu_ops.is_finite() { + return Err(AnalyticalCostError::Overflow); + } + Ok(ResourceEstimate { + cpu_ops, + peak_memory_bytes, + scan_bytes: 0, + }) +} + +fn validate_partitioning( + input: EdgeStatistics, + partitioning: &crate::physical_operator_statistics::PartitionStatistics, + partitioned: bool, +) -> Result<(), AnalyticalCostError> { + let inconsistent = |reason| Err(AnalyticalCostError::InconsistentOperatorStatistics(reason)); + if input.rows == 0 { + return if partitioning.partitions.is_empty() { + Ok(()) + } else { + inconsistent("empty input has non-empty partition evidence") + }; + } + if partitioning.partitions.is_empty() { + return inconsistent("non-empty ordered input has no partition evidence"); + } + if !partitioned && partitioning.partitions.len() != 1 { + return inconsistent("global ordering must have exactly one partition"); + } + let total = partitioning.partitions.iter().try_fold( + EdgeStatistics { rows: 0, bytes: 0 }, + |total, partition| { + if !partition.is_consistent() || partition.rows == 0 { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "ordered partition evidence is invalid", + )); + } + Ok(EdgeStatistics { + rows: total + .rows + .checked_add(partition.rows) + .ok_or(AnalyticalCostError::Overflow)?, + bytes: total + .bytes + .checked_add(partition.bytes) + .ok_or(AnalyticalCostError::Overflow)?, + }) + }, + )?; + if total != input { + return inconsistent("ordered partitions do not sum to the input edge"); + } + Ok(()) +} + /// Estimate one physical operator. Child costs are deliberately excluded; /// a DAG walker sums CPU/disk once per node and combines simultaneously /// retained state separately. pub fn estimate_operator( operator: PhysicalOperator, - input: OperatorInputs, + statistics: OperatorStatistics, ) -> Result { - validate_operator_semantics(operator, &input)?; + validate_operator_semantics(operator, &statistics)?; let per_row_width = |rows: u64, bytes: u64| -> Result { - if rows == 0 || bytes == 0 { - return Err(AnalyticalCostError::MissingOrZero("operator rows/bytes")); + match (rows, bytes) { + (0, 0) => return Ok(0), + (0, _) | (_, 0) => { + return Err(AnalyticalCostError::MissingOrZero("operator rows/bytes")); + } + _ => {} } Ok(bytes.div_ceil(rows)) }; - let estimate = match operator { - PhysicalOperator::Scan => ResourceEstimate { - cpu_ops: input.input_rows as f64, - peak_memory_bytes: per_row_width(input.input_rows, input.input_bytes)?, - scan_bytes: input.input_bytes, + let input = |index: usize| { + statistics + .input(index) + .ok_or(AnalyticalCostError::MissingOrZero("operator input edge")) + }; + let output = statistics.output(); + if let ( + PhysicalOperator::PromqlScalarLeaf, + OperatorStatistics::PromqlScalarLeaf { promql_output, .. }, + ) = (operator, &statistics) + { + validate_promql_edge(*promql_output)?; + if promql_output.value_kind != PromqlValueKind::Scalar + || output.rows != promql_output.evaluation_steps + { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL scalar leaf must emit one scalar row per evaluation step", + )); + } + return Ok(ResourceEstimate { + cpu_ops: output.rows as f64, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, + scan_bytes: 0, + }); + } + let left = input(0)?; + let estimate = match (operator, &statistics) { + ( + PhysicalOperator::Scan, + OperatorStatistics::Scan { + source_read_bytes, .. + }, + ) => ResourceEstimate { + cpu_ops: left.rows as f64, + peak_memory_bytes: per_row_width(left.rows, left.bytes)?, + scan_bytes: *source_read_bytes, + }, + ( + PhysicalOperator::Filter { + predicate_operations_per_row, + }, + _, + ) => ResourceEstimate { + cpu_ops: checked_cpu_product(left.rows, predicate_operations_per_row)?, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, + scan_bytes: 0, + }, + ( + PhysicalOperator::Project { + expression_operations_per_row, + }, + _, + ) => ResourceEstimate { + cpu_ops: checked_cpu_product(left.rows, expression_operations_per_row)?, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, + scan_bytes: 0, + }, + (PhysicalOperator::PassThrough, _) => ResourceEstimate { + cpu_ops: left.rows as f64, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, + scan_bytes: 0, + }, + ( + PhysicalOperator::HashAggregate { + grouping_key_count, + accumulator_count, + }, + OperatorStatistics::HashAggregate { + group_count, + key_bytes, + accumulator_bytes_per_group, + .. + }, + ) => ResourceEstimate { + cpu_ops: checked_cpu_product( + left.rows, + grouping_key_count + .checked_add(accumulator_count) + .ok_or(AnalyticalCostError::Overflow)?, + )?, + peak_memory_bytes: checked_bytes(&[ + *group_count, + key_bytes + .checked_add(*accumulator_bytes_per_group) + .and_then(|bytes| bytes.checked_add(16)) + .ok_or(AnalyticalCostError::Overflow)?, + ])?, + scan_bytes: 0, + }, + ( + PhysicalOperator::HashDeduplicate { key_count }, + OperatorStatistics::HashDeduplicate { + distinct_key_count, + key_bytes, + .. + }, + ) => ResourceEstimate { + cpu_ops: checked_cpu_product(left.rows, key_count)?, + peak_memory_bytes: checked_bytes(&[ + *distinct_key_count, + key_bytes + .checked_add(16) + .ok_or(AnalyticalCostError::Overflow)?, + ])?, + scan_bytes: 0, }, - PhysicalOperator::Filter | PhysicalOperator::Project | PhysicalOperator::PassThrough => { + ( + PhysicalOperator::InMemoryComparisonSort { + ordering_key_count, .. + }, + OperatorStatistics::InMemoryComparisonSort { + input_partitioning, .. + }, + ) => partitioned_order_estimate(input_partitioning, ordering_key_count, 0)?, + ( + PhysicalOperator::InMemoryAnalyticWindow { + partition_key_count, + ordering_key_count, + function_operations_per_row, + }, + OperatorStatistics::InMemoryAnalyticWindow { + input_partitioning, .. + }, + ) => partitioned_order_estimate( + input_partitioning, + partition_key_count + .checked_add(ordering_key_count) + .ok_or(AnalyticalCostError::Overflow)?, + function_operations_per_row, + )?, + ( + PhysicalOperator::TopK { + limit, + offset, + ordering_key_count, + }, + _, + ) => { + let heap_capacity = limit + .checked_add(offset) + .ok_or(AnalyticalCostError::Overflow)?; + let heap_rows = heap_capacity.min(left.rows); ResourceEstimate { - cpu_ops: input.input_rows as f64, - peak_memory_bytes: per_row_width(input.output_rows, input.output_bytes)?, + cpu_ops: left.rows as f64 + * (heap_rows.max(2) as f64).log2().ceil() + * ordering_key_count as f64, + peak_memory_bytes: checked_bytes(&[ + heap_rows, + per_row_width(left.rows, left.bytes)?, + ])?, scan_bytes: 0, } } - PhysicalOperator::HashAggregate => { - let groups = input - .group_count - .ok_or(AnalyticalCostError::MissingOrZero("group_count"))?; - let key = input - .key_bytes - .ok_or(AnalyticalCostError::MissingOrZero("key_bytes"))?; - let value = input - .aggregate_value_bytes - .ok_or(AnalyticalCostError::MissingOrZero("aggregate_value_bytes"))?; + ( + PhysicalOperator::HashJoin { + build_side, + equality_key_count, + }, + _, + ) => { + let right = input(1)?; ResourceEstimate { - cpu_ops: input.input_rows as f64, - peak_memory_bytes: checked_bytes(&[ - groups, - key.checked_add(value) - .and_then(|bytes| bytes.checked_add(16)) + cpu_ops: checked_cpu_product( + left.rows + .checked_add(right.rows) .ok_or(AnalyticalCostError::Overflow)?, - ])?, + equality_key_count, + )? + output.rows as f64, + peak_memory_bytes: match build_side { + HashJoinBuildSide::Left => left + .rows + .checked_mul(16) + .and_then(|metadata| left.bytes.checked_add(metadata)), + HashJoinBuildSide::Right => right + .rows + .checked_mul(16) + .and_then(|metadata| right.bytes.checked_add(metadata)), + } + .ok_or(AnalyticalCostError::Overflow)?, + scan_bytes: 0, + } + } + (PhysicalOperator::Concat, _) => ResourceEstimate { + cpu_ops: output.rows as f64, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, + scan_bytes: 0, + }, + (PhysicalOperator::Limit { limit, offset }, _) => { + let consumed = if limit == 0 { + 0 + } else { + left.rows.min( + offset + .checked_add(limit) + .ok_or(AnalyticalCostError::Overflow)?, + ) + }; + ResourceEstimate { + cpu_ops: consumed as f64, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, scan_bytes: 0, } } - PhysicalOperator::Deduplicate => { - let groups = input - .group_count - .ok_or(AnalyticalCostError::MissingOrZero("group_count"))?; - let key = input - .key_bytes - .ok_or(AnalyticalCostError::MissingOrZero("key_bytes"))?; + ( + PhysicalOperator::PromqlRange { .. }, + OperatorStatistics::PromqlRange { + edges, + max_window_samples_per_series, + }, + ) => { + let promql = require_promql_unary(edges)?; ResourceEstimate { - cpu_ops: input.input_rows as f64, + cpu_ops: left.rows as f64, peak_memory_bytes: checked_bytes(&[ - groups, - key.checked_add(16).ok_or(AnalyticalCostError::Overflow)?, + promql.input.series, + *max_window_samples_per_series, + per_row_width(left.rows, left.bytes)?, ])?, scan_bytes: 0, } } - PhysicalOperator::Sort | PhysicalOperator::Window => ResourceEstimate { - cpu_ops: input.input_rows as f64 * (input.input_rows.max(2) as f64).log2().ceil(), - peak_memory_bytes: input.input_bytes, + ( + PhysicalOperator::PromqlSubquery { .. }, + OperatorStatistics::PromqlSubquery { edges, .. }, + ) => ResourceEstimate { + cpu_ops: left.rows as f64 + output.rows as f64, + peak_memory_bytes: edges.input.bytes, + scan_bytes: 0, + }, + ( + PhysicalOperator::PromqlBinary { + operand_mode, + build_side, + .. + }, + OperatorStatistics::PromqlBinary { + edges, + matching_key_bytes, + }, + ) => { + 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"), + )? { + HashJoinBuildSide::Left => promql.inputs[0].series, + HashJoinBuildSide::Right => promql.inputs[1].series, + }; + checked_bytes(&[ + build_series, + matching_key_bytes + .checked_add(16) + .ok_or(AnalyticalCostError::Overflow)?, + ])? + } + }; + ResourceEstimate { + cpu_ops: left.rows as f64 + right.rows as f64 + output.rows as f64, + peak_memory_bytes: matching_bytes, + scan_bytes: 0, + } + } + ( + PhysicalOperator::PromqlRelabel { + expression_operations_per_row, + }, + OperatorStatistics::PromqlRelabel { .. }, + ) => ResourceEstimate { + cpu_ops: checked_cpu_product(left.rows, expression_operations_per_row)?, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, scan_bytes: 0, }, - PhysicalOperator::TopK => { - let k = input.k.ok_or(AnalyticalCostError::MissingOrZero("k"))?; - let heap_rows = k.min(input.input_rows); + ( + PhysicalOperator::PromqlInfoEnrich { + matcher_operations_per_info_row, + }, + OperatorStatistics::PromqlInfoEnrich { + edges, + matching_key_bytes, + }, + ) => { + let promql = require_promql_binary(edges)?; + let right = input(1)?; ResourceEstimate { - cpu_ops: input.input_rows as f64 * (heap_rows.max(2) as f64).log2().ceil(), + cpu_ops: left.rows as f64 + + right.rows as f64 + + output.rows as f64 + + checked_cpu_product(right.rows, matcher_operations_per_info_row)?, peak_memory_bytes: checked_bytes(&[ - heap_rows, - per_row_width(input.input_rows, input.input_bytes)?, + promql.inputs[1].series, + matching_key_bytes + .checked_add(16) + .ok_or(AnalyticalCostError::Overflow)?, ])?, scan_bytes: 0, } } - PhysicalOperator::HashJoin => { - let right_rows = input - .right_rows - .ok_or(AnalyticalCostError::MissingOrZero("right_rows"))?; - let right_bytes = input - .right_bytes - .ok_or(AnalyticalCostError::MissingOrZero("right_bytes"))?; - let build_side = input - .hash_join_build_side - .ok_or(AnalyticalCostError::MissingOrZero("hash_join_build_side"))?; + ( + PhysicalOperator::PromqlSeriesSample { .. }, + OperatorStatistics::PromqlSeriesSample { + edges, key_bytes, .. + }, + ) => { + let promql = require_promql_unary(edges)?; ResourceEstimate { - cpu_ops: input.input_rows as f64 + right_rows as f64 + input.output_rows as f64, - peak_memory_bytes: match build_side { - HashJoinBuildSide::Left => input - .input_rows - .checked_mul(16) - .and_then(|metadata| input.input_bytes.checked_add(metadata)), - HashJoinBuildSide::Right => right_rows - .checked_mul(16) - .and_then(|metadata| right_bytes.checked_add(metadata)), - } - .ok_or(AnalyticalCostError::Overflow)?, + cpu_ops: left.rows as f64 + promql.input.series as f64, + peak_memory_bytes: checked_bytes(&[ + promql.output.series, + key_bytes + .checked_add(16) + .ok_or(AnalyticalCostError::Overflow)?, + ])?, scan_bytes: 0, } } - PhysicalOperator::Concat => ResourceEstimate { - cpu_ops: input.output_rows as f64, - peak_memory_bytes: per_row_width(input.output_rows, input.output_bytes)?, - scan_bytes: 0, - }, - PhysicalOperator::Limit => { - let consumed = input - .limit_rows_consumed - .ok_or(AnalyticalCostError::MissingOrZero("limit_rows_consumed"))?; - if consumed > input.input_rows || consumed < input.output_rows { - return Err(AnalyticalCostError::InconsistentOperatorStatistics( - "Limit rows consumed must cover its output without exceeding its input", - )); + (PhysicalOperator::PromqlScalarToVector | PhysicalOperator::PromqlVectorToScalar, _) => { + ResourceEstimate { + cpu_ops: left.rows as f64 + output.rows as f64, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, + scan_bytes: 0, } + } + ( + PhysicalOperator::PromqlPerSeries { + operations_per_row, .. + }, + OperatorStatistics::PromqlPerSeries { + edges, + accumulator_bytes_per_series, + }, + ) => { + let promql = require_promql_unary(edges)?; ResourceEstimate { - cpu_ops: consumed as f64, - peak_memory_bytes: per_row_width(input.output_rows, input.output_bytes)?, + cpu_ops: checked_cpu_product(left.rows, operations_per_row)?, + peak_memory_bytes: checked_bytes(&[ + promql.input.series, + *accumulator_bytes_per_series, + ])?, scan_bytes: 0, } } + ( + PhysicalOperator::PromqlPresence { + operations_per_row, .. + }, + OperatorStatistics::PromqlPresence { .. }, + ) => ResourceEstimate { + cpu_ops: checked_cpu_product(left.rows, operations_per_row)? + output.rows as f64, + peak_memory_bytes: per_row_width(output.rows, output.bytes)?, + scan_bytes: 0, + }, + (PhysicalOperator::PromqlScalarLeaf, _) => unreachable!(), + _ => { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "statistics variant does not match physical operator", + )); + } }; if estimate.cpu_ops.is_finite() { Ok(estimate) @@ -388,59 +1186,737 @@ pub fn estimate_operator( } } -fn validate_operator_semantics( +pub(crate) fn validate_operator_semantics( operator: PhysicalOperator, - input: &OperatorInputs, + statistics: &OperatorStatistics, ) -> Result<(), AnalyticalCostError> { let inconsistent = |reason| Err(AnalyticalCostError::InconsistentOperatorStatistics(reason)); - match operator { - PhysicalOperator::Scan => { - if input.input_rows != input.output_rows || input.input_bytes != input.output_bytes { - return inconsistent("Scan input and output edges differ"); - } + if !statistics_match_operator(operator, statistics) { + return inconsistent("statistics variant does not match physical operator"); + } + if matches!( + (operator, statistics), + ( + PhysicalOperator::Concat, + OperatorStatistics::Concat { inputs, .. } + ) if inputs.is_empty() + ) { + return inconsistent("Concat must have at least one input"); + } + if let ( + PhysicalOperator::PromqlScalarLeaf, + OperatorStatistics::PromqlScalarLeaf { + output, + promql_output, + }, + ) = (operator, statistics) + { + validate_promql_edge_shape(*output, *promql_output)?; + return if promql_output.value_kind == PromqlValueKind::Scalar { + Ok(()) + } else { + inconsistent("PromQL scalar leaf output is not scalar") + }; + } + let input = statistics + .input(0) + .ok_or(AnalyticalCostError::InconsistentOperatorStatistics( + "operator input is missing", + ))?; + let output = statistics.output(); + match (operator, statistics) { + ( + PhysicalOperator::Scan, + OperatorStatistics::Scan { + edges, + source_read_bytes, + }, + ) => { + if input != output { + return inconsistent("Scan input and output edges differ"); + } + if input.rows > 0 && *source_read_bytes == 0 { + return inconsistent("non-empty Scan has zero source-read bytes"); + } + if let Some(promql) = edges.promql { + validate_promql_edge_shape(edges.input, promql.input)?; + validate_promql_edge_shape(edges.output, promql.output)?; + if promql.input != promql.output { + return inconsistent("Scan changes its PromQL edge shape"); + } + } } - PhysicalOperator::Filter => { - if input.output_rows > input.input_rows || input.output_bytes > input.input_bytes { + ( + PhysicalOperator::Filter { + predicate_operations_per_row, + }, + _, + ) => { + if predicate_operations_per_row == 0 { + return inconsistent("Filter has no predicate work"); + } + if output.rows > input.rows || output.bytes > input.bytes { return inconsistent("Filter output expands its input"); } + if let Some(promql) = statistics.unary_promql() { + validate_promql_filter_shape(promql)?; + } } - PhysicalOperator::Project => { - if input.output_rows != input.input_rows { + ( + PhysicalOperator::Project { + expression_operations_per_row, + }, + _, + ) => { + if expression_operations_per_row == 0 { + return inconsistent("Project has no expression work"); + } + if output.rows != input.rows { return inconsistent("Project changes row cardinality"); } + if let Some(promql) = statistics.unary_promql() { + validate_promql_cardinality_preserving_shape(promql)?; + } } - PhysicalOperator::HashAggregate | PhysicalOperator::Deduplicate => { - let groups = input - .group_count - .filter(|groups| *groups > 0) - .ok_or(AnalyticalCostError::MissingOrZero("group_count"))?; - if groups > input.input_rows || input.output_rows != groups { + ( + PhysicalOperator::HashAggregate { + grouping_key_count, + accumulator_count, + }, + OperatorStatistics::HashAggregate { + group_count, + key_bytes, + accumulator_bytes_per_group, + .. + }, + ) => { + if statistics.promql_output().is_some() { + return inconsistent( + "PromQL cross-series aggregation needs an explicit physical operator", + ); + } + if accumulator_count == 0 || *accumulator_bytes_per_group == 0 { + return inconsistent("HashAggregate accumulator work and width must be positive"); + } + if grouping_key_count == 0 { + if *key_bytes != 0 || *group_count != 1 { + return inconsistent( + "ungrouped HashAggregate must have one zero-key-width group", + ); + } + } else if *key_bytes == 0 || *group_count > input.rows { + return inconsistent("grouped HashAggregate has invalid group evidence"); + } + if output.rows != *group_count { return inconsistent("grouped output differs from distinct group cardinality"); } } - PhysicalOperator::Sort - | PhysicalOperator::Window - | PhysicalOperator::PassThrough - | PhysicalOperator::Concat => { - if input.input_rows != input.output_rows || input.input_bytes != input.output_bytes { + ( + PhysicalOperator::HashDeduplicate { key_count }, + OperatorStatistics::HashDeduplicate { + distinct_key_count, + key_bytes, + .. + }, + ) => { + if key_count == 0 || *key_bytes == 0 { + return inconsistent("Deduplicate key count and width must be positive"); + } + if *distinct_key_count > input.rows || output.rows != *distinct_key_count { + return inconsistent("deduplicated output differs from distinct key cardinality"); + } + } + ( + PhysicalOperator::InMemoryComparisonSort { + ordering_key_count, + partitioned, + }, + OperatorStatistics::InMemoryComparisonSort { + input_partitioning, .. + }, + ) => { + if ordering_key_count == 0 { + return inconsistent("Sort has no ordering keys"); + } + if input != output { + return inconsistent("cardinality-preserving operator changes its edge"); + } + validate_partitioning(input, input_partitioning, partitioned)?; + } + (PhysicalOperator::PassThrough, _) => { + if input != output { return inconsistent("cardinality-preserving operator changes its edge"); } + if let Some(promql) = statistics.unary_promql() { + validate_promql_cardinality_preserving_shape(promql)?; + } + } + ( + PhysicalOperator::InMemoryAnalyticWindow { + partition_key_count, + ordering_key_count, + function_operations_per_row, + }, + OperatorStatistics::InMemoryAnalyticWindow { + input_partitioning, .. + }, + ) => { + if ordering_key_count == 0 || function_operations_per_row == 0 { + return inconsistent("analytic Window work must be positive"); + } + if input.rows != output.rows { + return inconsistent("Window changes row cardinality"); + } + validate_partitioning(input, input_partitioning, partition_key_count > 0)?; + } + (PhysicalOperator::Concat, OperatorStatistics::Concat { inputs, .. }) => { + if inputs.is_empty() { + return inconsistent("Concat must have at least one input"); + } + let total = + inputs + .iter() + .try_fold(EdgeStatistics { rows: 0, bytes: 0 }, |total, edge| { + Ok::<_, AnalyticalCostError>(EdgeStatistics { + rows: total + .rows + .checked_add(edge.rows) + .ok_or(AnalyticalCostError::Overflow)?, + bytes: total + .bytes + .checked_add(edge.bytes) + .ok_or(AnalyticalCostError::Overflow)?, + }) + })?; + if output != total { + return inconsistent("Concat output differs from the sum of its inputs"); + } + if let OperatorStatistics::Concat { + promql: Some(promql), + .. + } = statistics + { + if promql.inputs.len() != inputs.len() || promql.inputs.is_empty() { + return inconsistent("PromQL Concat edge arity is invalid"); + } + let first = promql.inputs[0]; + let series_bound = promql.inputs.iter().try_fold(0_u64, |total, edge| { + if edge.evaluation_steps != first.evaluation_steps + || edge.value_kind != first.value_kind + { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL Concat inputs have different shapes", + )); + } + total + .checked_add(edge.series) + .ok_or(AnalyticalCostError::Overflow) + })?; + if promql.output.evaluation_steps != first.evaluation_steps + || promql.output.value_kind != first.value_kind + || promql.output.series > series_bound + { + return inconsistent("PromQL Concat output exceeds its input-series bound"); + } + } } - PhysicalOperator::TopK => { - let k = input - .k - .filter(|k| *k > 0) - .ok_or(AnalyticalCostError::MissingOrZero("k"))?; - if input.output_rows != input.input_rows.min(k) { + ( + PhysicalOperator::TopK { + limit, + offset, + ordering_key_count, + }, + _, + ) => { + if limit == 0 || ordering_key_count == 0 { + return inconsistent("Top-K limit and ordering-key count must be positive"); + } + let expected = input.rows.saturating_sub(offset).min(limit); + if output.rows != expected { return inconsistent("Top-K output differs from its cardinality bound"); } } - PhysicalOperator::Limit => { - if input.output_rows > input.input_rows { - return inconsistent("Limit output exceeds its input"); + (PhysicalOperator::Limit { limit, offset }, _) => { + let expected = input.rows.saturating_sub(offset).min(limit); + if output.rows != expected { + return inconsistent("Limit output differs from limit and offset"); + } + } + ( + PhysicalOperator::HashJoin { + equality_key_count, .. + }, + _, + ) => { + if equality_key_count == 0 { + return inconsistent("HashJoin has no equality keys"); + } + } + ( + PhysicalOperator::PromqlRange { range_millis }, + OperatorStatistics::PromqlRange { + edges, + max_window_samples_per_series, + }, + ) => { + let promql = require_promql_unary(edges)?; + if range_millis == 0 || *max_window_samples_per_series == 0 { + return inconsistent("PromQL range needs a positive range and window bound"); + } + if promql.input.value_kind != PromqlValueKind::Vector + || promql.output.value_kind != PromqlValueKind::RangeVector + || promql.input.series != promql.output.series + || promql.input.evaluation_steps != promql.output.evaluation_steps + { + return inconsistent("PromQL range edge shape is invalid"); + } + } + ( + PhysicalOperator::PromqlSubquery { + range_millis, + resolution_millis, + }, + OperatorStatistics::PromqlSubquery { + edges, + subquery_steps, + }, + ) => { + let promql = require_promql_unary(edges)?; + if range_millis == 0 + || resolution_millis == Some(0) + || *subquery_steps == 0 + || promql.input.value_kind != PromqlValueKind::RangeVector + || promql.output.value_kind != PromqlValueKind::RangeVector + || promql.input.series != promql.output.series + { + return inconsistent("PromQL subquery configuration or edge shape is invalid"); + } + let expected = promql + .output + .evaluation_steps + .checked_mul(*subquery_steps) + .ok_or(AnalyticalCostError::Overflow)?; + if promql.input.evaluation_steps != expected { + return inconsistent( + "subquery child steps do not equal output steps times subquery steps", + ); + } + } + ( + PhysicalOperator::PromqlBinary { + operation, + operand_mode, + cardinality, + build_side, + }, + OperatorStatistics::PromqlBinary { + edges, + matching_key_bytes, + }, + ) => validate_promql_binary( + operation, + operand_mode, + cardinality, + build_side, + *matching_key_bytes, + edges, + )?, + ( + PhysicalOperator::PromqlRelabel { + expression_operations_per_row, + }, + OperatorStatistics::PromqlRelabel { edges }, + ) => { + let promql = require_promql_unary(edges)?; + validate_instant_vector_rows(output, promql.output)?; + if expression_operations_per_row == 0 + || input.rows != output.rows + || promql.input.series != promql.output.series + || promql.input.evaluation_steps != promql.output.evaluation_steps + || promql.input.value_kind != PromqlValueKind::Vector + || promql.output.value_kind != PromqlValueKind::Vector + { + return inconsistent("PromQL relabel configuration or edge shape is invalid"); } } - PhysicalOperator::HashJoin => {} + ( + PhysicalOperator::PromqlInfoEnrich { + matcher_operations_per_info_row, + }, + OperatorStatistics::PromqlInfoEnrich { + edges, + matching_key_bytes, + }, + ) => { + let promql = require_promql_binary(edges)?; + validate_instant_vector_rows(output, promql.output)?; + if matcher_operations_per_info_row == 0 + || *matching_key_bytes == 0 + || input.rows != output.rows + || promql.inputs[0].series != promql.output.series + || promql.inputs[0].evaluation_steps != promql.output.evaluation_steps + || promql.inputs[1].evaluation_steps != promql.output.evaluation_steps + || promql + .inputs + .iter() + .any(|edge| edge.value_kind != PromqlValueKind::Vector) + || promql.output.value_kind != PromqlValueKind::Vector + { + return inconsistent( + "PromQL info enrichment configuration or edge shape is invalid", + ); + } + } + ( + PhysicalOperator::PromqlSeriesSample { + kind, + grouping_key_count, + }, + OperatorStatistics::PromqlSeriesSample { + edges, + group_count, + key_bytes, + }, + ) => validate_promql_series_sample( + kind, + grouping_key_count, + *group_count, + *key_bytes, + edges, + )?, + ( + PhysicalOperator::PromqlScalarToVector, + OperatorStatistics::PromqlScalarToVector { edges }, + ) + | ( + PhysicalOperator::PromqlVectorToScalar, + OperatorStatistics::PromqlVectorToScalar { edges }, + ) => { + validate_promql_bridge(operator, edges)?; + } + (PhysicalOperator::PromqlScalarLeaf, _) => unreachable!(), + ( + PhysicalOperator::PromqlPerSeries { + operations_per_row, + accumulator_count, + }, + OperatorStatistics::PromqlPerSeries { + edges, + accumulator_bytes_per_series, + }, + ) => { + let promql = require_promql_unary(edges)?; + validate_instant_vector_rows(output, promql.output)?; + if operations_per_row == 0 + || accumulator_count == 0 + || *accumulator_bytes_per_series == 0 + || promql.output.value_kind != PromqlValueKind::Vector + || promql.output.series > promql.input.series + || promql.output.evaluation_steps != promql.input.evaluation_steps + { + return inconsistent("PromQL per-series configuration or edge shape is invalid"); + } + } + ( + PhysicalOperator::PromqlPresence { + kind, + operations_per_row, + }, + OperatorStatistics::PromqlPresence { edges }, + ) => { + let promql = require_promql_unary(edges)?; + validate_instant_vector_rows(output, promql.output)?; + if operations_per_row == 0 || promql.output.value_kind != PromqlValueKind::Vector { + return inconsistent("PromQL presence output violates its per-step bound"); + } + match kind { + PromqlPresenceKind::Absent + if promql.output.series > 1 + || output.rows > promql.output.evaluation_steps + || (input.rows == 0 && output.rows != promql.output.evaluation_steps) + || (output.rows == 0 && promql.output.series != 0) + || (output.rows > 0 && promql.output.series != 1) => + { + return inconsistent("PromQL absence output violates its per-step bound"); + } + PromqlPresenceKind::PresentPerSeries + if output.rows > input.rows + || promql.output.series > promql.input.series + || promql.output.evaluation_steps != promql.input.evaluation_steps + || (input.rows == 0 && (output.rows != 0 || promql.output.series != 0)) => + { + return inconsistent( + "PromQL present-over-time output exceeds its per-series bound", + ); + } + _ => {} + } + } + _ => return inconsistent("statistics variant does not match physical operator"), + } + Ok(()) +} + +fn require_promql_unary( + edges: &crate::physical_operator_statistics::UnaryEdgeStatistics, +) -> Result { + let promql = edges.promql.ok_or(AnalyticalCostError::MissingOrStale( + "promql_edge_statistics", + ))?; + validate_promql_edge_shape(edges.input, promql.input)?; + validate_promql_edge_shape(edges.output, promql.output)?; + Ok(promql) +} + +fn require_promql_binary( + edges: &crate::physical_operator_statistics::BinaryEdgeStatistics, +) -> Result { + let promql = edges.promql.ok_or(AnalyticalCostError::MissingOrStale( + "promql_edge_statistics", + ))?; + validate_promql_edge_shape(edges.inputs[0], promql.inputs[0])?; + validate_promql_edge_shape(edges.inputs[1], promql.inputs[1])?; + validate_promql_edge_shape(edges.output, promql.output)?; + Ok(promql) +} + +fn validate_promql_cardinality_preserving_shape( + promql: crate::physical_operator_statistics::PromqlUnaryEdgeStatistics, +) -> Result<(), AnalyticalCostError> { + validate_promql_edge(promql.input)?; + validate_promql_edge(promql.output)?; + if promql.input == promql.output { + Ok(()) + } else { + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "cardinality-preserving operator changes its PromQL edge shape", + )) + } +} + +fn validate_promql_filter_shape( + promql: crate::physical_operator_statistics::PromqlUnaryEdgeStatistics, +) -> Result<(), AnalyticalCostError> { + validate_promql_edge(promql.input)?; + validate_promql_edge(promql.output)?; + if promql.input.evaluation_steps == promql.output.evaluation_steps + && promql.input.value_kind == promql.output.value_kind + && promql.output.series <= promql.input.series + { + Ok(()) + } else { + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "Filter changes PromQL steps/kind or expands its series", + )) + } +} + +fn validate_instant_vector_rows( + logical: EdgeStatistics, + promql: PromqlEdgeStatistics, +) -> Result<(), AnalyticalCostError> { + if promql.value_kind != PromqlValueKind::Vector { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "instant-vector result has a non-vector value kind", + )); + } + let bound = promql + .series + .checked_mul(promql.evaluation_steps) + .ok_or(AnalyticalCostError::Overflow)?; + if logical.rows <= bound { + Ok(()) + } else { + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "instant-vector rows exceed series times evaluation steps", + )) + } +} + +fn validate_promql_edge_shape( + logical: EdgeStatistics, + promql: PromqlEdgeStatistics, +) -> Result<(), AnalyticalCostError> { + validate_promql_edge(promql)?; + let max_rows = promql.series.checked_mul(promql.evaluation_steps); + match promql.value_kind { + PromqlValueKind::Scalar if logical.rows != promql.evaluation_steps => { + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL scalar rows do not equal evaluation steps", + )) + } + PromqlValueKind::RangeVector if max_rows.is_none_or(|bound| logical.rows > bound) => { + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL vector rows exceed series times evaluation steps", + )) + } + PromqlValueKind::Vector if logical.rows > 0 && promql.series == 0 => { + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL samples have no source series", + )) + } + _ => Ok(()), + } +} + +fn validate_promql_bridge( + operator: PhysicalOperator, + edges: &crate::physical_operator_statistics::UnaryEdgeStatistics, +) -> Result<(), AnalyticalCostError> { + let promql = require_promql_unary(edges)?; + let valid = match operator { + PhysicalOperator::PromqlScalarToVector => { + validate_instant_vector_rows(edges.output, promql.output)?; + promql.input.value_kind == PromqlValueKind::Scalar + && promql.output.value_kind == PromqlValueKind::Vector + && promql.output.series == 1 + && promql.input.evaluation_steps == promql.output.evaluation_steps + && edges.input.rows == edges.output.rows + } + PhysicalOperator::PromqlVectorToScalar => { + promql.input.value_kind == PromqlValueKind::Vector + && promql.output.value_kind == PromqlValueKind::Scalar + && promql.input.evaluation_steps == promql.output.evaluation_steps + } + _ => false, + }; + if valid { + Ok(()) + } else { + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL scalar/vector bridge edge shape is invalid", + )) + } +} + +fn validate_promql_binary( + operation: PromqlBinaryOperation, + operand_mode: PromqlBinaryOperandMode, + cardinality: PromqlVectorCardinality, + build_side: Option, + matching_key_bytes: u64, + edges: &crate::physical_operator_statistics::BinaryEdgeStatistics, +) -> Result<(), AnalyticalCostError> { + let promql = require_promql_binary(edges)?; + validate_instant_vector_rows(edges.output, promql.output)?; + let invalid = |reason| Err(AnalyticalCostError::InconsistentOperatorStatistics(reason)); + if matches!( + operation, + PromqlBinaryOperation::And | PromqlBinaryOperation::Or | PromqlBinaryOperation::Unless + ) && cardinality != PromqlVectorCardinality::OneToOne + { + return invalid("PromQL set operations cannot use group_left or group_right"); + } + if promql.output.value_kind != PromqlValueKind::Vector + || promql.inputs[0].evaluation_steps != promql.output.evaluation_steps + || promql.inputs[1].evaluation_steps != promql.output.evaluation_steps + { + 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 + .iter() + .any(|input| input.value_kind != PromqlValueKind::Vector) + || build_side.is_none() + || matching_key_bytes == 0 + { + return invalid("vector/vector binary label-match configuration is invalid"); + } + match operation { + PromqlBinaryOperation::And | PromqlBinaryOperation::Unless => { + (edges.inputs[0].rows, promql.inputs[0].series) + } + PromqlBinaryOperation::Or => ( + edges.inputs[0] + .rows + .checked_add(edges.inputs[1].rows) + .ok_or(AnalyticalCostError::Overflow)?, + promql.inputs[0] + .series + .checked_add(promql.inputs[1].series) + .ok_or(AnalyticalCostError::Overflow)?, + ), + PromqlBinaryOperation::ArithmeticOrComparison => match cardinality { + PromqlVectorCardinality::OneToOne => ( + edges.inputs[0].rows.min(edges.inputs[1].rows), + promql.inputs[0].series.min(promql.inputs[1].series), + ), + PromqlVectorCardinality::ManyToOne => { + (edges.inputs[0].rows, promql.inputs[0].series) + } + PromqlVectorCardinality::OneToMany => { + (edges.inputs[1].rows, promql.inputs[1].series) + } + }, + } + } + }; + if edges.output.rows > row_bound || promql.output.series > series_bound { + return invalid("PromQL binary output exceeds its semantic cardinality bound"); + } + Ok(()) +} + +fn validate_promql_series_sample( + kind: PromqlSeriesSampleKind, + grouping_key_count: u64, + group_count: u64, + key_bytes: u64, + edges: &crate::physical_operator_statistics::UnaryEdgeStatistics, +) -> Result<(), AnalyticalCostError> { + let promql = require_promql_unary(edges)?; + validate_instant_vector_rows(edges.output, promql.output)?; + let invalid = |reason| Err(AnalyticalCostError::InconsistentOperatorStatistics(reason)); + if key_bytes == 0 + || promql.input.value_kind != PromqlValueKind::Vector + || promql.output.value_kind != PromqlValueKind::Vector + || promql.input.evaluation_steps != promql.output.evaluation_steps + || edges.output.rows > edges.input.rows + || promql.output.series > promql.input.series + || (grouping_key_count == 0 && group_count != 1) + || (grouping_key_count > 0 && group_count == 0 && promql.input.series > 0) + { + return invalid("PromQL series-sample configuration or edge shape is invalid"); + } + if let PromqlSeriesSampleKind::LimitK { k } = kind { + if k == 0 { + return invalid("PromQL limitk must be positive"); + } + let bound = group_count + .checked_mul(k) + .ok_or(AnalyticalCostError::Overflow)?; + if promql.output.series > promql.input.series.min(bound) { + return invalid("PromQL limitk output exceeds its group bound"); + } + } else if let PromqlSeriesSampleKind::LimitRatio { ratio_bits } = kind { + let ratio = f64::from_bits(ratio_bits); + if !ratio.is_finite() || !(-1.0..=1.0).contains(&ratio) { + return invalid("PromQL limit_ratio is outside [-1, 1]"); + } } Ok(()) } @@ -464,18 +1940,56 @@ impl ResourceEstimate { #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum AnalyticalCostError { + #[error("analytical resource model v1 supports only DataArrival::AtRest, got {0:?}")] + UnsupportedDataArrival(DataArrival), #[error("required analytical input {0} is missing or zero")] MissingOrZero(&'static str), + #[error("required analytical evidence {0} is missing or stale")] + MissingOrStale(&'static str), + #[error("query recurrence cannot be resolved over the planning horizon")] + InvalidRecurrence, + #[error("query has no evaluations in the planning horizon")] + NoEvaluationsInHorizon, #[error("calibration {0} must be finite and non-negative, got {1}")] InvalidCalibration(&'static str, f64), #[error("at least one calibration coefficient must be positive")] ZeroCalibration, + #[error("algorithm {0:?} does not match parameters {1:?}")] + ParameterMismatch(SketchAlgorithm, SketchParams), + #[error("{0} needs a value-range/bin-count model before it can be estimated")] + UnsupportedWithoutDistribution(&'static str), #[error("analytical arithmetic overflowed")] Overflow, + #[error("candidate has no supported exact or sketch state")] + UnsupportedCandidate, + #[error("query operator has no physical implementation in the analytical model")] + UnsupportedQueryOperator, + #[error("inconsistent operator statistics: {0}")] + InconsistentOperatorStatistics(&'static str), + #[error("summary operation {0} has no lifecycle-aware cost formula")] + UnsupportedSummaryOperation(&'static str), + #[error("required comparison-scope field {0} is missing")] + MissingComparisonScope(&'static str), + #[error("scan node {0} does not declare source coverage")] + MissingScanSourceCoverage(String), + #[error("scan node {0} reads source coverage outside the comparison scope")] + ScanOutsideComparisonScope(String), + #[error("raw and candidate comparison scopes differ in {0}")] + ComparisonScopeMismatch(&'static str), + #[error("operator statistics are unavailable for physical node {0}")] + MissingOperatorStatistics(String), + #[error("invalid operator statistics for {node}: {reason}")] + InvalidOperatorStatistics { node: String, reason: &'static str }, + #[error( + "operator statistics conflict: parent {parent} input {input_index} does not match child {child} output" + )] + ConflictingEdgeStatistics { + parent: String, + child: String, + input_index: usize, + }, #[error("invalid physical DAG: {0}")] InvalidPhysicalDag(&'static str), - #[error("inconsistent physical operator statistics: {0}")] - InconsistentOperatorStatistics(&'static str), } fn checked_bytes(parts: &[u64]) -> Result { @@ -488,48 +2002,382 @@ fn checked_bytes(parts: &[u64]) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::physical_operator_statistics::{ + validate_comparison_scopes, BinaryEdgeStatistics, ComparisonScope, EdgeStatistics, + OperatorStatistics, PartitionStatistics, PromqlBinaryEdgeStatistics, PromqlEdgeStatistics, + PromqlUnaryEdgeStatistics, PromqlValueKind, SourceCoverage, UnaryEdgeStatistics, + }; + + fn filter_operator() -> PhysicalOperator { + PhysicalOperator::Filter { + predicate_operations_per_row: 1, + } + } - fn unary_inputs(input_rows: u64, output_rows: u64) -> OperatorInputs { - OperatorInputs { - input_rows, - input_bytes: input_rows.saturating_mul(8), - output_rows, - output_bytes: output_rows.saturating_mul(8), - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + fn project_operator() -> PhysicalOperator { + PhysicalOperator::Project { + expression_operations_per_row: 1, + } + } + + fn aggregate_operator() -> PhysicalOperator { + PhysicalOperator::HashAggregate { + grouping_key_count: 1, + accumulator_count: 1, + } + } + + fn unary_row_edges(input_rows: u64, output_rows: u64) -> UnaryEdgeStatistics { + UnaryEdgeStatistics { + input: EdgeStatistics { + rows: input_rows, + bytes: input_rows.saturating_mul(8), + }, + output: EdgeStatistics { + rows: output_rows, + bytes: output_rows.saturating_mul(8), + }, + promql: None, } } + fn promql_edge( + series: u64, + evaluation_steps: u64, + value_kind: PromqlValueKind, + ) -> PromqlEdgeStatistics { + PromqlEdgeStatistics { + series, + evaluation_steps, + value_kind, + } + } + + fn promql_binary_statistics(output_rows: u64, output_series: u64) -> OperatorStatistics { + let left = EdgeStatistics { + rows: 100, + bytes: 1_600, + }; + let right = EdgeStatistics { + rows: 50, + bytes: 800, + }; + let output = EdgeStatistics { + rows: output_rows, + bytes: output_rows.saturating_mul(16), + }; + OperatorStatistics::PromqlBinary { + edges: BinaryEdgeStatistics { + inputs: [left, right], + output, + promql: Some(PromqlBinaryEdgeStatistics { + inputs: [ + promql_edge(10, 10, PromqlValueKind::Vector), + promql_edge(5, 10, PromqlValueKind::Vector), + ], + output: promql_edge(output_series, 10, PromqlValueKind::Vector), + }), + }, + matching_key_bytes: 8, + } + } + + #[test] + fn operator_arity_distinguishes_source_statistics_from_dag_children() { + assert_eq!( + expected_input_arity(PhysicalOperator::Scan, 0), + OperatorInputArity { + statistics_inputs: 1, + dag_children: 0, + } + ); + assert_eq!( + expected_input_arity(filter_operator(), 1), + OperatorInputArity { + statistics_inputs: 1, + dag_children: 1, + } + ); + assert_eq!( + expected_input_arity( + PhysicalOperator::HashJoin { + build_side: HashJoinBuildSide::Left, + equality_key_count: 1, + }, + 2, + ), + OperatorInputArity { + statistics_inputs: 2, + dag_children: 2, + } + ); + assert_eq!( + expected_input_arity(PhysicalOperator::Concat, 3), + OperatorInputArity { + statistics_inputs: 3, + dag_children: 3, + } + ); + } + + #[test] + fn promql_binary_operation_controls_cardinality_bounds() { + let operator = |operation| PhysicalOperator::PromqlBinary { + operation, + operand_mode: PromqlBinaryOperandMode::VectorVector, + cardinality: PromqlVectorCardinality::OneToOne, + build_side: Some(HashJoinBuildSide::Right), + }; + + assert!(estimate_operator( + operator(PromqlBinaryOperation::Or), + promql_binary_statistics(150, 15), + ) + .is_ok()); + assert!(matches!( + estimate_operator( + operator(PromqlBinaryOperation::ArithmeticOrComparison), + promql_binary_statistics(60, 5), + ), + Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) + )); + assert!(matches!( + estimate_operator( + operator(PromqlBinaryOperation::And), + promql_binary_statistics(101, 10), + ), + Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) + )); + } + + #[test] + fn promql_bridges_require_directional_edge_kinds() { + let scalar = promql_edge(0, 10, PromqlValueKind::Scalar); + let vector = promql_edge(1, 10, PromqlValueKind::Vector); + let edges = |input, output| UnaryEdgeStatistics { + input: EdgeStatistics { + rows: 10, + bytes: 80, + }, + output: EdgeStatistics { + rows: 10, + bytes: 80, + }, + promql: Some(PromqlUnaryEdgeStatistics { input, output }), + }; + assert!(estimate_operator( + PhysicalOperator::PromqlScalarToVector, + OperatorStatistics::PromqlScalarToVector { + edges: edges(scalar, vector), + }, + ) + .is_ok()); + assert!(matches!( + estimate_operator( + PhysicalOperator::PromqlScalarToVector, + OperatorStatistics::PromqlScalarToVector { + edges: edges(vector, scalar), + }, + ), + Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) + )); + } + + #[test] + fn promql_scalar_leaf_has_no_input_and_one_row_per_step() { + let statistics = OperatorStatistics::PromqlScalarLeaf { + output: EdgeStatistics { + rows: 10, + bytes: 80, + }, + promql_output: promql_edge(0, 10, PromqlValueKind::Scalar), + }; + let estimate = estimate_operator(PhysicalOperator::PromqlScalarLeaf, statistics).unwrap(); + + assert_eq!(estimate.cpu_ops, 10.0); + assert_eq!(estimate.peak_memory_bytes, 8); + } + + #[test] + fn physical_dag_cannot_drop_promql_edge_evidence() { + let edge = EdgeStatistics { + rows: 10, + bytes: 80, + }; + let promql = promql_edge(1, 10, PromqlValueKind::Vector); + let nodes = vec![ + PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(comparison_scope().sources[0].clone()), + output_buffer_bytes: 8, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + PhysicalDagNode { + id: "filter".into(), + operator: filter_operator(), + children: vec!["scan".into()], + source_coverage: None, + output_buffer_bytes: 8, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + ]; + let provided = HashMap::from([ + ( + "scan".into(), + OperatorStatistics::Scan { + edges: UnaryEdgeStatistics { + input: edge, + output: edge, + promql: Some(PromqlUnaryEdgeStatistics { + input: promql, + output: promql, + }), + }, + source_read_bytes: 80, + }, + ), + ( + "filter".into(), + OperatorStatistics::Filter { + edges: unary_edges(edge, edge), + }, + ), + ]); + + assert!(matches!( + estimate_physical_dag(&nodes, "filter", &comparison_scope(), &provided), + Err(AnalyticalCostError::InvalidOperatorStatistics { + reason: "operator drops child PromQL edge statistics", + .. + }) + )); + } + + #[test] + fn generic_hash_aggregate_cannot_masquerade_as_promql_aggregation() { + let input = EdgeStatistics { + rows: 10, + bytes: 80, + }; + let output = EdgeStatistics { rows: 1, bytes: 8 }; + let statistics = OperatorStatistics::HashAggregate { + edges: UnaryEdgeStatistics { + input, + output, + promql: Some(PromqlUnaryEdgeStatistics { + input: promql_edge(1, 10, PromqlValueKind::Vector), + output: promql_edge(1, 10, PromqlValueKind::Vector), + }), + }, + group_count: 1, + key_bytes: 0, + accumulator_bytes_per_group: 8, + }; + + assert_eq!( + estimate_operator( + PhysicalOperator::HashAggregate { + grouping_key_count: 0, + accumulator_count: 1, + }, + statistics, + ), + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL cross-series aggregation needs an explicit physical operator" + )) + ); + } + + #[test] + fn present_over_time_keeps_per_series_cardinality() { + let input = EdgeStatistics { + rows: 100, + bytes: 1_600, + }; + let output = EdgeStatistics { + rows: 80, + bytes: 1_280, + }; + let valid = OperatorStatistics::PromqlPresence { + edges: UnaryEdgeStatistics { + input, + output, + promql: Some(PromqlUnaryEdgeStatistics { + input: promql_edge(10, 10, PromqlValueKind::RangeVector), + output: promql_edge(8, 10, PromqlValueKind::Vector), + }), + }, + }; + + assert!(estimate_operator( + PhysicalOperator::PromqlPresence { + kind: PromqlPresenceKind::PresentPerSeries, + operations_per_row: 1, + }, + valid.clone(), + ) + .is_ok()); + assert!(matches!( + estimate_operator( + PhysicalOperator::PromqlPresence { + kind: PromqlPresenceKind::Absent, + operations_per_row: 1, + }, + valid, + ), + Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) + )); + } + #[test] fn operator_estimator_rejects_contradictory_cardinality_evidence() { assert!(matches!( - estimate_operator(PhysicalOperator::Filter, unary_inputs(10, 11)), + estimate_operator( + filter_operator(), + OperatorStatistics::Filter { + edges: unary_row_edges(10, 11), + }, + ), Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) )); assert!(matches!( - estimate_operator(PhysicalOperator::Project, unary_inputs(10, 9)), + estimate_operator( + project_operator(), + OperatorStatistics::Project { + edges: unary_row_edges(10, 9), + }, + ), Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) )); - let mut aggregate = unary_inputs(10, 3); - aggregate.group_count = Some(2); - aggregate.key_bytes = Some(8); - aggregate.aggregate_value_bytes = Some(8); assert!(matches!( - estimate_operator(PhysicalOperator::HashAggregate, aggregate), + estimate_operator( + aggregate_operator(), + OperatorStatistics::HashAggregate { + edges: unary_row_edges(10, 3), + group_count: 2, + key_bytes: 8, + accumulator_bytes_per_group: 8, + }, + ), Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) )); - let mut topk = unary_inputs(10, 4); - topk.k = Some(3); assert!(matches!( - estimate_operator(PhysicalOperator::TopK, topk), + estimate_operator( + PhysicalOperator::TopK { + limit: 3, + offset: 0, + ordering_key_count: 1, + }, + OperatorStatistics::TopK { + edges: unary_row_edges(10, 4), + }, + ), Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) )); } @@ -538,39 +2386,42 @@ mod tests { fn physical_operator_formulas_keep_disk_at_scan_and_require_join_stats() { let scan = estimate_operator( PhysicalOperator::Scan, - OperatorInputs { - input_rows: 1_000, - input_bytes: 64_000, - output_rows: 1_000, - output_bytes: 64_000, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + OperatorStatistics::Scan { + source_read_bytes: 64_000, + edges: UnaryEdgeStatistics { + input: EdgeStatistics { + rows: 1_000, + bytes: 64_000, + }, + output: EdgeStatistics { + rows: 1_000, + bytes: 64_000, + }, + promql: None, + }, }, ) .unwrap(); assert_eq!(scan.scan_bytes, 64_000); let topk = estimate_operator( - PhysicalOperator::TopK, - OperatorInputs { - input_rows: 1_000, - input_bytes: 40_000, - output_rows: 10, - output_bytes: 400, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: Some(10), - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + PhysicalOperator::TopK { + limit: 10, + offset: 0, + ordering_key_count: 1, + }, + OperatorStatistics::TopK { + edges: UnaryEdgeStatistics { + input: EdgeStatistics { + rows: 1_000, + bytes: 40_000, + }, + output: EdgeStatistics { + rows: 10, + bytes: 400, + }, + promql: None, + }, }, ) .unwrap(); @@ -578,125 +2429,112 @@ mod tests { assert_eq!(topk.cpu_ops, 4_000.0); assert_eq!(topk.peak_memory_bytes, 400); - let missing_join_stats = estimate_operator( - PhysicalOperator::HashJoin, - OperatorInputs { - input_rows: 1_000, - input_bytes: 64_000, - output_rows: 100, - output_bytes: 12_800, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + let mismatched_join_statistics = estimate_operator( + PhysicalOperator::HashJoin { + build_side: HashJoinBuildSide::Left, + equality_key_count: 1, }, - ); - assert_eq!( - missing_join_stats, - Err(AnalyticalCostError::MissingOrZero("right_rows")) - ); - - let missing_build_side = estimate_operator( - PhysicalOperator::HashJoin, - OperatorInputs { - input_rows: 1_000, - input_bytes: 64_000, - output_rows: 100, - output_bytes: 12_800, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: None, - right_rows: Some(10), - right_bytes: Some(1_280), - hash_join_build_side: None, + OperatorStatistics::Filter { + edges: unary_row_edges(1_000, 100), }, ); assert_eq!( - missing_build_side, - Err(AnalyticalCostError::MissingOrZero("hash_join_build_side")) + mismatched_join_statistics, + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "statistics variant does not match physical operator" + )) ); let build_left = estimate_operator( - PhysicalOperator::HashJoin, - OperatorInputs { - input_rows: 1_000, - input_bytes: 64_000, - output_rows: 100, - output_bytes: 12_800, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: None, - right_rows: Some(10), - right_bytes: Some(1_280), - hash_join_build_side: Some(HashJoinBuildSide::Left), + PhysicalOperator::HashJoin { + build_side: HashJoinBuildSide::Left, + equality_key_count: 1, + }, + OperatorStatistics::HashJoin { + edges: BinaryEdgeStatistics { + inputs: [ + EdgeStatistics { + rows: 1_000, + bytes: 64_000, + }, + EdgeStatistics { + rows: 10, + bytes: 1_280, + }, + ], + output: EdgeStatistics { + rows: 100, + bytes: 12_800, + }, + promql: None, + }, }, ) .unwrap(); assert_eq!(build_left.peak_memory_bytes, 80_000); let aggregate = estimate_operator( - PhysicalOperator::HashAggregate, - OperatorInputs { - input_rows: 1_000, - input_bytes: 64_000, - output_rows: 100, - output_bytes: 4_000, - group_count: Some(100), - key_bytes: Some(16), - aggregate_value_bytes: Some(24), - k: None, - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + aggregate_operator(), + OperatorStatistics::HashAggregate { + group_count: 100, + key_bytes: 16, + accumulator_bytes_per_group: 24, + edges: UnaryEdgeStatistics { + input: EdgeStatistics { + rows: 1_000, + bytes: 64_000, + }, + output: EdgeStatistics { + rows: 100, + bytes: 4_000, + }, + promql: None, + }, }, ) .unwrap(); assert_eq!(aggregate.peak_memory_bytes, 5_600); let oversized_topk = estimate_operator( - PhysicalOperator::TopK, - OperatorInputs { - input_rows: 4, - input_bytes: 160, - output_rows: 4, - output_bytes: 160, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: Some(1_000), - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + PhysicalOperator::TopK { + limit: 1_000, + offset: 0, + ordering_key_count: 1, + }, + OperatorStatistics::TopK { + edges: UnaryEdgeStatistics { + input: EdgeStatistics { + rows: 4, + bytes: 160, + }, + output: EdgeStatistics { + rows: 4, + bytes: 160, + }, + promql: None, + }, }, ) .unwrap(); assert_eq!(oversized_topk.cpu_ops, 8.0); let offset_limit = estimate_operator( - PhysicalOperator::Limit, - OperatorInputs { - input_rows: 1_000_000, - input_bytes: 40_000_000, - output_rows: 10, - output_bytes: 400, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: Some(900_010), - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + PhysicalOperator::Limit { + limit: 10, + offset: 900_000, + }, + OperatorStatistics::Limit { + edges: UnaryEdgeStatistics { + input: EdgeStatistics { + rows: 1_000_000, + bytes: 40_000_000, + }, + output: EdgeStatistics { + rows: 10, + bytes: 400, + }, + promql: None, + }, }, ) .unwrap(); @@ -705,44 +2543,31 @@ mod tests { #[test] fn physical_dag_counts_shared_scan_once_and_uses_live_memory() { - let input = |input_rows, input_bytes, output_rows, output_bytes| OperatorInputs { - input_rows, - input_bytes, - output_rows, - output_bytes, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, - }; + let coverage = comparison_scope().sources[0].clone(); let nodes = vec![ PhysicalDagNode { id: "scan".into(), operator: PhysicalOperator::Scan, - inputs: input(100, 1_000, 100, 1_000), children: vec![], + source_coverage: Some(coverage), output_buffer_bytes: 10, retained_bytes: 0, execution: ExecutionMultiplicity::PerEvaluation, }, PhysicalDagNode { id: "left".into(), - operator: PhysicalOperator::Filter, - inputs: input(100, 1_000, 40, 400), + operator: filter_operator(), children: vec!["scan".into()], + source_coverage: None, output_buffer_bytes: 4, retained_bytes: 0, execution: ExecutionMultiplicity::PerEvaluation, }, PhysicalDagNode { id: "right".into(), - operator: PhysicalOperator::Filter, - inputs: input(100, 1_000, 40, 400), + operator: filter_operator(), children: vec!["scan".into()], + source_coverage: None, output_buffer_bytes: 4, retained_bytes: 0, execution: ExecutionMultiplicity::PerEvaluation, @@ -750,93 +2575,792 @@ mod tests { PhysicalDagNode { id: "root".into(), operator: PhysicalOperator::Concat, - inputs: input(80, 800, 80, 800), children: vec!["left".into(), "right".into()], + source_coverage: None, output_buffer_bytes: 8, retained_bytes: 0, execution: ExecutionMultiplicity::PerEvaluation, }, ]; - let estimate = estimate_physical_dag(&nodes, "root", 2).unwrap(); + let scan_edge = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let branch_edge = EdgeStatistics { + rows: 40, + bytes: 400, + }; + let provided = HashMap::from([ + ( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(scan_edge, scan_edge), + source_read_bytes: 1_000, + }, + ), + ( + "left".into(), + OperatorStatistics::Filter { + edges: unary_edges(scan_edge, branch_edge), + }, + ), + ( + "right".into(), + OperatorStatistics::Filter { + edges: unary_edges(scan_edge, branch_edge), + }, + ), + ( + "root".into(), + OperatorStatistics::Concat { + inputs: vec![branch_edge, branch_edge], + output: EdgeStatistics { + rows: 80, + bytes: 800, + }, + promql: None, + }, + ), + ]); + let mut scope = comparison_scope(); + scope.horizon.0 = 20_000; + let estimate = estimate_physical_dag(&nodes, "root", &scope, &provided).unwrap(); assert_eq!(estimate.cpu_ops, 760.0); assert_eq!(estimate.scan_bytes, 2_000); // This is neither the sum of every node's memory nor just the largest // node: it is the maximum state simultaneously live at the fan-out. - assert_eq!(estimate.peak_memory_bytes, 24); + assert_eq!(estimate.peak_memory_bytes, 28); } #[test] fn physical_dag_separates_build_once_from_per_evaluation_work() { + let coverage = comparison_scope().sources[0].clone(); let nodes = vec![ PhysicalDagNode { id: "scan".into(), operator: PhysicalOperator::Scan, - inputs: OperatorInputs { - input_rows: 100, - input_bytes: 1_000, - output_rows: 100, - output_bytes: 1_000, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, - }, children: vec![], + source_coverage: Some(coverage), output_buffer_bytes: 10, retained_bytes: 0, execution: ExecutionMultiplicity::Once, }, PhysicalDagNode { id: "state".into(), - operator: PhysicalOperator::HashAggregate, - inputs: OperatorInputs { - input_rows: 100, - input_bytes: 1_000, - output_rows: 1, - output_bytes: 16, - group_count: Some(1), - key_bytes: Some(8), - aggregate_value_bytes: Some(8), - k: None, - limit_rows_consumed: None, - right_rows: None, - right_bytes: None, - hash_join_build_side: None, - }, + operator: aggregate_operator(), children: vec!["scan".into()], + source_coverage: None, output_buffer_bytes: 16, retained_bytes: 32, execution: ExecutionMultiplicity::Once, }, PhysicalDagNode { id: "read".into(), - operator: PhysicalOperator::Limit, - inputs: OperatorInputs { - input_rows: 1, - input_bytes: 16, - output_rows: 1, - output_bytes: 16, - group_count: None, - key_bytes: None, - aggregate_value_bytes: None, - k: None, - limit_rows_consumed: Some(1), - right_rows: None, - right_bytes: None, - hash_join_build_side: None, + operator: PhysicalOperator::Limit { + limit: 1, + offset: 0, }, children: vec!["state".into()], + source_coverage: None, output_buffer_bytes: 16, retained_bytes: 0, execution: ExecutionMultiplicity::PerEvaluation, }, ]; - let estimate = estimate_physical_dag(&nodes, "read", 10).unwrap(); - assert_eq!(estimate.cpu_ops, 210.0); + let scan_edge = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let state_edge = EdgeStatistics { rows: 1, bytes: 16 }; + let provided = HashMap::from([ + ( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(scan_edge, scan_edge), + source_read_bytes: 1_000, + }, + ), + ( + "state".into(), + OperatorStatistics::HashAggregate { + edges: unary_edges(scan_edge, state_edge), + group_count: 1, + key_bytes: 8, + accumulator_bytes_per_group: 8, + }, + ), + ( + "read".into(), + OperatorStatistics::Limit { + edges: unary_edges(state_edge, state_edge), + }, + ), + ]); + let mut scope = comparison_scope(); + scope.horizon.0 = 100_000; + let estimate = estimate_physical_dag(&nodes, "read", &scope, &provided).unwrap(); + assert_eq!(estimate.cpu_ops, 310.0); assert_eq!(estimate.scan_bytes, 1_000); } + + fn comparison_scope() -> ComparisonScope { + use asap_types::pre_asap::query_expr::Source; + use asap_types::workload::{ + DurationMs, QueryRecurrence, QueryTimeScope, RepeatedDemand, RepetitionInterval, + TimeSelection, TimestampMs, + }; + + ComparisonScope { + data_arrival: DataArrival::AtRest, + planning_time: TimestampMs(1_000), + horizon: DurationMs(60_000), + recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( + RepetitionInterval(10_000), + )), + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(300_000)), + as_of: Some(TimestampMs(1_000)), + }, + sources: vec![SourceCoverage { + source: Source::Table { + table_ref: "metrics".into(), + }, + source_snapshot_id: "catalog-version-42".into(), + predicates: vec![], + info_matchers: vec![], + }], + } + } + + fn unary_edges(input: EdgeStatistics, output: EdgeStatistics) -> UnaryEdgeStatistics { + UnaryEdgeStatistics { + input, + output, + promql: None, + } + } + + #[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::workload::{DurationMs, TimestampMs}; + + let raw = comparison_scope(); + assert_eq!(validate_comparison_scopes(&raw, &raw).unwrap(), 6); + + let mut candidate = raw.clone(); + candidate.sources[0].source_snapshot_id = "catalog-version-43".into(); + assert_eq!( + validate_comparison_scopes(&raw, &candidate), + Err(AnalyticalCostError::ComparisonScopeMismatch("sources")) + ); + + candidate = raw.clone(); + candidate.sources[0] + .predicates + .push(Predicate(Rc::new(QueryExpr::promql_scalar(1.0)))); + assert_eq!( + validate_comparison_scopes(&raw, &candidate), + Err(AnalyticalCostError::ComparisonScopeMismatch("sources")) + ); + + candidate = raw.clone(); + candidate.time_selection.as_of = Some(TimestampMs(2_000)); + assert_eq!( + validate_comparison_scopes(&raw, &candidate), + Err(AnalyticalCostError::ComparisonScopeMismatch( + "time_selection" + )) + ); + + candidate = raw.clone(); + candidate.horizon = DurationMs(120_000); + assert_eq!( + validate_comparison_scopes(&raw, &candidate), + Err(AnalyticalCostError::ComparisonScopeMismatch("horizon")) + ); + } + + #[test] + fn physical_dag_fails_closed_on_missing_or_conflicting_edge_statistics() { + use std::collections::HashMap; + + let coverage = comparison_scope().sources[0].clone(); + let nodes = vec![ + PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(coverage), + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + PhysicalDagNode { + id: "filter".into(), + operator: filter_operator(), + children: vec!["scan".into()], + source_coverage: None, + output_buffer_bytes: 4, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + ]; + let scope = comparison_scope(); + let mut provided = HashMap::from([( + "scan".to_string(), + OperatorStatistics::Scan { + edges: unary_edges( + EdgeStatistics { + rows: 100, + bytes: 1_000, + }, + EdgeStatistics { + rows: 100, + bytes: 1_000, + }, + ), + source_read_bytes: 1_000, + }, + )]); + + assert_eq!( + estimate_physical_dag(&nodes, "filter", &scope, &provided), + Err(AnalyticalCostError::MissingOperatorStatistics( + "filter".into() + )) + ); + + provided.insert( + "filter".into(), + OperatorStatistics::Filter { + edges: unary_edges( + EdgeStatistics { + rows: 99, + bytes: 990, + }, + EdgeStatistics { + rows: 40, + bytes: 400, + }, + ), + }, + ); + assert_eq!( + estimate_physical_dag(&nodes, "filter", &scope, &provided), + Err(AnalyticalCostError::ConflictingEdgeStatistics { + parent: "filter".into(), + child: "scan".into(), + input_index: 0, + }) + ); + } + + #[test] + fn provider_statistics_drive_a_consistent_physical_dag_estimate() { + use std::collections::HashMap; + + let coverage = comparison_scope().sources[0].clone(); + let nodes = vec![ + PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(coverage), + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + PhysicalDagNode { + id: "filter".into(), + operator: filter_operator(), + children: vec!["scan".into()], + source_coverage: None, + output_buffer_bytes: 4, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + ]; + let provided = HashMap::from([ + ( + "scan".to_string(), + OperatorStatistics::Scan { + edges: unary_edges( + EdgeStatistics { + rows: 100, + bytes: 1_000, + }, + EdgeStatistics { + rows: 100, + bytes: 1_000, + }, + ), + source_read_bytes: 1_000, + }, + ), + ( + "filter".to_string(), + OperatorStatistics::Filter { + edges: unary_edges( + EdgeStatistics { + rows: 100, + bytes: 1_000, + }, + EdgeStatistics { + rows: 40, + bytes: 400, + }, + ), + }, + ), + ]); + + let estimate = + estimate_physical_dag(&nodes, "filter", &comparison_scope(), &provided).unwrap(); + assert_eq!(estimate.cpu_ops, 1_200.0); + assert_eq!(estimate.scan_bytes, 6_000); + } + + #[test] + fn physical_dag_accepts_an_empty_operator_output() { + let coverage = comparison_scope().sources[0].clone(); + let nodes = vec![ + PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(coverage), + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + PhysicalDagNode { + id: "filter".into(), + operator: filter_operator(), + children: vec!["scan".into()], + source_coverage: None, + output_buffer_bytes: 0, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + ]; + let input = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let provided = HashMap::from([ + ( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(input, input), + source_read_bytes: 1_000, + }, + ), + ( + "filter".into(), + OperatorStatistics::Filter { + edges: unary_edges(input, EdgeStatistics { rows: 0, bytes: 0 }), + }, + ), + ]); + + let estimate = + estimate_physical_dag(&nodes, "filter", &comparison_scope(), &provided).unwrap(); + assert_eq!(estimate.cpu_ops, 1_200.0); + assert_eq!(estimate.peak_memory_bytes, 20); + assert_eq!(estimate.scan_bytes, 6_000); + } + + #[test] + fn physical_dag_rejects_a_scan_not_covered_by_its_scope() { + let nodes = vec![PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(SourceCoverage { + source: asap_types::pre_asap::query_expr::Source::Table { + table_ref: "other_metrics".into(), + }, + source_snapshot_id: "catalog-version-42".into(), + predicates: vec![], + info_matchers: vec![], + }), + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }]; + let edge = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let provided = HashMap::from([( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(edge, edge), + source_read_bytes: 250, + }, + )]); + + assert_eq!( + estimate_physical_dag(&nodes, "scan", &comparison_scope(), &provided), + Err(AnalyticalCostError::ScanOutsideComparisonScope( + "scan".into() + )) + ); + } + + #[test] + fn physical_dag_rejects_a_scan_without_explicit_coverage() { + let nodes = vec![PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: None, + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }]; + let edge = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let provided = HashMap::from([( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(edge, edge), + source_read_bytes: 250, + }, + )]); + + assert_eq!( + estimate_physical_dag(&nodes, "scan", &comparison_scope(), &provided), + Err(AnalyticalCostError::MissingScanSourceCoverage( + "scan".into() + )) + ); + } + + #[test] + fn physical_dag_rejects_an_unconsumed_scope_source() { + let mut scope = comparison_scope(); + let coverage = scope.sources[0].clone(); + scope.sources.push(SourceCoverage { + source: asap_types::pre_asap::query_expr::Source::Table { + table_ref: "auxiliary".into(), + }, + source_snapshot_id: "catalog-version-42".into(), + predicates: vec![], + info_matchers: vec![], + }); + let nodes = vec![PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(coverage), + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }]; + let edge = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let provided = HashMap::from([( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(edge, edge), + source_read_bytes: 250, + }, + )]); + + assert_eq!( + estimate_physical_dag(&nodes, "scan", &scope, &provided), + Err(AnalyticalCostError::InvalidPhysicalDag( + "physical scans omit a comparison-scope source" + )) + ); + } + + #[test] + fn build_once_parent_cannot_consume_a_per_evaluation_child() { + let coverage = comparison_scope().sources[0].clone(); + let nodes = vec![ + PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(coverage), + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + PhysicalDagNode { + id: "aggregate".into(), + operator: aggregate_operator(), + children: vec!["scan".into()], + source_coverage: None, + output_buffer_bytes: 16, + retained_bytes: 32, + execution: ExecutionMultiplicity::Once, + }, + ]; + let input = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let output = EdgeStatistics { rows: 1, bytes: 16 }; + let provided = HashMap::from([ + ( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(input, input), + source_read_bytes: 250, + }, + ), + ( + "aggregate".into(), + OperatorStatistics::HashAggregate { + edges: unary_edges(input, output), + group_count: 1, + key_bytes: 8, + accumulator_bytes_per_group: 8, + }, + ), + ]); + + assert_eq!( + estimate_physical_dag(&nodes, "aggregate", &comparison_scope(), &provided), + Err(AnalyticalCostError::InvalidPhysicalDag( + "build-once node cannot consume a per-evaluation child" + )) + ); + } + + #[test] + fn scoped_comparison_rejects_different_source_snapshots() { + let coverage = comparison_scope().sources[0].clone(); + let nodes = vec![PhysicalDagNode { + id: "scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(coverage), + output_buffer_bytes: 10, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }]; + let edge = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let provided = HashMap::from([( + "scan".into(), + OperatorStatistics::Scan { + edges: unary_edges(edge, edge), + source_read_bytes: 250, + }, + )]); + let raw_scope = comparison_scope(); + let mut candidate_scope = raw_scope.clone(); + candidate_scope.sources[0].source_snapshot_id = "catalog-version-43".into(); + + assert_eq!( + estimate_physical_dag_comparison( + PhysicalDagEstimateRequest { + nodes: &nodes, + root: "scan", + scope: &raw_scope, + statistics: &provided, + }, + PhysicalDagEstimateRequest { + nodes: &nodes, + root: "scan", + scope: &candidate_scope, + statistics: &provided, + }, + ), + Err(AnalyticalCostError::ComparisonScopeMismatch("sources")) + ); + } + + #[test] + fn scan_uses_physical_source_bytes_not_decoded_logical_bytes() { + let logical = EdgeStatistics { + rows: 100, + bytes: 10_000, + }; + let estimate = estimate_operator( + PhysicalOperator::Scan, + OperatorStatistics::Scan { + edges: unary_edges(logical, logical), + source_read_bytes: 2_500, + }, + ) + .unwrap(); + + assert_eq!(estimate.scan_bytes, 2_500); + assert_eq!(estimate.peak_memory_bytes, 100); + } + + #[test] + fn non_scan_operator_estimates_cannot_charge_source_reads() { + let edge = EdgeStatistics { + rows: 100, + bytes: 1_000, + }; + let filter = OperatorStatistics::Filter { + edges: unary_edges(edge, edge), + }; + + assert_eq!( + estimate_operator(filter_operator(), filter) + .unwrap() + .scan_bytes, + 0 + ); + } + + #[test] + fn non_empty_scan_cannot_claim_zero_source_reads() { + let logical = EdgeStatistics { + rows: 10, + bytes: 80, + }; + assert_eq!( + estimate_operator( + PhysicalOperator::Scan, + OperatorStatistics::Scan { + edges: unary_edges(logical, logical), + source_read_bytes: 0, + }, + ), + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "non-empty Scan has zero source-read bytes" + )) + ); + } + + #[test] + fn hash_aggregate_handles_empty_grouped_and_ungrouped_inputs() { + let empty = EdgeStatistics { rows: 0, bytes: 0 }; + let ungrouped = estimate_operator( + PhysicalOperator::HashAggregate { + grouping_key_count: 0, + accumulator_count: 1, + }, + OperatorStatistics::HashAggregate { + edges: unary_edges(empty, EdgeStatistics { rows: 1, bytes: 8 }), + group_count: 1, + key_bytes: 0, + accumulator_bytes_per_group: 8, + }, + ) + .unwrap(); + assert_eq!(ungrouped.cpu_ops, 0.0); + assert_eq!(ungrouped.peak_memory_bytes, 24); + + let grouped = estimate_operator( + PhysicalOperator::HashAggregate { + grouping_key_count: 1, + accumulator_count: 1, + }, + OperatorStatistics::HashAggregate { + edges: unary_edges(empty, empty), + group_count: 0, + key_bytes: 8, + accumulator_bytes_per_group: 8, + }, + ) + .unwrap(); + assert_eq!(grouped.peak_memory_bytes, 0); + } + + #[test] + fn operator_local_work_and_partition_distribution_change_cpu_and_memory() { + let edge = EdgeStatistics { + rows: 100, + bytes: 800, + }; + let filter = OperatorStatistics::Filter { + edges: unary_edges(edge, edge), + }; + assert_eq!( + estimate_operator( + PhysicalOperator::Filter { + predicate_operations_per_row: 3, + }, + filter, + ) + .unwrap() + .cpu_ops, + 300.0 + ); + + let sort = estimate_operator( + PhysicalOperator::InMemoryComparisonSort { + ordering_key_count: 1, + partitioned: true, + }, + OperatorStatistics::InMemoryComparisonSort { + edges: unary_edges(edge, edge), + input_partitioning: PartitionStatistics { + partitions: vec![ + EdgeStatistics { + rows: 50, + bytes: 400, + }, + EdgeStatistics { + rows: 50, + bytes: 400, + }, + ], + }, + }, + ) + .unwrap(); + assert_eq!(sort.cpu_ops, 600.0); + assert_eq!(sort.peak_memory_bytes, 400); + } + + #[test] + fn concat_requires_at_least_one_physical_input() { + assert_eq!( + estimate_operator( + PhysicalOperator::Concat, + OperatorStatistics::Concat { + inputs: vec![], + output: EdgeStatistics { rows: 0, bytes: 0 }, + promql: None, + }, + ), + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "Concat must have at least one input" + )) + ); + } + + #[test] + fn serialized_operator_statistics_reject_unrelated_fields() { + let edge = serde_json::json!({ "rows": 100, "bytes": 1_000 }); + let edges = serde_json::json!({ "input": edge, "output": edge }); + + assert!( + serde_json::from_value::(serde_json::json!({ + "operator": "filter", + "edges": edges.clone(), + "source_read_bytes": 1_000 + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "operator": "top_k", + "edges": edges, + "k": 10 + })) + .is_err() + ); + } } diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index 7f3407d3..bb65c776 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -190,6 +190,18 @@ pub fn default_cse_shared_maintenance_cost(family: &SummaryFamilyType) -> Cost { /// `replacement::implementations_for_with` constructs every candidate in the /// resulting order. pub trait CostModel { + /// Whether [`Self::candidate_cost`] prices a complete physical + /// alternative, including its raw baseline, rather than a local + /// heuristic for one memo-group node. + /// + /// Complete-plan models make every CSE alternative available to final + /// ranking. Choosing a share/recompute arm first through the legacy + /// structural hooks would discard a physical alternative before its + /// evidence-backed cost was compared. + fn candidate_cost_covers_complete_plan(&self) -> bool { + false + } + /// Candidate-level availability for final selection. The default keeps /// every candidate, including legacy models whose numeric estimate is a /// display-only placeholder. Models that require evidence override this diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index e29984a8..3e4fa92b 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -187,6 +187,9 @@ pub mod analytical_cost; pub mod cost_model; pub mod explanation; pub mod grouping; +pub mod physical_operator_statistics; +pub mod physical_plan_cost_model; +pub mod query_physical_lowering; pub mod recurrence; pub mod replacement; pub mod rewrite; diff --git a/crates/asap-aware-mapping/src/physical_operator_statistics.rs b/crates/asap-aware-mapping/src/physical_operator_statistics.rs new file mode 100644 index 00000000..ecd3a9ef --- /dev/null +++ b/crates/asap-aware-mapping/src/physical_operator_statistics.rs @@ -0,0 +1,536 @@ +//! Authoritative comparison-scope and physical-statistics contracts. +//! +//! This module does not lower logical query nodes. It defines the evidence a +//! lowering or catalog provider must supply before two physical DAGs can be +//! compared by the analytical resource model. + +use std::collections::HashMap; + +use asap_types::pre_asap::query_expr::{InfoMatcher, Predicate, Source}; +use asap_types::workload::{ + DataArrival, DataWorkload, DurationMs, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, + TimeSelection, TimestampMs, +}; +use serde::{Deserialize, Serialize}; + +use crate::analytical_cost::AnalyticalCostError; + +/// The semantic and workload boundary within which two resource estimates +/// may be compared. Canonical workload and query-IR types remain authoritative; +/// only the storage snapshot identifier is new because neither IR names a +/// concrete catalog/storage version. +#[derive(Debug, Clone, PartialEq)] +pub struct ComparisonScope { + pub data_arrival: DataArrival, + pub planning_time: TimestampMs, + pub horizon: DurationMs, + pub recurrence: QueryRecurrence, + pub time_selection: TimeSelection, + pub sources: Vec, +} + +/// Exact source selection covered by a physical plan. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SourceCoverage { + pub source: Source, + /// Provider-owned stable identifier for the physical source contents, + /// such as a catalog snapshot, table version, or object generation. + /// This is independent of the query's event-time `as_of` value. + pub source_snapshot_id: String, + /// Canonical predicates copied from the bound/canonicalized query IR. + pub predicates: Vec, + /// Symbolic selectors on an info-metric source. Ordinary query scans leave + /// this empty because their selection is represented by `predicates`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub info_matchers: Vec, +} + +impl ComparisonScope { + /// Build a comparison boundary from canonical workload fields plus the + /// physical snapshot identities supplied by the storage/catalog layer. + pub fn from_workload( + data: &DataWorkload, + query: &QueryWorkloadEntry, + planning_time: TimestampMs, + horizon: DurationMs, + sources: Vec, + ) -> Result { + let scope = Self { + data_arrival: data.arrival, + planning_time, + horizon, + recurrence: query.recurrence.clone(), + time_selection: query.time_selection.clone(), + sources, + }; + scope.validate()?; + Ok(scope) + } + + /// Validate this scope and return its effective query evaluation count. + pub fn validate(&self) -> Result { + if self.data_arrival != DataArrival::AtRest { + return Err(AnalyticalCostError::UnsupportedDataArrival( + self.data_arrival, + )); + } + if self.horizon.0 == 0 { + return Err(AnalyticalCostError::MissingOrZero("horizon")); + } + if self + .sources + .iter() + .enumerate() + .any(|(index, source)| self.sources[..index].contains(source)) + { + return Err(AnalyticalCostError::MissingComparisonScope( + "duplicate source coverage", + )); + } + if self + .sources + .iter() + .any(|source| source.source_snapshot_id.is_empty()) + { + return Err(AnalyticalCostError::MissingComparisonScope( + "source_snapshot_id", + )); + } + evaluations_in_horizon(&self.recurrence, self.planning_time.0, self.horizon.0) + } +} + +/// Require exact scope equality before comparing raw and post-ASAP costs. +/// Exact matching is intentionally conservative: coverage/subsumption needs +/// a separate semantic proof and is not inferred by the resource estimator. +pub fn validate_comparison_scopes( + raw: &ComparisonScope, + candidate: &ComparisonScope, +) -> Result { + let evaluations = raw.validate()?; + candidate.validate()?; + for (name, matches) in [ + ("data_arrival", raw.data_arrival == candidate.data_arrival), + ( + "planning_time", + raw.planning_time == candidate.planning_time, + ), + ("horizon", raw.horizon == candidate.horizon), + ("recurrence", raw.recurrence == candidate.recurrence), + ( + "time_selection", + raw.time_selection == candidate.time_selection, + ), + ( + "sources", + raw.sources.len() == candidate.sources.len() + && raw + .sources + .iter() + .all(|source| candidate.sources.contains(source)), + ), + ] { + if !matches { + return Err(AnalyticalCostError::ComparisonScopeMismatch(name)); + } + } + Ok(evaluations) +} + +pub(crate) fn evaluations_in_horizon( + recurrence: &QueryRecurrence, + planning_time_ms: u64, + horizon_ms: u64, +) -> Result { + if horizon_ms == 0 { + return Err(AnalyticalCostError::MissingOrZero("horizon_ms")); + } + let end = planning_time_ms.saturating_add(horizon_ms); + let count = match recurrence { + QueryRecurrence::OneTime { + invocations, + execute_at, + } => { + if execute_at.is_none_or(|at| at.0 >= planning_time_ms && at.0 <= end) { + *invocations + } else { + 0 + } + } + QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(interval)) => { + if interval.0 == 0 { + return Err(AnalyticalCostError::InvalidRecurrence); + } + horizon_ms / u64::from(interval.0) + } + QueryRecurrence::Repeated(RepeatedDemand::Scheduled(schedule)) => schedule + .iter() + .filter(|at| at.0 >= planning_time_ms && at.0 <= end) + .count() + as u64, + QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(estimate)) => { + if !estimate.is_fresh_at(planning_time_ms) + || !estimate.expected_rate.0.is_finite() + || estimate.expected_rate.0 < 0.0 + { + return Err(AnalyticalCostError::InvalidRecurrence); + } + let expected = estimate.expected_rate.0 * horizon_ms as f64 / 1000.0; + if expected > u64::MAX as f64 { + return Err(AnalyticalCostError::Overflow); + } + expected.ceil() as u64 + } + QueryRecurrence::Unknown => return Err(AnalyticalCostError::InvalidRecurrence), + }; + if count == 0 { + return Err(AnalyticalCostError::NoEvaluationsInHorizon); + } + Ok(count) +} + +/// Logical cardinality and byte width carried by one physical edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct EdgeStatistics { + pub rows: u64, + pub bytes: u64, +} + +impl EdgeStatistics { + /// Empty logical edges carry neither rows nor bytes. Non-empty edges need + /// bytes so row-width-dependent formulas do not invent a width. + pub(crate) fn is_consistent(self) -> bool { + matches!((self.rows, self.bytes), (0, 0) | (1.., 1..)) + } +} + +/// Input and output evidence for a unary physical operator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnaryEdgeStatistics { + pub input: EdgeStatistics, + pub output: EdgeStatistics, + /// Time-series shape on the same logical edges. This is edge metadata, + /// not an operator-specific bag of optional cost parameters. + #[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)] +pub struct BinaryEdgeStatistics { + pub inputs: [EdgeStatistics; 2], + pub output: EdgeStatistics, + #[serde(default)] + pub promql: Option, +} + +/// PromQL value shape carried alongside rows/bytes on a physical edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PromqlEdgeStatistics { + pub series: u64, + pub evaluation_steps: u64, + pub value_kind: PromqlValueKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PromqlValueKind { + Scalar, + /// Vector/sample data. Operator semantics determine whether rows are + /// instant results or decoded source samples for a range evaluation. + Vector, + RangeVector, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PromqlUnaryEdgeStatistics { + pub input: PromqlEdgeStatistics, + pub output: PromqlEdgeStatistics, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PromqlBinaryEdgeStatistics { + pub inputs: [PromqlEdgeStatistics; 2], + pub output: PromqlEdgeStatistics, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PromqlNaryEdgeStatistics { + pub inputs: Vec, + pub output: PromqlEdgeStatistics, +} + +/// Input distribution for an algorithm that independently orders partitions. +/// The checked sum of these edges must equal the operator input. A global sort +/// has exactly one partition; an empty input has no partitions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PartitionStatistics { + pub partitions: Vec, +} + +/// Workload-dependent evidence for one operator in an already-lowered +/// physical DAG. [`PhysicalOperator`](crate::analytical_cost::PhysicalOperator) +/// 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 +/// 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 +/// required to cost the selected algorithm. Structuring that evidence by +/// physical kind prevents unrelated facts from being combined in a flat bag +/// of `Option`s. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "operator", rename_all = "snake_case", deny_unknown_fields)] +pub enum OperatorStatistics { + Scan { + edges: UnaryEdgeStatistics, + /// Physical bytes read from storage, independent of decoded logical + /// bytes on the source edge. + source_read_bytes: u64, + }, + Filter { + edges: UnaryEdgeStatistics, + }, + Project { + edges: UnaryEdgeStatistics, + }, + HashAggregate { + edges: UnaryEdgeStatistics, + group_count: u64, + key_bytes: u64, + accumulator_bytes_per_group: u64, + }, + InMemoryComparisonSort { + edges: UnaryEdgeStatistics, + input_partitioning: PartitionStatistics, + }, + TopK { + edges: UnaryEdgeStatistics, + }, + HashJoin { + edges: BinaryEdgeStatistics, + }, + HashDeduplicate { + edges: UnaryEdgeStatistics, + distinct_key_count: u64, + key_bytes: u64, + }, + Concat { + inputs: Vec, + output: EdgeStatistics, + #[serde(default)] + promql: Option, + }, + InMemoryAnalyticWindow { + edges: UnaryEdgeStatistics, + input_partitioning: PartitionStatistics, + }, + Limit { + edges: UnaryEdgeStatistics, + }, + PassThrough { + edges: UnaryEdgeStatistics, + }, + PromqlRange { + edges: UnaryEdgeStatistics, + max_window_samples_per_series: u64, + }, + PromqlSubquery { + edges: UnaryEdgeStatistics, + subquery_steps: u64, + }, + PromqlBinary { + edges: BinaryEdgeStatistics, + matching_key_bytes: u64, + }, + PromqlRelabel { + edges: UnaryEdgeStatistics, + }, + PromqlInfoEnrich { + edges: BinaryEdgeStatistics, + matching_key_bytes: u64, + }, + PromqlSeriesSample { + edges: UnaryEdgeStatistics, + group_count: u64, + key_bytes: u64, + }, + PromqlScalarToVector { + edges: UnaryEdgeStatistics, + }, + PromqlVectorToScalar { + edges: UnaryEdgeStatistics, + }, + PromqlScalarLeaf { + output: EdgeStatistics, + promql_output: PromqlEdgeStatistics, + }, + PromqlPerSeries { + edges: UnaryEdgeStatistics, + accumulator_bytes_per_series: u64, + }, + PromqlPresence { + edges: UnaryEdgeStatistics, + }, +} + +impl OperatorStatistics { + pub fn input_count(&self) -> usize { + match self { + Self::Scan { .. } + | Self::Filter { .. } + | Self::Project { .. } + | Self::HashAggregate { .. } + | Self::InMemoryComparisonSort { .. } + | Self::TopK { .. } + | Self::HashDeduplicate { .. } + | Self::InMemoryAnalyticWindow { .. } + | Self::Limit { .. } + | Self::PassThrough { .. } + | Self::PromqlRange { .. } + | Self::PromqlSubquery { .. } + | Self::PromqlRelabel { .. } + | Self::PromqlSeriesSample { .. } + | Self::PromqlScalarToVector { .. } + | Self::PromqlVectorToScalar { .. } + | Self::PromqlPerSeries { .. } + | Self::PromqlPresence { .. } => 1, + Self::HashJoin { .. } | Self::PromqlBinary { .. } | Self::PromqlInfoEnrich { .. } => 2, + Self::Concat { inputs, .. } => inputs.len(), + Self::PromqlScalarLeaf { .. } => 0, + } + } + + pub fn input(&self, index: usize) -> Option { + match self { + 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::TopK { edges } + | Self::InMemoryAnalyticWindow { edges, .. } + | Self::Limit { edges } + | Self::PassThrough { edges } + | Self::PromqlRange { edges, .. } + | Self::PromqlSubquery { edges, .. } + | Self::PromqlRelabel { edges } + | Self::PromqlSeriesSample { edges, .. } + | Self::PromqlScalarToVector { edges } + | Self::PromqlVectorToScalar { edges } + | Self::PromqlPerSeries { edges, .. } + | Self::PromqlPresence { edges } => (index == 0).then_some(edges.input), + Self::HashJoin { edges } + | Self::PromqlBinary { edges, .. } + | Self::PromqlInfoEnrich { edges, .. } => edges.inputs.get(index).copied(), + Self::Concat { inputs, .. } => inputs.get(index).copied(), + Self::PromqlScalarLeaf { .. } => None, + } + } + + pub fn output(&self) -> EdgeStatistics { + match self { + Self::Scan { edges, .. } + | Self::HashAggregate { edges, .. } + | Self::HashDeduplicate { edges, .. } => edges.output, + Self::Filter { edges } + | Self::Project { edges } + | Self::InMemoryComparisonSort { edges, .. } + | Self::TopK { edges } + | Self::InMemoryAnalyticWindow { edges, .. } + | Self::Limit { edges } + | Self::PassThrough { edges } + | Self::PromqlRange { edges, .. } + | Self::PromqlSubquery { edges, .. } + | Self::PromqlRelabel { edges } + | Self::PromqlSeriesSample { edges, .. } + | Self::PromqlScalarToVector { edges } + | Self::PromqlVectorToScalar { edges } + | Self::PromqlPerSeries { edges, .. } + | Self::PromqlPresence { edges } => edges.output, + Self::HashJoin { edges } + | Self::PromqlBinary { edges, .. } + | Self::PromqlInfoEnrich { edges, .. } => edges.output, + Self::Concat { output, .. } => *output, + Self::PromqlScalarLeaf { output, .. } => *output, + } + } + + pub fn promql_input(&self, index: usize) -> Option { + match self { + Self::Concat { + promql: Some(edges), + .. + } => edges.inputs.get(index).copied(), + Self::PromqlScalarLeaf { .. } => None, + Self::HashJoin { edges } + | Self::PromqlBinary { edges, .. } + | Self::PromqlInfoEnrich { edges, .. } => edges.promql?.inputs.get(index).copied(), + _ => self.unary_promql().and_then( + |edges| { + if index == 0 { + Some(edges.input) + } else { + None + } + }, + ), + } + } + + pub fn promql_output(&self) -> Option { + match self { + Self::Concat { + promql: Some(edges), + .. + } => Some(edges.output), + Self::PromqlScalarLeaf { promql_output, .. } => Some(*promql_output), + Self::HashJoin { edges } + | Self::PromqlBinary { edges, .. } + | Self::PromqlInfoEnrich { edges, .. } => Some(edges.promql?.output), + _ => Some(self.unary_promql()?.output), + } + } + + pub(crate) fn unary_promql(&self) -> Option { + match self { + Self::Scan { edges, .. } + | Self::Filter { edges } + | Self::Project { edges } + | Self::HashAggregate { edges, .. } + | Self::InMemoryComparisonSort { edges, .. } + | Self::TopK { edges } + | Self::HashDeduplicate { edges, .. } + | Self::InMemoryAnalyticWindow { edges, .. } + | Self::Limit { edges } + | Self::PassThrough { edges } + | Self::PromqlRange { edges, .. } + | Self::PromqlSubquery { edges, .. } + | Self::PromqlRelabel { edges } + | Self::PromqlSeriesSample { edges, .. } + | Self::PromqlScalarToVector { edges } + | Self::PromqlVectorToScalar { edges } + | Self::PromqlPerSeries { edges, .. } + | Self::PromqlPresence { edges } => edges.promql, + _ => None, + } + } +} + +/// Resolves physical statistics and owns their catalog/observation freshness. +/// Returning an error makes the entire candidate unavailable. +pub trait OperatorStatisticsProvider { + fn statistics(&self, node_id: &str) -> Result; +} + +impl OperatorStatisticsProvider for HashMap { + fn statistics(&self, node_id: &str) -> Result { + self.get(node_id) + .cloned() + .ok_or_else(|| AnalyticalCostError::MissingOperatorStatistics(node_id.into())) + } +} diff --git a/crates/asap-aware-mapping/src/physical_plan_cost_model.rs b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs new file mode 100644 index 00000000..0761e326 --- /dev/null +++ b/crates/asap-aware-mapping/src/physical_plan_cost_model.rs @@ -0,0 +1,626 @@ +//! Evidence-backed physical-plan costing at the planner selection boundary. + +use std::{cell::RefCell, rc::Rc}; + +use asap_types::post_asap::{SketchAlgorithm, SummaryExpr, SummaryNode}; +use asap_types::pre_asap::{AggIntent, QueryExpr}; + +use crate::analytical_cost::{ + estimate_physical_dag_comparison, AnalyticalCostError, + EvidenceBackedPhysicalDag as PhysicalDag, PhysicalDagComparisonEstimate, + PhysicalDagEstimateRequest, PhysicalNodeEvidence, ResourceCalibration, +}; +use crate::cost_model::{Cost, CostModel, DefaultCostModel}; +use crate::physical_operator_statistics::ComparisonScope; +use crate::query_physical_lowering::{ + lower_query_physical_dag, PhysicalNodeEvidenceProvider, PhysicalNodeRequest, +}; +use crate::replacement::{Replacement, ReplacementSubDAG, TargetSubDAG}; + +/// One immutable generation of deployment evidence for a planner target. +/// +/// The opaque version lets a provider bind every subsequent query and summary +/// lookup to the same catalog/runtime snapshot. A cost-model instance retains +/// this value for the target, so a caller that needs fresher evidence creates a +/// new model instead of mixing generations in one ranking decision. +#[derive(Debug, Clone, PartialEq)] +pub struct PhysicalEvidenceSnapshot { + pub version: String, + pub scope: ComparisonScope, +} + +/// Deployment evidence needed to price one planner alternative. +/// +/// The planner lowers raw queries and logical rewrites itself. A deployment +/// supplies the comparison scope and atomic evidence for each selected query +/// operator. Post-ASAP summary operators need a physical binder because their +/// implementation, placement, and retained-state layout are deployment +/// choices; that binder must return the complete summary DAG, including any +/// embedded `KeepPreAsap` work. +pub trait PlannerPhysicalPlanProvider { + /// Atomically captures the comparison scope and evidence generation. + fn capture_evidence_snapshot( + &self, + target: &TargetSubDAG<'_>, + ) -> Result; + + fn query_node_evidence( + &self, + snapshot: &PhysicalEvidenceSnapshot, + request: PhysicalNodeRequest<'_>, + ) -> Result; + + fn summary_physical_dag( + &self, + snapshot: &PhysicalEvidenceSnapshot, + summary: &Rc, + target: &TargetSubDAG<'_>, + ) -> Result; +} + +/// Dimensional comparison retained for explanations and verification. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PhysicalPlanComparison { + pub resources: PhysicalDagComparisonEstimate, + pub raw_cost: Cost, + pub candidate_cost: Cost, +} + +/// Planner cost model that admits only complete, cheaper physical plans. +/// +/// There is deliberately no structural or compact-formula fallback. Failure +/// to lower either alternative, missing statistics, an unknown physical +/// algorithm, or invalid source/horizon evidence makes the candidate +/// unavailable and leaves the target on its raw path. +pub struct PhysicalPlanCostModel<'a> { + provider: &'a dyn PlannerPhysicalPlanProvider, + calibration: ResourceCalibration, + target_evidence: RefCell>, +} + +struct CachedTargetEvidence { + root: Rc, + consumer_count: usize, + snapshot: PhysicalEvidenceSnapshot, + raw: PhysicalDag, +} + +impl<'a> PhysicalPlanCostModel<'a> { + pub fn new( + provider: &'a dyn PlannerPhysicalPlanProvider, + calibration: ResourceCalibration, + ) -> Result { + calibration.validate()?; + Ok(Self { + provider, + calibration, + target_evidence: RefCell::new(Vec::new()), + }) + } + + fn target_evidence( + &self, + target: &TargetSubDAG<'_>, + ) -> Result<(PhysicalEvidenceSnapshot, PhysicalDag), AnalyticalCostError> { + if let Some(cached) = self.target_evidence.borrow().iter().find(|cached| { + Rc::ptr_eq(&cached.root, target.root) && cached.consumer_count == target.consumer_count + }) { + return Ok((cached.snapshot.clone(), cached.raw.clone())); + } + + let snapshot = self.provider.capture_evidence_snapshot(target)?; + if snapshot.version.trim().is_empty() { + return Err(AnalyticalCostError::MissingOrStale( + "planner_evidence_snapshot.version", + )); + } + snapshot.scope.validate()?; + let evidence = QueryEvidence { + provider: self.provider, + snapshot: &snapshot, + }; + let raw = lower_query_physical_dag(target.root, &snapshot.scope, &evidence)?; + self.target_evidence + .borrow_mut() + .push(CachedTargetEvidence { + root: Rc::clone(target.root), + consumer_count: target.consumer_count, + snapshot: snapshot.clone(), + raw: raw.clone(), + }); + Ok((snapshot, raw)) + } + + pub fn estimate_candidate( + &self, + candidate: &ReplacementSubDAG, + target: &TargetSubDAG<'_>, + ) -> Result { + let (snapshot, raw) = self.target_evidence(target)?; + let scope = &snapshot.scope; + let evidence = QueryEvidence { + provider: self.provider, + snapshot: &snapshot, + }; + let replacement = match &candidate.replacement { + 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)?, + }, + }; + let resources = estimate_physical_dag_comparison( + PhysicalDagEstimateRequest { + nodes: &raw.nodes, + root: &raw.root, + scope, + statistics: &raw, + }, + PhysicalDagEstimateRequest { + nodes: &replacement.nodes, + root: &replacement.root, + scope, + statistics: &replacement, + }, + )?; + let raw_cost = Cost(resources.raw.calibrated_cost(&self.calibration)?); + let candidate_cost = Cost(resources.candidate.calibrated_cost(&self.calibration)?); + Ok(PhysicalPlanComparison { + resources, + raw_cost, + candidate_cost, + }) + } +} + +struct QueryEvidence<'a> { + provider: &'a dyn PlannerPhysicalPlanProvider, + snapshot: &'a PhysicalEvidenceSnapshot, +} + +impl PhysicalNodeEvidenceProvider for QueryEvidence<'_> { + fn evidence( + &self, + request: PhysicalNodeRequest<'_>, + ) -> Result { + self.provider.query_node_evidence(self.snapshot, request) + } +} + +impl CostModel for PhysicalPlanCostModel<'_> { + fn candidate_cost_covers_complete_plan(&self) -> bool { + true + } + + fn candidate_cost( + &self, + candidate: &ReplacementSubDAG, + target: &TargetSubDAG<'_>, + ) -> Option { + self.estimate_candidate(candidate, target) + .ok() + .filter(|estimate| estimate.candidate_cost < estimate.raw_cost) + .map(|estimate| estimate.candidate_cost) + } + + fn rank_candidates( + &self, + intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + // Physical plans do not exist at algorithm enumeration time. Final + // ranking happens after binding, through `candidate_cost` above. + DefaultCostModel.rank_candidates(intent, candidates) + } + + fn estimate_cost(&self, candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>) -> f64 { + self.candidate_cost(candidate, target) + .map_or(f64::NAN, |cost| cost.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::collections::HashMap; + + use asap_types::pre_asap::{Column, DataType, QueryExpr, Reduction, Schema, Source}; + use asap_types::types::AccuracyTarget; + use asap_types::workload::{ + DataArrival, DurationMs, QueryRecurrence, QueryTimeScope, TimeSelection, TimestampMs, + }; + + use crate::analytical_cost::{ExecutionMultiplicity, PhysicalDagNode, PhysicalOperator}; + use crate::physical_operator_statistics::{ + EdgeStatistics, OperatorStatistics, SourceCoverage, UnaryEdgeStatistics, + }; + use crate::replacement::ReplacementStrategy; + + fn edge(rows: u64, bytes: u64) -> EdgeStatistics { + EdgeStatistics { rows, bytes } + } + + fn unary_edges(input: EdgeStatistics, output: EdgeStatistics) -> UnaryEdgeStatistics { + UnaryEdgeStatistics { + input, + output, + promql: None, + } + } + + fn scan_statistics(source_read_bytes: u64, edge: EdgeStatistics) -> OperatorStatistics { + OperatorStatistics::Scan { + edges: unary_edges(edge, edge), + source_read_bytes, + } + } + + fn aggregate_statistics(input: EdgeStatistics, output: EdgeStatistics) -> OperatorStatistics { + OperatorStatistics::HashAggregate { + edges: unary_edges(input, output), + group_count: 1, + key_bytes: 0, + accumulator_bytes_per_group: 8, + } + } + + fn pass_through_statistics(edge: EdgeStatistics) -> OperatorStatistics { + OperatorStatistics::PassThrough { + edges: unary_edges(edge, edge), + } + } + + fn query() -> Rc { + Rc::new(QueryExpr::Aggregate { + reduction: Reduction::by(vec![]), + measures: vec![AggIntent::Count { + accuracy: AccuracyTarget::Epsilon(0.01), + }], + output_names: vec![], + having: None, + child: Rc::new(QueryExpr::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("value", DataType::Float64, false)]), + }), + }) + } + + fn scope() -> ComparisonScope { + ComparisonScope { + data_arrival: DataArrival::AtRest, + planning_time: TimestampMs(1_000), + horizon: DurationMs(10_000), + recurrence: QueryRecurrence::OneTime { + invocations: 10, + execute_at: None, + }, + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(10_000)), + as_of: Some(TimestampMs(1_000)), + }, + sources: vec![SourceCoverage { + source: Source::Table { + table_ref: "events".into(), + }, + source_snapshot_id: "snapshot-1".into(), + predicates: vec![], + info_matchers: vec![], + }], + } + } + + struct TestProvider { + summary_available: bool, + candidate_scan_bytes: u64, + snapshot_calls: Cell, + raw_evidence_calls: Cell, + } + + impl TestProvider { + fn new(summary_available: bool, candidate_scan_bytes: u64) -> Self { + Self { + summary_available, + candidate_scan_bytes, + snapshot_calls: Cell::new(0), + raw_evidence_calls: Cell::new(0), + } + } + + fn summary_dag(&self, scope: &ComparisonScope) -> PhysicalDag { + let scan_statistics = scan_statistics(self.candidate_scan_bytes, edge(100, 800)); + let aggregate_statistics = aggregate_statistics(edge(100, 800), edge(1, 8)); + let read_statistics = pass_through_statistics(edge(1, 8)); + let evidence = HashMap::from([ + ( + "candidate-scan".into(), + PhysicalNodeEvidence { + physical_id: "candidate-scan".into(), + statistics: scan_statistics, + output_buffer_bytes: 8, + }, + ), + ( + "candidate-state".into(), + PhysicalNodeEvidence { + physical_id: "candidate-state".into(), + statistics: aggregate_statistics, + output_buffer_bytes: 8, + }, + ), + ( + "candidate-read".into(), + PhysicalNodeEvidence { + physical_id: "candidate-read".into(), + statistics: read_statistics, + output_buffer_bytes: 8, + }, + ), + ]); + PhysicalDag { + nodes: vec![ + PhysicalDagNode { + id: "candidate-scan".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(scope.sources[0].clone()), + output_buffer_bytes: 8, + retained_bytes: 0, + execution: ExecutionMultiplicity::Once, + }, + PhysicalDagNode { + id: "candidate-state".into(), + operator: PhysicalOperator::HashAggregate { + grouping_key_count: 0, + accumulator_count: 1, + }, + children: vec!["candidate-scan".into()], + source_coverage: None, + output_buffer_bytes: 8, + retained_bytes: 8, + execution: ExecutionMultiplicity::Once, + }, + PhysicalDagNode { + id: "candidate-read".into(), + operator: PhysicalOperator::PassThrough, + children: vec!["candidate-state".into()], + source_coverage: None, + output_buffer_bytes: 8, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }, + ], + root: "candidate-read".into(), + evidence, + } + } + } + + impl PlannerPhysicalPlanProvider for TestProvider { + fn capture_evidence_snapshot( + &self, + _target: &TargetSubDAG<'_>, + ) -> Result { + self.snapshot_calls.set(self.snapshot_calls.get() + 1); + Ok(PhysicalEvidenceSnapshot { + version: "test-snapshot-1".into(), + scope: scope(), + }) + } + + fn query_node_evidence( + &self, + snapshot: &PhysicalEvidenceSnapshot, + request: PhysicalNodeRequest<'_>, + ) -> Result { + assert_eq!(snapshot.version, "test-snapshot-1"); + self.raw_evidence_calls + .set(self.raw_evidence_calls.get() + 1); + let (physical_id, statistics) = match request.operator { + PhysicalOperator::Scan => ("raw-scan", scan_statistics(800, edge(100, 800))), + PhysicalOperator::HashAggregate { + grouping_key_count: 0, + accumulator_count: 1, + } => ( + "raw-aggregate", + aggregate_statistics(edge(100, 800), edge(1, 8)), + ), + _ => return Err(AnalyticalCostError::UnsupportedQueryOperator), + }; + Ok(PhysicalNodeEvidence { + physical_id: physical_id.into(), + output_buffer_bytes: 8, + statistics, + }) + } + + fn summary_physical_dag( + &self, + snapshot: &PhysicalEvidenceSnapshot, + _summary: &Rc, + _target: &TargetSubDAG<'_>, + ) -> Result { + assert_eq!(snapshot.version, "test-snapshot-1"); + self.summary_available + .then(|| self.summary_dag(&snapshot.scope)) + .ok_or(AnalyticalCostError::MissingOrStale("summary_physical_plan")) + } + } + + fn calibration() -> ResourceCalibration { + ResourceCalibration { + cost_per_cpu_op: 1.0, + cost_per_scan_byte: 1.0, + cost_per_retained_byte: 1.0, + version: "test-v1".into(), + } + } + + #[test] + fn global_selection_uses_complete_physical_comparison() { + let root = query(); + let space = crate::replacement::search_workload_with( + vec![("q", Rc::clone(&root))], + &crate::replacement::default_strategies(), + ); + let planned_root = Rc::clone(&space.roots[0].1); + let provider = TestProvider::new(true, 800); + let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); + + let selected = space.global_selection(&model); + assert!( + selected.for_target(&planned_root).unwrap().chosen.is_some(), + "a fully bound build-once summary cheaper than ten raw scans must be selected" + ); + } + + #[test] + fn missing_summary_evidence_keeps_the_raw_target() { + let root = query(); + let space = crate::replacement::search_workload_with( + vec![("q", Rc::clone(&root))], + &crate::replacement::default_strategies(), + ); + let planned_root = Rc::clone(&space.roots[0].1); + let provider = TestProvider::new(false, 800); + let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); + + let selected = space.global_selection(&model); + assert!( + selected.for_target(&planned_root).unwrap().chosen.is_none(), + "missing physical summary evidence must not fall back to a structural estimate" + ); + } + + #[test] + fn candidate_with_incomplete_scope_is_unavailable() { + struct WrongScope(TestProvider); + impl PlannerPhysicalPlanProvider for WrongScope { + fn capture_evidence_snapshot( + &self, + target: &TargetSubDAG<'_>, + ) -> Result { + self.0.capture_evidence_snapshot(target) + } + + fn query_node_evidence( + &self, + snapshot: &PhysicalEvidenceSnapshot, + request: PhysicalNodeRequest<'_>, + ) -> Result { + self.0.query_node_evidence(snapshot, request) + } + + fn summary_physical_dag( + &self, + snapshot: &PhysicalEvidenceSnapshot, + summary: &Rc, + target: &TargetSubDAG<'_>, + ) -> Result { + let mut dag = self.0.summary_physical_dag(snapshot, summary, target)?; + dag.nodes[0] + .source_coverage + .as_mut() + .unwrap() + .source_snapshot_id = "other".into(); + Ok(dag) + } + } + + let root = query(); + let candidates = crate::replacement::SketchAlgorithmStrategy::default_cost_model() + .replacements(&TargetSubDAG::new(&root)); + let provider = WrongScope(TestProvider::new(true, 800)); + let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); + assert_eq!( + model.candidate_cost(&candidates[0], &TargetSubDAG::new(&root)), + None + ); + } + + #[test] + fn blank_snapshot_version_is_unavailable_before_evidence_lookup() { + struct BlankVersionProvider; + impl PlannerPhysicalPlanProvider for BlankVersionProvider { + fn capture_evidence_snapshot( + &self, + _target: &TargetSubDAG<'_>, + ) -> Result { + Ok(PhysicalEvidenceSnapshot { + version: " \t".into(), + scope: scope(), + }) + } + + fn query_node_evidence( + &self, + _snapshot: &PhysicalEvidenceSnapshot, + _request: PhysicalNodeRequest<'_>, + ) -> Result { + panic!("blank snapshot versions must fail before evidence lookup") + } + + fn summary_physical_dag( + &self, + _snapshot: &PhysicalEvidenceSnapshot, + _summary: &Rc, + _target: &TargetSubDAG<'_>, + ) -> Result { + panic!("blank snapshot versions must fail before summary binding") + } + } + + let root = query(); + let candidates = crate::replacement::SketchAlgorithmStrategy::default_cost_model() + .replacements(&TargetSubDAG::new(&root)); + let model = PhysicalPlanCostModel::new(&BlankVersionProvider, calibration()).unwrap(); + assert_eq!( + model.candidate_cost(&candidates[0], &TargetSubDAG::new(&root)), + None + ); + } + + #[test] + fn complete_candidate_that_costs_more_than_raw_is_not_selected() { + let root = query(); + let space = crate::replacement::search_workload_with( + vec![("q", Rc::clone(&root))], + &crate::replacement::default_strategies(), + ); + let planned_root = Rc::clone(&space.roots[0].1); + let provider = TestProvider::new(true, 100_000); + let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); + + let selected = space.global_selection(&model); + assert!(selected.for_target(&planned_root).unwrap().chosen.is_none()); + } + + #[test] + fn sibling_candidates_share_one_scope_and_raw_baseline() { + let root = query(); + let candidates = crate::replacement::SketchAlgorithmStrategy::default_cost_model() + .replacements(&TargetSubDAG::new(&root)); + assert!(candidates.len() >= 2); + let provider = TestProvider::new(true, 800); + let model = PhysicalPlanCostModel::new(&provider, calibration()).unwrap(); + let target = TargetSubDAG::new(&root); + + model.estimate_candidate(&candidates[0], &target).unwrap(); + model.estimate_candidate(&candidates[1], &target).unwrap(); + + assert_eq!(provider.snapshot_calls.get(), 1); + assert_eq!( + provider.raw_evidence_calls.get(), + 2, + "the scan and aggregate evidence for the raw baseline are captured once" + ); + } +} diff --git a/crates/asap-aware-mapping/src/query_physical_lowering.rs b/crates/asap-aware-mapping/src/query_physical_lowering.rs new file mode 100644 index 00000000..be89847a --- /dev/null +++ b/crates/asap-aware-mapping/src/query_physical_lowering.rs @@ -0,0 +1,2579 @@ +//! Recursive lowering from the canonical query IR to evidenced physical DAGs. + +use std::rc::Rc; + +use crate::analytical_cost::{ + validate_operator_semantics, AnalyticalCostError, EvidenceBackedPhysicalDag, + ExecutionMultiplicity, HashJoinBuildSide, PhysicalDagNode, PhysicalNodeEvidence, + PhysicalOperator, PromqlBinaryOperandMode, PromqlBinaryOperation, PromqlPresenceKind, + PromqlSeriesSampleKind, PromqlVectorCardinality, +}; +use crate::physical_operator_statistics::{ + ComparisonScope, EdgeStatistics, OperatorStatistics, SourceCoverage, +}; + +pub struct PhysicalNodeRequest<'a> { + pub logical_node: &'a asap_types::pre_asap::QueryExpr, + pub operator: PhysicalOperator, + pub occurrence: usize, + pub synthetic: bool, + pub children: &'a [String], + pub source_coverage: Option<&'a SourceCoverage>, +} + +pub trait PhysicalNodeEvidenceProvider { + fn evidence( + &self, + request: PhysicalNodeRequest<'_>, + ) -> Result; +} + +impl PhysicalNodeEvidenceProvider for F +where + F: Fn(PhysicalNodeRequest<'_>) -> Result, +{ + fn evidence( + &self, + request: PhysicalNodeRequest<'_>, + ) -> Result { + self(request) + } +} + +/// Lower a resolved query 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. +pub fn lower_query_physical_dag( + root: &Rc, + scope: &ComparisonScope, + evidence: &dyn PhysicalNodeEvidenceProvider, +) -> Result { + use std::collections::HashMap; + + use asap_types::pre_asap::{GroupKeys, QueryExpr, SetOpKind}; + + scope.validate()?; + + struct Lowerer<'a> { + scope: &'a ComparisonScope, + provider: &'a dyn PhysicalNodeEvidenceProvider, + evidence: HashMap, + next_id: usize, + nodes: Vec, + } + + impl Lowerer<'_> { + fn lower(&mut self, query: &QueryExpr) -> Result { + let occurrence = self.next_id; + self.next_id += 1; + self.lower_new(query, occurrence) + } + + fn resolve( + &self, + query: &QueryExpr, + operator: PhysicalOperator, + occurrence: usize, + synthetic: bool, + children: &[String], + source_coverage: Option<&SourceCoverage>, + ) -> Result { + let evidence = self.provider.evidence(PhysicalNodeRequest { + logical_node: query, + operator, + occurrence, + synthetic, + children, + source_coverage, + })?; + if evidence.physical_id.is_empty() { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "provider returned an empty physical identity", + )); + } + Ok(evidence) + } + + fn push( + &mut self, + evidence: PhysicalNodeEvidence, + operator: PhysicalOperator, + children: Vec, + source_coverage: Option, + ) -> Result { + let id = evidence.physical_id.clone(); + let node = PhysicalDagNode { + id: id.clone(), + operator, + children, + source_coverage, + output_buffer_bytes: evidence.output_buffer_bytes, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }; + if let Some(existing) = self.nodes.iter().find(|existing| existing.id == id) { + if existing != &node || self.evidence.get(&id) != Some(&evidence) { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "provider reused a physical identity for conflicting evidence", + )); + } + return Ok(id); + } + self.nodes.push(node); + self.evidence.insert(id.clone(), evidence); + Ok(id) + } + + fn lower_unary( + &mut self, + query: &QueryExpr, + occurrence: usize, + operator: PhysicalOperator, + child: &QueryExpr, + ) -> Result { + let child_id = self.lower(child)?; + let children = vec![child_id.clone()]; + let evidence = self.resolve(query, operator, occurrence, false, &children, None)?; + let statistics = &evidence.statistics; + let child_statistics = self.node_statistics(&child_id)?; + require_unary_edge( + &evidence.physical_id, + statistics, + &child_id, + child_statistics, + )?; + require_promql_edge(statistics, 0, child_statistics)?; + require_operator_statistics(operator, statistics)?; + self.push(evidence, operator, children, None) + } + + fn lower_promql_unary( + &mut self, + query: &QueryExpr, + occurrence: usize, + operator: PhysicalOperator, + child: &QueryExpr, + ) -> Result { + self.lower_unary(query, occurrence, operator, child) + } + + fn lower_promql_scalar_leaf( + &mut self, + query: &QueryExpr, + occurrence: usize, + ) -> Result { + let operator = PhysicalOperator::PromqlScalarLeaf; + let evidence = self.resolve(query, operator, occurrence, false, &[], None)?; + require_statistics_shape(&evidence.physical_id, &evidence.statistics, 0)?; + require_operator_statistics(operator, &evidence.statistics)?; + self.push(evidence, operator, vec![], None) + } + + fn node_statistics(&self, id: &str) -> Result<&OperatorStatistics, AnalyticalCostError> { + self.evidence + .get(id) + .map(|evidence| &evidence.statistics) + .ok_or(AnalyticalCostError::InvalidPhysicalDag( + "lowered child statistics are missing", + )) + } + + fn lower_new( + &mut self, + query: &QueryExpr, + occurrence: usize, + ) -> Result { + match query { + QueryExpr::Scan { + source, predicates, .. + } => { + let coverage = bind_scan_coverage( + &format!("occurrence-{occurrence}"), + source, + predicates, + self.scope, + )?; + if predicates.is_empty() { + let evidence = self.resolve( + query, + PhysicalOperator::Scan, + occurrence, + false, + &[], + Some(&coverage), + )?; + require_statistics_shape(&evidence.physical_id, &evidence.statistics, 1)?; + require_scan_edges_equal(&evidence.statistics)?; + return self.push(evidence, PhysicalOperator::Scan, vec![], Some(coverage)); + } + let scan_evidence = self.resolve( + query, + PhysicalOperator::Scan, + occurrence, + true, + &[], + Some(&coverage), + )?; + require_statistics_shape( + &scan_evidence.physical_id, + &scan_evidence.statistics, + 1, + )?; + require_scan_edges_equal(&scan_evidence.statistics)?; + let scan_statistics = scan_evidence.statistics.clone(); + let scan_id = self.push( + scan_evidence, + PhysicalOperator::Scan, + vec![], + Some(coverage), + )?; + let children = vec![scan_id.clone()]; + let predicate_operations_per_row = predicates + .iter() + .try_fold(0_u64, |total, predicate| { + total + .checked_add(scalar_operation_count(&predicate.0)?) + .ok_or(AnalyticalCostError::Overflow) + })? + .max(1); + let filter_operator = PhysicalOperator::Filter { + predicate_operations_per_row, + }; + let filter_evidence = + self.resolve(query, filter_operator, occurrence, false, &children, None)?; + require_unary_edge( + &filter_evidence.physical_id, + &filter_evidence.statistics, + &scan_id, + &scan_statistics, + )?; + require_operator_statistics(filter_operator, &filter_evidence.statistics)?; + self.push(filter_evidence, filter_operator, children, None) + } + QueryExpr::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, .. } => { + let expression_operations_per_row = cols + .iter() + .try_fold(0_u64, |total, item| { + total + .checked_add( + scalar_operation_count(&item.expr)? + .checked_add(1) + .ok_or(AnalyticalCostError::Overflow)?, + ) + .ok_or(AnalyticalCostError::Overflow) + })? + .max(1); + self.lower_unary( + query, + occurrence, + PhysicalOperator::Project { + expression_operations_per_row, + }, + child, + ) + } + QueryExpr::Aggregate { + reduction, + measures, + having, + child, + .. + } => { + if having.is_some() || measures.is_empty() { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + if matches!(reduction, asap_types::pre_asap::Reduction::PerEntity) { + if measures.len() != 1 { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + let accumulator_count = u64::try_from(measures.len()) + .map_err(|_| AnalyticalCostError::Overflow)?; + let operator = if measures.iter().all(presence_intent) { + PhysicalOperator::PromqlPresence { + kind: PromqlPresenceKind::Absent, + operations_per_row: accumulator_count, + } + } else if measures.iter().all(present_over_time_intent) { + PhysicalOperator::PromqlPresence { + kind: PromqlPresenceKind::PresentPerSeries, + operations_per_row: accumulator_count, + } + } else if measures.iter().all(fixed_state_per_series_intent) { + PhysicalOperator::PromqlPerSeries { + operations_per_row: accumulator_count, + accumulator_count, + } + } else { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + }; + return self.lower_promql_unary(query, occurrence, operator, child); + } + let asap_types::pre_asap::Reduction::Reduce(grouping) = reduction else { + unreachable!("per-entity reduction returned above") + }; + if grouping.is_without() || !supports_hash_aggregate(reduction, measures) { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + self.lower_unary( + query, + occurrence, + PhysicalOperator::HashAggregate { + grouping_key_count: u64::try_from(grouping.keys().len()) + .map_err(|_| AnalyticalCostError::Overflow)?, + accumulator_count: u64::try_from(measures.len()) + .map_err(|_| AnalyticalCostError::Overflow)?, + }, + child, + ) + } + QueryExpr::Dedup { cols, child } => { + let key_count = if cols.is_empty() { + child + .output_schema() + .map_err(|_| AnalyticalCostError::UnsupportedQueryOperator)? + .columns + .len() + } else { + cols.len() + }; + if key_count == 0 { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + self.lower_unary( + query, + occurrence, + PhysicalOperator::HashDeduplicate { + key_count: u64::try_from(key_count) + .map_err(|_| AnalyticalCostError::Overflow)?, + }, + child, + ) + } + QueryExpr::Sort { + keys, + partition_by, + child, + .. + } => { + if keys.is_empty() { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + self.lower_unary( + query, + occurrence, + PhysicalOperator::InMemoryComparisonSort { + ordering_key_count: u64::try_from(keys.len()) + .map_err(|_| AnalyticalCostError::Overflow)?, + partitioned: partition_by != &GroupKeys::none(), + }, + child, + ) + } + QueryExpr::Limit { n, offset, child } => { + if let QueryExpr::Sort { + keys, + partition_by, + child: sorted_child, + .. + } = child.as_ref() + { + if !keys.is_empty() && partition_by == &GroupKeys::none() { + let child_id = self.lower(sorted_child)?; + let children = vec![child_id.clone()]; + let limit = + u64::try_from(*n).map_err(|_| AnalyticalCostError::Overflow)?; + let offset = u64::try_from(*offset) + .map_err(|_| AnalyticalCostError::Overflow)?; + let operator = PhysicalOperator::TopK { + limit, + offset, + ordering_key_count: u64::try_from(keys.len()) + .map_err(|_| AnalyticalCostError::Overflow)?, + }; + let evidence = + self.resolve(query, operator, occurrence, false, &children, None)?; + let statistics = &evidence.statistics; + let child_statistics = self.node_statistics(&child_id)?; + require_unary_edge( + &evidence.physical_id, + statistics, + &child_id, + child_statistics, + )?; + let bound = limit + .checked_add(offset) + .ok_or(AnalyticalCostError::Overflow)?; + if bound == 0 { + return Err(AnalyticalCostError::MissingOrZero("topk_k")); + } + require_operator_statistics(operator, statistics)?; + return self.push(evidence, operator, children, None); + } + } + let child_id = self.lower(child)?; + let children = vec![child_id.clone()]; + let operator = PhysicalOperator::Limit { + limit: u64::try_from(*n).map_err(|_| AnalyticalCostError::Overflow)?, + offset: u64::try_from(*offset) + .map_err(|_| AnalyticalCostError::Overflow)?, + }; + let evidence = + self.resolve(query, operator, occurrence, false, &children, None)?; + let statistics = &evidence.statistics; + let child_statistics = self.node_statistics(&child_id)?; + require_unary_edge( + &evidence.physical_id, + statistics, + &child_id, + child_statistics, + )?; + require_operator_statistics(operator, statistics)?; + self.push(evidence, operator, children, None) + } + QueryExpr::SQLWindowFunc { + func, + partition_by, + order_by, + child, + .. + } => { + if order_by.is_empty() + || !matches!( + func, + asap_types::pre_asap::WindowFuncKind::RowNumber + | asap_types::pre_asap::WindowFuncKind::Rank + | asap_types::pre_asap::WindowFuncKind::DenseRank + ) + { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + self.lower_unary( + query, + occurrence, + PhysicalOperator::InMemoryAnalyticWindow { + partition_key_count: u64::try_from(partition_by.keys().len()) + .map_err(|_| AnalyticalCostError::Overflow)?, + ordering_key_count: u64::try_from(order_by.len()) + .map_err(|_| AnalyticalCostError::Overflow)?, + function_operations_per_row: 1, + }, + child, + ) + } + QueryExpr::TimeRange { range, child } => { + let range_millis = duration_millis(*range, "range")?; + self.lower_promql_unary( + query, + occurrence, + PhysicalOperator::PromqlRange { range_millis }, + child, + ) + } + QueryExpr::PromqlSubquery { + range, + resolution, + child, + } => { + let range_millis = duration_millis(*range, "subquery range")?; + let resolution_millis = resolution + .map(|value| duration_millis(value, "subquery resolution")) + .transpose()?; + let operator = PhysicalOperator::PromqlSubquery { + range_millis, + resolution_millis, + }; + let id = self.lower_promql_unary(query, occurrence, operator, child)?; + let statistics = self.node_statistics(&id)?; + let OperatorStatistics::PromqlSubquery { subquery_steps, .. } = statistics + else { + unreachable!("operator/statistics matching was validated") + }; + if let Some(resolution_millis) = resolution_millis { + let expected = range_millis + .checked_div(resolution_millis) + .and_then(|steps| steps.checked_add(1)) + .ok_or(AnalyticalCostError::Overflow)?; + if *subquery_steps != expected { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "subquery steps disagree with range and resolution", + )); + } + } + Ok(id) + } + QueryExpr::PromqlRelabel { value, child, .. } => self.lower_promql_unary( + query, + occurrence, + PhysicalOperator::PromqlRelabel { + expression_operations_per_row: scalar_operation_count(value)?.max(1), + }, + child, + ), + QueryExpr::PromqlSeriesSample { + by, kind, child, .. + } => { + if by.is_without() { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + let kind = match kind { + asap_types::pre_asap::SampleKind::LimitK(k) => { + let k = u64::try_from(*k).map_err(|_| AnalyticalCostError::Overflow)?; + if k == 0 { + return Err(AnalyticalCostError::MissingOrZero("limitk")); + } + PromqlSeriesSampleKind::LimitK { k } + } + asap_types::pre_asap::SampleKind::LimitRatio(ratio) + if ratio.is_finite() && (-1.0..=1.0).contains(ratio) => + { + PromqlSeriesSampleKind::LimitRatio { + ratio_bits: ratio.to_bits(), + } + } + asap_types::pre_asap::SampleKind::LimitRatio(_) => { + return Err(AnalyticalCostError::UnsupportedQueryOperator) + } + }; + self.lower_promql_unary( + query, + occurrence, + PhysicalOperator::PromqlSeriesSample { + kind, + grouping_key_count: u64::try_from(by.keys().len()) + .map_err(|_| AnalyticalCostError::Overflow)?, + }, + child, + ) + } + QueryExpr::PromqlInfoEnrich { selector, child } => { + let left_id = self.lower(child)?; + let coverage = bind_info_coverage( + &format!("occurrence-{occurrence}-info"), + selector, + self.scope, + )?; + let info_evidence = self.resolve( + query, + PhysicalOperator::Scan, + occurrence, + true, + &[], + Some(&coverage), + )?; + require_statistics_shape( + &info_evidence.physical_id, + &info_evidence.statistics, + 1, + )?; + require_scan_edges_equal(&info_evidence.statistics)?; + let info_statistics = info_evidence.statistics.clone(); + let info_id = self.push( + info_evidence, + PhysicalOperator::Scan, + vec![], + Some(coverage), + )?; + let children = vec![left_id.clone(), info_id.clone()]; + let operator = PhysicalOperator::PromqlInfoEnrich { + matcher_operations_per_info_row: u64::try_from( + selector + .iter() + .filter(|matcher| matcher.label != "__name__") + .count() + .max(1), + ) + .map_err(|_| AnalyticalCostError::Overflow)?, + }; + let evidence = + self.resolve(query, operator, occurrence, false, &children, None)?; + require_binary_edges( + &evidence.physical_id, + &evidence.statistics, + &left_id, + self.node_statistics(&left_id)?, + &info_id, + &info_statistics, + )?; + require_operator_statistics(operator, &evidence.statistics)?; + self.push(evidence, operator, children, None) + } + QueryExpr::BinaryOp { + op, + lhs, + rhs, + vector_match, + } => { + let left_scalar = is_promql_scalar(lhs); + let right_scalar = is_promql_scalar(rhs); + if left_scalar && right_scalar { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + 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 cardinality = promql_vector_cardinality(vector_match.as_ref()); + let left_id = self.lower(lhs)?; + let right_id = self.lower(rhs)?; + let children = vec![left_id.clone(), right_id.clone()]; + let left_statistics = self.node_statistics(&left_id)?; + let right_statistics = self.node_statistics(&right_id)?; + let build_side = (operand_mode == PromqlBinaryOperandMode::VectorVector) + .then_some( + if left_statistics.output().bytes <= right_statistics.output().bytes { + HashJoinBuildSide::Left + } else { + HashJoinBuildSide::Right + }, + ); + let operator = PhysicalOperator::PromqlBinary { + operation, + operand_mode, + cardinality, + build_side, + }; + let evidence = + self.resolve(query, operator, occurrence, false, &children, None)?; + require_binary_edges( + &evidence.physical_id, + &evidence.statistics, + &left_id, + left_statistics, + &right_id, + right_statistics, + )?; + 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) + } + QueryExpr::EvalTimestamp => self.lower_promql_scalar_leaf(query, occurrence), + QueryExpr::TimeShift { shift, child } => { + if !shift.is_identity() { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + self.lower_unary(query, occurrence, PhysicalOperator::PassThrough, child) + } + QueryExpr::Concat { children } => { + let child_ids = children + .iter() + .map(|child| self.lower(child)) + .collect::, _>>()?; + self.lower_concat(query, occurrence, child_ids) + } + QueryExpr::SetOp { + kind: SetOpKind::Union, + all: true, + left, + right, + } => { + let left_id = self.lower(left)?; + let right_id = self.lower(right)?; + self.lower_concat(query, occurrence, vec![left_id, right_id]) + } + QueryExpr::Join { + kind, + pred, + left, + right, + } => { + let equality_key_count = + if matches!(kind, asap_types::pre_asap::JoinKind::Cross) { + None + } else { + hash_join_key_count(&pred.0, left, right) + }; + let Some(equality_key_count) = equality_key_count else { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + }; + let left_id = self.lower(left)?; + let right_id = self.lower(right)?; + let children = vec![left_id.clone(), right_id.clone()]; + let left_statistics = self.node_statistics(&left_id)?; + let right_statistics = self.node_statistics(&right_id)?; + let build_side = + if left_statistics.output().bytes <= right_statistics.output().bytes { + HashJoinBuildSide::Left + } else { + HashJoinBuildSide::Right + }; + let operator = PhysicalOperator::HashJoin { + build_side, + equality_key_count, + }; + let evidence = + self.resolve(query, operator, occurrence, false, &children, None)?; + let statistics = &evidence.statistics; + require_binary_edges( + &evidence.physical_id, + statistics, + &left_id, + left_statistics, + &right_id, + right_statistics, + )?; + require_operator_statistics(operator, statistics)?; + self.push(evidence, operator, children, None) + } + _ => Err(AnalyticalCostError::UnsupportedQueryOperator), + } + } + + fn lower_concat( + &mut self, + query: &QueryExpr, + occurrence: usize, + child_ids: Vec, + ) -> Result { + if child_ids.is_empty() { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "concat has no children", + )); + } + let evidence = self.resolve( + query, + PhysicalOperator::Concat, + occurrence, + false, + &child_ids, + None, + )?; + let statistics = &evidence.statistics; + require_statistics_shape(&evidence.physical_id, statistics, child_ids.len())?; + let (rows, bytes) = child_ids.iter().enumerate().try_fold( + (0_u64, 0_u64), + |(rows, bytes), (index, child)| { + let child_statistics = self.node_statistics(child)?; + if statistics.input(index) != Some(child_statistics.output()) { + return Err(AnalyticalCostError::ConflictingEdgeStatistics { + parent: evidence.physical_id.clone(), + child: child.clone(), + input_index: index, + }); + } + require_promql_edge(statistics, index, child_statistics)?; + Ok::<_, AnalyticalCostError>(( + rows.checked_add(child_statistics.output().rows) + .ok_or(AnalyticalCostError::Overflow)?, + bytes + .checked_add(child_statistics.output().bytes) + .ok_or(AnalyticalCostError::Overflow)?, + )) + }, + )?; + if statistics.output() != (EdgeStatistics { rows, bytes }) { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "concat statistics do not equal the sum of child outputs", + )); + } + require_operator_statistics(PhysicalOperator::Concat, statistics)?; + self.push(evidence, PhysicalOperator::Concat, child_ids, None) + } + } + + let mut lowerer = Lowerer { + scope, + provider: evidence, + evidence: HashMap::new(), + next_id: 0, + nodes: Vec::new(), + }; + let root = lowerer.lower(root)?; + validate_source_consumption(&lowerer.nodes, scope)?; + Ok(EvidenceBackedPhysicalDag { + nodes: lowerer.nodes, + root, + evidence: lowerer.evidence, + }) +} + +fn validate_source_consumption( + nodes: &[PhysicalDagNode], + scope: &ComparisonScope, +) -> Result<(), AnalyticalCostError> { + let consumed = nodes + .iter() + .filter(|node| matches!(node.operator, PhysicalOperator::Scan)) + .filter_map(|node| node.source_coverage.as_ref()) + .collect::>(); + for coverage in &consumed { + if !scope.sources.contains(coverage) { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "physical scan consumes a source outside the comparison scope", + )); + } + } + if scope + .sources + .iter() + .any(|expected| !consumed.contains(&expected)) + { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "physical scans omit a comparison-scope source", + )); + } + Ok(()) +} + +fn require_statistics_shape( + node: &str, + statistics: &OperatorStatistics, + input_count: usize, +) -> Result<(), AnalyticalCostError> { + if statistics.input_count() != input_count { + return Err(AnalyticalCostError::InvalidOperatorStatistics { + node: node.into(), + reason: "wrong input-edge count", + }); + } + if (0..statistics.input_count()) + .filter_map(|index| statistics.input(index)) + .chain(std::iter::once(statistics.output())) + .any(|edge| !edge.is_consistent()) + { + return Err(AnalyticalCostError::InvalidOperatorStatistics { + node: node.into(), + reason: "edge rows and logical bytes are inconsistent", + }); + } + Ok(()) +} + +fn require_scan_edges_equal(statistics: &OperatorStatistics) -> Result<(), AnalyticalCostError> { + if statistics.input(0) != Some(statistics.output()) { + return Err(AnalyticalCostError::InconsistentOperatorStatistics( + "Scan external input edge does not match its output edge", + )); + } + Ok(()) +} + +fn require_unary_edge( + node: &str, + statistics: &OperatorStatistics, + child_id: &str, + child: &OperatorStatistics, +) -> Result<(), AnalyticalCostError> { + require_statistics_shape(node, statistics, 1)?; + if statistics.input(0) != Some(child.output()) { + return Err(AnalyticalCostError::ConflictingEdgeStatistics { + parent: node.into(), + child: child_id.into(), + input_index: 0, + }); + } + Ok(()) +} + +fn require_binary_edges( + node: &str, + statistics: &OperatorStatistics, + left_id: &str, + left: &OperatorStatistics, + right_id: &str, + right: &OperatorStatistics, +) -> Result<(), AnalyticalCostError> { + require_statistics_shape(node, statistics, 2)?; + for (index, (child_id, child)) in [(left_id, left), (right_id, right)].into_iter().enumerate() { + if statistics.input(index) != Some(child.output()) { + return Err(AnalyticalCostError::ConflictingEdgeStatistics { + parent: node.into(), + child: child_id.into(), + input_index: index, + }); + } + require_promql_edge(statistics, index, child)?; + } + Ok(()) +} + +fn require_promql_edge( + parent: &OperatorStatistics, + input_index: usize, + child: &OperatorStatistics, +) -> Result<(), AnalyticalCostError> { + match (parent.promql_input(input_index), child.promql_output()) { + (None, None) => Ok(()), + (Some(input), Some(output)) if input == output => Ok(()), + (Some(_), Some(_)) => Err(AnalyticalCostError::InconsistentOperatorStatistics( + "PromQL parent input does not match child output", + )), + _ => Err(AnalyticalCostError::MissingOrStale( + "promql_edge_statistics", + )), + } +} + +fn require_operator_statistics( + operator: PhysicalOperator, + statistics: &OperatorStatistics, +) -> Result<(), AnalyticalCostError> { + validate_operator_semantics(operator, statistics) +} + +fn bind_scan_coverage( + node_id: &str, + source: &asap_types::pre_asap::Source, + predicates: &[asap_types::pre_asap::Predicate], + scope: &ComparisonScope, +) -> Result { + let mut matches = scope.sources.iter().filter(|coverage| { + coverage.source == *source + && coverage.predicates == predicates + && coverage.info_matchers.is_empty() + }); + let coverage = matches + .next() + .cloned() + .ok_or_else(|| AnalyticalCostError::ScanOutsideComparisonScope(node_id.into()))?; + if matches.any(|candidate| candidate != &coverage) { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "scan source coverage is ambiguous", + )); + } + Ok(coverage) +} + +fn bind_info_coverage( + node_id: &str, + selector: &[asap_types::pre_asap::InfoMatcher], + scope: &ComparisonScope, +) -> Result { + use asap_types::pre_asap::{CompareOpKind, Source}; + + let mut metric: Option<&str> = None; + for matcher in selector + .iter() + .filter(|matcher| matcher.label == "__name__") + { + if matcher.op != CompareOpKind::Eq || metric.is_some_and(|current| current != matcher.value) + { + return Err(AnalyticalCostError::UnsupportedQueryOperator); + } + metric = Some(&matcher.value); + } + let source = Source::TimeSeries { + metric: metric.unwrap_or("target_info").into(), + }; + let mut matches = scope.sources.iter().filter(|coverage| { + coverage.source == source + && coverage.predicates.is_empty() + && coverage.info_matchers == selector + }); + let coverage = matches + .next() + .cloned() + .ok_or_else(|| AnalyticalCostError::ScanOutsideComparisonScope(node_id.into()))?; + if matches.next().is_some() { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "info source coverage is ambiguous", + )); + } + Ok(coverage) +} + +fn duration_millis( + duration: std::time::Duration, + field: &'static str, +) -> Result { + if duration.is_zero() { + return Err(AnalyticalCostError::MissingOrZero(field)); + } + u64::try_from(duration.as_millis()).map_err(|_| AnalyticalCostError::Overflow) +} + +fn promql_binary_operation( + operation: &asap_types::pre_asap::BinaryOpKind, +) -> PromqlBinaryOperation { + use asap_types::pre_asap::BinaryOpKind; + match operation { + BinaryOpKind::And => PromqlBinaryOperation::And, + BinaryOpKind::Or => PromqlBinaryOperation::Or, + BinaryOpKind::Unless => PromqlBinaryOperation::Unless, + BinaryOpKind::Arithmetic(_) + | BinaryOpKind::Compare(_) + | BinaryOpKind::Pow + | BinaryOpKind::Atan2 => PromqlBinaryOperation::ArithmeticOrComparison, + } +} + +fn promql_vector_cardinality( + vector_match: Option<&asap_types::pre_asap::VectorMatch>, +) -> PromqlVectorCardinality { + use asap_types::pre_asap::GroupSide; + match vector_match.and_then(|matching| matching.grouping.as_ref()) { + Some(grouping) if grouping.side == GroupSide::Left => PromqlVectorCardinality::ManyToOne, + Some(_) => PromqlVectorCardinality::OneToMany, + None => PromqlVectorCardinality::OneToOne, + } +} + +fn hash_join_key_count( + expr: &asap_types::pre_asap::QueryExpr, + left: &asap_types::pre_asap::QueryExpr, + right: &asap_types::pre_asap::QueryExpr, +) -> Option { + use asap_types::pre_asap::{CompareOpKind, QueryExpr}; + + let (Ok(left_schema), Ok(right_schema)) = (left.output_schema(), right.output_schema()) else { + return None; + }; + let left_width = left_schema.columns.len(); + let total_width = left_width.saturating_add(right_schema.columns.len()); + + fn column_side(column: usize, left_width: usize, total_width: usize) -> Option { + if column < left_width { + Some(false) + } else if column < total_width { + Some(true) + } else { + None + } + } + + fn predicate(expr: &QueryExpr, left_width: usize, total_width: usize) -> Option { + match expr { + QueryExpr::Compare { + left, + op: CompareOpKind::Eq, + right, + } => match (left.as_ref(), right.as_ref()) { + (QueryExpr::Column(left), QueryExpr::Column(right)) => match ( + column_side(*left, left_width, total_width), + column_side(*right, left_width, total_width), + ) { + (Some(false), Some(true)) | (Some(true), Some(false)) => Some(1), + _ => None, + }, + _ => None, + }, + QueryExpr::BoolAnd(parts) if !parts.is_empty() => { + parts.iter().try_fold(0_u64, |count, part| { + count.checked_add(predicate(part, left_width, total_width)?) + }) + } + _ => None, + } + } + + 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]| { + parts.iter().try_fold(0_u64, |total, part| { + total + .checked_add(scalar_operation_count(part)?) + .ok_or(AnalyticalCostError::Overflow) + }) + }; + let with_local = |children| { + add(children)? + .checked_add(1) + .ok_or(AnalyticalCostError::Overflow) + }; + match expr { + QueryExpr::Column(_) + | QueryExpr::Literal(_) + | QueryExpr::EvalTimestamp + | QueryExpr::CurrentTimestamp => Ok(0), + QueryExpr::Compare { left, right, .. } | QueryExpr::Arithmetic { left, right, .. } => { + with_local(&[left, right]) + } + QueryExpr::BoolAnd(parts) | QueryExpr::BoolOr(parts) => { + let children = parts.iter().collect::>(); + add(&children)? + .checked_add( + u64::try_from(parts.len().saturating_sub(1)) + .map_err(|_| AnalyticalCostError::Overflow)?, + ) + .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, .. } => { + let mut children = Vec::with_capacity(list.len() + 1); + children.push(expr.as_ref()); + children.extend(list.iter()); + add(&children)? + .checked_add(u64::try_from(list.len()).map_err(|_| AnalyticalCostError::Overflow)?) + .ok_or(AnalyticalCostError::Overflow) + } + QueryExpr::FunctionCall { args, .. } => { + let children = args.iter().collect::>(); + with_local(&children) + } + QueryExpr::Case { + operand, + branches, + else_expr, + } => { + let mut children = Vec::new(); + if let Some(operand) = operand { + children.push(operand.as_ref()); + } + for (when, then) in branches { + children.extend([when, then]); + } + if let Some(else_expr) = else_expr { + children.push(else_expr.as_ref()); + } + with_local(&children) + } + _ => Err(AnalyticalCostError::UnsupportedQueryOperator), + } +} + +fn supports_hash_aggregate( + reduction: &asap_types::pre_asap::Reduction, + measures: &[asap_types::pre_asap::AggIntent], +) -> bool { + use asap_types::pre_asap::{AggIntent, Reduction}; + + matches!(reduction, Reduction::Reduce(_)) + && !measures.is_empty() + && measures.iter().all(|intent| { + matches!( + intent, + AggIntent::Count { .. } + | AggIntent::Sum { .. } + | AggIntent::Min { .. } + | AggIntent::Max { .. } + | AggIntent::Avg { .. } + | AggIntent::StdDev { .. } + | AggIntent::Variance { .. } + | AggIntent::Group + | AggIntent::CountValues { .. } + ) + }) +} + +fn presence_intent(intent: &asap_types::pre_asap::AggIntent) -> bool { + matches!( + intent, + asap_types::pre_asap::AggIntent::Absent | asap_types::pre_asap::AggIntent::AbsentOverTime + ) +} + +fn present_over_time_intent(intent: &asap_types::pre_asap::AggIntent) -> bool { + matches!(intent, asap_types::pre_asap::AggIntent::PresentOverTime) +} + +fn fixed_state_per_series_intent(intent: &asap_types::pre_asap::AggIntent) -> bool { + use asap_types::pre_asap::AggIntent; + matches!( + intent, + AggIntent::Rate + | AggIntent::Count { .. } + | AggIntent::Sum { .. } + | AggIntent::Min { .. } + | AggIntent::Max { .. } + | AggIntent::Avg { .. } + | AggIntent::StdDev { .. } + | AggIntent::Variance { .. } + | AggIntent::Increase + | AggIntent::Changes + | AggIntent::Delta + | AggIntent::IDelta + | AggIntent::Deriv + | AggIntent::Resets + | AggIntent::PredictLinear { .. } + | AggIntent::DoubleExpSmoothing { .. } + | AggIntent::HistogramCount + | AggIntent::HistogramSum + | AggIntent::HistogramAvg + | AggIntent::HistogramStdDev + | AggIntent::HistogramStdVar + | AggIntent::HistogramFraction { .. } + | AggIntent::Math(_) + | AggIntent::TimeFn(_) + | AggIntent::LastOverTime + | AggIntent::FirstOverTime + | AggIntent::TsOfMinOverTime + | AggIntent::TsOfMaxOverTime + | AggIntent::TsOfFirstOverTime + | AggIntent::TsOfLastOverTime + ) +} + +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::*; + use crate::analytical_cost::{ + estimate_physical_dag, estimate_physical_dag_comparison, PhysicalDagEstimateRequest, + }; + use crate::physical_operator_statistics::{ + validate_comparison_scopes, BinaryEdgeStatistics, PartitionStatistics, + PromqlEdgeStatistics, PromqlUnaryEdgeStatistics, PromqlValueKind, UnaryEdgeStatistics, + }; + use asap_types::workload::{ + DataArrival, DurationMs, QueryRecurrence, QueryTimeScope, TimeSelection, TimestampMs, + }; + use std::collections::HashMap; + + fn edge(rows: u64, bytes: u64) -> EdgeStatistics { + EdgeStatistics { rows, bytes } + } + + fn unary_edges(input: EdgeStatistics, output: EdgeStatistics) -> UnaryEdgeStatistics { + UnaryEdgeStatistics { + input, + output, + promql: None, + } + } + + fn scan_stats(edge: EdgeStatistics, source_read_bytes: u64) -> OperatorStatistics { + OperatorStatistics::Scan { + edges: unary_edges(edge, edge), + source_read_bytes, + } + } + + fn promql_edge( + series: u64, + evaluation_steps: u64, + value_kind: PromqlValueKind, + ) -> PromqlEdgeStatistics { + PromqlEdgeStatistics { + series, + evaluation_steps, + value_kind, + } + } + + fn promql_unary_edges( + input: EdgeStatistics, + output: EdgeStatistics, + promql_input: PromqlEdgeStatistics, + promql_output: PromqlEdgeStatistics, + ) -> UnaryEdgeStatistics { + UnaryEdgeStatistics { + input, + output, + promql: Some(PromqlUnaryEdgeStatistics { + input: promql_input, + output: promql_output, + }), + } + } + + fn unary_statistics( + operator: PhysicalOperator, + input: EdgeStatistics, + output: EdgeStatistics, + ) -> OperatorStatistics { + let edges = unary_edges(input, output); + match operator { + PhysicalOperator::Filter { .. } => OperatorStatistics::Filter { edges }, + PhysicalOperator::Project { .. } => OperatorStatistics::Project { edges }, + PhysicalOperator::InMemoryComparisonSort { .. } => { + OperatorStatistics::InMemoryComparisonSort { + edges, + input_partitioning: PartitionStatistics { + partitions: (!input.eq(&edge(0, 0))) + .then_some(input) + .into_iter() + .collect(), + }, + } + } + PhysicalOperator::TopK { .. } => OperatorStatistics::TopK { edges }, + PhysicalOperator::InMemoryAnalyticWindow { .. } => { + OperatorStatistics::InMemoryAnalyticWindow { + edges, + input_partitioning: PartitionStatistics { + partitions: (!input.eq(&edge(0, 0))) + .then_some(input) + .into_iter() + .collect(), + }, + } + } + PhysicalOperator::Limit { .. } => OperatorStatistics::Limit { edges }, + PhysicalOperator::PassThrough => OperatorStatistics::PassThrough { edges }, + _ => panic!("test helper requires a stateless unary operator"), + } + } + + fn evidence(statistics: OperatorStatistics) -> PhysicalNodeEvidence { + PhysicalNodeEvidence { + physical_id: String::new(), + output_buffer_bytes: statistics.output().bytes.min(1_024), + statistics, + } + } + + fn scripted<'a>( + provided: &'a HashMap, + ) -> impl Fn(PhysicalNodeRequest<'_>) -> Result + 'a + { + move |request| { + let key = if request.synthetic { + format!("query-{}-scan", request.occurrence) + } else { + format!("query-{}", request.occurrence) + }; + let mut evidence = provided + .get(&key) + .cloned() + .ok_or_else(|| AnalyticalCostError::MissingOperatorStatistics(key.clone()))?; + evidence.physical_id = key; + Ok(evidence) + } + } + + fn scope(sources: Vec) -> ComparisonScope { + ComparisonScope { + data_arrival: DataArrival::AtRest, + planning_time: TimestampMs(1_000), + horizon: DurationMs(1_000), + recurrence: QueryRecurrence::OneTime { + invocations: 1, + execute_at: None, + }, + time_selection: TimeSelection { + scope: QueryTimeScope::Longitudinal, + lookback: Some(DurationMs(1_000)), + as_of: Some(TimestampMs(1_000)), + }, + sources, + } + } + + fn coverage( + source: asap_types::pre_asap::Source, + predicates: Vec, + ) -> SourceCoverage { + SourceCoverage { + source, + source_snapshot_id: "snapshot-1".into(), + predicates, + info_matchers: vec![], + } + } + + #[test] + fn info_source_coverage_includes_symbolic_selector_matchers() { + use asap_types::pre_asap::{CompareOpKind, InfoMatcher, Source}; + + let selector = vec![InfoMatcher { + label: "cluster".into(), + op: CompareOpKind::Eq, + value: "prod".into(), + }]; + let info_coverage = SourceCoverage { + source: Source::TimeSeries { + metric: "target_info".into(), + }, + source_snapshot_id: "snapshot-1".into(), + predicates: vec![], + info_matchers: selector.clone(), + }; + let matching_scope = scope(vec![info_coverage.clone()]); + assert_eq!( + bind_info_coverage("info", &selector, &matching_scope), + Ok(info_coverage) + ); + + let wrong_scope = scope(vec![coverage( + Source::TimeSeries { + metric: "target_info".into(), + }, + vec![], + )]); + assert!(matches!( + bind_info_coverage("info", &selector, &wrong_scope), + Err(AnalyticalCostError::ScanOutsideComparisonScope(_)) + )); + } + + #[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::{Column, DataType, Schema}; + use std::rc::Rc; + + let scan = Rc::new(QueryExpr::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)), + ))], + schema: Schema::new(vec![ + Column::new("service", DataType::Utf8, false), + Column::new("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![], + having: None, + child: Rc::clone(&scan), + }); + let sort = Rc::new(QueryExpr::Sort { + keys: vec![SortKey { + expr: QueryExpr::Column(0), + ascending: false, + nulls_first: false, + }], + partition_by: GroupKeys::none(), + child: aggregate, + }); + let root = Rc::new(QueryExpr::Limit { + n: 10, + offset: 5, + child: sort, + }); + + 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)), + ))], + ); + let scope = scope(vec![scan_coverage]); + let aggregate_statistics = OperatorStatistics::HashAggregate { + edges: unary_edges(edge(400, 25_600), edge(100, 4_000)), + group_count: 100, + key_bytes: 16, + accumulator_bytes_per_group: 8, + }; + let topk_statistics = unary_statistics( + PhysicalOperator::TopK { + limit: 10, + offset: 5, + ordering_key_count: 1, + }, + edge(100, 4_000), + edge(10, 400), + ); + let raw_scan = scan_stats(edge(1_000, 64_000), 64_000); + let provided = HashMap::from([ + ("query-2-scan".into(), evidence(raw_scan)), + ( + "query-2".into(), + evidence(unary_statistics( + PhysicalOperator::Filter { + predicate_operations_per_row: 1, + }, + edge(1_000, 64_000), + edge(400, 25_600), + )), + ), + ("query-1".into(), evidence(aggregate_statistics)), + ("query-0".into(), evidence(topk_statistics)), + ]); + let dag = lower_query_physical_dag(&root, &scope, &scripted(&provided)).unwrap(); + + assert_eq!( + dag.nodes + .iter() + .map(|node| node.operator) + .collect::>(), + vec![ + PhysicalOperator::Scan, + PhysicalOperator::Filter { + predicate_operations_per_row: 1, + }, + PhysicalOperator::HashAggregate { + grouping_key_count: 1, + accumulator_count: 1, + }, + PhysicalOperator::TopK { + limit: 10, + offset: 5, + ordering_key_count: 1, + }, + ] + ); + let topk = dag.nodes.last().unwrap(); + assert_eq!(topk.children, vec![dag.nodes[2].id.clone()]); + assert!(matches!( + provided[&topk.id].statistics, + OperatorStatistics::TopK { .. } + )); + let physical_scan = &dag.nodes[0]; + assert_eq!(physical_scan.id, "query-2-scan"); + assert_eq!( + physical_scan.source_coverage, + Some(scope.sources[0].clone()) + ); + assert_eq!(physical_scan.output_buffer_bytes, 1_024); + assert_ne!( + physical_scan.output_buffer_bytes, + provided[&physical_scan.id].statistics.output().bytes + ); + assert!(estimate_physical_dag(&dag.nodes, &dag.root, &scope, &dag.evidence).is_ok()); + + let mut inconsistent_scan = provided.clone(); + inconsistent_scan + .get_mut("query-2-scan") + .unwrap() + .statistics = OperatorStatistics::Scan { + edges: unary_edges(edge(1_000, 64_000), edge(999, 63_936)), + source_read_bytes: 64_000, + }; + assert_eq!( + lower_query_physical_dag(&root, &scope, &scripted(&inconsistent_scan)), + Err(AnalyticalCostError::InconsistentOperatorStatistics( + "Scan external input edge does not match its output edge" + )) + ); + } + + #[test] + fn query_lowering_shares_only_provider_identified_physical_nodes() { + use asap_types::pre_asap::{Column, CompareOpKind, DataType, Schema}; + use asap_types::pre_asap::{JoinKind, Predicate, QueryExpr, Source}; + use std::rc::Rc; + + let shared = Rc::new(QueryExpr::Scan { + source: Source::Table { + table_ref: "dimensions".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("id", DataType::Int64, false)]), + }); + let root = 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(1)), + })), + left: Rc::clone(&shared), + right: Rc::clone(&shared), + }); + let source_coverage = coverage( + Source::Table { + table_ref: "dimensions".into(), + }, + vec![], + ); + let independent_scope = scope(vec![source_coverage.clone()]); + let scan_statistics = scan_stats(edge(100, 800), 800); + let join_statistics = OperatorStatistics::HashJoin { + edges: BinaryEdgeStatistics { + inputs: [edge(100, 800), edge(100, 800)], + output: edge(25, 400), + promql: None, + }, + }; + let provided = HashMap::from([ + ("query-1".into(), evidence(scan_statistics.clone())), + ("query-2".into(), evidence(scan_statistics.clone())), + ("query-0".into(), evidence(join_statistics.clone())), + ]); + let dag = + lower_query_physical_dag(&root, &independent_scope, &scripted(&provided)).unwrap(); + + assert_eq!(dag.nodes.len(), 3); + assert_ne!(dag.nodes[2].children[0], dag.nodes[2].children[1]); + let estimate = + estimate_physical_dag(&dag.nodes, &dag.root, &independent_scope, &dag.evidence) + .unwrap(); + assert_eq!(estimate.scan_bytes, 1_600); + + let shared_provider = |request: PhysicalNodeRequest<'_>| { + let (physical_id, statistics) = match request.operator { + PhysicalOperator::Scan => ("shared-scan", scan_statistics.clone()), + PhysicalOperator::HashJoin { .. } => ("join", join_statistics.clone()), + _ => return Err(AnalyticalCostError::UnsupportedQueryOperator), + }; + Ok(PhysicalNodeEvidence { + physical_id: physical_id.into(), + output_buffer_bytes: statistics.output().bytes.min(1_024), + statistics, + }) + }; + let shared_scope = scope(vec![source_coverage]); + let shared_dag = lower_query_physical_dag(&root, &shared_scope, &shared_provider).unwrap(); + assert_eq!(shared_dag.nodes.len(), 2); + assert_eq!( + shared_dag.nodes[1].children, + vec!["shared-scan".to_owned(); 2] + ); + let comparison = estimate_physical_dag_comparison( + PhysicalDagEstimateRequest { + nodes: &dag.nodes, + root: &dag.root, + scope: &independent_scope, + statistics: &dag, + }, + PhysicalDagEstimateRequest { + nodes: &shared_dag.nodes, + root: &shared_dag.root, + scope: &shared_scope, + statistics: &shared_dag, + }, + ) + .unwrap(); + assert_eq!(comparison.raw.scan_bytes, 1_600); + assert_eq!(comparison.candidate.scan_bytes, 800); + + let mut drifted_buffer = shared_dag.clone(); + drifted_buffer.nodes[0].output_buffer_bytes += 1; + assert_eq!( + estimate_physical_dag( + &drifted_buffer.nodes, + &drifted_buffer.root, + &shared_scope, + &drifted_buffer, + ), + Err(AnalyticalCostError::InvalidPhysicalDag( + "physical node buffer differs from evidence snapshot" + )) + ); + let mut drifted_identity = shared_dag.clone(); + drifted_identity + .evidence + .get_mut("shared-scan") + .unwrap() + .physical_id = "different".into(); + assert_eq!( + estimate_physical_dag( + &drifted_identity.nodes, + &drifted_identity.root, + &shared_scope, + &drifted_identity, + ), + Err(AnalyticalCostError::InvalidPhysicalDag( + "evidence map key differs from embedded physical identity" + )) + ); + + let conflicting_identity = |request: PhysicalNodeRequest<'_>| { + let (physical_id, mut statistics) = match request.operator { + PhysicalOperator::Scan => ("shared-scan", scan_statistics.clone()), + PhysicalOperator::HashJoin { .. } => ("join", join_statistics.clone()), + _ => return Err(AnalyticalCostError::UnsupportedQueryOperator), + }; + if request.operator == PhysicalOperator::Scan && request.occurrence == 2 { + statistics = scan_stats(edge(100, 800), 801); + } + Ok(PhysicalNodeEvidence { + physical_id: physical_id.into(), + output_buffer_bytes: statistics.output().bytes.min(1_024), + statistics, + }) + }; + assert_eq!( + lower_query_physical_dag(&root, &shared_scope, &conflicting_identity), + Err(AnalyticalCostError::InvalidPhysicalDag( + "provider reused a physical identity for conflicting evidence" + )) + ); + + 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), + }); + assert_eq!( + lower_query_physical_dag(&invalid, &shared_scope, &shared_provider), + Err(AnalyticalCostError::UnsupportedQueryOperator) + ); + } + + #[test] + fn query_lowering_covers_relational_unary_operators() { + use asap_types::pre_asap::{Column, DataType, ScalarValue, Schema}; + use asap_types::pre_asap::{ + GroupKeys, Predicate, QueryExpr, SortKey, Source, TimeShift, WindowFuncKind, + }; + use std::rc::Rc; + + let scan = Rc::new(QueryExpr::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("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 { + 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 { + keys: vec![SortKey { + expr: QueryExpr::Column(0), + ascending: true, + nulls_first: false, + }], + partition_by: GroupKeys::by(vec![0]), + child: window, + }); + let limit = Rc::new(QueryExpr::Limit { + n: 20, + offset: 0, + child: sort, + }); + let root = Rc::new(QueryExpr::TimeShift { + shift: TimeShift::default(), + child: limit, + }); + + let source_coverage = coverage( + Source::Table { + table_ref: "events".into(), + }, + vec![], + ); + let scope = scope(vec![source_coverage]); + let scan_statistics = scan_stats(edge(1_000, 8_000), 8_000); + let dedup_statistics = OperatorStatistics::HashDeduplicate { + edges: unary_edges(edge(800, 3_200), edge(500, 2_000)), + distinct_key_count: 500, + key_bytes: 8, + }; + let provided = HashMap::from([ + ("query-7".into(), evidence(scan_statistics)), + ( + "query-6".into(), + evidence(unary_statistics( + PhysicalOperator::Filter { + predicate_operations_per_row: 1, + }, + edge(1_000, 8_000), + edge(800, 6_400), + )), + ), + ( + "query-5".into(), + evidence(unary_statistics( + PhysicalOperator::Project { + expression_operations_per_row: 1, + }, + edge(800, 6_400), + edge(800, 3_200), + )), + ), + ("query-4".into(), evidence(dedup_statistics)), + ( + "query-3".into(), + evidence(unary_statistics( + PhysicalOperator::InMemoryAnalyticWindow { + partition_key_count: 0, + ordering_key_count: 1, + function_operations_per_row: 1, + }, + edge(500, 2_000), + edge(500, 6_000), + )), + ), + ( + "query-2".into(), + evidence(unary_statistics( + PhysicalOperator::InMemoryComparisonSort { + ordering_key_count: 1, + partitioned: true, + }, + edge(500, 6_000), + edge(500, 6_000), + )), + ), + ( + "query-1".into(), + evidence(unary_statistics( + PhysicalOperator::Limit { + limit: 20, + offset: 0, + }, + edge(500, 6_000), + edge(20, 240), + )), + ), + ( + "query-0".into(), + evidence(unary_statistics( + PhysicalOperator::PassThrough, + edge(20, 240), + edge(20, 240), + )), + ), + ]); + let dag = lower_query_physical_dag(&root, &scope, &scripted(&provided)).unwrap(); + + assert_eq!( + dag.nodes + .iter() + .map(|node| node.operator) + .collect::>(), + vec![ + PhysicalOperator::Scan, + PhysicalOperator::Filter { + predicate_operations_per_row: 1, + }, + PhysicalOperator::Project { + expression_operations_per_row: 1, + }, + PhysicalOperator::HashDeduplicate { key_count: 1 }, + PhysicalOperator::InMemoryAnalyticWindow { + partition_key_count: 0, + ordering_key_count: 1, + function_operations_per_row: 1, + }, + PhysicalOperator::InMemoryComparisonSort { + ordering_key_count: 1, + partitioned: true, + }, + PhysicalOperator::Limit { + limit: 20, + offset: 0, + }, + PhysicalOperator::PassThrough, + ] + ); + assert!(estimate_physical_dag(&dag.nodes, &dag.root, &scope, &dag.evidence).is_ok()); + } + + #[test] + fn query_lowering_maps_concat_and_union_all_but_rejects_distinct_set_ops() { + use asap_types::pre_asap::{Column, DataType, Schema}; + use asap_types::pre_asap::{QueryExpr, SetOpKind, Source}; + use std::rc::Rc; + + let scan = |name: &str| QueryExpr::Scan { + source: Source::Table { + table_ref: name.into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("id", DataType::Int64, false)]), + }; + let union = Rc::new(QueryExpr::SetOp { + kind: SetOpKind::Union, + all: true, + left: Rc::new(scan("a")), + right: Rc::new(scan("b")), + }); + let scope = scope(vec![ + coverage( + Source::Table { + table_ref: "a".into(), + }, + vec![], + ), + coverage( + Source::Table { + table_ref: "b".into(), + }, + vec![], + ), + ]); + let left_statistics = scan_stats(edge(10, 80), 80); + let right_statistics = scan_stats(edge(20, 160), 160); + let provided = HashMap::from([ + ("query-1".into(), evidence(left_statistics)), + ("query-2".into(), evidence(right_statistics)), + ( + "query-0".into(), + evidence(OperatorStatistics::Concat { + inputs: vec![edge(10, 80), edge(20, 160)], + output: edge(30, 240), + promql: None, + }), + ), + ]); + let dag = lower_query_physical_dag(&union, &scope, &scripted(&provided)).unwrap(); + assert_eq!(dag.nodes.last().unwrap().operator, PhysicalOperator::Concat); + + let mut reversed_scope = scope.clone(); + reversed_scope.sources.reverse(); + assert_eq!(validate_comparison_scopes(&scope, &reversed_scope), Ok(1)); + let mut duplicate_scope = scope.clone(); + duplicate_scope.sources.push(scope.sources[0].clone()); + assert_eq!( + duplicate_scope.validate(), + Err(AnalyticalCostError::MissingComparisonScope( + "duplicate source coverage" + )) + ); + + let concat = Rc::new(QueryExpr::Concat { + children: vec![scan("a"), scan("b")], + }); + 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: SetOpKind::Union, + all: false, + left: Rc::new(scan("a")), + right: Rc::new(scan("b")), + }); + assert_eq!( + lower_query_physical_dag(&distinct_union, &scope, &scripted(&provided)), + Err(AnalyticalCostError::UnsupportedQueryOperator) + ); + } + + #[test] + fn query_lowering_fails_closed_for_missing_or_inconsistent_statistics() { + use asap_types::pre_asap::{Column, DataType, Schema}; + use asap_types::pre_asap::{QueryExpr, Source}; + use std::rc::Rc; + + let scan = Rc::new(QueryExpr::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("id", DataType::Int64, false)]), + }); + let root = Rc::new(QueryExpr::Project { + cols: vec![], + qualifier: None, + child: scan, + }); + + let comparison_scope = scope(vec![coverage( + Source::Table { + table_ref: "events".into(), + }, + vec![], + )]); + let missing = HashMap::::new(); + assert_eq!( + lower_query_physical_dag(&root, &comparison_scope, &scripted(&missing)), + Err(AnalyticalCostError::MissingOperatorStatistics( + "query-1".into() + )) + ); + struct MissingBuffer; + impl PhysicalNodeEvidenceProvider for MissingBuffer { + fn evidence( + &self, + _request: PhysicalNodeRequest<'_>, + ) -> Result { + Err(AnalyticalCostError::MissingOrStale("output_buffer_bytes")) + } + } + assert_eq!( + lower_query_physical_dag(&root, &comparison_scope, &MissingBuffer), + Err(AnalyticalCostError::MissingOrStale("output_buffer_bytes")) + ); + let scan_statistics = scan_stats(edge(100, 800), 800); + let conflicting = HashMap::from([ + ("query-1".into(), evidence(scan_statistics)), + ( + "query-0".into(), + evidence(unary_statistics( + PhysicalOperator::Project { + expression_operations_per_row: 1, + }, + edge(99, 792), + edge(99, 396), + )), + ), + ]); + assert_eq!( + lower_query_physical_dag(&root, &comparison_scope, &scripted(&conflicting)), + Err(AnalyticalCostError::ConflictingEdgeStatistics { + parent: "query-0".into(), + child: "query-1".into(), + input_index: 0, + }) + ); + + let outside_scope = scope(vec![coverage( + Source::Table { + table_ref: "other".into(), + }, + vec![], + )]); + assert_eq!( + lower_query_physical_dag(&root, &outside_scope, &scripted(&conflicting)), + Err(AnalyticalCostError::ScanOutsideComparisonScope( + "occurrence-1".into() + )) + ); + + let mut second_snapshot = comparison_scope.sources[0].clone(); + second_snapshot.source_snapshot_id = "snapshot-2".into(); + let ambiguous_scope = scope(vec![comparison_scope.sources[0].clone(), second_snapshot]); + assert_eq!( + lower_query_physical_dag(&root, &ambiguous_scope, &scripted(&conflicting)), + Err(AnalyticalCostError::InvalidPhysicalDag( + "scan source coverage is ambiguous" + )) + ); + + let mut complete = conflicting.clone(); + complete.get_mut("query-0").unwrap().statistics = unary_statistics( + PhysicalOperator::Project { + expression_operations_per_row: 1, + }, + edge(100, 800), + edge(100, 400), + ); + let extra_scope = scope(vec![ + comparison_scope.sources[0].clone(), + coverage( + Source::Table { + table_ref: "unused".into(), + }, + vec![], + ), + ]); + assert_eq!( + lower_query_physical_dag(&root, &extra_scope, &scripted(&complete)), + Err(AnalyticalCostError::InvalidPhysicalDag( + "physical scans omit a comparison-scope source" + )) + ); + } + + #[test] + fn query_lowering_accepts_a_consistently_empty_edge() { + use asap_types::pre_asap::{Column, DataType, ScalarValue, Schema}; + use asap_types::pre_asap::{Predicate, QueryExpr, Source}; + use std::rc::Rc; + + let scan = Rc::new(QueryExpr::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("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, + offset: 0, + child: filter, + }); + + let scope = scope(vec![coverage( + Source::Table { + table_ref: "events".into(), + }, + vec![], + )]); + let scan_statistics = scan_stats(edge(100, 800), 800); + let provided = HashMap::from([ + ("query-2".into(), evidence(scan_statistics)), + ( + "query-1".into(), + evidence(unary_statistics( + PhysicalOperator::Filter { + predicate_operations_per_row: 1, + }, + edge(100, 800), + edge(0, 0), + )), + ), + ( + "query-0".into(), + evidence(unary_statistics( + PhysicalOperator::Limit { + limit: 10, + offset: 0, + }, + edge(0, 0), + edge(0, 0), + )), + ), + ]); + let dag = lower_query_physical_dag(&root, &scope, &scripted(&provided)).unwrap(); + let estimate = estimate_physical_dag(&dag.nodes, &dag.root, &scope, &dag.evidence).unwrap(); + assert_eq!(estimate.cpu_ops, 200.0); + assert_eq!(estimate.scan_bytes, 800); + } + + #[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::{Column, DataType, Schema}; + use asap_types::types::AccuracyTarget; + use std::rc::Rc; + + let scan = || { + Rc::new(QueryExpr::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("value", DataType::Float64, false)]), + }) + }; + 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![], + having: None, + child: scan(), + }); + let empty_sort_limit = Rc::new(QueryExpr::Limit { + n: 10, + offset: 0, + child: Rc::new(QueryExpr::Sort { + keys: vec![], + partition_by: GroupKeys::none(), + 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(), + }); + let scope = scope(vec![coverage( + Source::Table { + table_ref: "events".into(), + }, + vec![], + )]); + let unavailable = HashMap::::new(); + for query in [ + &exact_quantile, + &empty_sort_limit, + &unsupported_window, + &shifted, + ] { + assert_eq!( + lower_query_physical_dag(query, &scope, &scripted(&unavailable)), + Err(AnalyticalCostError::UnsupportedQueryOperator) + ); + } + } + + #[test] + fn scalar_work_counts_every_local_predicate_operation() { + use asap_types::pre_asap::{CompareOpKind, QueryExpr, ScalarValue}; + + let comparison = || QueryExpr::Compare { + left: Rc::new(QueryExpr::Column(0)), + op: CompareOpKind::Eq, + right: Rc::new(QueryExpr::Literal(ScalarValue::Int64(1))), + }; + let predicate = QueryExpr::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, Column, DataType, QueryExpr, Reduction, Schema, Source, + }; + + let source = Source::TimeSeries { + metric: "missing".into(), + }; + let scan = Rc::new(QueryExpr::Scan { + source: source.clone(), + predicates: vec![], + schema: Schema::new(vec![Column::new("value", DataType::Float64, false)]), + }); + let root = Rc::new(QueryExpr::Aggregate { + reduction: Reduction::PerEntity, + measures: vec![AggIntent::Absent], + output_names: vec![], + having: None, + child: scan, + }); + 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), + source_read_bytes: 0, + }; + let output = promql_edge(1, 2, PromqlValueKind::Vector); + let presence_statistics = OperatorStatistics::PromqlPresence { + edges: promql_unary_edges(edge(0, 0), edge(2, 16), vector, output), + }; + let provided = HashMap::from([ + ("query-1".into(), evidence(scan_statistics)), + ("query-0".into(), evidence(presence_statistics.clone())), + ]); + let query_scope = scope(vec![coverage(source, vec![])]); + let dag = lower_query_physical_dag(&root, &query_scope, &scripted(&provided)).unwrap(); + assert!(matches!( + dag.nodes.last().map(|node| node.operator), + Some(PhysicalOperator::PromqlPresence { + kind: PromqlPresenceKind::Absent, + operations_per_row: 1 + }) + )); + + let OperatorStatistics::PromqlPresence { mut edges } = presence_statistics else { + unreachable!() + }; + edges.output = edge(3, 24); + let invalid = HashMap::from([ + ( + "query-1".into(), + evidence(OperatorStatistics::Scan { + edges: promql_unary_edges(edge(0, 0), edge(0, 0), vector, vector), + source_read_bytes: 0, + }), + ), + ( + "query-0".into(), + evidence(OperatorStatistics::PromqlPresence { edges }), + ), + ]); + assert!(matches!( + lower_query_physical_dag(&root, &query_scope, &scripted(&invalid)), + Err(AnalyticalCostError::InconsistentOperatorStatistics(_)) + )); + } + + #[test] + fn promql_range_and_subquery_preserve_internal_steps() { + use asap_types::pre_asap::{Column, DataType, QueryExpr, Schema, Source}; + use std::time::Duration; + + let source = Source::TimeSeries { metric: "m".into() }; + let scan = Rc::new(QueryExpr::Scan { + source: source.clone(), + predicates: vec![], + schema: Schema::new(vec![Column::new("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, + }); + 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); + let provided = HashMap::from([ + ( + "query-2".into(), + evidence(OperatorStatistics::Scan { + edges: promql_unary_edges(edge(600, 9_600), edge(600, 9_600), vector, vector), + source_read_bytes: 4_800, + }), + ), + ( + "query-1".into(), + evidence(OperatorStatistics::PromqlRange { + edges: promql_unary_edges( + edge(600, 9_600), + edge(60, 960), + vector, + range_vector, + ), + max_window_samples_per_series: 10, + }), + ), + ( + "query-0".into(), + evidence(OperatorStatistics::PromqlSubquery { + edges: promql_unary_edges( + edge(60, 960), + edge(10, 160), + range_vector, + outer_range, + ), + subquery_steps: 6, + }), + ), + ]); + let query_scope = scope(vec![coverage(source, vec![])]); + let dag = lower_query_physical_dag(&root, &query_scope, &scripted(&provided)).unwrap(); + assert!(matches!( + dag.nodes[1].operator, + PhysicalOperator::PromqlRange { + range_millis: 300_000 + } + )); + assert!(matches!( + dag.nodes[2].operator, + PhysicalOperator::PromqlSubquery { + range_millis: 300_000, + resolution_millis: Some(60_000) + } + )); + assert!(estimate_physical_dag(&dag.nodes, &dag.root, &query_scope, &dag.evidence).is_ok()); + } + + #[test] + fn promql_binary_lowering_keeps_operation_and_matching_cardinality() { + use asap_types::pre_asap::{ + ArithmeticOpKind, BinaryOpKind, Column, DataType, GroupSide, QueryExpr, 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 { + source, + predicates: vec![], + schema: Schema::new(vec![Column::new("value", DataType::Float64, false)]), + }) + }; + 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 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); + let left_edge = edge(100, 1_600); + let right_edge = edge(50, 800); + let output_edge = edge(80, 1_280); + let provided = HashMap::from([ + ( + "query-1".into(), + evidence(OperatorStatistics::Scan { + edges: promql_unary_edges(left_edge, left_edge, left_promql, left_promql), + source_read_bytes: 800, + }), + ), + ( + "query-2".into(), + evidence(OperatorStatistics::Scan { + edges: promql_unary_edges(right_edge, right_edge, right_promql, right_promql), + source_read_bytes: 400, + }), + ), + ( + "query-0".into(), + evidence(OperatorStatistics::PromqlBinary { + edges: BinaryEdgeStatistics { + inputs: [left_edge, right_edge], + output: output_edge, + promql: Some( + crate::physical_operator_statistics::PromqlBinaryEdgeStatistics { + inputs: [left_promql, right_promql], + output: output_promql, + }, + ), + }, + matching_key_bytes: 16, + }), + ), + ]); + let query_scope = scope(vec![ + coverage(left_source, vec![]), + coverage(right_source, vec![]), + ]); + let dag = lower_query_physical_dag(&root, &query_scope, &scripted(&provided)).unwrap(); + assert!(matches!( + dag.nodes.last().map(|node| node.operator), + Some(PhysicalOperator::PromqlBinary { + operation: PromqlBinaryOperation::ArithmeticOrComparison, + operand_mode: PromqlBinaryOperandMode::VectorVector, + cardinality: PromqlVectorCardinality::ManyToOne, + build_side: Some(HashJoinBuildSide::Right), + }) + )); + } + + #[test] + fn promql_relabel_sample_and_per_series_lower_as_a_complete_chain() { + use asap_types::pre_asap::{ + AggIntent, Column, DataType, GroupKeys, QueryExpr, Reduction, SampleKind, ScalarValue, + Schema, Source, + }; + + let source = Source::TimeSeries { + metric: "requests".into(), + }; + let scan = Rc::new(QueryExpr::Scan { + source: source.clone(), + predicates: vec![], + schema: Schema::new(vec![Column::new("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![], + having: None, + child: sample, + }); + + let input = edge(100, 1_600); + let sampled = edge(50, 800); + let input_promql = promql_edge(10, 10, PromqlValueKind::Vector); + let sampled_promql = promql_edge(5, 10, PromqlValueKind::Vector); + let provided = HashMap::from([ + ( + "query-3".into(), + evidence(OperatorStatistics::Scan { + edges: promql_unary_edges(input, input, input_promql, input_promql), + source_read_bytes: 800, + }), + ), + ( + "query-2".into(), + evidence(OperatorStatistics::PromqlRelabel { + edges: promql_unary_edges(input, input, input_promql, input_promql), + }), + ), + ( + "query-1".into(), + evidence(OperatorStatistics::PromqlSeriesSample { + edges: promql_unary_edges(input, sampled, input_promql, sampled_promql), + group_count: 1, + key_bytes: 8, + }), + ), + ( + "query-0".into(), + evidence(OperatorStatistics::PromqlPerSeries { + edges: promql_unary_edges(sampled, sampled, sampled_promql, sampled_promql), + accumulator_bytes_per_series: 8, + }), + ), + ]); + let query_scope = scope(vec![coverage(source, vec![])]); + + let dag = lower_query_physical_dag(&root, &query_scope, &scripted(&provided)).unwrap(); + assert!(matches!( + dag.nodes.as_slice(), + [ + PhysicalDagNode { + operator: PhysicalOperator::Scan, + .. + }, + PhysicalDagNode { + operator: PhysicalOperator::PromqlRelabel { .. }, + .. + }, + PhysicalDagNode { + operator: PhysicalOperator::PromqlSeriesSample { .. }, + .. + }, + PhysicalDagNode { + operator: PhysicalOperator::PromqlPerSeries { .. }, + .. + } + ] + )); + assert!(estimate_physical_dag(&dag.nodes, &dag.root, &query_scope, &dag.evidence).is_ok()); + } +} diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 402b5267..7ac1dcc7 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -3083,6 +3083,36 @@ impl PlanSpace { }); let chosen = if lifecycle_choice.is_some() { lifecycle_choice + } else if cost_model.candidate_cost_covers_complete_plan() { + let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); + let bound_physical = group + .candidates + .iter() + // A logical CSE rewrite does not encode shared retained + // state or independent execution multiplicity. Until it + // is bound as a complete physical DAG, it must not enter + // evidence-backed ranking as though those costs were + // known. + .filter(|candidate| !is_cse_candidate(candidate)) + .filter_map(|candidate| { + cost_model + .candidate_cost(candidate, &effective_target) + .map(|cost| (candidate, cost)) + }) + .min_by(|(_, left), (_, right)| left.0.total_cmp(&right.0)) + .map(|(candidate, _)| candidate); + bound_physical.or_else(|| { + (effective >= 2) + .then(|| { + decide_with_effective_count(group, effective, cost_model).and_then( + |decision| { + chosen_share.insert(*ptr, decision); + pick_shared_subtree_candidate(group, decision) + }, + ) + }) + .flatten() + }) } else if effective >= 2 && cse_candidate_pair(group).is_some() { let decision = if let Some(profiles) = profiles { decide_group_with_recurrence( @@ -5823,6 +5853,51 @@ mod tests { ); } + #[test] + fn complete_plan_costs_reject_unbound_cse_arms() { + struct CompletePlanCost; + impl CostModel for CompletePlanCost { + fn candidate_cost_covers_complete_plan(&self) -> bool { + true + } + + fn candidate_cost( + &self, + candidate: &ReplacementSubDAG, + _target: &TargetSubDAG<'_>, + ) -> Option { + assert!(!is_cse_candidate(candidate)); + None + } + + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + + fn cse_share_decision(&self, _candidate: &CseCandidate) -> ShareDecision { + ShareDecision::RecomputeIndependently + } + } + + let shared = Rc::new(QueryExpr::Dedup { + cols: vec![0], + child: Rc::new(metric_scan(&["job"])), + }); + let space = search_workload(vec![ + ("left", Rc::clone(&shared)), + ("right", Rc::clone(&shared)), + ]); + let planned = &space.roots[0].1; + + let selected = space.global_selection(&CompletePlanCost); + let chosen = selected.for_target(planned).unwrap().chosen.unwrap(); + assert_eq!(chosen.provenance, ReplacementProvenance::CseRecompute); + } + #[test] fn effective_repetition_materializes_a_cse_choice_for_a_single_edge_child() { use asap_types::pre_asap::expr_ir::ScalarValue; diff --git a/docs/design_docs/asap-aware-mapping/README.md b/docs/design_docs/asap-aware-mapping/README.md index 018b9fb5..fa3d186f 100644 --- a/docs/design_docs/asap-aware-mapping/README.md +++ b/docs/design_docs/asap-aware-mapping/README.md @@ -94,6 +94,9 @@ The design is split into focused documents: guarantee IR, sketch contracts, composition rules, target checking, and fail-closed boundaries. - [Analytical resource cost](analytical-resource-cost.md) defines CPU, retained-memory, and scan-I/O formulas, calibration, planner ranking, and fail-closed behavior. +- [Physical plan integration](physical-plan-integration.md) defines how pre-ASAP and + post-ASAP logical plans lower into the physical operator DAG consumed by statistics + resolution and analytical costing. - [Query workloads, data workloads, and summary lifecycle maintenance](workload-demand-and-summary-lifecycle.md) separates query-workload properties from data-workload properties and defines ephemeral, prepared, shared, and continuously maintained summary-state alternatives. diff --git a/docs/design_docs/asap-aware-mapping/analytical-resource-cost.md b/docs/design_docs/asap-aware-mapping/analytical-resource-cost.md index 10d5a6f6..ec6756a3 100644 --- a/docs/design_docs/asap-aware-mapping/analytical-resource-cost.md +++ b/docs/design_docs/asap-aware-mapping/analytical-resource-cost.md @@ -2,26 +2,42 @@ ## Purpose and boundaries -The analytical resource cost model version implemented here is explicitly for -`DataArrival::AtRest`. It compares legal physical plans using CPU work, peak -memory, and source/disk I/O. It replaces dimensionless plan-node counts with -estimates derived from operator complexity, cardinality, row width, and -concrete summary parameters. +This document defines an analytical estimation method over evidenced physical +plans. These are separate concerns: + +- the **physical input layer** names the physical operators and DAG edges being + estimated and supplies catalog or observed statistics for them; and +- the **analytical estimation layer** applies algorithmic formulas to that + evidence to estimate CPU work, peak memory, and source/disk I/O. + +The model version implemented here is explicitly for `DataArrival::AtRest`. +It replaces dimensionless plan-node counts with estimates derived from +operator complexity, cardinality, row width, and concrete summary parameters. +The estimates are predictions; they are not measurements reported by a +physical executor. The model does not decide semantic or accuracy legality. Candidate generation and guarantee composition run first; costing ranks only the candidates that survive. Missing evidence produces an unavailable estimate, never an assumed zero or a structural-cost fallback. -This document distinguishes two implementation layers: +The implementation keeps five layers distinct: -- the physical-DAG estimator, which can compose any DAG whose nodes have - supported physical operators and complete `OperatorInputs`; and -- the planner bridge, which lowers a deliberately small set of replacement - shapes into that estimator and participates in automatic candidate ranking. +- physical query lowering (`query_physical_lowering`), which recursively maps + supported resolved `QueryExpr` operators to the physical representation; +- physical evidence (`physical_operator_statistics`), which pairs every + physical operator with the statistics required by its formula; +- analytical estimation (`analytical_cost`), which composes any evidenced DAG + whose operators have supported formulas; +- the deployment summary binder, which maps a selected `SummaryExpr` DAG to + physical summary operators and snapshots their evidence; and +- the planner-ranking adapter, which compares the complete raw and replacement + DAGs before making a candidate available to global selection. -Support in the first layer does not imply that the planner can yet lower and -rank the same shape. The current bridge boundary is stated explicitly below. +Support in the estimator does not imply that a deployment has selected and +bound that physical algorithm. Unknown query lowering or summary binding makes +the entire alternative unavailable. +No layer may substitute a shape-specific shortcut or structural node count. An estimate has physical dimensions: @@ -51,7 +67,7 @@ normalized workload, lowered query IR, and freshness-aware statistics: | Query demand | `QueryWorkloadEntry.recurrence`, normalized over an explicit horizon. | | Event-time range | `QueryWorkloadEntry.time_selection`. | | Accuracy and latency requirements | `QueryWorkloadEntry.requirements`. | -| Operator shape, grouping keys, and constants such as Top-K `k` | Lowered `QueryExpr` and `AggIntent`. | +| Operator shape, grouping keys, and constants such as Top-K limit and offset | Lowered query and selected physical plan. | Evidence is read through `Evidence::value_at(planning_time)`. Stale, future, or improperly time-bounded evidence remains unknown. Costing follows @@ -69,11 +85,54 @@ The missing facts have explicit ownership: | Join-side and join-output cardinality | Join-key/operator statistics. | | Memory budget, spill I/O, cache behavior, and network bytes | Deployment/execution-profile evidence. | -`OperatorInputs` contains the resolved statistics for one physical operator. -It is an estimator input, not another planner workload object. The component -that lowers a query into a physical DAG owns resolving these fields from the -canonical workload, catalog, and operator-statistics sources. It must leave a -plan unavailable when required evidence cannot be resolved. +`OperatorStatistics` contains the resolved statistics for one physical +operator. It is estimator evidence, not another planner workload object. The +lowering provider owns resolving it from the canonical workload, catalog, and +operator-statistics sources. Missing required evidence makes the entire plan +unavailable. + +### Operator vocabulary and source of truth + +The complete logical-to-physical boundary is specified in +[Physical plan integration](physical-plan-integration.md). This section +summarizes the part required by the resource estimator. + +`PhysicalOperator` is the source of truth for the cost model's operator +vocabulary. `OperatorStatistics` is paired one-to-one with that enum: each +supported physical algorithm has one evidence shape containing exactly the +facts its formula consumes. Exhaustive matches enforce that a newly added +physical operator must define its arity, statistics variant, validation, and +resource formula. + +Neither logical IR is the statistics schema: + +```text +pre-ASAP QueryExpr ─┐ + ├─ physical lowering ─> PhysicalDagNode/PhysicalOperator +post-ASAP SummaryExpr┘ │ + v + OperatorStatistics + │ + v + ResourceEstimate +``` + +The pre-ASAP IR describes exact query semantics. The post-ASAP IR describes +logical summary semantics, selected summary families, and summary operations. +Neither identifies every physical algorithm, buffer, build side, or execution +layout. For example, one logical `SummaryAgg` may lower to a CMS build, an +exact accumulator build, or another supported summary implementation; a +logical `SummaryEstimate` lowers to the corresponding physical readout. Those +physical nodes need different formulas and evidence even though they originate +from the same logical variant. + +The operator list in this version covers the physical query operators declared +by `PhysicalOperator`. It is not a claim that every `SummaryExpr` variant has +already been physically lowered. Summary build, join, merge, subtract, delete, +and readout become costable only after their lowering introduces explicit +physical operators and matching statistics variants. Until then, a candidate +containing such an unlowered operation is unavailable rather than partially +costed. ## Workload horizon and lifecycle @@ -91,8 +150,8 @@ It is not an independent workload axis. A repeated rate without a horizon cannot produce a finite total cost. An at-rest estimate must not be reused for `unknown`, `mixed`, or -`continuously_ingesting` data. Callers must fail closed instead of pretending -that incremental updates are a one-time snapshot build. +`continuously_ingesting` data. Callers fail closed instead of pretending that +incremental updates are a one-time snapshot build. The sketch alternative scans the selected source snapshot once and retains state. The raw alternative recomputes from that snapshot for every query @@ -101,24 +160,200 @@ retention duration belong to the summary-maintenance lifecycle model. They must contribute update/build/delete work before a continuously maintained plan is compared with raw execution. +### Comparable source and workload scope + +A lower numerical cost is meaningful only when the two plans answer the same +request over the same physical data. `ComparisonScope` therefore reuses the +canonical workload and query terms rather than defining parallel strings: + +| Scope field | Authoritative type and meaning | +|---|---| +| Arrival mode | `DataArrival`; this model accepts only `AtRest`. | +| Planning instant and finite horizon | `TimestampMs` and `DurationMs`. | +| Invocation schedule | `QueryRecurrence`; the evaluation count is derived, not copied. | +| Event-time coverage | `TimeSelection`. | +| Logical sources | the existing query-IR `Source`, one per scan. | +| Source selection | canonical bound `Predicate` values for ordinary scans and symbolic `InfoMatcher` values for info-metric scans. | +| Physical source contents | provider-owned `source_snapshot_id` per source. | + +The snapshot identifier is the only new scope concept. It is necessary because +`Source` names a metric or table but neither the query IR nor workload schema +identifies a catalog version, object generation, or storage snapshot. Reusing a +query timestamp would be incorrect: query event time and storage version are +independent facts. + +`ComparisonScope::from_workload` copies arrival, recurrence, and time selection +from `DataWorkload` and `QueryWorkloadEntry`; the catalog/lowering boundary adds +the source, snapshot identifier, and canonical source selection. Raw and candidate +scopes must match exactly in every field before their estimates are compared. +Unknown subsumption such as "this wider retained summary covers the requested +interval" is not guessed here; it requires a separate semantic coverage proof. +An empty source set is valid only for a fully source-free logical DAG such as +`time()`, a number literal, or `vector(1)`. After lowering, the reachable Scan +coverage set must equal the scope source set: a Scan query with an empty scope, +or a source-free query with a non-empty scope, fails closed. Empty snapshot +identifiers, invalid recurrence, or a zero horizon also fail closed. + +Every reachable physical `Scan` carries one exact `SourceCoverage` copied from +this scope. That coverage includes the existing `Source`, its provider-owned +snapshot ID, and canonical ordinary predicates or info-metric matchers. A scan with no coverage, or coverage +not present in `ComparisonScope.sources`, makes the plan unavailable. Other +operators cannot declare source coverage. This prevents a DAG over source B +from being estimated under source A's comparison scope. + ## General DAG costing Costing operates on the physical DAG, not on a list of logical operators. -Each costed node produces: +An `OperatorStatisticsProvider` resolves one `OperatorStatistics` value for +each reachable physical node: ```text -NodeEstimate { - output_rows, - output_bytes, - cpu_ops, - retained_bytes, - working_bytes, - source_read_bytes, +EdgeStatistics { rows, bytes } + +OperatorStatistics = + Scan { + edges: UnaryEdgeStatistics, + source_read_bytes, + } + | Filter { edges: UnaryEdgeStatistics } + | Project { edges: UnaryEdgeStatistics } + | HashAggregate { + edges: UnaryEdgeStatistics, + group_count, + key_bytes, + accumulator_bytes_per_group, + } + | InMemoryComparisonSort { + edges: UnaryEdgeStatistics, + input_partitioning: PartitionStatistics, + } + | TopK { edges: UnaryEdgeStatistics } + | HashJoin { edges: BinaryEdgeStatistics } + | HashDeduplicate { + edges: UnaryEdgeStatistics, + distinct_key_count, + key_bytes, + } + | Concat { inputs, output } + | InMemoryAnalyticWindow { + edges: UnaryEdgeStatistics, + input_partitioning: PartitionStatistics, + } + | Limit { edges: UnaryEdgeStatistics } + | PassThrough { edges: UnaryEdgeStatistics } + | PromqlRange { edges, max_window_samples_per_series } + | PromqlSubquery { edges, subquery_steps } + | PromqlBinary { edges, matching_key_bytes } + | PromqlRelabel { edges } + | PromqlInfoEnrich { edges, matching_key_bytes } + | PromqlSeriesSample { edges, group_count, key_bytes } + | PromqlScalarToVector { edges } + | PromqlVectorToScalar { edges } + | PromqlScalarLeaf { output, promql_output } + | PromqlPerSeries { edges, accumulator_bytes_per_series } + | PromqlPresence { edges } } ``` -The node's output statistics feed its parents. A parent never substitutes -the original source cardinality for an intermediate edge. +`UnaryEdgeStatistics` contains exactly one input and one output; +`BinaryEdgeStatistics` contains an ordered pair of inputs and one output. +`Concat` is the only variadic case. `EdgeStatistics` itself intentionally +remains operator-independent: an edge carries logical rows and bytes, and the +same edge is the output of one node and an input of every consumer. Making the +edge type depend on either endpoint would prevent direct consistency checks. + +PromQL operators additionally attach a typed `PromqlEdgeStatistics` view to +the same physical edge: + +```text +PromqlEdgeStatistics { + series, + evaluation_steps, + value_kind: Scalar | Vector | RangeVector, +} +``` + +Rows/bytes still describe materialized logical data; PromQL metadata describes +its series and step shape. They are complementary, not alternative +cardinalities. Unary, binary, and variadic wrappers preserve the physical +arity, and parent input metadata must exactly match the corresponding child +output. Once a child carries PromQL metadata, a parent may not silently drop +it. Scalar leaves have zero inputs and emit exactly one scalar row per step. + +The outer enum is operator-specific. A Filter cannot accidentally carry +group cardinality, a Top-K statistics record cannot carry a join build side, +and a non-scan record cannot carry source-read bytes. Serialized evidence is +internally tagged by operator and rejects unknown fields. + +Physical configuration is not catalog evidence and therefore lives on +`PhysicalOperator`: + +| Physical operator | Configuration owned by the plan | +|---|---| +| `Filter` | predicate operations per input row | +| `Project` | expression/copy operations per input row | +| `HashAggregate` | grouping-key and accumulator counts | +| `InMemoryComparisonSort` | ordering-key count and whether ordering is partitioned | +| `TopK` | output `limit`, `offset`, and ordering-key count; heap capacity is `limit + offset` | +| `Limit` | output `limit` and `offset` | +| `HashJoin` | left or right build side and equality-key count | +| `HashDeduplicate` | deduplication-key count | +| `InMemoryAnalyticWindow` | partition/order-key counts and window-function work per row | +| `PromqlRange` | selector range duration | +| `PromqlSubquery` | range and optional explicit resolution | +| `PromqlBinary` | operation class, operand modes, match cardinality, and vector/vector hash-build side | +| `PromqlRelabel` | expression work per sample | +| `PromqlInfoEnrich` | info-selector matcher work per info row | +| `PromqlSeriesSample` | `limitk`/`limit_ratio` choice and grouping-key count | +| `PromqlPerSeries` | primitive update work and accumulator count | +| `PromqlPresence` | absence versus per-series-presence mode and test work per input row | + +This distinction removes the former flat optional `k` field. A Top-K bound is +part of the chosen algorithm, while input/output cardinality and width are +observed or estimated facts about that operator in this workload. + +The provider owns provenance, freshness, and derivation. The estimator resolves +each reachable node once, so one estimate cannot mix values across a live +catalog refresh. A scan has one external source input; every other input is in +the same order as `PhysicalDagNode.children`. Every parent input must equal the +corresponding child's output in both rows and bytes. Missing node evidence, +invalid arity, inconsistent edge dimensions, or a parent/child conflict makes +the entire DAG unavailable. `{ rows: 0, bytes: 0 }` is a valid empty logical +edge, including after a filter, join, limit, or aggregate; non-empty edges need +positive logical bytes so width-dependent formulas do not invent a row width. +A parent therefore cannot silently substitute the original source cardinality +for an intermediate edge. + +The statistics inputs and `PhysicalDagNode.children` therefore have +different arity only for a source leaf: + +| Operator shape | Statistics inputs | DAG children | +|---|---:|---:| +| `Scan` | 1 external source edge | 0 | +| Unary operator | 1 | 1 | +| `HashJoin` | 2 | 2 | +| `Concat` | one per input | one per input | + +The implementation matches every `PhysicalOperator` variant explicitly. A new +operator cannot silently inherit unary arity; its statistics-input and +DAG-child counts must both be defined. + +`EdgeStatistics.bytes` is decoded logical data carried on an edge. +`Scan.source_read_bytes` is physical storage I/O and is charged only by +`Scan`. +Compression, column pruning, or encoded storage can therefore make these +values different; neither is inferred from the other. Other statistics +variants have no source-read field, so charging source I/O at a non-scan node +is not representable. A non-empty Scan must report positive source-read bytes; +zero cannot stand in for missing I/O evidence. + +Hash-aggregate validation distinguishes logical grouping from the workload's +observed number of groups. An ungrouped aggregate has +`grouping_key_count = 0`, `key_bytes = 0`, and `group_count = 1`, including on +empty input because SQL scalar aggregation still emits one row. A grouped +aggregate has at least one grouping key and positive encoded key width, but it +may report `group_count = 0` when its input is empty. In both cases output rows +must equal `group_count`. ### Composition rules @@ -130,20 +365,31 @@ For a selected DAG: 4. Add source/disk reads only at nodes that actually read source or spilled data; an in-memory edge contributes zero source reads. 5. Count a shared node once even when several parents consume it. -6. Compute peak memory from liveness: add states that coexist, but do not add - disjoint transient buffers merely because both appear somewhere in the - DAG. +6. Compute peak memory from liveness. During one node's execution, all live + child outputs, the operator's local workspace, and its new output buffer + coexist. Do not add disjoint transient buffers merely because both appear + somewhere in the DAG. 7. Retained summaries remain live across reads. Streaming buffers may be released after their last consumer. -`estimate_physical_dag` implements these rules for `PhysicalDagNode` values. +`estimate_physical_dag` implements these rules for `PhysicalDagNode` values, +one `ComparisonScope`, and an `OperatorStatisticsProvider`. It is a +single-plan diagnostic API. Code that ranks a raw and candidate plan must use +`estimate_physical_dag_comparison`, which validates exact scope equality before +estimating either plan and returns both dimensional estimates together. Node IDs are physical identities: duplicate IDs, missing children, and cycles are rejected. A child-before-parent schedule maintains remaining-consumer counts, releases transient output after its last consumer, and keeps retained state live. Consequently a shared scan is charged once per execution and a fan-out's memory includes the outputs that really coexist. -Logical `output_bytes` feeds parent cardinality estimates; it is not an +Each estimate independently requires the semantic set of source coverages on +its reachable Scan nodes to equal `ComparisonScope.sources`. Multiple physical +Scans may repeat one coverage, but no scope source may be omitted and no Scan +may add another coverage. This invariant is enforced by the estimator itself, +including for callers that construct a physical DAG without the query lowerer. + +Logical edge `bytes` feeds parent cardinality estimates; it is not an allocation. Each physical node separately supplies `output_buffer_bytes` for its live batch/edge buffer and `retained_bytes` for state that survives the operator. A streaming scan therefore retains a batch, not the complete source. @@ -153,6 +399,15 @@ Build/maintenance nodes can therefore be charged once while query-side nodes are multiplied by the horizon's evaluation count; retention does not silently imply either execution frequency. +The parent/child compatibility rules are: + +| Parent | Child | Validity | +|---|---|---| +| `Once` | `Once` | valid | +| `Once` | `PerEvaluation` | invalid; a build-once result cannot depend on repeated executions | +| `PerEvaluation` | `PerEvaluation` | valid | +| `PerEvaluation` | `Once` | valid only when the child exposes retained state | + For a tree-shaped pipeline, peak memory is normally the maximum live pipeline state, not the sum of every node's memory. At a fan-out, join, merge, or nested summary boundary, multiple child states may coexist and must be combined. @@ -162,6 +417,106 @@ inherit the target's old node costs. A replacement candidate includes any newly embedded child summaries, while an independently shared child is deduplicated by physical identity. +### Query-DAG lowering and statistics contract + +`lower_query_physical_dag` recursively lowers a resolved `Rc` and +returns an `EvidenceBackedPhysicalDag` containing both its nodes and root ID. +It consumes the existing query and physical-operator enums; it does not +introduce a parallel logical operator vocabulary. For every occurrence, the lowerer sends a +`PhysicalNodeRequest` containing the logical node, selected existing +`PhysicalOperator`, occurrence and synthetic-role metadata, already-lowered +child physical IDs, and any source coverage to a +`PhysicalNodeEvidenceProvider`. The provider atomically returns its own stable +`physical_id`, the authoritative `OperatorStatistics`, and explicit +`output_buffer_bytes`; logical edge bytes are never substituted for an +allocation. Missing evidence makes the entire query unavailable. The returned +`EvidenceBackedPhysicalDag` snapshots this evidence so costing does not re-read a live +catalog after lowering. + +Each lowered Scan is bound to exactly one `SourceCoverage` in the comparison +scope by the existing source and canonical predicate values. The bound value +therefore also supplies the provider-owned snapshot ID. Zero matches fail as +outside scope; multiple matching coverages fail as ambiguous rather than +choosing an arbitrary snapshot. When a predicate-bearing logical Scan expands +to Scan → Filter, the synthetic Scan has its own physical ID, statistics, and +buffer evidence and carries that exact coverage; the Filter has separate +evidence and no source coverage. +`ComparisonScope.sources` is an order-independent set of semantic coverages; +duplicates are invalid. After lowering, every reachable physical Scan must use +a member of that set and every member must be used by at least one Scan. +Multiple independent physical Scans may use the same coverage, while a +provider-declared shared Scan uses it once, so those physical alternatives can +still be compared under the same semantic scope. + +The lowering validates every physical edge before costing: + +- `(rows = 0, bytes = 0)` is a valid empty edge, while positive rows still + require byte-width evidence and zero rows cannot carry non-zero bytes; +- a unary operator's `input_rows` and `input_bytes` equal its child's output; +- a Scan's external logical input edge equals its output edge, including the + synthetic raw Scan created for a predicate-bearing logical Scan; +- a hash join's left and right inputs equal the corresponding child outputs; +- Concat and `UNION ALL` input/output totals equal the checked sum of all + child outputs; and +- row-preserving, reducing, and bounded operators obey their cardinality + invariants. + +The supported mappings are: + +| Existing `QueryExpr` shape | Physical DAG | +|---|---| +| Scan without predicates | Scan | +| Scan with pushed predicates | Scan → Filter | +| Filter | Filter | +| Project | Project | +| Reducing Count/Sum/Min/Max/Avg/StdDev/Variance/Group/CountValues without HAVING | HashAggregate | +| Dedup | HashDeduplicate | +| Equi-Join | HashJoin whose build side is selected from child output evidence | +| Concat or `UNION ALL` | Concat | +| Sort with at least one ordering key | in-memory Sort | +| global non-empty-key Sort followed by Limit | heap TopK, with `k = offset + n` from the query IR | +| partitioned Sort followed by Limit | Sort → Limit | +| RowNumber/Rank/DenseRank SQLWindowFunc with non-empty order_by | InMemoryAnalyticWindow | +| identity TimeShift only | PassThrough | +| range selector `[duration]` | PromqlRange | +| PromQL subquery `[range:resolution]` | PromqlSubquery | +| PromQL scalar/vector or vector/vector binary expression | PromqlBinary | +| `label_replace`/`label_join` | PromqlRelabel | +| `info(...)` | info-series Scan → PromqlInfoEnrich | +| `limitk`/`limit_ratio` | PromqlSeriesSample | +| `vector(scalar)` / `scalar(vector)` | PromqlScalarToVector / PromqlVectorToScalar | +| scalar literal, `time()`, or `pi()` | PromqlScalarLeaf | +| supported fixed-state per-series range reduction | PromqlPerSeries | +| absence/presence reduction | PromqlPresence | + +HAVING, cross-series PromQL aggregation, and ordered/distribution-dependent +per-series intents such as exact quantile, cardinality, and Top-K aggregate +intents remain unavailable until they have an explicit physical algorithm. +Hash-join lowering +also uses the bound left and right output schemas to prove that every equality +compares one column from each side; same-side or out-of-range `ColumnId`s fail +closed. + +An `Rc` address is not physical identity. Every logical occurrence +is independent unless the provider returns the same non-empty `physical_id`. +Repeated IDs deduplicate only when operator, children, coverage, statistics, +and buffer evidence are identical; conflicting reuse fails closed. This +generic lowering creates raw-query operators with +`ExecutionMultiplicity::PerEvaluation` and zero retained state. Buffer sizes +are always provider-owned physical evidence. + +The DAG itself implements `OperatorStatisticsProvider` over its evidence +snapshot. That boundary verifies that each map key equals the evidence's +embedded physical identity and that every node's buffer equals the provider +snapshot before returning statistics, preventing the public node and evidence +views from silently drifting apart. + +Cross/non-equi joins, `INTERSECT`, `EXCEPT`, distinct `UNION`, and unlisted +PromQL algorithms stay unavailable. They are not treated as free pass-through +work. In particular, generic `HashAggregate` cannot masquerade as a PromQL +cross-series aggregation: such a candidate needs a physical operator whose +per-step series grouping and memory semantics are explicit. + ## Physical operator formulas Operator estimates are local: child CPU and I/O are excluded and composed by @@ -169,20 +524,34 @@ the DAG rules above. | Physical operator | CPU operations | Local memory | Source/disk reads | |---|---:|---:|---:| -| Scan | `input_rows` | one input row/batch | `input_bytes` | -| Filter | `input_rows` | one output row/batch | `0` | -| Project or scalar pass-through | `input_rows` | one output row/batch | `0` | -| Hash aggregate | `input_rows` | `groups × (key + aggregate_value_bytes + hash metadata)` | `0` | -| Deduplicate | `input_rows` | keyed hash state | `0` | -| In-memory sort | `rows × ceil(log2(rows))` | `input_bytes` | `0` | -| Heap Top-K | `rows × ceil(log2(max(min(k, rows), 2)))` | `min(k, rows) × row_bytes` | `0` | -| Hash join | `left_rows + right_rows + output_rows` | selected build-side logical bytes plus 16 bytes of hash metadata per build row | `0` beyond children | +| Scan | `input_rows` | one decoded input row/batch | `source_read_bytes` | +| Filter | `input_rows × predicate_operations_per_row` | one output row/batch | `0` | +| Project | `input_rows × expression_operations_per_row` | one output row/batch | `0` | +| Scalar pass-through | `input_rows` | one output row/batch | `0` | +| Hash aggregate | `input_rows × (grouping_key_count + accumulator_count)` | `groups × (key_bytes + accumulator_bytes_per_group + hash metadata)` | `0` | +| Hash deduplicate | `input_rows × key_count` | `distinct_key_count × (key_bytes + hash metadata)` | `0` | +| In-memory comparison sort | `sum(n_i × ceil(log2(n_i)) × ordering_key_count)` | largest partition bytes | `0` | +| Heap Top-K | `rows × ceil(log2(max(min(limit + offset, rows), 2))) × ordering_key_count` | `min(limit + offset, rows) × row_bytes` | `0` | +| Hash join | `(left_rows + right_rows) × equality_key_count + output_rows` | selected build-side logical bytes plus 16 bytes of hash metadata per build row | `0` beyond children | | Concat | `output_rows` | one output row/batch | `0` | -| Ordered window | `rows × ceil(log2(rows))` | live partition/input bytes | `0` | -| Limit | `limit_rows_consumed` (including `OFFSET`) | one output row/batch | `0` | - -These formulas name physical implementations. An external sort must add -spill writes and reads; a nested-loop join must not use the hash-join formula. +| In-memory analytic window | per-partition ordering work plus per-row function work | largest partition bytes | `0` | +| Limit | `min(input_rows, limit + offset)` | one output row/batch | `0` | +| PromQL range | `input_rows` | `input_series × max_window_samples_per_series × sample_bytes` | `0` | +| PromQL subquery | `input_rows + output_rows` | materialized inner-step input bytes | `0` | +| PromQL vector binary | `left_rows + right_rows + output_rows` | vector/vector match hash state, or one output row for scalar/vector | `0` | +| PromQL relabel | `input_rows × expression_operations_per_row` | one output row/batch | `0` | +| PromQL info enrichment | input/output visits plus matcher work | info-side series hash state | `0` | +| PromQL series sample | `input_rows + input_series` | selected-series key state | `0` | +| PromQL scalar/vector bridge | `input_rows + output_rows` | one output row/batch | `0` | +| PromQL scalar leaf | `evaluation_steps` | one scalar row | `0` | +| PromQL fixed-state per-series operation | `input_rows × operations_per_row` | `input_series × accumulator_bytes_per_series` | `0` | +| PromQL presence | `input_rows × operations_per_row + output_rows` | one output row/batch | `0` | + +These formulas name physical implementations. The enum uses names such as +`InMemoryComparisonSort`, `HashDeduplicate`, and `InMemoryAnalyticWindow` so a +new algorithm cannot silently inherit a formula merely because it has the +same logical purpose. An external sort must add spill writes and reads; a +nested-loop join must not use the hash-join formula. If the physical choice or its required statistics are unknown, the estimate is unavailable. @@ -198,9 +567,9 @@ A retained sketch performs one build and serves later reads from state: ```text cpu_ops = input_rows // build scan + input_rows × update_ops(params) - + evaluation_count × physical_sketch_count × read_ops(params) + + evaluation_count × physical_summary_count × read_ops(params) -scan_bytes = source_scan_bytes for the build +scan_bytes = source_read_bytes for the build ``` Concrete accuracy-sized parameters determine state and work: @@ -217,7 +586,7 @@ Concrete accuracy-sized parameters determine state and work: | Theta | `ceil(log2(k))` | `k` | `k × 8` | For a per-subpopulation layout, -`physical_sketch_count = subpopulation_count`. A shared layout has the number +`physical_summary_count = subpopulation_count`. A shared layout has the number of physical structures described by that layout; the model must not infer it from logical group count alone. @@ -231,12 +600,37 @@ operators and resolved statistics. Until an operation has such a formula, a complete candidate containing it is unavailable; costing only its modeled children would undercount the plan. -`estimate_physical_dag` is deliberately independent of query shape. A caller -supplies the complete physical DAG and one `OperatorInputs` record per node. -Filters, projections, joins, windows, nested aggregates, Top-K, and shared -sub-DAGs therefore use the same estimation path. Query lowering and planner -selection are separate integration responsibilities and must not introduce a -shape-specific shortcut or structural-node-count fallback. +`estimate_physical_dag` is independent of query shape. A caller supplies the +complete physical DAG and per-node evidence. Filters, projections, joins, +windows, nested aggregates, Top-K, and shared sub-DAGs therefore use the same +estimation path. The generic query lowerer recursively maps the supported raw +query operators into that representation. + +`PhysicalPlanCostModel` is the final-selection adapter. For every +candidate it obtains one canonical `ComparisonScope`, recursively lowers the +actual raw target, and then lowers a logical rewrite or requests the fully +bound physical DAG for a `SummaryExpr` candidate. The deployment implements +`PlannerPhysicalPlanProvider`: query-node evidence is consumed atomically by +the generic query lowerer, while summary binding returns a complete +`EvidenceBackedPhysicalDag`, including embedded raw work, build/read operators, retained +state, execution multiplicity, and source coverage. The adapter calls +`estimate_physical_dag_comparison`; it never calls `DefaultCostModel` or a +structural-node-count fallback for final cost. + +A candidate is exposed to global selection only when both complete DAGs are +valid and its calibrated cost is strictly below the raw baseline. Missing or +stale evidence, an unknown physical algorithm, invalid edges, incomplete +source coverage, or a candidate that is not cheaper yields `None`. When no +candidate remains, `chosen = None` preserves the raw pre-ASAP target. + +Logical CSE share/recompute rewrites are not complete physical alternatives: +they do not encode retained shared state or independent execution +multiplicity. The analytical adapter therefore does not assign them an +optimistic complete-plan cost. They continue through the existing CSE policy +until a physical binder can return explicit DAGs for both arms. Within every +bound physical DAG, stable provider-owned physical IDs deduplicate shared +scans, builds, and retained states; logical `Rc` identity is never substituted +for physical identity. DDSketch is unavailable because occupied bins depend on value range and distribution. The model does not invent a bin count. Algorithm/parameter @@ -287,22 +681,26 @@ The intended end-to-end selection pipeline is: 5. estimates the complete candidate DAG; 6. applies calibration and ranks candidates by ascending cost. -This module implements the physical estimation step. Lowering, evidence -resolution, legality checks, and planner integration remain separate layers; -each must preserve the complete-plan and fail-closed requirements above. +The query lowerer and physical estimator cover the supported raw-query shapes +listed above. `PhysicalPlanCostModel` executes this pipeline for every +candidate supplied to `PlanSpace::global_selection`. Logical rewrites are +lowered recursively. Summary candidates participate only after the deployment +has bound their complete `SummaryExpr` DAG; there is no optimistic generic +summary fallback. -The raw baseline and selected alternative use the same source snapshot, -horizon, and calibration: +Before applying the following arithmetic, callers validate exact equality of +the raw and selected alternative's `ComparisonScope`, and use the same +calibration: ```text benefit = baseline_cost - selected_cost benefit_ratio = benefit / baseline_cost ``` -Exported annotations contain resource totals, workload horizon, resolved -operator inputs, calibration coefficients, evidence/model versions, and the -baseline reference. This makes the scalar reproducible and identifies which -resource dimension drove a decision. +An export of a cost decision includes resource totals, calibration +coefficients, evidence/model versions, the baseline reference, validated +comparison scope, and per-node statistics provenance. These facts reproduce +why two estimates were considered comparable. ## Worked patterns diff --git a/docs/design_docs/asap-aware-mapping/physical-plan-integration.md b/docs/design_docs/asap-aware-mapping/physical-plan-integration.md new file mode 100644 index 00000000..fbc4a455 --- /dev/null +++ b/docs/design_docs/asap-aware-mapping/physical-plan-integration.md @@ -0,0 +1,390 @@ +# Physical plan integration + +## Purpose + +This document defines the boundary between ASAPPlanner's logical plans, +physical lowering, statistics resolution, and analytical resource estimation. +It answers which representation is authoritative at each stage and prevents +the cost model from being coupled directly to either logical IR. + +The integration pipeline is: + +```text +pre-ASAP QueryExpr ─┐ + ├─ physical lowering ─> PhysicalOperator DAG +post-ASAP SummaryExpr┘ │ + v + OperatorStatistics + │ + v + ResourceEstimate +``` + +The arrows are boundaries, not casts. A logical node does not necessarily +become one physical node, and a physical operator is not merely a renamed +logical variant. + +## Sources of truth + +Each representation is authoritative for a different concern: + +| Representation | Authoritative concern | +|---|---| +| `QueryExpr` | Original exact query semantics: sources, predicates, relational and PromQL operations, and output shape. | +| `SummaryExpr` | Logical summary semantics: selected family, grouping strategy, summary composition, and summary readout. | +| `PhysicalOperator` DAG | Selected executable algorithms, their configuration, physical identity, edges, and execution multiplicity. | +| `OperatorStatistics` | Workload-dependent evidence required by each selected physical operator's resource formula. | +| `ResourceEstimate` | Estimated CPU operations, peak live memory, and physical source/disk reads over one comparison scope. | + +`PhysicalOperator` is therefore the source of truth for the operator vocabulary +consumed by analytical costing. `OperatorStatistics` corresponds one-to-one +with that vocabulary. It must not independently invent operator kinds or copy +all variants from either logical IR. + +The canonical physical-plan types should live at a neutral boundary shared by +lowering, costing, explanation, and downstream compilation. Their conceptual +ownership is not the cost formula implementation, even if an intermediate +implementation keeps the Rust type in the cost module while the interface is +being established. + +## Why logical and physical variants are not one-to-one + +One logical operation may choose between algorithms or expand into a physical +sub-DAG. Conversely, one physical operator may implement nodes originating +from either logical IR. + +Examples include: + +- `Sort` followed by `Limit` may lower to an in-memory comparison sort plus a + limit, or to one heap Top-K operator configured by limit and offset. +- `Join` may lower to a hash join with an explicit build side or to another + supported join algorithm. +- `SummaryAgg` may lower to an exact accumulator build, CMS build, KLL build, + or another physical summary algorithm selected by the candidate. +- `SummaryEstimate` must lower to a readout operator compatible with the + concrete summary state it consumes. +- shared logical sub-DAGs become shared physical nodes only when they refer to + the same physical identity and compatible evidence. + +For this reason, aligning `OperatorStatistics` directly with `QueryExpr` would +lose post-ASAP summary implementations, while aligning it directly with +`SummaryExpr` would lose raw query operators and physical algorithm choices. + +## Lowering obligations + +Physical lowering is complete only when it recursively lowers the entire +selected candidate DAG. It must: + +1. preserve the semantics and source coverage of the logical candidate; +2. select an explicit physical algorithm for every logical operation; +3. carry algorithm configuration on the physical operator rather than in a + generic statistics record; +4. connect every physical edge and preserve child order for non-commutative + operators; +5. assign stable physical identities so shared sub-DAGs can be counted once; +6. assign execution multiplicity and retained/transient state ownership; +7. reject the complete candidate if any logical operation has no supported + physical lowering. + +Keeping an unsupported logical node outside the physical DAG and costing only +its modeled descendants is invalid because it undercounts the candidate. + +### Pre-ASAP lowering + +`KeepPreAsap` recursively lowers its contained `QueryExpr`. Typical physical +operators include scans, filters, projections, hash aggregates, joins, +ordering, bounded Top-K, limits, and PromQL-specific operators. The selected +physical algorithm, rather than the logical spelling, determines the formula. + +### Post-ASAP lowering + +Every `SummaryExpr` operation also needs explicit physical realization: + +| Logical summary operation | Required physical realization | +|---|---| +| `SummaryAgg` | concrete summary or exact-state build/update operator, including family parameters and grouping layout | +| `SummaryJoin` | concrete summary-join algorithm and state layout | +| `SummaryMerge` | merge operator over compatible concrete summary states | +| `SummarySubtract` | subtract operator supported by the selected state representation | +| `SummaryDelete` | physical deletion/update operator supported by the selected representation | +| `SummaryEstimate` | family- and query-specific readout operator | +| `KeepPreAsap` | recursive lowering of the contained `QueryExpr` | + +This table is a completeness requirement, not a claim that every realization +already exists. Until lowering introduces an explicit physical operator, +statistics contract, validation rule, and resource formula for an operation, +a candidate containing it is unavailable. + +Lifecycle choice affects the physical DAG but does not replace it. Ephemeral, +prepared, shared, and continuously maintained alternatives determine when +build, update, readout, merge, subtract, or delete nodes execute. The physical +operators still determine how each execution consumes CPU, memory, and I/O. + +## Statistics contract + +Statistics describe how a selected physical algorithm behaves for a specific +workload and data snapshot. They do not select the algorithm. + +Every physical node supplies: + +- logical input and output edge cardinality and decoded byte size; +- operator-specific distribution or state facts required by its formula; +- physical source-read bytes only when the operator actually reads storage; +- provenance and freshness sufficient to reproduce the estimate. + +Plan-owned configuration and observed statistics remain separate. For +example, a Top-K operator owns its limit and offset, while its statistics +describe input and output rows and bytes. A hash join owns its build-side +choice, while its statistics describe both input edges and its output edge. + +The statistics enum is structured by `PhysicalOperator`. This prevents a +Filter from carrying group cardinality, a Top-K record from carrying join +facts, or a non-scan record from charging source bytes. A new physical variant +requires a corresponding statistics variant and exhaustive integration into: + +- statistics and DAG arity validation; +- parent/child edge consistency validation; +- operator semantic validation; +- CPU, memory, and I/O formulas; +- lowering and coverage tests. + +## Physical operator catalog + +This catalog defines every physical node currently accepted by the analytical +estimator. SQL examples describe the logical shape; the physical node is the +algorithm selected when lowering that shape. A query outside the stated shape +is unavailable until another physical operator is defined. + +### `Scan` + +Reads a physical source snapshot and emits decoded logical rows. For example, +`SELECT * FROM metrics` lowers its source leaf to `Scan`. Its evidence contains +the external/output edge and `source_read_bytes`. A non-empty scan must report +positive physical read bytes. CPU is one decode/visit per input row; memory is +one decoded row or batch; I/O is `source_read_bytes`. + +### `Filter { predicate_operations_per_row }` + +Evaluates a scalar predicate and retains matching rows. For example, +`SELECT * FROM metrics WHERE latency > 100 AND status = 500` lowers to +`Scan -> Filter`. The configuration counts the comparison/boolean operations +in the predicate; the evidence provides input and filtered output edges. CPU +is `input_rows * predicate_operations_per_row`; memory is one output row or +batch; it adds no source I/O. + +### `Project { expression_operations_per_row }` + +Computes and copies a SELECT list without changing row cardinality. For +example, `SELECT latency * 1000 AS latency_us, service FROM metrics` lowers to +`Scan -> Project`. The configuration counts expression and output-copy work; +the evidence supplies the changed logical row width. CPU is +`input_rows * expression_operations_per_row`; memory is one output row or +batch; it adds no source I/O. + +### `HashAggregate { grouping_key_count, accumulator_count }` + +Builds hash-group state and updates one or more accumulators. For example, +`SELECT service, COUNT(*), SUM(bytes) FROM metrics GROUP BY service` uses one +grouping key and two accumulators. Evidence supplies `group_count`, encoded +key bytes, and total accumulator bytes per group. CPU is +`input_rows * (grouping_key_count + accumulator_count)`; memory is +`group_count * (key_bytes + accumulator_bytes_per_group + hash metadata)`. +An ungrouped aggregate has zero keys, zero key bytes, and exactly one output +group even for empty input. A grouped aggregate may have zero groups when its +input is empty. + +### `InMemoryComparisonSort { ordering_key_count, partitioned }` + +Comparison-sorts rows without spilling. A global example is +`SELECT * FROM metrics ORDER BY latency`; a partitioned logical shape is +`ORDER BY latency` within each region. Evidence lists the observed input edge +for every independently sorted partition. CPU is +`sum(n_i * ceil(log2(n_i)) * ordering_key_count)` and peak local memory is the +largest partition bytes. A global sort must provide exactly one partition. +If a provider cannot prove an in-memory implementation or its partition +distribution, the candidate is unavailable rather than silently using a +global-sort estimate. + +### `TopK { limit, offset, ordering_key_count }` + +Maintains a bounded comparison heap for a global `ORDER BY ... LIMIT/OFFSET`. +For example, `SELECT service, count FROM counts ORDER BY count DESC LIMIT 10` +uses a heap of at most ten rows. CPU is +`input_rows * ceil(log2(min(limit + offset, input_rows))) * ordering_key_count`; +memory is the bounded heap rows times logical row width. Partitioned Top-K is +not this operator and requires its own supported physical realization. + +### `HashJoin { build_side, equality_key_count }` + +Builds a hash table on the selected side and probes it with the other side. +For example, `SELECT * FROM requests r JOIN services s ON r.service_id = s.id` +uses one equality key. Evidence supplies ordered left/right edges and the join +output. CPU charges equality-key hashing/probing for both inputs plus emitted +output rows; memory is the selected build-side bytes plus hash metadata. A +cross join, non-equality predicate, or unknown join algorithm is unavailable. + +### `HashDeduplicate { key_count }` + +Retains one hash-table entry per distinct key. For example, +`SELECT DISTINCT service, region FROM metrics` has two deduplication keys. +Evidence supplies distinct-key count and encoded key bytes. CPU is +`input_rows * key_count`; memory is +`distinct_key_count * (key_bytes + hash metadata)`. Empty input legitimately +has zero distinct keys. + +### `Concat` + +Concatenates one or more union-compatible inputs without deduplication. For +example, `SELECT * FROM east UNION ALL SELECT * FROM west` lowers both scans +into one `Concat`. Its output rows and bytes must equal the checked sum of all +input edges. CPU is one append/forward operation per output row; memory is one +output row or batch. A zero-input Concat is invalid. + +### `InMemoryAnalyticWindow` + +Evaluates an ordered SQL analytic function over in-memory partitions. For +example, +`ROW_NUMBER() OVER (PARTITION BY region ORDER BY latency DESC)` partitions by +region, orders each partition, and appends the row-number column. Its physical +configuration records partition keys, ordering keys, and function work per +row; evidence supplies the actual partition distribution. Ordering CPU and +memory use the same per-partition calculation as comparison sort, plus window +function work for each row. This is **not** a streaming tumbling window, +sliding window, pane layout, or exponential-histogram window framework. + +### `Limit { limit, offset }` + +Stops after consuming enough rows to satisfy an unordered limit. For example, +`SELECT * FROM metrics LIMIT 10 OFFSET 5` consumes at most fifteen rows and +emits at most ten. CPU is the number of consumed rows; memory is one output row +or batch. An ordered limit is represented by Sort plus Limit or a supported +Top-K implementation. + +### `PassThrough` + +Represents a proven row- and byte-preserving physical boundary with per-row +forwarding work. The current lowerer uses it only for a programmatically +constructed identity `TimeShift(default, Scan(metrics))`, whose query semantics +are the same as `SELECT * FROM metrics`; normal front ends omit that identity +wrapper. A non-identity PromQL `offset` or `@` changes source time coverage and +is unavailable until lowering propagates that temporal context into descendant +Scan evidence and physical identity. `PassThrough` must never hide an +unsupported operation. + +### `PromqlRange { range_millis }` + +Forms a range vector for each evaluation step and retains at most the observed +`max_window_samples_per_series` samples for each input series. For example, +the selector in `rate(http_requests_total[5m])` lowers to `PromqlRange` with +`range_millis = 300000`. Evidence carries series count, evaluation-step count, +value kind, and the maximum samples in one per-series range. CPU visits every +input sample; local memory is `series × max_window_samples_per_series × +sample_width`. This query-time range buffer is not a tumbling, sliding, pane, +or exponential-histogram layout for maintaining a summary. + +### `PromqlSubquery { range_millis, resolution_millis }` + +Evaluates a child expression at inner steps and groups those results into a +range vector at each outer step. For example, +`max_over_time(rate(http_requests_total[5m])[1h:1m])` contains a one-hour +subquery at one-minute resolution. Evidence supplies the realized +`subquery_steps`; child steps must equal `outer_steps × subquery_steps`. CPU +charges child-result visits plus emitted results, and memory retains the +materialized inner-step input for one invocation. + +### `PromqlBinary { operation, operand_mode, cardinality, build_side }` + +Evaluates scalar/vector arithmetic or comparison, or a PromQL `and`, `or`, or +`unless` set operation. For example, +`rate(errors_total[5m]) / on(service) group_left(region) service_info` is a +many-to-one vector match; `up or maintenance_mode` is a set union. The node +records whether each operand is scalar or vector, vector-match cardinality, +operation class, and a hash-build side for vector/vector matching. Evidence +supplies both edge shapes and encoded matching-key width. CPU visits both +inputs and the output; vector/vector memory is the selected build-side series +times key width plus hash metadata. Operation-specific cardinality bounds are +validated (`or` may use the sum, while `and`/`unless` cannot exceed the left). + +### `PromqlRelabel { expression_operations_per_row }` + +Evaluates a PromQL label transformation without changing series or sample +cardinality. For example, +`label_replace(up, "host", "$1", "instance", "(.*):.*")` lowers to this +operator. The physical configuration counts expression work per sample; CPU +is rows times that count and memory is one output row or batch. + +### `PromqlInfoEnrich { matcher_operations_per_info_row }` + +Builds a lookup over an info metric and enriches the data vector while +preserving its left-side cardinality. For example, +`info(rate(http_server_request_duration_seconds_count[2m]))` lowers the data +expression and a separately scoped info-series `Scan`, then joins them here. +Evidence supplies both vector edges and matching-key width. CPU visits data, +info, and output rows plus selector-matcher work; memory is the info-side hash +state. Both scans must be present in the comparison scope. + +### `PromqlSeriesSample { kind, grouping_key_count }` + +Selects series by deterministic sampling within optional label groups. For +example, `limitk(10, up)` records `LimitK { k: 10 }`, while +`limit_ratio(0.1, up)` records the exact ratio bits. Evidence supplies group +count and encoded selection-key width. CPU visits samples and hashes input +series; memory retains selected series keys. `limitk` output is bounded by +`min(input_series, group_count × k)`. The current lowerer rejects the +unsupported `without` grouping form instead of changing its semantics. + +### `PromqlScalarToVector` + +Materializes one vector sample per evaluation step from a scalar, as in +`vector(1)`. Its edge contract is Scalar to a one-series Vector with the same +steps. CPU visits input and output rows; memory is one output row or batch. + +### `PromqlVectorToScalar` + +Converts a vector to a scalar at each evaluation step, as in `scalar(up)`. +Its edge contract is Vector to Scalar with the same steps. CPU visits input +and output rows; memory is one output row or batch. + +### `PromqlScalarLeaf` + +Produces a source-free scalar for each evaluation step. Number literals, +`time()`, and `pi()` are examples. It has no child or external input edge, and +its output must contain exactly one scalar row per step. CPU is one operation +per emitted scalar and memory is one scalar row. + +### `PromqlPerSeries { operations_per_row, accumulator_count }` + +Updates fixed-size state independently for each series and emits at most one +instant-vector sample per series and step. Examples include +`rate(http_requests_total[5m])` after `PromqlRange`, and +`avg_over_time(temperature_celsius[10m])`. The configuration records primitive +update work and accumulator count; evidence supplies accumulator bytes per +series. CPU is `input_rows × operations_per_row`; memory is +`input_series × accumulator_bytes_per_series`. Ordered or +distribution-dependent functions are unavailable until a distinct physical +algorithm and formula exist. + +### `PromqlPresence { kind, operations_per_row }` + +Implements presence semantics while preserving their two different +cardinality rules. `Absent` covers `absent(up)` and +`absent_over_time(up[5m])`; it may synthesize at most one series and one row +per evaluation step, and an entirely empty input emits one row per step. +`PresentPerSeries` covers `present_over_time(up[5m])`; its output is bounded by +the input series and input rows. CPU visits input and output rows; memory is +one output row or batch. + +## Comparison and failure behavior + +Raw and post-ASAP alternatives must cover the same `ComparisonScope`: arrival +mode, planning time, workload horizon, recurrence, event-time selection, +logical sources, ordinary predicates or info-metric matchers, and physical +source snapshots. + +Missing statistics, stale evidence, an unsupported physical algorithm, +unlowered logical operations, inconsistent edges, or different comparison +scopes make the complete candidate unavailable. The integration must never +replace those failures with zero cost or structural node counting. + +See [Analytical resource cost](analytical-resource-cost.md) for the resource +formulas, evidence validation, comparison-scope rules, and calibration model.