From 5b3394989241d25296b4a73f517eab6f516f745e Mon Sep 17 00:00:00 2001 From: zz_y Date: Sat, 29 Aug 2026 16:38:37 -0600 Subject: [PATCH 01/15] feat(post-asap): model and validate execution phases --- crates/types/src/dag_export.rs | 145 ++++- crates/types/src/post_asap/expr.rs | 74 +++ crates/types/src/post_asap/mod.rs | 8 +- crates/types/src/post_asap/phase.rs | 867 ++++++++++++++++++++++++++++ 4 files changed, 1082 insertions(+), 12 deletions(-) create mode 100644 crates/types/src/post_asap/phase.rs diff --git a/crates/types/src/dag_export.rs b/crates/types/src/dag_export.rs index 5df2ed8c..bda2196f 100644 --- a/crates/types/src/dag_export.rs +++ b/crates/types/src/dag_export.rs @@ -46,7 +46,10 @@ use std::rc::Rc; use serde::Serialize; -use crate::post_asap::{AccuracyError, ResultGuarantee, SummaryExpr, SummaryNode}; +use crate::post_asap::{ + assigned_child_stage, produced_availability, AccuracyError, ExactOperator, + ExecutionAvailability, ResultGuarantee, SummaryExpr, SummaryNode, ValueOperator, +}; use crate::pre_asap::cse::{structural_hash, HashCache}; use crate::pre_asap::query_expr::{QueryExpr, Source}; @@ -152,6 +155,24 @@ pub struct DagDecision { /// `replacement_root` for the node replacing the pre-ASAP target; /// `replacement_region` for its generated or carried descendants. pub role: &'static str, + /// Machine-readable origin of the winning candidate (a `Debug`-formatted + /// `asap_aware_mapping::ReplacementProvenance`, e.g. + /// `"ExactPostProcess"`), so a viewer never infers *how* a node was + /// composed from its label or shape (issue #171). Omitted when the + /// producing layer predates this field. + #[serde(skip_serializing_if = "Option::is_none")] + pub provenance: Option, + /// The unit `cost` is expressed in — e.g. `"cost_units_per_second"` for + /// a recurring-rate comparison, or absent for the legacy unitless + /// structural estimate. Additive; consumers must not assume one unit. + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_unit: Option, + /// For a composed decision (an exact operator over another target's + /// own selected decision), the `id`s of the child decisions this one + /// was committed together with — the explicit target-to-decision + /// provenance chain, never reconstructed from graph adjacency. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub child_decisions: Vec, } /// One query's exported graph. `nodes[root as usize]` is the tree's root. @@ -338,10 +359,63 @@ pub struct SummaryDagGraph { /// top-level `DagNode::kind`. pub fn export_summary(node: &SummaryNode) -> SummaryDagGraph { let mut nodes = Vec::new(); - let root = build_summary(node, &mut nodes); + let root = build_summary(node, &mut nodes, root_stage(node)); SummaryDagGraph { nodes, root } } +/// The explicit execution stage of an exported plan's root — its own +/// produced availability, or query-time readout for a bare `KeepPreAsap` +/// (the same convention `post_asap::phase::validate_execution_phases` +/// uses for a root). +fn root_stage(node: &SummaryNode) -> ExecutionAvailability { + produced_availability(&node.expr).unwrap_or(ExecutionAvailability::ReadoutValue) +} + +/// `detail` for an [`ExactOperator`] payload — its own fields, rendered the +/// same way the pre-ASAP `Aggregate` node renders them. +fn exact_operator_detail(op: &ExactOperator) -> serde_json::Value { + match op { + ExactOperator::Aggregate { + reduction, + measures, + output_names, + having, + } => serde_json::json!({ + "op": "Aggregate", + "reduction": reduction, + "measures": measures, + "output_names": output_names, + "having": having.as_ref().map(|p| export(&p.0)), + }), + } +} + +fn exact_operator_label(op: &ExactOperator) -> String { + match op { + ExactOperator::Aggregate { measures, .. } => { + let funcs: Vec = measures.iter().map(|m| format!("{m:?}")).collect(); + format!("Aggregate[{}]", funcs.join(", ")) + } + } +} + +fn value_operator_detail(op: &ValueOperator) -> serde_json::Value { + match op { + ValueOperator::Exact(op) => exact_operator_detail(op), + ValueOperator::Extension { name } => serde_json::json!({ + "op": "Extension", + "name": name, + }), + } +} + +fn value_operator_label(op: &ValueOperator) -> String { + match op { + ValueOperator::Exact(op) => exact_operator_label(op), + ValueOperator::Extension { name } => name.clone(), + } +} + fn push_summary_node( nodes: &mut Vec, kind: &'static str, @@ -391,8 +465,16 @@ fn family_label(family: &crate::post_asap::SummaryFamilyType) -> String { /// shared [`DagGraph`] node list — see [`export_post_asap`]) can't drift /// apart on how every *other* variant's own shape is described, since /// nothing about that description differs between the two. -fn summary_shape(expr: &SummaryExpr) -> (&'static str, String, serde_json::Value) { - match expr { +/// +/// `stage` is the node's explicit execution phase (issue #171) — its own +/// [`produced_availability`], or the edge-assigned phase for a `KeepPreAsap` +/// — and is written into `detail.stage` on every post-ASAP node so a viewer +/// reads it rather than inferring it from the node's kind. +fn summary_shape( + expr: &SummaryExpr, + stage: ExecutionAvailability, +) -> (&'static str, String, serde_json::Value) { + let (kind, label, mut detail) = match expr { SummaryExpr::KeepPreAsap(_) => { unreachable!("summary_shape's callers special-case KeepPreAsap before calling it") } @@ -438,7 +520,22 @@ fn summary_shape(expr: &SummaryExpr) -> (&'static str, String, serde_json::Value let label = format!("SummaryMerge({} children)", children.len()); ("SummaryMerge", label, serde_json::json!({})) } + SummaryExpr::UpdateTransform { op, .. } => { + let label = format!("UpdateTransform({})", value_operator_label(op)); + ("UpdateTransform", label, value_operator_detail(op)) + } + SummaryExpr::ReadoutPostProcess { op, .. } => { + let label = format!("ReadoutPostProcess({})", value_operator_label(op)); + ("ReadoutPostProcess", label, value_operator_detail(op)) + } + }; + if let serde_json::Value::Object(map) = &mut detail { + map.insert( + "stage".into(), + serde_json::Value::String(stage.as_str().into()), + ); } + (kind, label, detail) } /// `expr`'s own `Rc` children, in the variant's field order @@ -455,6 +552,10 @@ fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { SummaryExpr::SummaryDelete { summary_input, .. } => vec![summary_input], SummaryExpr::SummaryEstimate { summary_input, .. } => vec![summary_input], SummaryExpr::SummaryMerge { children } => children.iter().collect(), + SummaryExpr::UpdateTransform { child, .. } + | SummaryExpr::ReadoutPostProcess { child, .. } => { + vec![child] + } } } @@ -462,12 +563,19 @@ fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { /// post-order (children pushed before their parent), and return the pushed /// root's id. Exhaustive over every [`SummaryExpr`] variant, matching this /// file's own exhaustive style for `QueryExpr` in [`build`]. -fn build_summary(node: &SummaryNode, nodes: &mut Vec) -> u32 { +fn build_summary( + node: &SummaryNode, + nodes: &mut Vec, + stage: ExecutionAvailability, +) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { let pre_asap_subgraph = export(inner); let inner_kind = pre_asap_subgraph.nodes[pre_asap_subgraph.root as usize].kind; let label = format!("KeepPreAsap({inner_kind})"); - let detail = serde_json::json!({ "pre_asap_subgraph": pre_asap_subgraph }); + let detail = serde_json::json!({ + "pre_asap_subgraph": pre_asap_subgraph, + "stage": stage.as_str(), + }); return push_summary_node( nodes, "KeepPreAsap", @@ -479,9 +587,9 @@ fn build_summary(node: &SummaryNode, nodes: &mut Vec) -> u32 { } let children: Vec = summary_children(&node.expr) .into_iter() - .map(|child| build_summary(child, nodes)) + .map(|child| build_summary(child, nodes, assigned_child_stage(&node.expr, child))) .collect(); - let (kind, label, detail) = summary_shape(&node.expr); + let (kind, label, detail) = summary_shape(&node.expr, stage); push_summary_node(nodes, kind, label, detail, children, node.guarantee.clone()) } @@ -759,15 +867,24 @@ fn build_summary_hybrid( nodes: &mut Vec, cache: &mut HashCache, find_winner: &mut dyn FnMut(&QueryExpr) -> Option, + stage: ExecutionAvailability, ) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { return build(inner, nodes, cache, find_winner); } let children: Vec = summary_children(&node.expr) .into_iter() - .map(|child| build_summary_hybrid(child, nodes, cache, find_winner)) + .map(|child| { + build_summary_hybrid( + child, + nodes, + cache, + find_winner, + assigned_child_stage(&node.expr, child), + ) + }) .collect(); - let (kind, label, mut detail) = summary_shape(&node.expr); + let (kind, label, mut detail) = summary_shape(&node.expr, stage); // The merged graph's `DagNode` has no dedicated guarantee field (it is // the pre-ASAP node shape); the guarantee rides in `detail` under the // same key/shape `SummaryDagNode::guarantee` uses, additively. @@ -853,7 +970,13 @@ fn build( decision, }) => { let first = nodes.len(); - let root = build_summary_hybrid(&replacement, nodes, cache, find_winner); + let root = build_summary_hybrid( + &replacement, + nodes, + cache, + find_winner, + root_stage(&replacement), + ); for node in &mut nodes[first..] { if node.decision.is_none() { let mut node_decision = decision.clone(); diff --git a/crates/types/src/post_asap/expr.rs b/crates/types/src/post_asap/expr.rs index b93aa904..3eda8f82 100644 --- a/crates/types/src/post_asap/expr.rs +++ b/crates/types/src/post_asap/expr.rs @@ -3,8 +3,60 @@ use std::rc::Rc; use super::guarantee::ResultGuarantee; use super::schema::{SummaryFamilyType, SummarySchema}; use super::sketch::{GroupingStrategy, SketchQuery}; +use crate::pre_asap::agg_intent::AggIntent; +use crate::pre_asap::query_expr::Predicate; use crate::pre_asap::{ColumnRef, QueryExpr, Reduction}; +// ── Exact operators composed with summary plans (issue #171) ──────────────── + +/// An exact, plain-row operator that a mixed exact/summary plan executes at +/// an explicit phase. Exact composition is one producer of the generic +/// [`ValueOperator`] phase payload. +/// +/// Deliberately **not** an intact pre-ASAP [`QueryExpr`] subtree: a +/// `QueryExpr`'s children are always `Rc`, so embedding one here +/// would point back at pre-ASAP nodes and recreate exactly the opaque +/// boundary [`SummaryExpr::KeepPreAsap`] already has (a logical parent +/// swallowing an otherwise-realizable descendant). Instead this carries only +/// the operator's *own* fields; its input is the post-ASAP `child` of the +/// enclosing `SummaryExpr` variant. +/// +/// `#[non_exhaustive]`: starts with the one payload issue #171 needs. Future +/// PRs add `Filter`/`Project`/`BinaryOp`/`Sort`/`Limit` payloads when a +/// concrete mixed plan needs them — never every relational `QueryExpr` +/// variant at once. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum ExactOperator { + /// The same fields a pre-ASAP `QueryExpr::Aggregate` carries, applied + /// exactly (no summary family) over the enclosing node's post-ASAP + /// `child`. Output schema follows + /// `pre_asap::query_expr::aggregate_output_schema` over the child's + /// plain schema. + Aggregate { + reduction: Reduction, + measures: Vec, + output_names: Vec, + having: Option, + }, +} + +/// An operation over values at a declared execution phase. +/// +/// Phase placement is independent of whether the operation is exact or +/// approximate: [`SummaryExpr::UpdateTransform`] and +/// [`SummaryExpr::ReadoutPostProcess`] describe when their input is +/// available, while this payload describes what is computed. The extension +/// form lets summary families and approximate strategies name operations +/// whose output schema and guarantee are carried by the enclosing +/// [`SummaryNode`]. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum ValueOperator { + Exact(ExactOperator), + Extension { name: String }, +} + // ── Post-ASAP DAG node ─────────────────────────────────────────────────────── /// A node in the post-ASAP DAG: wraps the expression and its derived output @@ -134,4 +186,26 @@ pub enum SummaryExpr { /// allocator (not modeled in this crate) on cut edges. /// Output schema: one field (same family + params as inputs). SummaryMerge { children: Vec> }, + + /// Value transformation executed on the **update/ingest + /// path** (issue #171). Consumes `child`'s plain update values and + /// produces plain update values, so its output may feed a downstream + /// [`SummaryAgg`](SummaryExpr::SummaryAgg)'s maintenance — the "outer + /// summary over an inner non-accumulator exact transform" direction. + /// See [`super::phase::ExecutionAvailability`] for the edge contract. + UpdateTransform { + child: Rc, + op: ValueOperator, + }, + + /// Operation executed **after** `child`'s summary has been read + /// out (issue #171). Consumes plain readout values and produces the + /// final plain query result — the "outer exact fold over an inner + /// summary readout" direction. Can never feed maintained state: a + /// `SummaryAgg` above one of these is a plan-time + /// [`super::phase::PhaseError`], never a runtime failure. + ReadoutPostProcess { + child: Rc, + op: ValueOperator, + }, } diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index 5ce45387..503c5ca1 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -29,15 +29,21 @@ pub mod expr; pub mod guarantee; +pub mod phase; pub mod query_time; pub mod schema; pub mod sketch; -pub use expr::{SummaryExpr, SummaryNode}; +pub use expr::{ExactOperator, SummaryExpr, SummaryNode, ValueOperator}; pub use guarantee::{ AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, GuaranteeSource, ProbabilityExpr, ResultGuarantee, }; +pub use phase::{ + assigned_child_stage, exact_operator_output_schema, produced_availability, + validate_execution_phases, validate_execution_phases_at, ExactOperatorSchemaError, + ExecutionAvailability, PhaseAssignment, PhaseError, +}; pub use query_time::{ classic_cms_sizing, cms_posterior_error_bound, count_sketch_posterior_error_bound, cu_sketch_posterior_error_bound, traditional_a_priori_bound, diff --git a/crates/types/src/post_asap/phase.rs b/crates/types/src/post_asap/phase.rs new file mode 100644 index 00000000..45c68ec3 --- /dev/null +++ b/crates/types/src/post_asap/phase.rs @@ -0,0 +1,867 @@ +//! Execution-phase contract for mixed exact/summary plans (issue #171). +//! +//! A post-ASAP DAG mixes two very different moments of execution: the +//! **update/ingest path** (rows arrive, maintained summary state is updated) +//! and **query evaluation** (maintained state is read out and a final result +//! is produced). A plan that places a query-time residual *underneath* a +//! maintained summary is not merely expensive — it is unexecutable, because +//! the maintenance loop has no readout values to feed into that summary. +//! [`SummaryExpr::ReadoutPostProcess`] is exactly such a residual, which is +//! why it and [`SummaryExpr::UpdateTransform`] are two separate variants +//! rather than one phase-ambiguous value operation. +//! +//! [`ExecutionAvailability`] is what a node's output *is*, at which phase; +//! [`validate_execution_phases`] checks every edge of a DAG against the +//! rules below at plan construction, returning a typed [`PhaseError`] rather +//! than deferring to a runtime failure. +//! +//! ## Edge rules +//! +//! | Parent | Accepts from `child` | +//! |---|---| +//! | `SummaryAgg.child` | `UpdateValue`, or `SummaryState` of an **exact accumulator** family (the one explicitly supported state-composition input — `ExactAggregate` state *is* the value, so it can be re-accumulated on the update path). Never `ReadoutValue`. | +//! | `SummaryEstimate.summary_input` | `SummaryState` (any family). Produces `ReadoutValue`. | +//! | `SummaryJoin.outer/inner` | `UpdateValue` or `SummaryState`; never `ReadoutValue`. | +//! | `SummarySubtract`/`SummaryDelete`/`SummaryMerge` | `SummaryState`. | +//! | `UpdateTransform.child` | `UpdateValue`. Produces `UpdateValue`. | +//! | `ReadoutPostProcess.child` | `ReadoutValue`. Produces `ReadoutValue`. | +//! +//! ## `KeepPreAsap` declares its phase through the derivation +//! +//! A [`SummaryExpr::KeepPreAsap`] leaf is a raw pre-ASAP computation that a +//! runtime can execute at either phase: as update-path raw input beneath a +//! `SummaryAgg`/`UpdateTransform`, or as a query-time fallback beneath a +//! `ReadoutPostProcess` (or at the root). It carries no phase field of its own +//! — every existing consumer pattern-matches the one-field shape — so its +//! phase is *assigned* by [`validate_execution_phases`] from the edge that +//! reaches it and reported in the returned [`PhaseAssignment`]. What it may +//! not do is stay ambiguous inside one mixed plan: the same `Rc` +//! reached once as update input and once as query-time fallback is +//! [`PhaseError::AmbiguousKeepPreAsap`], because no single execution of that +//! subtree can serve both roles. + +use std::collections::HashMap; +use std::rc::Rc; + +use thiserror::Error; + +use super::expr::{ExactOperator, SummaryExpr, SummaryNode, ValueOperator}; +use super::schema::{SummaryFamilyType, SummaryField, SummarySchema}; +use crate::pre_asap::query_expr::{aggregate_output_schema, QueryExprError}; +use crate::pre_asap::schema::{Column, Schema}; + +/// What a post-ASAP node's output is, and at which execution phase it +/// exists — the edge-level contract [`validate_execution_phases`] enforces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExecutionAvailability { + /// Plain rows available on the update/ingest path, while maintaining + /// downstream state. + UpdateValue, + /// Partial, mergeable summary state — not directly readable as a plain + /// value (except for exact accumulators, whose state *is* the value). + SummaryState, + /// Plain values available at query evaluation, after a readout. + ReadoutValue, +} + +impl ExecutionAvailability { + /// Stable lower-case name for JSON/DAG export (`"update_value"`, …). + pub fn as_str(self) -> &'static str { + match self { + Self::UpdateValue => "update_value", + Self::SummaryState => "summary_state", + Self::ReadoutValue => "readout_value", + } + } +} + +impl std::fmt::Display for ExecutionAvailability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Which parent/edge a [`PhaseError`] is about — the variant name of the +/// parent `SummaryExpr` plus its field, for a message a plan author can act +/// on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhaseEdge { + SummaryAggChild, + SummaryEstimateInput, + SummaryJoinInput, + SummarySubtractInput, + SummaryDeleteInput, + SummaryMergeInput, + UpdateTransformChild, + ReadoutPostProcessChild, +} + +impl PhaseEdge { + fn describe(self) -> &'static str { + match self { + Self::SummaryAggChild => "SummaryAgg.child", + Self::SummaryEstimateInput => "SummaryEstimate.summary_input", + Self::SummaryJoinInput => "SummaryJoin.{outer,inner}", + Self::SummarySubtractInput => "SummarySubtract.{left,right}", + Self::SummaryDeleteInput => "SummaryDelete.summary_input", + Self::SummaryMergeInput => "SummaryMerge.children[]", + Self::UpdateTransformChild => "UpdateTransform.child", + Self::ReadoutPostProcessChild => "ReadoutPostProcess.child", + } + } +} + +/// A plan-construction-time phase violation. Typed (not a string) so a +/// strategy can degrade to a conservative fallback on the specific variant +/// it expects, and so tests can assert the *reason* a plan was rejected. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PhaseError { + /// A query-time value (`SummaryEstimate` / `ReadoutPostProcess` output) + /// placed beneath a maintained summary — the one shape issue #171's + /// phase split exists to make unrepresentable. + #[error( + "readout value under maintenance: {edge} received a {child} input, but a maintained \ + summary can only consume update-path values (or exact accumulator state)" + )] + ReadoutUnderMaintenance { + edge: &'static str, + child: ExecutionAvailability, + }, + /// Any other edge whose child availability the parent does not accept + /// (e.g. plain update rows fed straight into a `SummaryEstimate`, or a + /// sketch's opaque state fed into a `ReadoutPostProcess`). + #[error("{edge} does not accept a {child} input")] + IllegalChildPhase { + edge: &'static str, + child: ExecutionAvailability, + }, + /// A `SummaryAgg` whose child is summary state of a family other than an + /// exact accumulator — re-accumulating opaque sketch/sample/… state on + /// the update path has no defined semantics here. + #[error( + "SummaryAgg.child carries {family} summary state; only exact accumulator state can be \ + composed into another maintained summary" + )] + UnsupportedStateComposition { family: String }, + /// One shared `KeepPreAsap` node reached both as update-path raw input + /// and as a query-time fallback — see the module docs. + #[error( + "KeepPreAsap subtree is phase-ambiguous: reached as {first} and as {second} in the same \ + plan" + )] + AmbiguousKeepPreAsap { + first: ExecutionAvailability, + second: ExecutionAvailability, + }, + /// An update-path-only node (`UpdateTransform`) at the root of a plan: + /// nothing maintains state above it, so its output is never read. + #[error("UpdateTransform cannot be a plan root: its update-path output feeds nothing")] + UpdateValueAtRoot, + /// An `ExactOperator` whose input columns are not all `Plain` at its + /// declared phase. + #[error("exact operator consumes non-plain column {column:?} ({dtype})")] + NonPlainOperand { column: String, dtype: String }, +} + +/// The phase assigned to every node of a validated plan, keyed by +/// `Rc` pointer identity — the explicit per-node "stage" a +/// runtime or a DAG export reads instead of re-deriving it. For every +/// non-`KeepPreAsap` node this equals [`produced_availability`]; for a +/// `KeepPreAsap` leaf it is the phase the reaching edge assigned. +#[derive(Debug, Clone, Default)] +pub struct PhaseAssignment { + stages: HashMap<*const SummaryNode, ExecutionAvailability>, +} + +impl PhaseAssignment { + /// The stage assigned to `node`, if it was part of the validated plan. + pub fn stage_of(&self, node: &Rc) -> Option { + self.stages.get(&Rc::as_ptr(node)).copied() + } + + /// The stage assigned to the node at `ptr` — for callers walking a plan + /// by reference rather than by `Rc`. + pub fn stage_of_ptr(&self, ptr: *const SummaryNode) -> Option { + self.stages.get(&ptr).copied() + } +} + +/// The availability `expr` *produces*, independent of context — `None` for +/// [`SummaryExpr::KeepPreAsap`], whose phase is assigned by the edge reaching +/// it (see the module docs). +pub fn produced_availability(expr: &SummaryExpr) -> Option { + Some(match expr { + SummaryExpr::KeepPreAsap(_) => return None, + SummaryExpr::SummaryAgg { .. } + | SummaryExpr::SummaryJoin { .. } + | SummaryExpr::SummarySubtract { .. } + | SummaryExpr::SummaryDelete { .. } + | SummaryExpr::SummaryMerge { .. } => ExecutionAvailability::SummaryState, + SummaryExpr::SummaryEstimate { .. } | SummaryExpr::ReadoutPostProcess { .. } => { + ExecutionAvailability::ReadoutValue + } + SummaryExpr::UpdateTransform { .. } => ExecutionAvailability::UpdateValue, + }) +} + +/// Is `family` the exact-accumulator family whose partial state *is* the +/// value — the one summary state a `SummaryAgg` may re-accumulate? +fn is_exact_accumulator_state(schema: &SummarySchema) -> Result<(), PhaseError> { + for field in &schema.fields { + match &field.dtype { + SummaryFamilyType::Plain(_) | SummaryFamilyType::ExactAggregate(..) => {} + other => { + return Err(PhaseError::UnsupportedStateComposition { + family: format!("{other:?}"), + }) + } + } + } + Ok(()) +} + +/// Validate every edge of the DAG rooted at `root` against the module-level +/// rules, returning each node's assigned stage on success. Shared +/// `Rc`s are visited once per reaching edge (the assignment is +/// per node, so a conflict between two edges is what +/// [`PhaseError::AmbiguousKeepPreAsap`] detects). +pub fn validate_execution_phases(root: &Rc) -> Result { + // The root may be a readable value or bare maintained state (a + // deployment may hand an `ExactAggregate` accumulator straight to a + // consumer) — only an update-path-only root is meaningless. + let root_stage = match produced_availability(&root.expr) { + None => ExecutionAvailability::ReadoutValue, + Some(ExecutionAvailability::UpdateValue) => return Err(PhaseError::UpdateValueAtRoot), + Some(stage) => stage, + }; + validate_execution_phases_at(root, root_stage) +} + +/// [`validate_execution_phases`] for a *sub*-plan whose root is known to +/// sit at `stage` — e.g. an `UpdateTransform` about to be placed beneath a +/// `SummaryAgg`, which would be rejected as a whole-plan root but is a +/// legal update-path input. Validates every edge beneath `root` exactly +/// as the whole-plan entry point does. +pub fn validate_execution_phases_at( + root: &Rc, + stage: ExecutionAvailability, +) -> Result { + let mut assignment = PhaseAssignment::default(); + visit(root, stage, &mut assignment)?; + Ok(assignment) +} + +/// Record `stage` for `node` (detecting a conflicting earlier assignment +/// for a `KeepPreAsap`), then check and recurse into every child edge. +fn visit( + node: &Rc, + stage: ExecutionAvailability, + assignment: &mut PhaseAssignment, +) -> Result<(), PhaseError> { + let ptr = Rc::as_ptr(node); + if let Some(previous) = assignment.stages.get(&ptr) { + if *previous != stage { + return Err(PhaseError::AmbiguousKeepPreAsap { + first: *previous, + second: stage, + }); + } + // Already validated through another edge with the same stage. + return Ok(()); + } + assignment.stages.insert(ptr, stage); + + match &node.expr { + SummaryExpr::KeepPreAsap(_) => Ok(()), + SummaryExpr::SummaryAgg { child, .. } => { + let child_stage = + child_stage(child, PhaseEdge::SummaryAggChild, |avail| match avail { + ExecutionAvailability::UpdateValue => Ok(()), + ExecutionAvailability::SummaryState => { + is_exact_accumulator_state(&child.schema) + } + ExecutionAvailability::ReadoutValue => { + Err(PhaseError::ReadoutUnderMaintenance { + edge: PhaseEdge::SummaryAggChild.describe(), + child: avail, + }) + } + })?; + visit(child, child_stage, assignment) + } + SummaryExpr::SummaryJoin { outer, inner, .. } => { + for input in [outer, inner] { + let s = child_stage(input, PhaseEdge::SummaryJoinInput, |avail| match avail { + ExecutionAvailability::UpdateValue | ExecutionAvailability::SummaryState => { + Ok(()) + } + ExecutionAvailability::ReadoutValue => { + Err(PhaseError::ReadoutUnderMaintenance { + edge: PhaseEdge::SummaryJoinInput.describe(), + child: avail, + }) + } + })?; + visit(input, s, assignment)?; + } + Ok(()) + } + SummaryExpr::SummarySubtract { left, right } => { + for input in [left, right] { + let s = state_only(input, PhaseEdge::SummarySubtractInput)?; + visit(input, s, assignment)?; + } + Ok(()) + } + SummaryExpr::SummaryDelete { summary_input, .. } => { + let s = state_only(summary_input, PhaseEdge::SummaryDeleteInput)?; + visit(summary_input, s, assignment) + } + SummaryExpr::SummaryMerge { children } => { + for input in children { + let s = state_only(input, PhaseEdge::SummaryMergeInput)?; + visit(input, s, assignment)?; + } + Ok(()) + } + SummaryExpr::SummaryEstimate { summary_input, .. } => { + let s = state_only(summary_input, PhaseEdge::SummaryEstimateInput)?; + visit(summary_input, s, assignment) + } + SummaryExpr::UpdateTransform { child, op } => { + let s = child_stage( + child, + PhaseEdge::UpdateTransformChild, + |avail| match avail { + ExecutionAvailability::UpdateValue => Ok(()), + other => Err(PhaseError::IllegalChildPhase { + edge: PhaseEdge::UpdateTransformChild.describe(), + child: other, + }), + }, + )?; + check_plain_operands(op, &child.schema)?; + visit(child, s, assignment) + } + SummaryExpr::ReadoutPostProcess { child, op } => { + let s = child_stage( + child, + PhaseEdge::ReadoutPostProcessChild, + |avail| match avail { + ExecutionAvailability::ReadoutValue => Ok(()), + other => Err(PhaseError::IllegalChildPhase { + edge: PhaseEdge::ReadoutPostProcessChild.describe(), + child: other, + }), + }, + )?; + check_plain_operands(op, &child.schema)?; + visit(child, s, assignment) + } + } +} + +/// The stage `child` takes as a direct input of `parent`, without +/// validating legality — `child`'s own produced availability, or for a +/// `KeepPreAsap` leaf the phase `parent`'s edge assigns it (update-path raw +/// input under maintenance/transform edges, query-time fallback under a +/// post-process, and — meaninglessly, but for a stable answer — `UpdateValue` +/// under a state-only edge). For DAG export and other reporting that needs +/// an explicit per-node stage even on a plan that +/// [`validate_execution_phases`] would reject. +pub fn assigned_child_stage(parent: &SummaryExpr, child: &SummaryNode) -> ExecutionAvailability { + if let Some(avail) = produced_availability(&child.expr) { + return avail; + } + match parent { + SummaryExpr::ReadoutPostProcess { .. } => ExecutionAvailability::ReadoutValue, + SummaryExpr::KeepPreAsap(_) + | SummaryExpr::SummaryAgg { .. } + | SummaryExpr::SummaryJoin { .. } + | SummaryExpr::SummarySubtract { .. } + | SummaryExpr::SummaryDelete { .. } + | SummaryExpr::SummaryEstimate { .. } + | SummaryExpr::SummaryMerge { .. } + | SummaryExpr::UpdateTransform { .. } => ExecutionAvailability::UpdateValue, + } +} + +/// The stage `child` takes on `edge`: its own produced availability +/// (checked via `accept`), or — for a `KeepPreAsap` leaf — the phase the +/// edge assigns it, derived from what that edge accepts. +fn child_stage( + child: &Rc, + edge: PhaseEdge, + accept: impl Fn(ExecutionAvailability) -> Result<(), PhaseError>, +) -> Result { + match produced_availability(&child.expr) { + Some(avail) => { + accept(avail)?; + Ok(avail) + } + None => { + // A raw pre-ASAP subtree executes at whichever phase its consumer + // needs: update-path input for maintenance/transform edges, + // query-time fallback for a post-process edge. State-only edges + // can't consume plain rows at all. + let assigned = match edge { + PhaseEdge::SummaryAggChild + | PhaseEdge::SummaryJoinInput + | PhaseEdge::UpdateTransformChild => ExecutionAvailability::UpdateValue, + PhaseEdge::ReadoutPostProcessChild => ExecutionAvailability::ReadoutValue, + PhaseEdge::SummaryEstimateInput + | PhaseEdge::SummarySubtractInput + | PhaseEdge::SummaryDeleteInput + | PhaseEdge::SummaryMergeInput => { + return Err(PhaseError::IllegalChildPhase { + edge: edge.describe(), + child: ExecutionAvailability::UpdateValue, + }) + } + }; + accept(assigned)?; + Ok(assigned) + } + } +} + +fn state_only( + child: &Rc, + edge: PhaseEdge, +) -> Result { + child_stage(child, edge, |avail| match avail { + ExecutionAvailability::SummaryState => Ok(()), + other => Err(PhaseError::IllegalChildPhase { + edge: edge.describe(), + child: other, + }), + }) +} + +/// The exact operator must consume only `Plain` columns of its input: for +/// an `Aggregate` payload, every grouping key and every measure's input +/// column. +fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), PhaseError> { + let ValueOperator::Exact(op) = op else { + return check_all_plain(input); + }; + let ExactOperator::Aggregate { + reduction, + measures, + .. + } = op; + let mut referenced: Vec = reduction + .group_keys() + .map(|keys| keys.keys().to_vec()) + .unwrap_or_default(); + for m in measures { + if let Some(col) = m.input_col() { + referenced.push(col); + } + } + // With no explicit input column (the PromQL sample-value convention) + // the operator reads every non-key column, so all must be plain. + let implicit = measures.iter().any(|m| m.input_col().is_none()); + for (i, field) in input.fields.iter().enumerate() { + if !(implicit || referenced.contains(&i)) { + continue; + } + if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { + return Err(PhaseError::NonPlainOperand { + column: field.name.clone(), + dtype: format!("{:?}", field.dtype), + }); + } + } + Ok(()) +} + +fn check_all_plain(input: &SummarySchema) -> Result<(), PhaseError> { + for field in &input.fields { + if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { + return Err(PhaseError::NonPlainOperand { + column: field.name.clone(), + dtype: format!("{:?}", field.dtype), + }); + } + } + Ok(()) +} + +/// The plain pre-ASAP `Schema` underlying an all-`Plain` `SummarySchema`, or +/// `None` if any column carries summary state. +pub fn plain_schema(schema: &SummarySchema) -> Option { + let mut columns = Vec::with_capacity(schema.fields.len()); + for field in &schema.fields { + let SummaryFamilyType::Plain(dtype) = &field.dtype else { + return None; + }; + columns.push(Column::new(&field.name, dtype.clone(), field.nullable)); + } + Some(Schema { + columns, + time_index: schema.time_index, + unique_keys: Vec::new(), + closed: true, + }) +} + +/// Lift a plain pre-ASAP schema to a `SummarySchema` with every column +/// `Plain` — the output of every exact operator. +pub fn lift_plain(schema: &Schema) -> SummarySchema { + SummarySchema { + fields: schema + .columns + .iter() + .map(|c| SummaryField { + name: c.name.clone(), + dtype: SummaryFamilyType::Plain(c.dtype.clone()), + nullable: c.nullable, + }) + .collect(), + time_index: schema.time_index, + } +} + +/// Output schema of `op` applied to a child whose edge carries `input` — +/// the same canonical derivation the pre-ASAP `Aggregate` node uses, so an +/// exact `ReadoutPostProcess`/`UpdateTransform` never disagrees with the pre-ASAP +/// target it was lowered from. `Err` when the child carries non-plain +/// state the operator cannot read. +pub fn exact_operator_output_schema( + op: &ExactOperator, + input: &SummarySchema, +) -> Result { + let plain = plain_schema(input).ok_or(ExactOperatorSchemaError::NonPlainInput)?; + let ExactOperator::Aggregate { + reduction, + measures, + output_names, + .. + } = op; + let out = aggregate_output_schema(&plain, reduction, measures, output_names)?; + Ok(lift_plain(&out)) +} + +/// Why [`exact_operator_output_schema`] could not derive a schema. +#[derive(Debug, Error)] +pub enum ExactOperatorSchemaError { + #[error("exact operator input carries summary state, not plain columns")] + NonPlainInput, + #[error("schema derivation failed: {0}")] + Schema(#[from] QueryExprError), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::post_asap::{ExactKind, ExactParams, GroupingStrategy, SketchQuery}; + use crate::pre_asap::agg_intent::AggIntent; + use crate::pre_asap::expr_ir::ColumnRef; + use crate::pre_asap::query_expr::{QueryExpr, Reduction, Source}; + use crate::pre_asap::schema::DataType; + + fn scan() -> Rc { + Rc::new(QueryExpr::Scan { + source: Source::TimeSeries { metric: "m".into() }, + predicates: vec![], + schema: Schema::with_time_index( + vec![ + Column::new("ts", DataType::Timestamp, false), + Column::new("value", DataType::Float64, false), + Column::new("zone", DataType::Utf8, true), + ], + 0, + vec![], + ), + }) + } + + fn keep() -> Rc { + let s = scan(); + let schema = lift_plain(&s.output_schema().unwrap()); + Rc::new(SummaryNode { + expr: SummaryExpr::KeepPreAsap(s), + schema, + guarantee: None, + }) + } + + fn plain(names: &[&str]) -> SummarySchema { + SummarySchema { + fields: names + .iter() + .map(|n| SummaryField { + name: (*n).into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }) + .collect(), + time_index: None, + } + } + + fn agg(child: Rc, family: SummaryFamilyType) -> Rc { + Rc::new(SummaryNode { + expr: SummaryExpr::SummaryAgg { + child, + family: family.clone(), + col: ColumnRef::SampleValue, + reduction: Reduction::by(vec![]), + grouping: GroupingStrategy::default(), + }, + schema: SummarySchema { + fields: vec![SummaryField { + name: "state".into(), + dtype: family, + nullable: false, + }], + time_index: None, + }, + guarantee: None, + }) + } + + fn kll() -> SummaryFamilyType { + use crate::post_asap::{SketchAlgorithm, SketchKind, SketchParams}; + SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 200 }), + GroupingStrategy::default(), + ) + } + + fn estimate(child: Rc) -> Rc { + Rc::new(SummaryNode { + expr: SummaryExpr::SummaryEstimate { + summary_input: child, + query: SketchQuery::Quantile { q: 0.99 }, + }, + schema: plain(&["quantile_0_99"]), + guarantee: None, + }) + } + + fn max_op() -> ExactOperator { + ExactOperator::Aggregate { + reduction: Reduction::by(vec![]), + measures: vec![AggIntent::Max { col: None }], + output_names: vec![], + having: None, + } + } + + #[test] + fn keep_pre_asap_under_summary_agg_is_update_input() { + let leaf = keep(); + let root = agg(Rc::clone(&leaf), kll()); + let assignment = validate_execution_phases(&root).unwrap(); + assert_eq!( + assignment.stage_of(&leaf), + Some(ExecutionAvailability::UpdateValue) + ); + assert_eq!( + assignment.stage_of(&root), + Some(ExecutionAvailability::SummaryState) + ); + } + + #[test] + fn exact_accumulator_state_may_feed_another_summary_agg() { + let inner = agg( + keep(), + SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + ); + let root = estimate(agg(inner, kll())); + assert!(validate_execution_phases(&root).is_ok()); + } + + #[test] + fn readout_under_summary_agg_is_rejected() { + let inner = estimate(agg(keep(), kll())); + let root = agg(inner, kll()); + assert!(matches!( + validate_execution_phases(&root), + Err(PhaseError::ReadoutUnderMaintenance { .. }) + )); + } + + #[test] + fn post_process_over_readout_is_legal_and_root_is_readout() { + let inner = estimate(agg(keep(), kll())); + let root = Rc::new(SummaryNode { + expr: SummaryExpr::ReadoutPostProcess { + child: inner, + op: ValueOperator::Exact(max_op()), + }, + schema: plain(&["max"]), + guarantee: None, + }); + let assignment = validate_execution_phases(&root).unwrap(); + assert_eq!( + assignment.stage_of(&root), + Some(ExecutionAvailability::ReadoutValue) + ); + } + + #[test] + fn non_exact_operator_uses_the_same_readout_phase_contract() { + let inner = estimate(agg(keep(), kll())); + let root = Rc::new(SummaryNode { + expr: SummaryExpr::ReadoutPostProcess { + child: inner, + op: ValueOperator::Extension { + name: "approximate_calibration".into(), + }, + }, + schema: plain(&["calibrated"]), + guarantee: None, + }); + + let assignment = validate_execution_phases(&root).unwrap(); + assert_eq!( + assignment.stage_of(&root), + Some(ExecutionAvailability::ReadoutValue) + ); + } + + #[test] + fn post_process_under_summary_agg_is_rejected() { + let inner = estimate(agg(keep(), kll())); + let post = Rc::new(SummaryNode { + expr: SummaryExpr::ReadoutPostProcess { + child: inner, + op: ValueOperator::Exact(max_op()), + }, + schema: plain(&["max"]), + guarantee: None, + }); + let root = agg(post, kll()); + assert_eq!( + validate_execution_phases(&root).err(), + Some(PhaseError::ReadoutUnderMaintenance { + edge: "SummaryAgg.child", + child: ExecutionAvailability::ReadoutValue, + }) + ); + } + + #[test] + fn transform_under_summary_agg_is_legal_but_not_at_root() { + let transform = Rc::new(SummaryNode { + expr: SummaryExpr::UpdateTransform { + child: keep(), + op: ValueOperator::Exact(max_op()), + }, + schema: plain(&["max"]), + guarantee: None, + }); + assert_eq!( + validate_execution_phases(&transform).err(), + Some(PhaseError::UpdateValueAtRoot) + ); + let root = estimate(agg(Rc::clone(&transform), kll())); + let assignment = validate_execution_phases(&root).unwrap(); + assert_eq!( + assignment.stage_of(&transform), + Some(ExecutionAvailability::UpdateValue) + ); + } + + #[test] + fn transform_over_readout_is_rejected() { + let inner = estimate(agg(keep(), kll())); + let transform = Rc::new(SummaryNode { + expr: SummaryExpr::UpdateTransform { + child: inner, + op: ValueOperator::Exact(max_op()), + }, + schema: plain(&["max"]), + guarantee: None, + }); + let root = agg(transform, kll()); + assert!(matches!( + validate_execution_phases(&root), + Err(PhaseError::IllegalChildPhase { + edge: "UpdateTransform.child", + child: ExecutionAvailability::ReadoutValue + }) + )); + } + + #[test] + fn a_shared_keep_pre_asap_reached_at_two_phases_is_ambiguous() { + // One raw subtree used both as update input (under a SummaryAgg) and + // as a query-time fallback (under an ExactPostProcess) — no single + // execution can serve both, so the plan is rejected. + let shared = keep(); + let maintained = estimate(agg(Rc::clone(&shared), kll())); + let post_over_raw = Rc::new(SummaryNode { + expr: SummaryExpr::ReadoutPostProcess { + child: Rc::clone(&shared), + op: ValueOperator::Exact(max_op()), + }, + schema: plain(&["max"]), + guarantee: None, + }); + let root = Rc::new(SummaryNode { + expr: SummaryExpr::SummaryMerge { + children: vec![ + Rc::new(SummaryNode { + expr: SummaryExpr::ReadoutPostProcess { + child: maintained, + op: ValueOperator::Exact(max_op()), + }, + schema: plain(&["max"]), + guarantee: None, + }), + post_over_raw, + ], + }, + schema: plain(&["max"]), + guarantee: None, + }); + // SummaryMerge only accepts state, so this fails earlier for a + // different reason; probe the ambiguity through a direct visit. + let mut assignment = PhaseAssignment::default(); + visit(&shared, ExecutionAvailability::UpdateValue, &mut assignment).unwrap(); + assert_eq!( + visit( + &shared, + ExecutionAvailability::ReadoutValue, + &mut assignment + ), + Err(PhaseError::AmbiguousKeepPreAsap { + first: ExecutionAvailability::UpdateValue, + second: ExecutionAvailability::ReadoutValue, + }) + ); + assert!(validate_execution_phases(&root).is_err()); + } + + #[test] + fn exact_operator_schema_matches_pre_asap_aggregate_derivation() { + let child_schema = lift_plain(&scan().output_schema().unwrap()); + let op = ExactOperator::Aggregate { + reduction: Reduction::by(vec![2]), + measures: vec![AggIntent::Max { col: None }], + output_names: vec![], + having: None, + }; + let out = exact_operator_output_schema(&op, &child_schema).unwrap(); + let names: Vec<_> = out.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, vec!["zone", "max"]); + assert!(out + .fields + .iter() + .all(|f| matches!(f.dtype, SummaryFamilyType::Plain(_)))); + } + + #[test] + fn exact_operator_rejects_non_plain_input() { + let state = agg(keep(), kll()); + assert!(matches!( + exact_operator_output_schema(&max_op(), &state.schema), + Err(ExactOperatorSchemaError::NonPlainInput) + )); + } +} From 22e89981c401bf50725feb363f71d7c799c6c327 Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 12:09:01 -0600 Subject: [PATCH 02/15] refactor(post-asap): split value domain into timing and primitive --- crates/types/src/dag_export.rs | 58 +-- crates/types/src/post_asap/expr.rs | 10 +- crates/types/src/post_asap/mod.rs | 12 +- .../post_asap/{phase.rs => value_domain.rs} | 431 +++++++++--------- 4 files changed, 261 insertions(+), 250 deletions(-) rename crates/types/src/post_asap/{phase.rs => value_domain.rs} (65%) diff --git a/crates/types/src/dag_export.rs b/crates/types/src/dag_export.rs index bda2196f..d2a7b24d 100644 --- a/crates/types/src/dag_export.rs +++ b/crates/types/src/dag_export.rs @@ -14,7 +14,7 @@ //! //! This is literally the same hashing //! [`share_common_subtrees`](crate::pre_asap::cse::share_common_subtrees) -//! uses to bucket candidates in its `InternTable` (issue #223 stage 3) — not +//! uses to bucket candidates in its `InternTable` (issue #223 domain 3) — not //! a parallel reimplementation. `tools/dag-viewer`'s "shared subtree" //! highlighting is still a *proxy* for real CSE, though: a hash match here //! only means two nodes are legal `InternTable` bucket-mates (same coarse @@ -47,8 +47,8 @@ use std::rc::Rc; use serde::Serialize; use crate::post_asap::{ - assigned_child_stage, produced_availability, AccuracyError, ExactOperator, - ExecutionAvailability, ResultGuarantee, SummaryExpr, SummaryNode, ValueOperator, + assigned_child_domain, produced_domain, AccuracyError, ExactOperator, ResultGuarantee, + SummaryExpr, SummaryNode, ValueDomain, ValueOperator, }; use crate::pre_asap::cse::{structural_hash, HashCache}; use crate::pre_asap::query_expr::{QueryExpr, Source}; @@ -359,16 +359,16 @@ pub struct SummaryDagGraph { /// top-level `DagNode::kind`. pub fn export_summary(node: &SummaryNode) -> SummaryDagGraph { let mut nodes = Vec::new(); - let root = build_summary(node, &mut nodes, root_stage(node)); + let root = build_summary(node, &mut nodes, root_domain(node)); SummaryDagGraph { nodes, root } } -/// The explicit execution stage of an exported plan's root — its own -/// produced availability, or query-time readout for a bare `KeepPreAsap` -/// (the same convention `post_asap::phase::validate_execution_phases` +/// The explicit execution domain of an exported plan's root — its own +/// produced domain, or query-time readout for a bare `KeepPreAsap` +/// (the same convention `post_asap::value_domain::validate_execution_domains` /// uses for a root). -fn root_stage(node: &SummaryNode) -> ExecutionAvailability { - produced_availability(&node.expr).unwrap_or(ExecutionAvailability::ReadoutValue) +fn root_domain(node: &SummaryNode) -> ValueDomain { + produced_domain(&node.expr).unwrap_or(ValueDomain::READ_ROWS) } /// `detail` for an [`ExactOperator`] payload — its own fields, rendered the @@ -466,13 +466,13 @@ fn family_label(family: &crate::post_asap::SummaryFamilyType) -> String { /// apart on how every *other* variant's own shape is described, since /// nothing about that description differs between the two. /// -/// `stage` is the node's explicit execution phase (issue #171) — its own -/// [`produced_availability`], or the edge-assigned phase for a `KeepPreAsap` -/// — and is written into `detail.stage` on every post-ASAP node so a viewer +/// `domain` is the node's explicit execution domain (issue #171) — its own +/// [`produced_domain`], or the edge-assigned domain for a `KeepPreAsap` +/// — and is written into `detail.domain` on every post-ASAP node so a viewer /// reads it rather than inferring it from the node's kind. fn summary_shape( expr: &SummaryExpr, - stage: ExecutionAvailability, + domain: ValueDomain, ) -> (&'static str, String, serde_json::Value) { let (kind, label, mut detail) = match expr { SummaryExpr::KeepPreAsap(_) => { @@ -531,8 +531,11 @@ fn summary_shape( }; if let serde_json::Value::Object(map) = &mut detail { map.insert( - "stage".into(), - serde_json::Value::String(stage.as_str().into()), + "domain".into(), + serde_json::json!({ + "timing": domain.timing.as_str(), + "primitive": domain.primitive.as_str(), + }), ); } (kind, label, detail) @@ -563,18 +566,17 @@ fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { /// post-order (children pushed before their parent), and return the pushed /// root's id. Exhaustive over every [`SummaryExpr`] variant, matching this /// file's own exhaustive style for `QueryExpr` in [`build`]. -fn build_summary( - node: &SummaryNode, - nodes: &mut Vec, - stage: ExecutionAvailability, -) -> u32 { +fn build_summary(node: &SummaryNode, nodes: &mut Vec, domain: ValueDomain) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { let pre_asap_subgraph = export(inner); let inner_kind = pre_asap_subgraph.nodes[pre_asap_subgraph.root as usize].kind; let label = format!("KeepPreAsap({inner_kind})"); let detail = serde_json::json!({ "pre_asap_subgraph": pre_asap_subgraph, - "stage": stage.as_str(), + "domain": { + "timing": domain.timing.as_str(), + "primitive": domain.primitive.as_str(), + }, }); return push_summary_node( nodes, @@ -587,9 +589,9 @@ fn build_summary( } let children: Vec = summary_children(&node.expr) .into_iter() - .map(|child| build_summary(child, nodes, assigned_child_stage(&node.expr, child))) + .map(|child| build_summary(child, nodes, assigned_child_domain(&node.expr, child))) .collect(); - let (kind, label, detail) = summary_shape(&node.expr, stage); + let (kind, label, detail) = summary_shape(&node.expr, domain); push_summary_node(nodes, kind, label, detail, children, node.guarantee.clone()) } @@ -867,7 +869,7 @@ fn build_summary_hybrid( nodes: &mut Vec, cache: &mut HashCache, find_winner: &mut dyn FnMut(&QueryExpr) -> Option, - stage: ExecutionAvailability, + domain: ValueDomain, ) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { return build(inner, nodes, cache, find_winner); @@ -880,11 +882,11 @@ fn build_summary_hybrid( nodes, cache, find_winner, - assigned_child_stage(&node.expr, child), + assigned_child_domain(&node.expr, child), ) }) .collect(); - let (kind, label, mut detail) = summary_shape(&node.expr, stage); + let (kind, label, mut detail) = summary_shape(&node.expr, domain); // The merged graph's `DagNode` has no dedicated guarantee field (it is // the pre-ASAP node shape); the guarantee rides in `detail` under the // same key/shape `SummaryDagNode::guarantee` uses, additively. @@ -975,7 +977,7 @@ fn build( nodes, cache, find_winner, - root_stage(&replacement), + root_domain(&replacement), ); for node in &mut nodes[first..] { if node.decision.is_none() { @@ -1536,7 +1538,7 @@ mod tests { ); } - // ── Issue #223 stage 3: dag_export's hash literally *is* cse's hash ──── + // ── Issue #223 domain 3: dag_export's hash literally *is* cse's hash ──── #[test] fn root_hash_matches_cse_structural_hash_for_the_same_node() { diff --git a/crates/types/src/post_asap/expr.rs b/crates/types/src/post_asap/expr.rs index 3eda8f82..aedd0ba8 100644 --- a/crates/types/src/post_asap/expr.rs +++ b/crates/types/src/post_asap/expr.rs @@ -10,8 +10,8 @@ use crate::pre_asap::{ColumnRef, QueryExpr, Reduction}; // ── Exact operators composed with summary plans (issue #171) ──────────────── /// An exact, plain-row operator that a mixed exact/summary plan executes at -/// an explicit phase. Exact composition is one producer of the generic -/// [`ValueOperator`] phase payload. +/// an explicit domain. Exact composition is one producer of the generic +/// [`ValueOperator`] domain payload. /// /// Deliberately **not** an intact pre-ASAP [`QueryExpr`] subtree: a /// `QueryExpr`'s children are always `Rc`, so embedding one here @@ -41,7 +41,7 @@ pub enum ExactOperator { }, } -/// An operation over values at a declared execution phase. +/// An operation over values at a declared execution domain. /// /// Phase placement is independent of whether the operation is exact or /// approximate: [`SummaryExpr::UpdateTransform`] and @@ -192,7 +192,7 @@ pub enum SummaryExpr { /// produces plain update values, so its output may feed a downstream /// [`SummaryAgg`](SummaryExpr::SummaryAgg)'s maintenance — the "outer /// summary over an inner non-accumulator exact transform" direction. - /// See [`super::phase::ExecutionAvailability`] for the edge contract. + /// See [`super::value_domain::ValueDomain`] for the edge contract. UpdateTransform { child: Rc, op: ValueOperator, @@ -203,7 +203,7 @@ pub enum SummaryExpr { /// final plain query result — the "outer exact fold over an inner /// summary readout" direction. Can never feed maintained state: a /// `SummaryAgg` above one of these is a plan-time - /// [`super::phase::PhaseError`], never a runtime failure. + /// [`super::value_domain::DomainError`], never a runtime failure. ReadoutPostProcess { child: Rc, op: ValueOperator, diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index 503c5ca1..5ac2864b 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -29,21 +29,16 @@ pub mod expr; pub mod guarantee; -pub mod phase; pub mod query_time; pub mod schema; pub mod sketch; +pub mod value_domain; pub use expr::{ExactOperator, SummaryExpr, SummaryNode, ValueOperator}; pub use guarantee::{ AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, GuaranteeSource, ProbabilityExpr, ResultGuarantee, }; -pub use phase::{ - assigned_child_stage, exact_operator_output_schema, produced_availability, - validate_execution_phases, validate_execution_phases_at, ExactOperatorSchemaError, - ExecutionAvailability, PhaseAssignment, PhaseError, -}; pub use query_time::{ classic_cms_sizing, cms_posterior_error_bound, count_sketch_posterior_error_bound, cu_sketch_posterior_error_bound, traditional_a_priori_bound, @@ -54,3 +49,8 @@ pub use sketch::{ HydraParams, SamplingKind, SamplingParams, SketchAlgorithm, SketchCategory, SketchKind, SketchParams, SketchQuery, StatModelKind, StatModelParams, WaveletKind, WaveletParams, }; +pub use value_domain::{ + assigned_child_domain, exact_operator_output_schema, produced_domain, + validate_execution_domains, validate_execution_domains_at, DataPrimitive, DomainAssignment, + DomainError, ExactOperatorSchemaError, ExecutionTiming, ValueDomain, +}; diff --git a/crates/types/src/post_asap/phase.rs b/crates/types/src/post_asap/value_domain.rs similarity index 65% rename from crates/types/src/post_asap/phase.rs rename to crates/types/src/post_asap/value_domain.rs index 45c68ec3..c65d705b 100644 --- a/crates/types/src/post_asap/phase.rs +++ b/crates/types/src/post_asap/value_domain.rs @@ -1,4 +1,4 @@ -//! Execution-phase contract for mixed exact/summary plans (issue #171). +//! Execution-domain contract for mixed exact/summary plans (issue #171). //! //! A post-ASAP DAG mixes two very different moments of execution: the //! **update/ingest path** (rows arrive, maintained summary state is updated) @@ -8,36 +8,36 @@ //! the maintenance loop has no readout values to feed into that summary. //! [`SummaryExpr::ReadoutPostProcess`] is exactly such a residual, which is //! why it and [`SummaryExpr::UpdateTransform`] are two separate variants -//! rather than one phase-ambiguous value operation. +//! rather than one domain-ambiguous value operation. //! -//! [`ExecutionAvailability`] is what a node's output *is*, at which phase; -//! [`validate_execution_phases`] checks every edge of a DAG against the -//! rules below at plan construction, returning a typed [`PhaseError`] rather +//! [`ValueDomain`] is what a node's output *is*, at which domain; +//! [`validate_execution_domains`] checks every edge of a DAG against the +//! rules below at plan construction, returning a typed [`DomainError`] rather //! than deferring to a runtime failure. //! //! ## Edge rules //! //! | Parent | Accepts from `child` | //! |---|---| -//! | `SummaryAgg.child` | `UpdateValue`, or `SummaryState` of an **exact accumulator** family (the one explicitly supported state-composition input — `ExactAggregate` state *is* the value, so it can be re-accumulated on the update path). Never `ReadoutValue`. | -//! | `SummaryEstimate.summary_input` | `SummaryState` (any family). Produces `ReadoutValue`. | -//! | `SummaryJoin.outer/inner` | `UpdateValue` or `SummaryState`; never `ReadoutValue`. | -//! | `SummarySubtract`/`SummaryDelete`/`SummaryMerge` | `SummaryState`. | -//! | `UpdateTransform.child` | `UpdateValue`. Produces `UpdateValue`. | -//! | `ReadoutPostProcess.child` | `ReadoutValue`. Produces `ReadoutValue`. | +//! | `SummaryAgg.child` | `MAINTENANCE_ROWS`, or `MAINTENANCE_SUMMARY` of an **exact accumulator** family. Never a read-time domain. | +//! | `SummaryEstimate.summary_input` | `MAINTENANCE_SUMMARY` (any family). Produces `READ_ROWS`. | +//! | `SummaryJoin.outer/inner` | `MAINTENANCE_ROWS` or `MAINTENANCE_SUMMARY`; never a read-time domain. | +//! | `SummarySubtract`/`SummaryDelete`/`SummaryMerge` | `MAINTENANCE_SUMMARY`. | +//! | `UpdateTransform.child` | `MAINTENANCE_ROWS`. Produces `MAINTENANCE_ROWS`. | +//! | `ReadoutPostProcess.child` | `READ_ROWS`. Produces `READ_ROWS`. | //! -//! ## `KeepPreAsap` declares its phase through the derivation +//! ## `KeepPreAsap` declares its domain through the derivation //! //! A [`SummaryExpr::KeepPreAsap`] leaf is a raw pre-ASAP computation that a -//! runtime can execute at either phase: as update-path raw input beneath a +//! runtime can execute at either domain: as update-path raw input beneath a //! `SummaryAgg`/`UpdateTransform`, or as a query-time fallback beneath a -//! `ReadoutPostProcess` (or at the root). It carries no phase field of its own +//! `ReadoutPostProcess` (or at the root). It carries no domain field of its own //! — every existing consumer pattern-matches the one-field shape — so its -//! phase is *assigned* by [`validate_execution_phases`] from the edge that -//! reaches it and reported in the returned [`PhaseAssignment`]. What it may +//! domain is *assigned* by [`validate_execution_domains`] from the edge that +//! reaches it and reported in the returned [`DomainAssignment`]. What it may //! not do is stay ambiguous inside one mixed plan: the same `Rc` //! reached once as update input and once as query-time fallback is -//! [`PhaseError::AmbiguousKeepPreAsap`], because no single execution of that +//! [`DomainError::AmbiguousKeepPreAsap`], because no single execution of that //! subtree can serve both roles. use std::collections::HashMap; @@ -50,42 +50,72 @@ use super::schema::{SummaryFamilyType, SummaryField, SummarySchema}; use crate::pre_asap::query_expr::{aggregate_output_schema, QueryExprError}; use crate::pre_asap::schema::{Column, Schema}; -/// What a post-ASAP node's output is, and at which execution phase it -/// exists — the edge-level contract [`validate_execution_phases`] enforces. +/// When a post-ASAP value is produced. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ExecutionAvailability { - /// Plain rows available on the update/ingest path, while maintaining - /// downstream state. - UpdateValue, - /// Partial, mergeable summary state — not directly readable as a plain - /// value (except for exact accumulators, whose state *is* the value). +pub enum ExecutionTiming { + MaintenanceTime, + ReadTime, +} + +impl ExecutionTiming { + pub fn as_str(self) -> &'static str { + match self { + Self::MaintenanceTime => "maintenance_time", + Self::ReadTime => "read_time", + } + } +} + +/// The primitive representation carried by a post-ASAP edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DataPrimitive { + Rows, SummaryState, - /// Plain values available at query evaluation, after a readout. - ReadoutValue, } -impl ExecutionAvailability { - /// Stable lower-case name for JSON/DAG export (`"update_value"`, …). +impl DataPrimitive { pub fn as_str(self) -> &'static str { match self { - Self::UpdateValue => "update_value", + Self::Rows => "rows", Self::SummaryState => "summary_state", - Self::ReadoutValue => "readout_value", } } } -impl std::fmt::Display for ExecutionAvailability { +/// The two-dimensional edge contract: when a value exists and which data +/// primitive it carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ValueDomain { + pub timing: ExecutionTiming, + pub primitive: DataPrimitive, +} + +impl ValueDomain { + pub const MAINTENANCE_ROWS: Self = Self { + timing: ExecutionTiming::MaintenanceTime, + primitive: DataPrimitive::Rows, + }; + pub const MAINTENANCE_SUMMARY: Self = Self { + timing: ExecutionTiming::MaintenanceTime, + primitive: DataPrimitive::SummaryState, + }; + pub const READ_ROWS: Self = Self { + timing: ExecutionTiming::ReadTime, + primitive: DataPrimitive::Rows, + }; +} + +impl std::fmt::Display for ValueDomain { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) + write!(f, "{}/{}", self.timing.as_str(), self.primitive.as_str()) } } -/// Which parent/edge a [`PhaseError`] is about — the variant name of the +/// Which parent/edge a [`DomainError`] is about — the variant name of the /// parent `SummaryExpr` plus its field, for a message a plan author can act /// on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PhaseEdge { +pub enum DomainEdge { SummaryAggChild, SummaryEstimateInput, SummaryJoinInput, @@ -96,7 +126,7 @@ pub enum PhaseEdge { ReadoutPostProcessChild, } -impl PhaseEdge { +impl DomainEdge { fn describe(self) -> &'static str { match self { Self::SummaryAggChild => "SummaryAgg.child", @@ -111,29 +141,29 @@ impl PhaseEdge { } } -/// A plan-construction-time phase violation. Typed (not a string) so a +/// A plan-construction-time domain violation. Typed (not a string) so a /// strategy can degrade to a conservative fallback on the specific variant /// it expects, and so tests can assert the *reason* a plan was rejected. #[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum PhaseError { +pub enum DomainError { /// A query-time value (`SummaryEstimate` / `ReadoutPostProcess` output) /// placed beneath a maintained summary — the one shape issue #171's - /// phase split exists to make unrepresentable. + /// domain split exists to make unrepresentable. #[error( "readout value under maintenance: {edge} received a {child} input, but a maintained \ summary can only consume update-path values (or exact accumulator state)" )] ReadoutUnderMaintenance { edge: &'static str, - child: ExecutionAvailability, + child: ValueDomain, }, - /// Any other edge whose child availability the parent does not accept + /// Any other edge whose child domain the parent does not accept /// (e.g. plain update rows fed straight into a `SummaryEstimate`, or a /// sketch's opaque state fed into a `ReadoutPostProcess`). #[error("{edge} does not accept a {child} input")] IllegalChildPhase { edge: &'static str, - child: ExecutionAvailability, + child: ValueDomain, }, /// A `SummaryAgg` whose child is summary state of a family other than an /// exact accumulator — re-accumulating opaque sketch/sample/… state on @@ -146,72 +176,72 @@ pub enum PhaseError { /// One shared `KeepPreAsap` node reached both as update-path raw input /// and as a query-time fallback — see the module docs. #[error( - "KeepPreAsap subtree is phase-ambiguous: reached as {first} and as {second} in the same \ + "KeepPreAsap subtree is domain-ambiguous: reached as {first} and as {second} in the same \ plan" )] AmbiguousKeepPreAsap { - first: ExecutionAvailability, - second: ExecutionAvailability, + first: ValueDomain, + second: ValueDomain, }, /// An update-path-only node (`UpdateTransform`) at the root of a plan: /// nothing maintains state above it, so its output is never read. #[error("UpdateTransform cannot be a plan root: its update-path output feeds nothing")] - UpdateValueAtRoot, + MaintenanceRowsAtRoot, /// An `ExactOperator` whose input columns are not all `Plain` at its - /// declared phase. + /// declared domain. #[error("exact operator consumes non-plain column {column:?} ({dtype})")] NonPlainOperand { column: String, dtype: String }, } -/// The phase assigned to every node of a validated plan, keyed by -/// `Rc` pointer identity — the explicit per-node "stage" a +/// The domain assigned to every node of a validated plan, keyed by +/// `Rc` pointer identity — the explicit per-node "domain" a /// runtime or a DAG export reads instead of re-deriving it. For every -/// non-`KeepPreAsap` node this equals [`produced_availability`]; for a -/// `KeepPreAsap` leaf it is the phase the reaching edge assigned. +/// non-`KeepPreAsap` node this equals [`produced_domain`]; for a +/// `KeepPreAsap` leaf it is the domain the reaching edge assigned. #[derive(Debug, Clone, Default)] -pub struct PhaseAssignment { - stages: HashMap<*const SummaryNode, ExecutionAvailability>, +pub struct DomainAssignment { + domains: HashMap<*const SummaryNode, ValueDomain>, } -impl PhaseAssignment { - /// The stage assigned to `node`, if it was part of the validated plan. - pub fn stage_of(&self, node: &Rc) -> Option { - self.stages.get(&Rc::as_ptr(node)).copied() +impl DomainAssignment { + /// The domain assigned to `node`, if it was part of the validated plan. + pub fn domain_of(&self, node: &Rc) -> Option { + self.domains.get(&Rc::as_ptr(node)).copied() } - /// The stage assigned to the node at `ptr` — for callers walking a plan + /// The domain assigned to the node at `ptr` — for callers walking a plan /// by reference rather than by `Rc`. - pub fn stage_of_ptr(&self, ptr: *const SummaryNode) -> Option { - self.stages.get(&ptr).copied() + pub fn domain_of_ptr(&self, ptr: *const SummaryNode) -> Option { + self.domains.get(&ptr).copied() } } -/// The availability `expr` *produces*, independent of context — `None` for -/// [`SummaryExpr::KeepPreAsap`], whose phase is assigned by the edge reaching +/// The domain `expr` *produces*, independent of context — `None` for +/// [`SummaryExpr::KeepPreAsap`], whose domain is assigned by the edge reaching /// it (see the module docs). -pub fn produced_availability(expr: &SummaryExpr) -> Option { +pub fn produced_domain(expr: &SummaryExpr) -> Option { Some(match expr { SummaryExpr::KeepPreAsap(_) => return None, SummaryExpr::SummaryAgg { .. } | SummaryExpr::SummaryJoin { .. } | SummaryExpr::SummarySubtract { .. } | SummaryExpr::SummaryDelete { .. } - | SummaryExpr::SummaryMerge { .. } => ExecutionAvailability::SummaryState, + | SummaryExpr::SummaryMerge { .. } => ValueDomain::MAINTENANCE_SUMMARY, SummaryExpr::SummaryEstimate { .. } | SummaryExpr::ReadoutPostProcess { .. } => { - ExecutionAvailability::ReadoutValue + ValueDomain::READ_ROWS } - SummaryExpr::UpdateTransform { .. } => ExecutionAvailability::UpdateValue, + SummaryExpr::UpdateTransform { .. } => ValueDomain::MAINTENANCE_ROWS, }) } /// Is `family` the exact-accumulator family whose partial state *is* the /// value — the one summary state a `SummaryAgg` may re-accumulate? -fn is_exact_accumulator_state(schema: &SummarySchema) -> Result<(), PhaseError> { +fn is_exact_accumulator_state(schema: &SummarySchema) -> Result<(), DomainError> { for field in &schema.fields { match &field.dtype { SummaryFamilyType::Plain(_) | SummaryFamilyType::ExactAggregate(..) => {} other => { - return Err(PhaseError::UnsupportedStateComposition { + return Err(DomainError::UnsupportedStateComposition { family: format!("{other:?}"), }) } @@ -221,86 +251,78 @@ fn is_exact_accumulator_state(schema: &SummarySchema) -> Result<(), PhaseError> } /// Validate every edge of the DAG rooted at `root` against the module-level -/// rules, returning each node's assigned stage on success. Shared +/// rules, returning each node's assigned domain on success. Shared /// `Rc`s are visited once per reaching edge (the assignment is /// per node, so a conflict between two edges is what -/// [`PhaseError::AmbiguousKeepPreAsap`] detects). -pub fn validate_execution_phases(root: &Rc) -> Result { +/// [`DomainError::AmbiguousKeepPreAsap`] detects). +pub fn validate_execution_domains(root: &Rc) -> Result { // The root may be a readable value or bare maintained state (a // deployment may hand an `ExactAggregate` accumulator straight to a // consumer) — only an update-path-only root is meaningless. - let root_stage = match produced_availability(&root.expr) { - None => ExecutionAvailability::ReadoutValue, - Some(ExecutionAvailability::UpdateValue) => return Err(PhaseError::UpdateValueAtRoot), - Some(stage) => stage, + let root_domain = match produced_domain(&root.expr) { + None => ValueDomain::READ_ROWS, + Some(ValueDomain::MAINTENANCE_ROWS) => return Err(DomainError::MaintenanceRowsAtRoot), + Some(domain) => domain, }; - validate_execution_phases_at(root, root_stage) + validate_execution_domains_at(root, root_domain) } -/// [`validate_execution_phases`] for a *sub*-plan whose root is known to -/// sit at `stage` — e.g. an `UpdateTransform` about to be placed beneath a +/// [`validate_execution_domains`] for a *sub*-plan whose root is known to +/// sit at `domain` — e.g. an `UpdateTransform` about to be placed beneath a /// `SummaryAgg`, which would be rejected as a whole-plan root but is a /// legal update-path input. Validates every edge beneath `root` exactly /// as the whole-plan entry point does. -pub fn validate_execution_phases_at( +pub fn validate_execution_domains_at( root: &Rc, - stage: ExecutionAvailability, -) -> Result { - let mut assignment = PhaseAssignment::default(); - visit(root, stage, &mut assignment)?; + domain: ValueDomain, +) -> Result { + let mut assignment = DomainAssignment::default(); + visit(root, domain, &mut assignment)?; Ok(assignment) } -/// Record `stage` for `node` (detecting a conflicting earlier assignment +/// Record `domain` for `node` (detecting a conflicting earlier assignment /// for a `KeepPreAsap`), then check and recurse into every child edge. fn visit( node: &Rc, - stage: ExecutionAvailability, - assignment: &mut PhaseAssignment, -) -> Result<(), PhaseError> { + domain: ValueDomain, + assignment: &mut DomainAssignment, +) -> Result<(), DomainError> { let ptr = Rc::as_ptr(node); - if let Some(previous) = assignment.stages.get(&ptr) { - if *previous != stage { - return Err(PhaseError::AmbiguousKeepPreAsap { + if let Some(previous) = assignment.domains.get(&ptr) { + if *previous != domain { + return Err(DomainError::AmbiguousKeepPreAsap { first: *previous, - second: stage, + second: domain, }); } - // Already validated through another edge with the same stage. + // Already validated through another edge with the same domain. return Ok(()); } - assignment.stages.insert(ptr, stage); + assignment.domains.insert(ptr, domain); match &node.expr { SummaryExpr::KeepPreAsap(_) => Ok(()), SummaryExpr::SummaryAgg { child, .. } => { - let child_stage = - child_stage(child, PhaseEdge::SummaryAggChild, |avail| match avail { - ExecutionAvailability::UpdateValue => Ok(()), - ExecutionAvailability::SummaryState => { - is_exact_accumulator_state(&child.schema) - } - ExecutionAvailability::ReadoutValue => { - Err(PhaseError::ReadoutUnderMaintenance { - edge: PhaseEdge::SummaryAggChild.describe(), - child: avail, - }) - } + let child_domain = + child_domain(child, DomainEdge::SummaryAggChild, |avail| match avail { + ValueDomain::MAINTENANCE_ROWS => Ok(()), + ValueDomain::MAINTENANCE_SUMMARY => is_exact_accumulator_state(&child.schema), + other => Err(DomainError::ReadoutUnderMaintenance { + edge: DomainEdge::SummaryAggChild.describe(), + child: other, + }), })?; - visit(child, child_stage, assignment) + visit(child, child_domain, assignment) } SummaryExpr::SummaryJoin { outer, inner, .. } => { for input in [outer, inner] { - let s = child_stage(input, PhaseEdge::SummaryJoinInput, |avail| match avail { - ExecutionAvailability::UpdateValue | ExecutionAvailability::SummaryState => { - Ok(()) - } - ExecutionAvailability::ReadoutValue => { - Err(PhaseError::ReadoutUnderMaintenance { - edge: PhaseEdge::SummaryJoinInput.describe(), - child: avail, - }) - } + let s = child_domain(input, DomainEdge::SummaryJoinInput, |avail| match avail { + ValueDomain::MAINTENANCE_ROWS | ValueDomain::MAINTENANCE_SUMMARY => Ok(()), + other => Err(DomainError::ReadoutUnderMaintenance { + edge: DomainEdge::SummaryJoinInput.describe(), + child: other, + }), })?; visit(input, s, assignment)?; } @@ -308,34 +330,34 @@ fn visit( } SummaryExpr::SummarySubtract { left, right } => { for input in [left, right] { - let s = state_only(input, PhaseEdge::SummarySubtractInput)?; + let s = state_only(input, DomainEdge::SummarySubtractInput)?; visit(input, s, assignment)?; } Ok(()) } SummaryExpr::SummaryDelete { summary_input, .. } => { - let s = state_only(summary_input, PhaseEdge::SummaryDeleteInput)?; + let s = state_only(summary_input, DomainEdge::SummaryDeleteInput)?; visit(summary_input, s, assignment) } SummaryExpr::SummaryMerge { children } => { for input in children { - let s = state_only(input, PhaseEdge::SummaryMergeInput)?; + let s = state_only(input, DomainEdge::SummaryMergeInput)?; visit(input, s, assignment)?; } Ok(()) } SummaryExpr::SummaryEstimate { summary_input, .. } => { - let s = state_only(summary_input, PhaseEdge::SummaryEstimateInput)?; + let s = state_only(summary_input, DomainEdge::SummaryEstimateInput)?; visit(summary_input, s, assignment) } SummaryExpr::UpdateTransform { child, op } => { - let s = child_stage( + let s = child_domain( child, - PhaseEdge::UpdateTransformChild, + DomainEdge::UpdateTransformChild, |avail| match avail { - ExecutionAvailability::UpdateValue => Ok(()), - other => Err(PhaseError::IllegalChildPhase { - edge: PhaseEdge::UpdateTransformChild.describe(), + ValueDomain::MAINTENANCE_ROWS => Ok(()), + other => Err(DomainError::IllegalChildPhase { + edge: DomainEdge::UpdateTransformChild.describe(), child: other, }), }, @@ -344,13 +366,13 @@ fn visit( visit(child, s, assignment) } SummaryExpr::ReadoutPostProcess { child, op } => { - let s = child_stage( + let s = child_domain( child, - PhaseEdge::ReadoutPostProcessChild, + DomainEdge::ReadoutPostProcessChild, |avail| match avail { - ExecutionAvailability::ReadoutValue => Ok(()), - other => Err(PhaseError::IllegalChildPhase { - edge: PhaseEdge::ReadoutPostProcessChild.describe(), + ValueDomain::READ_ROWS => Ok(()), + other => Err(DomainError::IllegalChildPhase { + edge: DomainEdge::ReadoutPostProcessChild.describe(), child: other, }), }, @@ -361,20 +383,20 @@ fn visit( } } -/// The stage `child` takes as a direct input of `parent`, without -/// validating legality — `child`'s own produced availability, or for a -/// `KeepPreAsap` leaf the phase `parent`'s edge assigns it (update-path raw +/// The domain `child` takes as a direct input of `parent`, without +/// validating legality — `child`'s own produced domain, or for a +/// `KeepPreAsap` leaf the domain `parent`'s edge assigns it (update-path raw /// input under maintenance/transform edges, query-time fallback under a -/// post-process, and — meaninglessly, but for a stable answer — `UpdateValue` +/// post-process, and — meaninglessly, but for a stable answer — maintenance rows /// under a state-only edge). For DAG export and other reporting that needs -/// an explicit per-node stage even on a plan that -/// [`validate_execution_phases`] would reject. -pub fn assigned_child_stage(parent: &SummaryExpr, child: &SummaryNode) -> ExecutionAvailability { - if let Some(avail) = produced_availability(&child.expr) { +/// an explicit per-node domain even on a plan that +/// [`validate_execution_domains`] would reject. +pub fn assigned_child_domain(parent: &SummaryExpr, child: &SummaryNode) -> ValueDomain { + if let Some(avail) = produced_domain(&child.expr) { return avail; } match parent { - SummaryExpr::ReadoutPostProcess { .. } => ExecutionAvailability::ReadoutValue, + SummaryExpr::ReadoutPostProcess { .. } => ValueDomain::READ_ROWS, SummaryExpr::KeepPreAsap(_) | SummaryExpr::SummaryAgg { .. } | SummaryExpr::SummaryJoin { .. } @@ -382,40 +404,40 @@ pub fn assigned_child_stage(parent: &SummaryExpr, child: &SummaryNode) -> Execut | SummaryExpr::SummaryDelete { .. } | SummaryExpr::SummaryEstimate { .. } | SummaryExpr::SummaryMerge { .. } - | SummaryExpr::UpdateTransform { .. } => ExecutionAvailability::UpdateValue, + | SummaryExpr::UpdateTransform { .. } => ValueDomain::MAINTENANCE_ROWS, } } -/// The stage `child` takes on `edge`: its own produced availability -/// (checked via `accept`), or — for a `KeepPreAsap` leaf — the phase the +/// The domain `child` takes on `edge`: its own produced domain +/// (checked via `accept`), or — for a `KeepPreAsap` leaf — the domain the /// edge assigns it, derived from what that edge accepts. -fn child_stage( +fn child_domain( child: &Rc, - edge: PhaseEdge, - accept: impl Fn(ExecutionAvailability) -> Result<(), PhaseError>, -) -> Result { - match produced_availability(&child.expr) { + edge: DomainEdge, + accept: impl Fn(ValueDomain) -> Result<(), DomainError>, +) -> Result { + match produced_domain(&child.expr) { Some(avail) => { accept(avail)?; Ok(avail) } None => { - // A raw pre-ASAP subtree executes at whichever phase its consumer + // A raw pre-ASAP subtree executes at whichever domain its consumer // needs: update-path input for maintenance/transform edges, // query-time fallback for a post-process edge. State-only edges // can't consume plain rows at all. let assigned = match edge { - PhaseEdge::SummaryAggChild - | PhaseEdge::SummaryJoinInput - | PhaseEdge::UpdateTransformChild => ExecutionAvailability::UpdateValue, - PhaseEdge::ReadoutPostProcessChild => ExecutionAvailability::ReadoutValue, - PhaseEdge::SummaryEstimateInput - | PhaseEdge::SummarySubtractInput - | PhaseEdge::SummaryDeleteInput - | PhaseEdge::SummaryMergeInput => { - return Err(PhaseError::IllegalChildPhase { + DomainEdge::SummaryAggChild + | DomainEdge::SummaryJoinInput + | DomainEdge::UpdateTransformChild => ValueDomain::MAINTENANCE_ROWS, + DomainEdge::ReadoutPostProcessChild => ValueDomain::READ_ROWS, + DomainEdge::SummaryEstimateInput + | DomainEdge::SummarySubtractInput + | DomainEdge::SummaryDeleteInput + | DomainEdge::SummaryMergeInput => { + return Err(DomainError::IllegalChildPhase { edge: edge.describe(), - child: ExecutionAvailability::UpdateValue, + child: ValueDomain::MAINTENANCE_ROWS, }) } }; @@ -425,13 +447,10 @@ fn child_stage( } } -fn state_only( - child: &Rc, - edge: PhaseEdge, -) -> Result { - child_stage(child, edge, |avail| match avail { - ExecutionAvailability::SummaryState => Ok(()), - other => Err(PhaseError::IllegalChildPhase { +fn state_only(child: &Rc, edge: DomainEdge) -> Result { + child_domain(child, edge, |avail| match avail { + ValueDomain::MAINTENANCE_SUMMARY => Ok(()), + other => Err(DomainError::IllegalChildPhase { edge: edge.describe(), child: other, }), @@ -441,7 +460,7 @@ fn state_only( /// The exact operator must consume only `Plain` columns of its input: for /// an `Aggregate` payload, every grouping key and every measure's input /// column. -fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), PhaseError> { +fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), DomainError> { let ValueOperator::Exact(op) = op else { return check_all_plain(input); }; @@ -467,7 +486,7 @@ fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), continue; } if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { - return Err(PhaseError::NonPlainOperand { + return Err(DomainError::NonPlainOperand { column: field.name.clone(), dtype: format!("{:?}", field.dtype), }); @@ -476,10 +495,10 @@ fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), Ok(()) } -fn check_all_plain(input: &SummarySchema) -> Result<(), PhaseError> { +fn check_all_plain(input: &SummarySchema) -> Result<(), DomainError> { for field in &input.fields { if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { - return Err(PhaseError::NonPlainOperand { + return Err(DomainError::NonPlainOperand { column: field.name.clone(), dtype: format!("{:?}", field.dtype), }); @@ -654,14 +673,14 @@ mod tests { fn keep_pre_asap_under_summary_agg_is_update_input() { let leaf = keep(); let root = agg(Rc::clone(&leaf), kll()); - let assignment = validate_execution_phases(&root).unwrap(); + let assignment = validate_execution_domains(&root).unwrap(); assert_eq!( - assignment.stage_of(&leaf), - Some(ExecutionAvailability::UpdateValue) + assignment.domain_of(&leaf), + Some(ValueDomain::MAINTENANCE_ROWS) ); assert_eq!( - assignment.stage_of(&root), - Some(ExecutionAvailability::SummaryState) + assignment.domain_of(&root), + Some(ValueDomain::MAINTENANCE_SUMMARY) ); } @@ -672,7 +691,7 @@ mod tests { SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), ); let root = estimate(agg(inner, kll())); - assert!(validate_execution_phases(&root).is_ok()); + assert!(validate_execution_domains(&root).is_ok()); } #[test] @@ -680,8 +699,8 @@ mod tests { let inner = estimate(agg(keep(), kll())); let root = agg(inner, kll()); assert!(matches!( - validate_execution_phases(&root), - Err(PhaseError::ReadoutUnderMaintenance { .. }) + validate_execution_domains(&root), + Err(DomainError::ReadoutUnderMaintenance { .. }) )); } @@ -696,15 +715,12 @@ mod tests { schema: plain(&["max"]), guarantee: None, }); - let assignment = validate_execution_phases(&root).unwrap(); - assert_eq!( - assignment.stage_of(&root), - Some(ExecutionAvailability::ReadoutValue) - ); + let assignment = validate_execution_domains(&root).unwrap(); + assert_eq!(assignment.domain_of(&root), Some(ValueDomain::READ_ROWS)); } #[test] - fn non_exact_operator_uses_the_same_readout_phase_contract() { + fn non_exact_operator_uses_the_same_read_domain_contract() { let inner = estimate(agg(keep(), kll())); let root = Rc::new(SummaryNode { expr: SummaryExpr::ReadoutPostProcess { @@ -717,11 +733,8 @@ mod tests { guarantee: None, }); - let assignment = validate_execution_phases(&root).unwrap(); - assert_eq!( - assignment.stage_of(&root), - Some(ExecutionAvailability::ReadoutValue) - ); + let assignment = validate_execution_domains(&root).unwrap(); + assert_eq!(assignment.domain_of(&root), Some(ValueDomain::READ_ROWS)); } #[test] @@ -737,10 +750,10 @@ mod tests { }); let root = agg(post, kll()); assert_eq!( - validate_execution_phases(&root).err(), - Some(PhaseError::ReadoutUnderMaintenance { + validate_execution_domains(&root).err(), + Some(DomainError::ReadoutUnderMaintenance { edge: "SummaryAgg.child", - child: ExecutionAvailability::ReadoutValue, + child: ValueDomain::READ_ROWS, }) ); } @@ -756,14 +769,14 @@ mod tests { guarantee: None, }); assert_eq!( - validate_execution_phases(&transform).err(), - Some(PhaseError::UpdateValueAtRoot) + validate_execution_domains(&transform).err(), + Some(DomainError::MaintenanceRowsAtRoot) ); let root = estimate(agg(Rc::clone(&transform), kll())); - let assignment = validate_execution_phases(&root).unwrap(); + let assignment = validate_execution_domains(&root).unwrap(); assert_eq!( - assignment.stage_of(&transform), - Some(ExecutionAvailability::UpdateValue) + assignment.domain_of(&transform), + Some(ValueDomain::MAINTENANCE_ROWS) ); } @@ -780,16 +793,16 @@ mod tests { }); let root = agg(transform, kll()); assert!(matches!( - validate_execution_phases(&root), - Err(PhaseError::IllegalChildPhase { + validate_execution_domains(&root), + Err(DomainError::IllegalChildPhase { edge: "UpdateTransform.child", - child: ExecutionAvailability::ReadoutValue + child: ValueDomain::READ_ROWS }) )); } #[test] - fn a_shared_keep_pre_asap_reached_at_two_phases_is_ambiguous() { + fn a_shared_keep_pre_asap_reached_in_two_domains_is_ambiguous() { // One raw subtree used both as update input (under a SummaryAgg) and // as a query-time fallback (under an ExactPostProcess) — no single // execution can serve both, so the plan is rejected. @@ -822,20 +835,16 @@ mod tests { }); // SummaryMerge only accepts state, so this fails earlier for a // different reason; probe the ambiguity through a direct visit. - let mut assignment = PhaseAssignment::default(); - visit(&shared, ExecutionAvailability::UpdateValue, &mut assignment).unwrap(); + let mut assignment = DomainAssignment::default(); + visit(&shared, ValueDomain::MAINTENANCE_ROWS, &mut assignment).unwrap(); assert_eq!( - visit( - &shared, - ExecutionAvailability::ReadoutValue, - &mut assignment - ), - Err(PhaseError::AmbiguousKeepPreAsap { - first: ExecutionAvailability::UpdateValue, - second: ExecutionAvailability::ReadoutValue, + visit(&shared, ValueDomain::READ_ROWS, &mut assignment), + Err(DomainError::AmbiguousKeepPreAsap { + first: ValueDomain::MAINTENANCE_ROWS, + second: ValueDomain::READ_ROWS, }) ); - assert!(validate_execution_phases(&root).is_err()); + assert!(validate_execution_domains(&root).is_err()); } #[test] From 9db14098e92d3284bc18c10dd0536e3fdcc74775 Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 12:28:26 -0600 Subject: [PATCH 03/15] refactor(post-asap): rename value domain state type --- crates/types/src/dag_export.rs | 18 ++-- crates/types/src/post_asap/expr.rs | 2 +- crates/types/src/post_asap/mod.rs | 2 +- crates/types/src/post_asap/value_domain.rs | 109 ++++++++++++--------- 4 files changed, 77 insertions(+), 54 deletions(-) diff --git a/crates/types/src/dag_export.rs b/crates/types/src/dag_export.rs index d2a7b24d..b256fec3 100644 --- a/crates/types/src/dag_export.rs +++ b/crates/types/src/dag_export.rs @@ -47,8 +47,8 @@ use std::rc::Rc; use serde::Serialize; use crate::post_asap::{ - assigned_child_domain, produced_domain, AccuracyError, ExactOperator, ResultGuarantee, - SummaryExpr, SummaryNode, ValueDomain, ValueOperator, + assigned_child_domain, produced_domain, AccuracyError, ExactOperator, ExecutionDataState, + ResultGuarantee, SummaryExpr, SummaryNode, ValueOperator, }; use crate::pre_asap::cse::{structural_hash, HashCache}; use crate::pre_asap::query_expr::{QueryExpr, Source}; @@ -367,8 +367,8 @@ pub fn export_summary(node: &SummaryNode) -> SummaryDagGraph { /// produced domain, or query-time readout for a bare `KeepPreAsap` /// (the same convention `post_asap::value_domain::validate_execution_domains` /// uses for a root). -fn root_domain(node: &SummaryNode) -> ValueDomain { - produced_domain(&node.expr).unwrap_or(ValueDomain::READ_ROWS) +fn root_domain(node: &SummaryNode) -> ExecutionDataState { + produced_domain(&node.expr).unwrap_or(ExecutionDataState::READ_ROWS) } /// `detail` for an [`ExactOperator`] payload — its own fields, rendered the @@ -472,7 +472,7 @@ fn family_label(family: &crate::post_asap::SummaryFamilyType) -> String { /// reads it rather than inferring it from the node's kind. fn summary_shape( expr: &SummaryExpr, - domain: ValueDomain, + domain: ExecutionDataState, ) -> (&'static str, String, serde_json::Value) { let (kind, label, mut detail) = match expr { SummaryExpr::KeepPreAsap(_) => { @@ -566,7 +566,11 @@ fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { /// post-order (children pushed before their parent), and return the pushed /// root's id. Exhaustive over every [`SummaryExpr`] variant, matching this /// file's own exhaustive style for `QueryExpr` in [`build`]. -fn build_summary(node: &SummaryNode, nodes: &mut Vec, domain: ValueDomain) -> u32 { +fn build_summary( + node: &SummaryNode, + nodes: &mut Vec, + domain: ExecutionDataState, +) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { let pre_asap_subgraph = export(inner); let inner_kind = pre_asap_subgraph.nodes[pre_asap_subgraph.root as usize].kind; @@ -869,7 +873,7 @@ fn build_summary_hybrid( nodes: &mut Vec, cache: &mut HashCache, find_winner: &mut dyn FnMut(&QueryExpr) -> Option, - domain: ValueDomain, + domain: ExecutionDataState, ) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { return build(inner, nodes, cache, find_winner); diff --git a/crates/types/src/post_asap/expr.rs b/crates/types/src/post_asap/expr.rs index aedd0ba8..1f8dc920 100644 --- a/crates/types/src/post_asap/expr.rs +++ b/crates/types/src/post_asap/expr.rs @@ -192,7 +192,7 @@ pub enum SummaryExpr { /// produces plain update values, so its output may feed a downstream /// [`SummaryAgg`](SummaryExpr::SummaryAgg)'s maintenance — the "outer /// summary over an inner non-accumulator exact transform" direction. - /// See [`super::value_domain::ValueDomain`] for the edge contract. + /// See [`super::value_domain::ExecutionDataState`] for the edge contract. UpdateTransform { child: Rc, op: ValueOperator, diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index 5ac2864b..443e69b4 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -52,5 +52,5 @@ pub use sketch::{ pub use value_domain::{ assigned_child_domain, exact_operator_output_schema, produced_domain, validate_execution_domains, validate_execution_domains_at, DataPrimitive, DomainAssignment, - DomainError, ExactOperatorSchemaError, ExecutionTiming, ValueDomain, + DomainError, ExactOperatorSchemaError, ExecutionDataState, ExecutionTiming, }; diff --git a/crates/types/src/post_asap/value_domain.rs b/crates/types/src/post_asap/value_domain.rs index c65d705b..a6363ba7 100644 --- a/crates/types/src/post_asap/value_domain.rs +++ b/crates/types/src/post_asap/value_domain.rs @@ -10,7 +10,7 @@ //! why it and [`SummaryExpr::UpdateTransform`] are two separate variants //! rather than one domain-ambiguous value operation. //! -//! [`ValueDomain`] is what a node's output *is*, at which domain; +//! [`ExecutionDataState`] is what a node's output *is*, at which domain; //! [`validate_execution_domains`] checks every edge of a DAG against the //! rules below at plan construction, returning a typed [`DomainError`] rather //! than deferring to a runtime failure. @@ -85,12 +85,12 @@ impl DataPrimitive { /// The two-dimensional edge contract: when a value exists and which data /// primitive it carries. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ValueDomain { +pub struct ExecutionDataState { pub timing: ExecutionTiming, pub primitive: DataPrimitive, } -impl ValueDomain { +impl ExecutionDataState { pub const MAINTENANCE_ROWS: Self = Self { timing: ExecutionTiming::MaintenanceTime, primitive: DataPrimitive::Rows, @@ -105,7 +105,7 @@ impl ValueDomain { }; } -impl std::fmt::Display for ValueDomain { +impl std::fmt::Display for ExecutionDataState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}/{}", self.timing.as_str(), self.primitive.as_str()) } @@ -155,7 +155,7 @@ pub enum DomainError { )] ReadoutUnderMaintenance { edge: &'static str, - child: ValueDomain, + child: ExecutionDataState, }, /// Any other edge whose child domain the parent does not accept /// (e.g. plain update rows fed straight into a `SummaryEstimate`, or a @@ -163,7 +163,7 @@ pub enum DomainError { #[error("{edge} does not accept a {child} input")] IllegalChildPhase { edge: &'static str, - child: ValueDomain, + child: ExecutionDataState, }, /// A `SummaryAgg` whose child is summary state of a family other than an /// exact accumulator — re-accumulating opaque sketch/sample/… state on @@ -180,8 +180,8 @@ pub enum DomainError { plan" )] AmbiguousKeepPreAsap { - first: ValueDomain, - second: ValueDomain, + first: ExecutionDataState, + second: ExecutionDataState, }, /// An update-path-only node (`UpdateTransform`) at the root of a plan: /// nothing maintains state above it, so its output is never read. @@ -200,18 +200,18 @@ pub enum DomainError { /// `KeepPreAsap` leaf it is the domain the reaching edge assigned. #[derive(Debug, Clone, Default)] pub struct DomainAssignment { - domains: HashMap<*const SummaryNode, ValueDomain>, + domains: HashMap<*const SummaryNode, ExecutionDataState>, } impl DomainAssignment { /// The domain assigned to `node`, if it was part of the validated plan. - pub fn domain_of(&self, node: &Rc) -> Option { + pub fn domain_of(&self, node: &Rc) -> Option { self.domains.get(&Rc::as_ptr(node)).copied() } /// The domain assigned to the node at `ptr` — for callers walking a plan /// by reference rather than by `Rc`. - pub fn domain_of_ptr(&self, ptr: *const SummaryNode) -> Option { + pub fn domain_of_ptr(&self, ptr: *const SummaryNode) -> Option { self.domains.get(&ptr).copied() } } @@ -219,18 +219,18 @@ impl DomainAssignment { /// The domain `expr` *produces*, independent of context — `None` for /// [`SummaryExpr::KeepPreAsap`], whose domain is assigned by the edge reaching /// it (see the module docs). -pub fn produced_domain(expr: &SummaryExpr) -> Option { +pub fn produced_domain(expr: &SummaryExpr) -> Option { Some(match expr { SummaryExpr::KeepPreAsap(_) => return None, SummaryExpr::SummaryAgg { .. } | SummaryExpr::SummaryJoin { .. } | SummaryExpr::SummarySubtract { .. } | SummaryExpr::SummaryDelete { .. } - | SummaryExpr::SummaryMerge { .. } => ValueDomain::MAINTENANCE_SUMMARY, + | SummaryExpr::SummaryMerge { .. } => ExecutionDataState::MAINTENANCE_SUMMARY, SummaryExpr::SummaryEstimate { .. } | SummaryExpr::ReadoutPostProcess { .. } => { - ValueDomain::READ_ROWS + ExecutionDataState::READ_ROWS } - SummaryExpr::UpdateTransform { .. } => ValueDomain::MAINTENANCE_ROWS, + SummaryExpr::UpdateTransform { .. } => ExecutionDataState::MAINTENANCE_ROWS, }) } @@ -260,8 +260,10 @@ pub fn validate_execution_domains(root: &Rc) -> Result ValueDomain::READ_ROWS, - Some(ValueDomain::MAINTENANCE_ROWS) => return Err(DomainError::MaintenanceRowsAtRoot), + None => ExecutionDataState::READ_ROWS, + Some(ExecutionDataState::MAINTENANCE_ROWS) => { + return Err(DomainError::MaintenanceRowsAtRoot) + } Some(domain) => domain, }; validate_execution_domains_at(root, root_domain) @@ -274,7 +276,7 @@ pub fn validate_execution_domains(root: &Rc) -> Result, - domain: ValueDomain, + domain: ExecutionDataState, ) -> Result { let mut assignment = DomainAssignment::default(); visit(root, domain, &mut assignment)?; @@ -285,7 +287,7 @@ pub fn validate_execution_domains_at( /// for a `KeepPreAsap`), then check and recurse into every child edge. fn visit( node: &Rc, - domain: ValueDomain, + domain: ExecutionDataState, assignment: &mut DomainAssignment, ) -> Result<(), DomainError> { let ptr = Rc::as_ptr(node); @@ -306,8 +308,10 @@ fn visit( SummaryExpr::SummaryAgg { child, .. } => { let child_domain = child_domain(child, DomainEdge::SummaryAggChild, |avail| match avail { - ValueDomain::MAINTENANCE_ROWS => Ok(()), - ValueDomain::MAINTENANCE_SUMMARY => is_exact_accumulator_state(&child.schema), + ExecutionDataState::MAINTENANCE_ROWS => Ok(()), + ExecutionDataState::MAINTENANCE_SUMMARY => { + is_exact_accumulator_state(&child.schema) + } other => Err(DomainError::ReadoutUnderMaintenance { edge: DomainEdge::SummaryAggChild.describe(), child: other, @@ -318,7 +322,8 @@ fn visit( SummaryExpr::SummaryJoin { outer, inner, .. } => { for input in [outer, inner] { let s = child_domain(input, DomainEdge::SummaryJoinInput, |avail| match avail { - ValueDomain::MAINTENANCE_ROWS | ValueDomain::MAINTENANCE_SUMMARY => Ok(()), + ExecutionDataState::MAINTENANCE_ROWS + | ExecutionDataState::MAINTENANCE_SUMMARY => Ok(()), other => Err(DomainError::ReadoutUnderMaintenance { edge: DomainEdge::SummaryJoinInput.describe(), child: other, @@ -355,7 +360,7 @@ fn visit( child, DomainEdge::UpdateTransformChild, |avail| match avail { - ValueDomain::MAINTENANCE_ROWS => Ok(()), + ExecutionDataState::MAINTENANCE_ROWS => Ok(()), other => Err(DomainError::IllegalChildPhase { edge: DomainEdge::UpdateTransformChild.describe(), child: other, @@ -370,7 +375,7 @@ fn visit( child, DomainEdge::ReadoutPostProcessChild, |avail| match avail { - ValueDomain::READ_ROWS => Ok(()), + ExecutionDataState::READ_ROWS => Ok(()), other => Err(DomainError::IllegalChildPhase { edge: DomainEdge::ReadoutPostProcessChild.describe(), child: other, @@ -391,12 +396,12 @@ fn visit( /// under a state-only edge). For DAG export and other reporting that needs /// an explicit per-node domain even on a plan that /// [`validate_execution_domains`] would reject. -pub fn assigned_child_domain(parent: &SummaryExpr, child: &SummaryNode) -> ValueDomain { +pub fn assigned_child_domain(parent: &SummaryExpr, child: &SummaryNode) -> ExecutionDataState { if let Some(avail) = produced_domain(&child.expr) { return avail; } match parent { - SummaryExpr::ReadoutPostProcess { .. } => ValueDomain::READ_ROWS, + SummaryExpr::ReadoutPostProcess { .. } => ExecutionDataState::READ_ROWS, SummaryExpr::KeepPreAsap(_) | SummaryExpr::SummaryAgg { .. } | SummaryExpr::SummaryJoin { .. } @@ -404,7 +409,7 @@ pub fn assigned_child_domain(parent: &SummaryExpr, child: &SummaryNode) -> Value | SummaryExpr::SummaryDelete { .. } | SummaryExpr::SummaryEstimate { .. } | SummaryExpr::SummaryMerge { .. } - | SummaryExpr::UpdateTransform { .. } => ValueDomain::MAINTENANCE_ROWS, + | SummaryExpr::UpdateTransform { .. } => ExecutionDataState::MAINTENANCE_ROWS, } } @@ -414,8 +419,8 @@ pub fn assigned_child_domain(parent: &SummaryExpr, child: &SummaryNode) -> Value fn child_domain( child: &Rc, edge: DomainEdge, - accept: impl Fn(ValueDomain) -> Result<(), DomainError>, -) -> Result { + accept: impl Fn(ExecutionDataState) -> Result<(), DomainError>, +) -> Result { match produced_domain(&child.expr) { Some(avail) => { accept(avail)?; @@ -429,15 +434,15 @@ fn child_domain( let assigned = match edge { DomainEdge::SummaryAggChild | DomainEdge::SummaryJoinInput - | DomainEdge::UpdateTransformChild => ValueDomain::MAINTENANCE_ROWS, - DomainEdge::ReadoutPostProcessChild => ValueDomain::READ_ROWS, + | DomainEdge::UpdateTransformChild => ExecutionDataState::MAINTENANCE_ROWS, + DomainEdge::ReadoutPostProcessChild => ExecutionDataState::READ_ROWS, DomainEdge::SummaryEstimateInput | DomainEdge::SummarySubtractInput | DomainEdge::SummaryDeleteInput | DomainEdge::SummaryMergeInput => { return Err(DomainError::IllegalChildPhase { edge: edge.describe(), - child: ValueDomain::MAINTENANCE_ROWS, + child: ExecutionDataState::MAINTENANCE_ROWS, }) } }; @@ -447,9 +452,12 @@ fn child_domain( } } -fn state_only(child: &Rc, edge: DomainEdge) -> Result { +fn state_only( + child: &Rc, + edge: DomainEdge, +) -> Result { child_domain(child, edge, |avail| match avail { - ValueDomain::MAINTENANCE_SUMMARY => Ok(()), + ExecutionDataState::MAINTENANCE_SUMMARY => Ok(()), other => Err(DomainError::IllegalChildPhase { edge: edge.describe(), child: other, @@ -676,11 +684,11 @@ mod tests { let assignment = validate_execution_domains(&root).unwrap(); assert_eq!( assignment.domain_of(&leaf), - Some(ValueDomain::MAINTENANCE_ROWS) + Some(ExecutionDataState::MAINTENANCE_ROWS) ); assert_eq!( assignment.domain_of(&root), - Some(ValueDomain::MAINTENANCE_SUMMARY) + Some(ExecutionDataState::MAINTENANCE_SUMMARY) ); } @@ -716,7 +724,10 @@ mod tests { guarantee: None, }); let assignment = validate_execution_domains(&root).unwrap(); - assert_eq!(assignment.domain_of(&root), Some(ValueDomain::READ_ROWS)); + assert_eq!( + assignment.domain_of(&root), + Some(ExecutionDataState::READ_ROWS) + ); } #[test] @@ -734,7 +745,10 @@ mod tests { }); let assignment = validate_execution_domains(&root).unwrap(); - assert_eq!(assignment.domain_of(&root), Some(ValueDomain::READ_ROWS)); + assert_eq!( + assignment.domain_of(&root), + Some(ExecutionDataState::READ_ROWS) + ); } #[test] @@ -753,7 +767,7 @@ mod tests { validate_execution_domains(&root).err(), Some(DomainError::ReadoutUnderMaintenance { edge: "SummaryAgg.child", - child: ValueDomain::READ_ROWS, + child: ExecutionDataState::READ_ROWS, }) ); } @@ -776,7 +790,7 @@ mod tests { let assignment = validate_execution_domains(&root).unwrap(); assert_eq!( assignment.domain_of(&transform), - Some(ValueDomain::MAINTENANCE_ROWS) + Some(ExecutionDataState::MAINTENANCE_ROWS) ); } @@ -796,7 +810,7 @@ mod tests { validate_execution_domains(&root), Err(DomainError::IllegalChildPhase { edge: "UpdateTransform.child", - child: ValueDomain::READ_ROWS + child: ExecutionDataState::READ_ROWS }) )); } @@ -836,12 +850,17 @@ mod tests { // SummaryMerge only accepts state, so this fails earlier for a // different reason; probe the ambiguity through a direct visit. let mut assignment = DomainAssignment::default(); - visit(&shared, ValueDomain::MAINTENANCE_ROWS, &mut assignment).unwrap(); + visit( + &shared, + ExecutionDataState::MAINTENANCE_ROWS, + &mut assignment, + ) + .unwrap(); assert_eq!( - visit(&shared, ValueDomain::READ_ROWS, &mut assignment), + visit(&shared, ExecutionDataState::READ_ROWS, &mut assignment), Err(DomainError::AmbiguousKeepPreAsap { - first: ValueDomain::MAINTENANCE_ROWS, - second: ValueDomain::READ_ROWS, + first: ExecutionDataState::MAINTENANCE_ROWS, + second: ExecutionDataState::READ_ROWS, }) ); assert!(validate_execution_domains(&root).is_err()); From aa95a1aa4f575d5d5fc7d2c1cb8e280a95ae788a Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 12:33:41 -0600 Subject: [PATCH 04/15] refactor(post-asap): align module with execution data state --- crates/types/src/dag_export.rs | 2 +- .../{value_domain.rs => execution_data_state.rs} | 2 +- crates/types/src/post_asap/expr.rs | 4 ++-- crates/types/src/post_asap/mod.rs | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) rename crates/types/src/post_asap/{value_domain.rs => execution_data_state.rs} (99%) diff --git a/crates/types/src/dag_export.rs b/crates/types/src/dag_export.rs index b256fec3..5c1ee4ad 100644 --- a/crates/types/src/dag_export.rs +++ b/crates/types/src/dag_export.rs @@ -365,7 +365,7 @@ pub fn export_summary(node: &SummaryNode) -> SummaryDagGraph { /// The explicit execution domain of an exported plan's root — its own /// produced domain, or query-time readout for a bare `KeepPreAsap` -/// (the same convention `post_asap::value_domain::validate_execution_domains` +/// (the same convention `post_asap::execution_data_state::validate_execution_domains` /// uses for a root). fn root_domain(node: &SummaryNode) -> ExecutionDataState { produced_domain(&node.expr).unwrap_or(ExecutionDataState::READ_ROWS) diff --git a/crates/types/src/post_asap/value_domain.rs b/crates/types/src/post_asap/execution_data_state.rs similarity index 99% rename from crates/types/src/post_asap/value_domain.rs rename to crates/types/src/post_asap/execution_data_state.rs index a6363ba7..d0ca6fcd 100644 --- a/crates/types/src/post_asap/value_domain.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -1,4 +1,4 @@ -//! Execution-domain contract for mixed exact/summary plans (issue #171). +//! Execution-data-state contract for mixed exact/summary plans (issue #171). //! //! A post-ASAP DAG mixes two very different moments of execution: the //! **update/ingest path** (rows arrive, maintained summary state is updated) diff --git a/crates/types/src/post_asap/expr.rs b/crates/types/src/post_asap/expr.rs index 1f8dc920..7f95901f 100644 --- a/crates/types/src/post_asap/expr.rs +++ b/crates/types/src/post_asap/expr.rs @@ -192,7 +192,7 @@ pub enum SummaryExpr { /// produces plain update values, so its output may feed a downstream /// [`SummaryAgg`](SummaryExpr::SummaryAgg)'s maintenance — the "outer /// summary over an inner non-accumulator exact transform" direction. - /// See [`super::value_domain::ExecutionDataState`] for the edge contract. + /// See [`super::execution_data_state::ExecutionDataState`] for the edge contract. UpdateTransform { child: Rc, op: ValueOperator, @@ -203,7 +203,7 @@ pub enum SummaryExpr { /// final plain query result — the "outer exact fold over an inner /// summary readout" direction. Can never feed maintained state: a /// `SummaryAgg` above one of these is a plan-time - /// [`super::value_domain::DomainError`], never a runtime failure. + /// [`super::execution_data_state::DomainError`], never a runtime failure. ReadoutPostProcess { child: Rc, op: ValueOperator, diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index 443e69b4..49730550 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -27,13 +27,18 @@ //! alongside `reduction` and on sketch-valued edge types //! — see `asap_aware_mapping::grouping`'s module docs for why. +pub mod execution_data_state; pub mod expr; pub mod guarantee; pub mod query_time; pub mod schema; pub mod sketch; -pub mod value_domain; +pub use execution_data_state::{ + assigned_child_domain, exact_operator_output_schema, produced_domain, + validate_execution_domains, validate_execution_domains_at, DataPrimitive, DomainAssignment, + DomainError, ExactOperatorSchemaError, ExecutionDataState, ExecutionTiming, +}; pub use expr::{ExactOperator, SummaryExpr, SummaryNode, ValueOperator}; pub use guarantee::{ AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, GuaranteeSource, ProbabilityExpr, @@ -49,8 +54,3 @@ pub use sketch::{ HydraParams, SamplingKind, SamplingParams, SketchAlgorithm, SketchCategory, SketchKind, SketchParams, SketchQuery, StatModelKind, StatModelParams, WaveletKind, WaveletParams, }; -pub use value_domain::{ - assigned_child_domain, exact_operator_output_schema, produced_domain, - validate_execution_domains, validate_execution_domains_at, DataPrimitive, DomainAssignment, - DomainError, ExactOperatorSchemaError, ExecutionDataState, ExecutionTiming, -}; From 6679f971d75f374110d524e1ef337c16aae63f3f Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 12:45:06 -0600 Subject: [PATCH 05/15] fix(devtools): initialize DAG decision metadata --- crates/devtools/src/bin/dag_export.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index 071c4a40..afb910e5 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -514,6 +514,9 @@ fn run_post_asap_with_progress( rank: 0, cost: winner.cost, role: "replacement_region", + provenance: None, + cost_unit: None, + child_decisions: Vec::new(), }; Some(match &winners[i].candidate.replacement { Replacement::Rewrite(rc) => PostAsapSubstitution::Rewrite { From 1b967269cd6c45c4d424fb107e0a4a6d0513574d Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 13:02:14 -0600 Subject: [PATCH 06/15] refactor(post-asap): use execution data state terminology --- crates/types/src/dag_export.rs | 48 +-- .../src/post_asap/execution_data_state.rs | 276 +++++++++--------- crates/types/src/post_asap/expr.rs | 8 +- crates/types/src/post_asap/mod.rs | 7 +- 4 files changed, 176 insertions(+), 163 deletions(-) diff --git a/crates/types/src/dag_export.rs b/crates/types/src/dag_export.rs index 5c1ee4ad..3672c658 100644 --- a/crates/types/src/dag_export.rs +++ b/crates/types/src/dag_export.rs @@ -14,7 +14,7 @@ //! //! This is literally the same hashing //! [`share_common_subtrees`](crate::pre_asap::cse::share_common_subtrees) -//! uses to bucket candidates in its `InternTable` (issue #223 domain 3) — not +//! uses to bucket candidates in its `InternTable` (issue #223 data_state 3) — not //! a parallel reimplementation. `tools/dag-viewer`'s "shared subtree" //! highlighting is still a *proxy* for real CSE, though: a hash match here //! only means two nodes are legal `InternTable` bucket-mates (same coarse @@ -47,8 +47,8 @@ use std::rc::Rc; use serde::Serialize; use crate::post_asap::{ - assigned_child_domain, produced_domain, AccuracyError, ExactOperator, ExecutionDataState, - ResultGuarantee, SummaryExpr, SummaryNode, ValueOperator, + assigned_child_data_state, produced_data_state, AccuracyError, ExactOperator, + ExecutionDataState, ResultGuarantee, SummaryExpr, SummaryNode, ValueOperator, }; use crate::pre_asap::cse::{structural_hash, HashCache}; use crate::pre_asap::query_expr::{QueryExpr, Source}; @@ -363,12 +363,12 @@ pub fn export_summary(node: &SummaryNode) -> SummaryDagGraph { SummaryDagGraph { nodes, root } } -/// The explicit execution domain of an exported plan's root — its own -/// produced domain, or query-time readout for a bare `KeepPreAsap` -/// (the same convention `post_asap::execution_data_state::validate_execution_domains` +/// The explicit execution data_state of an exported plan's root — its own +/// produced data_state, or query-time readout for a bare `KeepPreAsap` +/// (the same convention `post_asap::execution_data_state::validate_execution_data_states` /// uses for a root). fn root_domain(node: &SummaryNode) -> ExecutionDataState { - produced_domain(&node.expr).unwrap_or(ExecutionDataState::READ_ROWS) + produced_data_state(&node.expr).unwrap_or(ExecutionDataState::READ_ROWS) } /// `detail` for an [`ExactOperator`] payload — its own fields, rendered the @@ -466,13 +466,13 @@ fn family_label(family: &crate::post_asap::SummaryFamilyType) -> String { /// apart on how every *other* variant's own shape is described, since /// nothing about that description differs between the two. /// -/// `domain` is the node's explicit execution domain (issue #171) — its own -/// [`produced_domain`], or the edge-assigned domain for a `KeepPreAsap` -/// — and is written into `detail.domain` on every post-ASAP node so a viewer +/// `data_state` is the node's explicit execution data_state (issue #171) — its own +/// [`produced_data_state`], or the edge-assigned data_state for a `KeepPreAsap` +/// — and is written into `detail.execution_data_state` on every post-ASAP node so a viewer /// reads it rather than inferring it from the node's kind. fn summary_shape( expr: &SummaryExpr, - domain: ExecutionDataState, + data_state: ExecutionDataState, ) -> (&'static str, String, serde_json::Value) { let (kind, label, mut detail) = match expr { SummaryExpr::KeepPreAsap(_) => { @@ -531,10 +531,10 @@ fn summary_shape( }; if let serde_json::Value::Object(map) = &mut detail { map.insert( - "domain".into(), + "execution_data_state".into(), serde_json::json!({ - "timing": domain.timing.as_str(), - "primitive": domain.primitive.as_str(), + "timing": data_state.timing.as_str(), + "primitive": data_state.primitive.as_str(), }), ); } @@ -569,7 +569,7 @@ fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { fn build_summary( node: &SummaryNode, nodes: &mut Vec, - domain: ExecutionDataState, + data_state: ExecutionDataState, ) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { let pre_asap_subgraph = export(inner); @@ -577,9 +577,9 @@ fn build_summary( let label = format!("KeepPreAsap({inner_kind})"); let detail = serde_json::json!({ "pre_asap_subgraph": pre_asap_subgraph, - "domain": { - "timing": domain.timing.as_str(), - "primitive": domain.primitive.as_str(), + "execution_data_state": { + "timing": data_state.timing.as_str(), + "primitive": data_state.primitive.as_str(), }, }); return push_summary_node( @@ -593,9 +593,9 @@ fn build_summary( } let children: Vec = summary_children(&node.expr) .into_iter() - .map(|child| build_summary(child, nodes, assigned_child_domain(&node.expr, child))) + .map(|child| build_summary(child, nodes, assigned_child_data_state(&node.expr, child))) .collect(); - let (kind, label, detail) = summary_shape(&node.expr, domain); + let (kind, label, detail) = summary_shape(&node.expr, data_state); push_summary_node(nodes, kind, label, detail, children, node.guarantee.clone()) } @@ -873,7 +873,7 @@ fn build_summary_hybrid( nodes: &mut Vec, cache: &mut HashCache, find_winner: &mut dyn FnMut(&QueryExpr) -> Option, - domain: ExecutionDataState, + data_state: ExecutionDataState, ) -> u32 { if let SummaryExpr::KeepPreAsap(inner) = &node.expr { return build(inner, nodes, cache, find_winner); @@ -886,11 +886,11 @@ fn build_summary_hybrid( nodes, cache, find_winner, - assigned_child_domain(&node.expr, child), + assigned_child_data_state(&node.expr, child), ) }) .collect(); - let (kind, label, mut detail) = summary_shape(&node.expr, domain); + let (kind, label, mut detail) = summary_shape(&node.expr, data_state); // The merged graph's `DagNode` has no dedicated guarantee field (it is // the pre-ASAP node shape); the guarantee rides in `detail` under the // same key/shape `SummaryDagNode::guarantee` uses, additively. @@ -1542,7 +1542,7 @@ mod tests { ); } - // ── Issue #223 domain 3: dag_export's hash literally *is* cse's hash ──── + // ── Issue #223 data_state 3: dag_export's hash literally *is* cse's hash ──── #[test] fn root_hash_matches_cse_structural_hash_for_the_same_node() { diff --git a/crates/types/src/post_asap/execution_data_state.rs b/crates/types/src/post_asap/execution_data_state.rs index d0ca6fcd..417a329f 100644 --- a/crates/types/src/post_asap/execution_data_state.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -8,36 +8,36 @@ //! the maintenance loop has no readout values to feed into that summary. //! [`SummaryExpr::ReadoutPostProcess`] is exactly such a residual, which is //! why it and [`SummaryExpr::UpdateTransform`] are two separate variants -//! rather than one domain-ambiguous value operation. +//! rather than one data_state-ambiguous value operation. //! -//! [`ExecutionDataState`] is what a node's output *is*, at which domain; -//! [`validate_execution_domains`] checks every edge of a DAG against the -//! rules below at plan construction, returning a typed [`DomainError`] rather +//! [`ExecutionDataState`] is what a node's output *is*, at which data_state; +//! [`validate_execution_data_states`] checks every edge of a DAG against the +//! rules below at plan construction, returning a typed [`ExecutionDataStateError`] rather //! than deferring to a runtime failure. //! //! ## Edge rules //! //! | Parent | Accepts from `child` | //! |---|---| -//! | `SummaryAgg.child` | `MAINTENANCE_ROWS`, or `MAINTENANCE_SUMMARY` of an **exact accumulator** family. Never a read-time domain. | +//! | `SummaryAgg.child` | `MAINTENANCE_ROWS`, or `MAINTENANCE_SUMMARY` of an **exact accumulator** family. Never a read-time data_state. | //! | `SummaryEstimate.summary_input` | `MAINTENANCE_SUMMARY` (any family). Produces `READ_ROWS`. | -//! | `SummaryJoin.outer/inner` | `MAINTENANCE_ROWS` or `MAINTENANCE_SUMMARY`; never a read-time domain. | +//! | `SummaryJoin.outer/inner` | `MAINTENANCE_ROWS` or `MAINTENANCE_SUMMARY`; never a read-time data_state. | //! | `SummarySubtract`/`SummaryDelete`/`SummaryMerge` | `MAINTENANCE_SUMMARY`. | //! | `UpdateTransform.child` | `MAINTENANCE_ROWS`. Produces `MAINTENANCE_ROWS`. | //! | `ReadoutPostProcess.child` | `READ_ROWS`. Produces `READ_ROWS`. | //! -//! ## `KeepPreAsap` declares its domain through the derivation +//! ## `KeepPreAsap` declares its data_state through the derivation //! //! A [`SummaryExpr::KeepPreAsap`] leaf is a raw pre-ASAP computation that a -//! runtime can execute at either domain: as update-path raw input beneath a +//! runtime can execute at either data_state: as update-path raw input beneath a //! `SummaryAgg`/`UpdateTransform`, or as a query-time fallback beneath a -//! `ReadoutPostProcess` (or at the root). It carries no domain field of its own +//! `ReadoutPostProcess` (or at the root). It carries no data_state field of its own //! — every existing consumer pattern-matches the one-field shape — so its -//! domain is *assigned* by [`validate_execution_domains`] from the edge that -//! reaches it and reported in the returned [`DomainAssignment`]. What it may +//! data_state is *assigned* by [`validate_execution_data_states`] from the edge that +//! reaches it and reported in the returned [`ExecutionDataStateAssignment`]. What it may //! not do is stay ambiguous inside one mixed plan: the same `Rc` //! reached once as update input and once as query-time fallback is -//! [`DomainError::AmbiguousKeepPreAsap`], because no single execution of that +//! [`ExecutionDataStateError::AmbiguousKeepPreAsap`], because no single execution of that //! subtree can serve both roles. use std::collections::HashMap; @@ -111,11 +111,11 @@ impl std::fmt::Display for ExecutionDataState { } } -/// Which parent/edge a [`DomainError`] is about — the variant name of the +/// Which parent/edge a [`ExecutionDataStateError`] is about — the variant name of the /// parent `SummaryExpr` plus its field, for a message a plan author can act /// on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DomainEdge { +pub enum ExecutionDataStateEdge { SummaryAggChild, SummaryEstimateInput, SummaryJoinInput, @@ -126,7 +126,7 @@ pub enum DomainEdge { ReadoutPostProcessChild, } -impl DomainEdge { +impl ExecutionDataStateEdge { fn describe(self) -> &'static str { match self { Self::SummaryAggChild => "SummaryAgg.child", @@ -141,14 +141,14 @@ impl DomainEdge { } } -/// A plan-construction-time domain violation. Typed (not a string) so a +/// A plan-construction-time data_state violation. Typed (not a string) so a /// strategy can degrade to a conservative fallback on the specific variant /// it expects, and so tests can assert the *reason* a plan was rejected. #[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum DomainError { +pub enum ExecutionDataStateError { /// A query-time value (`SummaryEstimate` / `ReadoutPostProcess` output) /// placed beneath a maintained summary — the one shape issue #171's - /// domain split exists to make unrepresentable. + /// data_state split exists to make unrepresentable. #[error( "readout value under maintenance: {edge} received a {child} input, but a maintained \ summary can only consume update-path values (or exact accumulator state)" @@ -157,7 +157,7 @@ pub enum DomainError { edge: &'static str, child: ExecutionDataState, }, - /// Any other edge whose child domain the parent does not accept + /// Any other edge whose child data_state the parent does not accept /// (e.g. plain update rows fed straight into a `SummaryEstimate`, or a /// sketch's opaque state fed into a `ReadoutPostProcess`). #[error("{edge} does not accept a {child} input")] @@ -176,7 +176,7 @@ pub enum DomainError { /// One shared `KeepPreAsap` node reached both as update-path raw input /// and as a query-time fallback — see the module docs. #[error( - "KeepPreAsap subtree is domain-ambiguous: reached as {first} and as {second} in the same \ + "KeepPreAsap subtree is data_state-ambiguous: reached as {first} and as {second} in the same \ plan" )] AmbiguousKeepPreAsap { @@ -188,38 +188,38 @@ pub enum DomainError { #[error("UpdateTransform cannot be a plan root: its update-path output feeds nothing")] MaintenanceRowsAtRoot, /// An `ExactOperator` whose input columns are not all `Plain` at its - /// declared domain. + /// declared data_state. #[error("exact operator consumes non-plain column {column:?} ({dtype})")] NonPlainOperand { column: String, dtype: String }, } -/// The domain assigned to every node of a validated plan, keyed by -/// `Rc` pointer identity — the explicit per-node "domain" a +/// The data_state assigned to every node of a validated plan, keyed by +/// `Rc` pointer identity — the explicit per-node "execution_data_state" a /// runtime or a DAG export reads instead of re-deriving it. For every -/// non-`KeepPreAsap` node this equals [`produced_domain`]; for a -/// `KeepPreAsap` leaf it is the domain the reaching edge assigned. +/// non-`KeepPreAsap` node this equals [`produced_data_state`]; for a +/// `KeepPreAsap` leaf it is the data_state the reaching edge assigned. #[derive(Debug, Clone, Default)] -pub struct DomainAssignment { +pub struct ExecutionDataStateAssignment { domains: HashMap<*const SummaryNode, ExecutionDataState>, } -impl DomainAssignment { - /// The domain assigned to `node`, if it was part of the validated plan. - pub fn domain_of(&self, node: &Rc) -> Option { +impl ExecutionDataStateAssignment { + /// The data_state assigned to `node`, if it was part of the validated plan. + pub fn data_state_of(&self, node: &Rc) -> Option { self.domains.get(&Rc::as_ptr(node)).copied() } - /// The domain assigned to the node at `ptr` — for callers walking a plan + /// The data_state assigned to the node at `ptr` — for callers walking a plan /// by reference rather than by `Rc`. - pub fn domain_of_ptr(&self, ptr: *const SummaryNode) -> Option { + pub fn data_state_of_ptr(&self, ptr: *const SummaryNode) -> Option { self.domains.get(&ptr).copied() } } -/// The domain `expr` *produces*, independent of context — `None` for -/// [`SummaryExpr::KeepPreAsap`], whose domain is assigned by the edge reaching +/// The data_state `expr` *produces*, independent of context — `None` for +/// [`SummaryExpr::KeepPreAsap`], whose data_state is assigned by the edge reaching /// it (see the module docs). -pub fn produced_domain(expr: &SummaryExpr) -> Option { +pub fn produced_data_state(expr: &SummaryExpr) -> Option { Some(match expr { SummaryExpr::KeepPreAsap(_) => return None, SummaryExpr::SummaryAgg { .. } @@ -236,12 +236,12 @@ pub fn produced_domain(expr: &SummaryExpr) -> Option { /// Is `family` the exact-accumulator family whose partial state *is* the /// value — the one summary state a `SummaryAgg` may re-accumulate? -fn is_exact_accumulator_state(schema: &SummarySchema) -> Result<(), DomainError> { +fn is_exact_accumulator_state(schema: &SummarySchema) -> Result<(), ExecutionDataStateError> { for field in &schema.fields { match &field.dtype { SummaryFamilyType::Plain(_) | SummaryFamilyType::ExactAggregate(..) => {} other => { - return Err(DomainError::UnsupportedStateComposition { + return Err(ExecutionDataStateError::UnsupportedStateComposition { family: format!("{other:?}"), }) } @@ -251,83 +251,90 @@ fn is_exact_accumulator_state(schema: &SummarySchema) -> Result<(), DomainError> } /// Validate every edge of the DAG rooted at `root` against the module-level -/// rules, returning each node's assigned domain on success. Shared +/// rules, returning each node's assigned data_state on success. Shared /// `Rc`s are visited once per reaching edge (the assignment is /// per node, so a conflict between two edges is what -/// [`DomainError::AmbiguousKeepPreAsap`] detects). -pub fn validate_execution_domains(root: &Rc) -> Result { +/// [`ExecutionDataStateError::AmbiguousKeepPreAsap`] detects). +pub fn validate_execution_data_states( + root: &Rc, +) -> Result { // The root may be a readable value or bare maintained state (a // deployment may hand an `ExactAggregate` accumulator straight to a // consumer) — only an update-path-only root is meaningless. - let root_domain = match produced_domain(&root.expr) { + let root_domain = match produced_data_state(&root.expr) { None => ExecutionDataState::READ_ROWS, Some(ExecutionDataState::MAINTENANCE_ROWS) => { - return Err(DomainError::MaintenanceRowsAtRoot) + return Err(ExecutionDataStateError::MaintenanceRowsAtRoot) } - Some(domain) => domain, + Some(data_state) => data_state, }; - validate_execution_domains_at(root, root_domain) + validate_execution_data_states_at(root, root_domain) } -/// [`validate_execution_domains`] for a *sub*-plan whose root is known to -/// sit at `domain` — e.g. an `UpdateTransform` about to be placed beneath a +/// [`validate_execution_data_states`] for a *sub*-plan whose root is known to +/// sit at `data_state` — e.g. an `UpdateTransform` about to be placed beneath a /// `SummaryAgg`, which would be rejected as a whole-plan root but is a /// legal update-path input. Validates every edge beneath `root` exactly /// as the whole-plan entry point does. -pub fn validate_execution_domains_at( +pub fn validate_execution_data_states_at( root: &Rc, - domain: ExecutionDataState, -) -> Result { - let mut assignment = DomainAssignment::default(); - visit(root, domain, &mut assignment)?; + data_state: ExecutionDataState, +) -> Result { + let mut assignment = ExecutionDataStateAssignment::default(); + visit(root, data_state, &mut assignment)?; Ok(assignment) } -/// Record `domain` for `node` (detecting a conflicting earlier assignment +/// Record `data_state` for `node` (detecting a conflicting earlier assignment /// for a `KeepPreAsap`), then check and recurse into every child edge. fn visit( node: &Rc, - domain: ExecutionDataState, - assignment: &mut DomainAssignment, -) -> Result<(), DomainError> { + data_state: ExecutionDataState, + assignment: &mut ExecutionDataStateAssignment, +) -> Result<(), ExecutionDataStateError> { let ptr = Rc::as_ptr(node); if let Some(previous) = assignment.domains.get(&ptr) { - if *previous != domain { - return Err(DomainError::AmbiguousKeepPreAsap { + if *previous != data_state { + return Err(ExecutionDataStateError::AmbiguousKeepPreAsap { first: *previous, - second: domain, + second: data_state, }); } - // Already validated through another edge with the same domain. + // Already validated through another edge with the same data_state. return Ok(()); } - assignment.domains.insert(ptr, domain); + assignment.domains.insert(ptr, data_state); match &node.expr { SummaryExpr::KeepPreAsap(_) => Ok(()), SummaryExpr::SummaryAgg { child, .. } => { - let child_domain = - child_domain(child, DomainEdge::SummaryAggChild, |avail| match avail { + let child_domain = child_domain( + child, + ExecutionDataStateEdge::SummaryAggChild, + |avail| match avail { ExecutionDataState::MAINTENANCE_ROWS => Ok(()), ExecutionDataState::MAINTENANCE_SUMMARY => { is_exact_accumulator_state(&child.schema) } - other => Err(DomainError::ReadoutUnderMaintenance { - edge: DomainEdge::SummaryAggChild.describe(), + other => Err(ExecutionDataStateError::ReadoutUnderMaintenance { + edge: ExecutionDataStateEdge::SummaryAggChild.describe(), child: other, }), - })?; + }, + )?; visit(child, child_domain, assignment) } SummaryExpr::SummaryJoin { outer, inner, .. } => { for input in [outer, inner] { - let s = child_domain(input, DomainEdge::SummaryJoinInput, |avail| match avail { - ExecutionDataState::MAINTENANCE_ROWS - | ExecutionDataState::MAINTENANCE_SUMMARY => Ok(()), - other => Err(DomainError::ReadoutUnderMaintenance { - edge: DomainEdge::SummaryJoinInput.describe(), - child: other, - }), + let s = child_domain(input, ExecutionDataStateEdge::SummaryJoinInput, |avail| { + match avail { + ExecutionDataState::MAINTENANCE_ROWS + | ExecutionDataState::MAINTENANCE_SUMMARY => Ok(()), + other => Err(ExecutionDataStateError::ReadoutUnderMaintenance { + edge: ExecutionDataStateEdge::SummaryJoinInput.describe(), + child: other, + }), + } })?; visit(input, s, assignment)?; } @@ -335,34 +342,34 @@ fn visit( } SummaryExpr::SummarySubtract { left, right } => { for input in [left, right] { - let s = state_only(input, DomainEdge::SummarySubtractInput)?; + let s = state_only(input, ExecutionDataStateEdge::SummarySubtractInput)?; visit(input, s, assignment)?; } Ok(()) } SummaryExpr::SummaryDelete { summary_input, .. } => { - let s = state_only(summary_input, DomainEdge::SummaryDeleteInput)?; + let s = state_only(summary_input, ExecutionDataStateEdge::SummaryDeleteInput)?; visit(summary_input, s, assignment) } SummaryExpr::SummaryMerge { children } => { for input in children { - let s = state_only(input, DomainEdge::SummaryMergeInput)?; + let s = state_only(input, ExecutionDataStateEdge::SummaryMergeInput)?; visit(input, s, assignment)?; } Ok(()) } SummaryExpr::SummaryEstimate { summary_input, .. } => { - let s = state_only(summary_input, DomainEdge::SummaryEstimateInput)?; + let s = state_only(summary_input, ExecutionDataStateEdge::SummaryEstimateInput)?; visit(summary_input, s, assignment) } SummaryExpr::UpdateTransform { child, op } => { let s = child_domain( child, - DomainEdge::UpdateTransformChild, + ExecutionDataStateEdge::UpdateTransformChild, |avail| match avail { ExecutionDataState::MAINTENANCE_ROWS => Ok(()), - other => Err(DomainError::IllegalChildPhase { - edge: DomainEdge::UpdateTransformChild.describe(), + other => Err(ExecutionDataStateError::IllegalChildPhase { + edge: ExecutionDataStateEdge::UpdateTransformChild.describe(), child: other, }), }, @@ -373,11 +380,11 @@ fn visit( SummaryExpr::ReadoutPostProcess { child, op } => { let s = child_domain( child, - DomainEdge::ReadoutPostProcessChild, + ExecutionDataStateEdge::ReadoutPostProcessChild, |avail| match avail { ExecutionDataState::READ_ROWS => Ok(()), - other => Err(DomainError::IllegalChildPhase { - edge: DomainEdge::ReadoutPostProcessChild.describe(), + other => Err(ExecutionDataStateError::IllegalChildPhase { + edge: ExecutionDataStateEdge::ReadoutPostProcessChild.describe(), child: other, }), }, @@ -388,16 +395,16 @@ fn visit( } } -/// The domain `child` takes as a direct input of `parent`, without -/// validating legality — `child`'s own produced domain, or for a -/// `KeepPreAsap` leaf the domain `parent`'s edge assigns it (update-path raw +/// The data_state `child` takes as a direct input of `parent`, without +/// validating legality — `child`'s own produced data_state, or for a +/// `KeepPreAsap` leaf the data_state `parent`'s edge assigns it (update-path raw /// input under maintenance/transform edges, query-time fallback under a /// post-process, and — meaninglessly, but for a stable answer — maintenance rows /// under a state-only edge). For DAG export and other reporting that needs -/// an explicit per-node domain even on a plan that -/// [`validate_execution_domains`] would reject. -pub fn assigned_child_domain(parent: &SummaryExpr, child: &SummaryNode) -> ExecutionDataState { - if let Some(avail) = produced_domain(&child.expr) { +/// an explicit per-node data_state even on a plan that +/// [`validate_execution_data_states`] would reject. +pub fn assigned_child_data_state(parent: &SummaryExpr, child: &SummaryNode) -> ExecutionDataState { + if let Some(avail) = produced_data_state(&child.expr) { return avail; } match parent { @@ -413,34 +420,36 @@ pub fn assigned_child_domain(parent: &SummaryExpr, child: &SummaryNode) -> Execu } } -/// The domain `child` takes on `edge`: its own produced domain -/// (checked via `accept`), or — for a `KeepPreAsap` leaf — the domain the +/// The data_state `child` takes on `edge`: its own produced data_state +/// (checked via `accept`), or — for a `KeepPreAsap` leaf — the data_state the /// edge assigns it, derived from what that edge accepts. fn child_domain( child: &Rc, - edge: DomainEdge, - accept: impl Fn(ExecutionDataState) -> Result<(), DomainError>, -) -> Result { - match produced_domain(&child.expr) { + edge: ExecutionDataStateEdge, + accept: impl Fn(ExecutionDataState) -> Result<(), ExecutionDataStateError>, +) -> Result { + match produced_data_state(&child.expr) { Some(avail) => { accept(avail)?; Ok(avail) } None => { - // A raw pre-ASAP subtree executes at whichever domain its consumer + // A raw pre-ASAP subtree executes at whichever data_state its consumer // needs: update-path input for maintenance/transform edges, // query-time fallback for a post-process edge. State-only edges // can't consume plain rows at all. let assigned = match edge { - DomainEdge::SummaryAggChild - | DomainEdge::SummaryJoinInput - | DomainEdge::UpdateTransformChild => ExecutionDataState::MAINTENANCE_ROWS, - DomainEdge::ReadoutPostProcessChild => ExecutionDataState::READ_ROWS, - DomainEdge::SummaryEstimateInput - | DomainEdge::SummarySubtractInput - | DomainEdge::SummaryDeleteInput - | DomainEdge::SummaryMergeInput => { - return Err(DomainError::IllegalChildPhase { + ExecutionDataStateEdge::SummaryAggChild + | ExecutionDataStateEdge::SummaryJoinInput + | ExecutionDataStateEdge::UpdateTransformChild => { + ExecutionDataState::MAINTENANCE_ROWS + } + ExecutionDataStateEdge::ReadoutPostProcessChild => ExecutionDataState::READ_ROWS, + ExecutionDataStateEdge::SummaryEstimateInput + | ExecutionDataStateEdge::SummarySubtractInput + | ExecutionDataStateEdge::SummaryDeleteInput + | ExecutionDataStateEdge::SummaryMergeInput => { + return Err(ExecutionDataStateError::IllegalChildPhase { edge: edge.describe(), child: ExecutionDataState::MAINTENANCE_ROWS, }) @@ -454,11 +463,11 @@ fn child_domain( fn state_only( child: &Rc, - edge: DomainEdge, -) -> Result { + edge: ExecutionDataStateEdge, +) -> Result { child_domain(child, edge, |avail| match avail { ExecutionDataState::MAINTENANCE_SUMMARY => Ok(()), - other => Err(DomainError::IllegalChildPhase { + other => Err(ExecutionDataStateError::IllegalChildPhase { edge: edge.describe(), child: other, }), @@ -468,7 +477,10 @@ fn state_only( /// The exact operator must consume only `Plain` columns of its input: for /// an `Aggregate` payload, every grouping key and every measure's input /// column. -fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), DomainError> { +fn check_plain_operands( + op: &ValueOperator, + input: &SummarySchema, +) -> Result<(), ExecutionDataStateError> { let ValueOperator::Exact(op) = op else { return check_all_plain(input); }; @@ -494,7 +506,7 @@ fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), continue; } if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { - return Err(DomainError::NonPlainOperand { + return Err(ExecutionDataStateError::NonPlainOperand { column: field.name.clone(), dtype: format!("{:?}", field.dtype), }); @@ -503,10 +515,10 @@ fn check_plain_operands(op: &ValueOperator, input: &SummarySchema) -> Result<(), Ok(()) } -fn check_all_plain(input: &SummarySchema) -> Result<(), DomainError> { +fn check_all_plain(input: &SummarySchema) -> Result<(), ExecutionDataStateError> { for field in &input.fields { if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { - return Err(DomainError::NonPlainOperand { + return Err(ExecutionDataStateError::NonPlainOperand { column: field.name.clone(), dtype: format!("{:?}", field.dtype), }); @@ -681,13 +693,13 @@ mod tests { fn keep_pre_asap_under_summary_agg_is_update_input() { let leaf = keep(); let root = agg(Rc::clone(&leaf), kll()); - let assignment = validate_execution_domains(&root).unwrap(); + let assignment = validate_execution_data_states(&root).unwrap(); assert_eq!( - assignment.domain_of(&leaf), + assignment.data_state_of(&leaf), Some(ExecutionDataState::MAINTENANCE_ROWS) ); assert_eq!( - assignment.domain_of(&root), + assignment.data_state_of(&root), Some(ExecutionDataState::MAINTENANCE_SUMMARY) ); } @@ -699,7 +711,7 @@ mod tests { SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), ); let root = estimate(agg(inner, kll())); - assert!(validate_execution_domains(&root).is_ok()); + assert!(validate_execution_data_states(&root).is_ok()); } #[test] @@ -707,8 +719,8 @@ mod tests { let inner = estimate(agg(keep(), kll())); let root = agg(inner, kll()); assert!(matches!( - validate_execution_domains(&root), - Err(DomainError::ReadoutUnderMaintenance { .. }) + validate_execution_data_states(&root), + Err(ExecutionDataStateError::ReadoutUnderMaintenance { .. }) )); } @@ -723,9 +735,9 @@ mod tests { schema: plain(&["max"]), guarantee: None, }); - let assignment = validate_execution_domains(&root).unwrap(); + let assignment = validate_execution_data_states(&root).unwrap(); assert_eq!( - assignment.domain_of(&root), + assignment.data_state_of(&root), Some(ExecutionDataState::READ_ROWS) ); } @@ -744,9 +756,9 @@ mod tests { guarantee: None, }); - let assignment = validate_execution_domains(&root).unwrap(); + let assignment = validate_execution_data_states(&root).unwrap(); assert_eq!( - assignment.domain_of(&root), + assignment.data_state_of(&root), Some(ExecutionDataState::READ_ROWS) ); } @@ -764,8 +776,8 @@ mod tests { }); let root = agg(post, kll()); assert_eq!( - validate_execution_domains(&root).err(), - Some(DomainError::ReadoutUnderMaintenance { + validate_execution_data_states(&root).err(), + Some(ExecutionDataStateError::ReadoutUnderMaintenance { edge: "SummaryAgg.child", child: ExecutionDataState::READ_ROWS, }) @@ -783,13 +795,13 @@ mod tests { guarantee: None, }); assert_eq!( - validate_execution_domains(&transform).err(), - Some(DomainError::MaintenanceRowsAtRoot) + validate_execution_data_states(&transform).err(), + Some(ExecutionDataStateError::MaintenanceRowsAtRoot) ); let root = estimate(agg(Rc::clone(&transform), kll())); - let assignment = validate_execution_domains(&root).unwrap(); + let assignment = validate_execution_data_states(&root).unwrap(); assert_eq!( - assignment.domain_of(&transform), + assignment.data_state_of(&transform), Some(ExecutionDataState::MAINTENANCE_ROWS) ); } @@ -807,8 +819,8 @@ mod tests { }); let root = agg(transform, kll()); assert!(matches!( - validate_execution_domains(&root), - Err(DomainError::IllegalChildPhase { + validate_execution_data_states(&root), + Err(ExecutionDataStateError::IllegalChildPhase { edge: "UpdateTransform.child", child: ExecutionDataState::READ_ROWS }) @@ -849,7 +861,7 @@ mod tests { }); // SummaryMerge only accepts state, so this fails earlier for a // different reason; probe the ambiguity through a direct visit. - let mut assignment = DomainAssignment::default(); + let mut assignment = ExecutionDataStateAssignment::default(); visit( &shared, ExecutionDataState::MAINTENANCE_ROWS, @@ -858,12 +870,12 @@ mod tests { .unwrap(); assert_eq!( visit(&shared, ExecutionDataState::READ_ROWS, &mut assignment), - Err(DomainError::AmbiguousKeepPreAsap { + Err(ExecutionDataStateError::AmbiguousKeepPreAsap { first: ExecutionDataState::MAINTENANCE_ROWS, second: ExecutionDataState::READ_ROWS, }) ); - assert!(validate_execution_domains(&root).is_err()); + assert!(validate_execution_data_states(&root).is_err()); } #[test] diff --git a/crates/types/src/post_asap/expr.rs b/crates/types/src/post_asap/expr.rs index 7f95901f..5441e832 100644 --- a/crates/types/src/post_asap/expr.rs +++ b/crates/types/src/post_asap/expr.rs @@ -10,8 +10,8 @@ use crate::pre_asap::{ColumnRef, QueryExpr, Reduction}; // ── Exact operators composed with summary plans (issue #171) ──────────────── /// An exact, plain-row operator that a mixed exact/summary plan executes at -/// an explicit domain. Exact composition is one producer of the generic -/// [`ValueOperator`] domain payload. +/// an explicit data_state. Exact composition is one producer of the generic +/// [`ValueOperator`] data_state payload. /// /// Deliberately **not** an intact pre-ASAP [`QueryExpr`] subtree: a /// `QueryExpr`'s children are always `Rc`, so embedding one here @@ -41,7 +41,7 @@ pub enum ExactOperator { }, } -/// An operation over values at a declared execution domain. +/// An operation over values at a declared execution data_state. /// /// Phase placement is independent of whether the operation is exact or /// approximate: [`SummaryExpr::UpdateTransform`] and @@ -203,7 +203,7 @@ pub enum SummaryExpr { /// final plain query result — the "outer exact fold over an inner /// summary readout" direction. Can never feed maintained state: a /// `SummaryAgg` above one of these is a plan-time - /// [`super::execution_data_state::DomainError`], never a runtime failure. + /// [`super::execution_data_state::ExecutionDataStateError`], never a runtime failure. ReadoutPostProcess { child: Rc, op: ValueOperator, diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index 49730550..63a13b29 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -35,9 +35,10 @@ pub mod schema; pub mod sketch; pub use execution_data_state::{ - assigned_child_domain, exact_operator_output_schema, produced_domain, - validate_execution_domains, validate_execution_domains_at, DataPrimitive, DomainAssignment, - DomainError, ExactOperatorSchemaError, ExecutionDataState, ExecutionTiming, + assigned_child_data_state, exact_operator_output_schema, produced_data_state, + validate_execution_data_states, validate_execution_data_states_at, DataPrimitive, + ExactOperatorSchemaError, ExecutionDataState, ExecutionDataStateAssignment, + ExecutionDataStateError, ExecutionTiming, }; pub use expr::{ExactOperator, SummaryExpr, SummaryNode, ValueOperator}; pub use guarantee::{ From fbd4cb39341a59b6ecd41e824c05451486f770bd Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 26 Aug 2026 17:42:07 -0600 Subject: [PATCH 07/15] feat(post-asap): compose exact operators with summary plans across phase boundaries (#171) Add phase-explicit post-ASAP nodes SummaryExpr::{ExactTransform, ExactPostProcess} carrying a non-exhaustive ExactOperator::Aggregate payload (never an intact QueryExpr subtree), plus an ExecutionAvailability {UpdateValue, SummaryState, ReadoutValue} derivation/validation (post_asap::phase) returning typed PhaseErrors at construction. construct_summary_agg now validates its edge, so a maintained summary over a query-time readout falls back conservatively instead of producing an unexecutable plan. Add ExactCompositionStrategy (registered in default_strategies) proposing Replacement::ExactComposition candidates that reference the child target rather than selecting a child; PlanSpace::global_selection commits the compatible parent/child pair using the issue's cost-units-per-second formulas (postprocess/pretransform vs raw-recompute baseline), counts shared child state once, and GlobalSelection::materialize links the committed decisions into one validated DAG with shared Rc identity. Cost hooks: CostModel::mixed_execution_capabilities and exact_composition_cost_inputs (unknowns stay None, never zero; missing statistics keep KeepPreAsap). DAG export gains explicit per-node stage, decision provenance, cost unit and child-decision links (additive). Co-Authored-By: Claude Fable 5 --- crates/asap-aware-mapping/src/cost_model.rs | 395 ++++++++++ .../src/exact_composition.rs | 653 +++++++++++++++++ crates/asap-aware-mapping/src/explanation.rs | 33 + crates/asap-aware-mapping/src/lib.rs | 18 +- crates/asap-aware-mapping/src/replacement.rs | 565 +++++++++++++-- crates/asap-aware-mapping/src/rollup.rs | 2 +- crates/devtools/src/bin/dag_export.rs | 136 +++- .../tests/exact_composition.rs | 676 ++++++++++++++++++ 8 files changed, 2385 insertions(+), 93 deletions(-) create mode 100644 crates/asap-aware-mapping/src/exact_composition.rs create mode 100644 crates/integration-tests/tests/exact_composition.rs diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index b1780c8d..bb256b47 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -56,6 +56,7 @@ use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::expr_ir::ColumnRef; use asap_types::pre_asap::query_expr::QueryExpr; +use crate::exact_composition::{CompositionPhase, ExactComposition}; use crate::recurrence::{ self, Horizon, RecurrenceCostExplanation, RecurrenceError, RecurrenceProfile, }; @@ -64,6 +65,257 @@ use crate::replacement::{ TargetSubDAG, }; +// ── Recurring-cost vocabulary for mixed exact/summary plans (issue #171) ── + +/// The unit a recurring cost is expressed in. One variant today; an enum so +/// a JSON/DAG export names the unit explicitly instead of a consumer +/// assuming it, and so a future per-resource unit can be added without +/// changing every hook's signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CostUnit { + /// Abstract cost units per wall-clock second — the common currency + /// every recurring alternative (maintain-and-read vs. recompute-per-eval) + /// is compared in. + CostUnitsPerSecond, +} + +impl CostUnit { + /// Stable name for export (`"cost_units_per_second"`). + pub fn as_str(self) -> &'static str { + match self { + Self::CostUnitsPerSecond => "cost_units_per_second", + } + } +} + +/// A recurring cost in [`CostUnit::CostUnitsPerSecond`]. Distinct from the +/// unitless one-shot [`Cost`] so the two can never be added or compared by +/// accident. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct CostRate { + pub units_per_second: f64, +} + +impl CostRate { + pub const UNIT: CostUnit = CostUnit::CostUnitsPerSecond; + + /// `total_cost(H) = recurring_cost_rate * H + one_shot_cost` — the cost + /// of running this rate for a finite horizon of `horizon_seconds`, + /// plus any one-shot work (`Cost` is unitless and treated as the same + /// abstract cost unit). + pub fn total_over_horizon(self, horizon_seconds: f64, one_shot: Cost) -> f64 { + self.units_per_second * horizon_seconds + one_shot.0 + } +} + +/// How often a plan is evaluated, in evaluations per second. For a shared +/// plan serving several repeating consumers, +/// `evaluation_rate = Σ 1 / query_interval_i` — see +/// [`EvaluationRate::from_intervals`]. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct EvaluationRate { + pub per_second: f64, +} + +impl EvaluationRate { + /// `Σ 1 / interval_i` over every consumer's own evaluation interval. + /// Non-positive/non-finite intervals contribute nothing (they describe + /// no repeating consumer). Returns `None` for an empty consumer set — + /// an unknown rate stays unknown, never zero. + pub fn from_intervals(intervals: &[std::time::Duration]) -> Option { + let mut per_second = 0.0; + let mut any = false; + for interval in intervals { + let secs = interval.as_secs_f64(); + if secs.is_finite() && secs > 0.0 { + per_second += 1.0 / secs; + any = true; + } + } + any.then_some(Self { per_second }) + } +} + +/// Who produced a set of [`ExactCompositionCostInputs`], and under which +/// model version — carried into every composed decision's explanation and +/// DAG export so a reviewer can tell a deployment's measured numbers from +/// a placeholder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CostProvenance { + /// The cost model's own name (e.g. `"DefaultCostModel"`). + pub model: String, + /// The model's own version string, whatever scheme it uses. + pub version: String, +} + +/// Which mixed-execution shapes the downstream runtime can actually +/// execute (issue #171). [`crate::exact_composition::ExactCompositionStrategy`] +/// proposes an `ExactPostProcess` candidate only when +/// `exact_post_process` is set, and an `ExactTransform` candidate only +/// when `exact_update_transform` is — a runtime that cannot run an exact +/// operator on the update path must never be handed one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MixedExecutionCapabilities { + /// The runtime can apply an exact operator to summary readouts at + /// query evaluation time. + pub exact_post_process: bool, + /// The runtime can apply an exact row transform on the update path, + /// feeding its output into maintained summary state. + pub exact_update_transform: bool, +} + +impl MixedExecutionCapabilities { + /// Neither shape supported. + pub const NONE: Self = Self { + exact_post_process: false, + exact_update_transform: false, + }; + /// Both shapes supported. + pub const ALL: Self = Self { + exact_post_process: true, + exact_update_transform: true, + }; + + pub fn supports(self, phase: CompositionPhase) -> bool { + match phase { + CompositionPhase::PostProcess => self.exact_post_process, + CompositionPhase::Transform => self.exact_update_transform, + } + } +} + +/// What [`CostModel::exact_composition_cost_inputs`] is asked about: one +/// composed alternative at one site, paired with the concrete summary it +/// composes with. +#[derive(Debug, Clone, Copy)] +pub struct ExactCompositionCostRequest<'a> { + /// The pre-ASAP target the composed candidate replaces. + pub target: &'a QueryExpr, + /// The composition itself — phase, operator, child target. + pub composition: &'a ExactComposition, + /// For [`CompositionPhase::PostProcess`]: the child target's *selected* + /// summary readout candidate the exact operator consumes. For + /// [`CompositionPhase::Transform`]: the maintained summary *above* the + /// transform that consumes its output (the `SummaryAgg` this transform + /// feeds). Either way, the summary whose maintenance/read cost the + /// formula charges. + pub summary: &'a SummaryNode, + /// How many times this site actually runs once ancestors' own choices + /// are accounted for (see `PlanSpace::global_selection`). + pub effective_consumer_count: usize, +} + +/// Every input the issue #171 cost formulas need, each individually +/// optional: **an unknown stays `None` — never a zero** — so a formula +/// with a missing input yields no rate at all rather than a spuriously +/// cheap one, and global selection then keeps the conservative +/// `KeepPreAsap` behavior. A deployment model that wants defaults supplies +/// them explicitly by overriding [`CostModel::exact_composition_cost_inputs`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ExactCompositionCostInputs { + /// Exact operator cost per row it processes — per readout row for a + /// post-process, per input row for an update-path transform. + pub exact_cost_per_row: Option, + /// Rows the exact operator consumes per evaluation (post-process) or + /// per update (transform). + pub expected_input_rows: Option, + /// Rows the exact operator emits per evaluation/update. + pub expected_output_rows: Option, + /// Cost of one update to the composed-with summary's maintained state. + pub summary_maintenance_cost_per_update: Option, + /// Cost of one readout of that summary at evaluation time. + pub summary_read_cost: Option, + /// Update (ingest) events per second reaching this site. + pub update_rate: Option, + /// Evaluations per second across every consumer of this site. + pub evaluation_rate: Option, + /// Cost of one full raw recompute of the target from pre-ASAP data — + /// the `KeepPreAsap` baseline's per-evaluation cost. + pub raw_recompute_cost: Option, + pub unit: CostUnit, + pub provenance: CostProvenance, +} + +impl ExactCompositionCostInputs { + /// Every input unknown, attributed to `provenance` — what a model that + /// has no statistics for a site returns. + pub fn unknown(provenance: CostProvenance) -> Self { + Self { + exact_cost_per_row: None, + expected_input_rows: None, + expected_output_rows: None, + summary_maintenance_cost_per_update: None, + summary_read_cost: None, + update_rate: None, + evaluation_rate: None, + raw_recompute_cost: None, + unit: CostUnit::CostUnitsPerSecond, + provenance, + } + } + + /// The rate for whichever phase `phase` names — + /// [`postprocess_plan_cost_rate`] or [`pretransform_plan_cost_rate`]. + pub fn composed_plan_cost_rate(&self, phase: CompositionPhase) -> Option { + match phase { + CompositionPhase::PostProcess => postprocess_plan_cost_rate(self), + CompositionPhase::Transform => pretransform_plan_cost_rate(self), + } + } +} + +/// Outer exact post-process over a maintained summary: +/// +/// ```text +/// postprocess_plan_cost_rate = +/// update_rate * summary_maintenance_cost_per_update +/// + evaluation_rate * (summary_read_cost +/// + output_rows_per_eval * exact_postprocess_cost_per_row) +/// ``` +/// +/// `None` if any input is unknown — see [`ExactCompositionCostInputs`]. +pub fn postprocess_plan_cost_rate(inputs: &ExactCompositionCostInputs) -> Option { + let maintenance = inputs.update_rate? * inputs.summary_maintenance_cost_per_update?; + let per_eval = + inputs.summary_read_cost? + inputs.expected_output_rows? * inputs.exact_cost_per_row?; + let evaluation = inputs.evaluation_rate?.per_second * per_eval; + finite_rate(maintenance + evaluation) +} + +/// Outer maintained summary over an exact update-time transform: +/// +/// ```text +/// pretransform_plan_cost_rate = +/// update_rate * (exact_transform_cost_per_input_row +/// + summary_maintenance_cost_per_update) +/// + evaluation_rate * summary_read_cost +/// ``` +/// +/// `None` if any input is unknown — see [`ExactCompositionCostInputs`]. +pub fn pretransform_plan_cost_rate(inputs: &ExactCompositionCostInputs) -> Option { + let per_update = inputs.exact_cost_per_row? + inputs.summary_maintenance_cost_per_update?; + let maintenance = inputs.update_rate? * per_update; + let evaluation = inputs.evaluation_rate?.per_second * inputs.summary_read_cost?; + finite_rate(maintenance + evaluation) +} + +/// The raw/pre-ASAP fallback baseline: +/// +/// ```text +/// raw_recompute_cost_rate = evaluation_rate * raw_recompute_cost +/// ``` +/// +/// `None` if either input is unknown — see [`ExactCompositionCostInputs`]. +pub fn raw_recompute_cost_rate(inputs: &ExactCompositionCostInputs) -> Option { + finite_rate(inputs.evaluation_rate?.per_second * inputs.raw_recompute_cost?) +} + +fn finite_rate(units_per_second: f64) -> Option { + units_per_second + .is_finite() + .then_some(CostRate { units_per_second }) +} + /// A CSE-detected, legality-gated shared subtree with two or more consumers /// — the unit [`CostModel::cse_share_decision`] decides over. Built by /// [`PlanSpace::cost_sorted`](crate::replacement::PlanSpace::cost_sorted) @@ -504,6 +756,47 @@ pub trait CostModel { let _ = (candidate, target); f64::NAN } + + /// Which mixed exact/summary execution shapes the downstream runtime + /// advertises (issue #171). Gates candidate *generation* in + /// [`crate::exact_composition::ExactCompositionStrategy`]: a shape the + /// runtime can't execute is never proposed, so it can't be selected + /// either. + /// + /// Default: [`MixedExecutionCapabilities::ALL`]. The built-in model + /// describes no particular runtime, and leaving both shapes *visible* + /// in `PlanSpace` (for explanations and the DAG viewer) is the more + /// informative default; selection is still gated separately by + /// [`Self::exact_composition_cost_inputs`], whose default supplies no + /// statistics, so nothing is ever *committed* to under the built-in + /// model. A deployment whose runtime lacks a shape narrows this. + fn mixed_execution_capabilities(&self) -> MixedExecutionCapabilities { + MixedExecutionCapabilities::ALL + } + + /// The statistics the issue #171 recurring-cost formulas need for one + /// composed alternative — see [`ExactCompositionCostInputs`] for each + /// input and [`postprocess_plan_cost_rate`]/ + /// [`pretransform_plan_cost_rate`]/[`raw_recompute_cost_rate`] for how + /// they combine. One structured hook rather than eight scalar ones, so + /// a deployment answers them all from one place (and can attach its own + /// [`CostProvenance`]). + /// + /// Default: every input unknown ([`ExactCompositionCostInputs::unknown`]) + /// — unknown is never zero, and with no rate derivable + /// `PlanSpace::global_selection` keeps the conservative `KeepPreAsap` + /// behavior for the site. A deployment that wants defaults must supply + /// them here explicitly. + fn exact_composition_cost_inputs( + &self, + request: &ExactCompositionCostRequest<'_>, + ) -> ExactCompositionCostInputs { + let _ = request; + ExactCompositionCostInputs::unknown(CostProvenance { + model: "CostModel::exact_composition_cost_inputs (default)".into(), + version: "unknown".into(), + }) + } } fn sketch_state( @@ -661,6 +954,12 @@ impl CostModel for DefaultCostModel { (self.cse_recompute_cost(&cse) * consumer_count).0 } } + // A composed candidate is costed in cost-units-per-second by + // `PlanSpace::global_selection` against the child decision it + // is committed with — a different unit from this structural + // estimate, and unknowable here without that child. `NaN` + // keeps it from ever out-ranking a real estimate by accident. + Replacement::ExactComposition(_) => f64::NAN, } } } @@ -805,6 +1104,102 @@ mod tests { ); } + // ── Recurring-cost formulas (issue #171) ───────────────────────────── + + fn known_inputs() -> ExactCompositionCostInputs { + ExactCompositionCostInputs { + exact_cost_per_row: Some(0.1), + expected_input_rows: Some(50.0), + expected_output_rows: Some(10.0), + summary_maintenance_cost_per_update: Some(0.01), + summary_read_cost: Some(1.0), + update_rate: Some(100.0), + evaluation_rate: Some(EvaluationRate { per_second: 2.0 }), + raw_recompute_cost: Some(100.0), + unit: CostUnit::CostUnitsPerSecond, + provenance: CostProvenance { + model: "test".into(), + version: "1".into(), + }, + } + } + + #[test] + fn composition_formulas_match_the_issue_definitions() { + let inputs = known_inputs(); + // 100 * 0.01 + 2 * (1 + 10 * 0.1) = 1 + 4 = 5 + assert_eq!( + postprocess_plan_cost_rate(&inputs) + .unwrap() + .units_per_second, + 5.0 + ); + // 100 * (0.1 + 0.01) + 2 * 1 = 11 + 2 = 13 + assert!( + (pretransform_plan_cost_rate(&inputs) + .unwrap() + .units_per_second + - 13.0) + .abs() + < 1e-9 + ); + // 2 * 100 + assert_eq!( + raw_recompute_cost_rate(&inputs).unwrap().units_per_second, + 200.0 + ); + assert_eq!( + CostRate { + units_per_second: 5.0 + } + .total_over_horizon(10.0, Cost(3.0)), + 53.0 + ); + } + + #[test] + fn a_missing_input_yields_no_rate_not_zero() { + let mut inputs = known_inputs(); + inputs.summary_maintenance_cost_per_update = None; + assert_eq!(postprocess_plan_cost_rate(&inputs), None); + assert_eq!(pretransform_plan_cost_rate(&inputs), None); + // The baseline doesn't need maintenance and is still known. + assert!(raw_recompute_cost_rate(&inputs).is_some()); + let unknown = ExactCompositionCostInputs::unknown(known_inputs().provenance); + assert_eq!(raw_recompute_cost_rate(&unknown), None); + } + + #[test] + fn evaluation_rate_sums_reciprocal_intervals() { + use std::time::Duration; + let rate = + EvaluationRate::from_intervals(&[Duration::from_secs(10), Duration::from_secs(5)]) + .unwrap(); + assert!((rate.per_second - 0.3).abs() < 1e-12); + assert_eq!(EvaluationRate::from_intervals(&[]), None); + assert_eq!(EvaluationRate::from_intervals(&[Duration::ZERO]), None); + } + + #[test] + fn default_model_advertises_capabilities_but_no_statistics() { + assert_eq!( + DefaultCostModel.mixed_execution_capabilities(), + MixedExecutionCapabilities::ALL + ); + assert!(MixedExecutionCapabilities::NONE + .supports(CompositionPhase::PostProcess) + .not()); + } + + trait Not { + fn not(self) -> bool; + } + impl Not for bool { + fn not(self) -> bool { + !self + } + } + // ── CSE sharing (issue #237, #223 stage 4) ────────────────────────── use asap_types::post_asap::{ diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs new file mode 100644 index 00000000..086e65ab --- /dev/null +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -0,0 +1,653 @@ +//! [`ExactCompositionStrategy`] — composing an exact operator with a +//! summary plan across an explicit update/readout boundary (issue #171). +//! +//! ## The gap this closes +//! +//! `construct_summary_agg` already nests: a `SummaryAgg` recursively +//! realizes its child, so a KLL over an exact `Sum` accumulator, or a +//! `quantile(rate(...))` over a `Rate` accumulator, come out as one bound +//! DAG. What it cannot represent is an exact operator that is **not** +//! maintained summary state sitting next to a summary: +//! +//! - `max by (zone) (quantile_over_time(0.99, latency[5m]))` — the outer +//! `max` is an exact fold over the inner summary's *readout*. A `MinMax` +//! accumulator over that readout is phase-illegal (a maintained summary +//! can't consume query-time values — see `asap_types::post_asap::phase`), +//! and `avg` has no accumulator at all, so today either shape collapses +//! into one opaque `KeepPreAsap` that swallows the realizable inner +//! quantile. +//! - `quantile(0.99, deriv(m[5m]))` — the inner `deriv` is an exact, +//! per-sample transform with no accumulator form; the outer summary can +//! only consume it as an opaque raw `KeepPreAsap` blob today, with no +//! explicit "this row transform runs on the update path" node. +//! +//! [`SummaryExpr::ExactPostProcess`] and [`SummaryExpr::ExactTransform`] +//! are the two phase-explicit representations; this strategy is what +//! proposes them. +//! +//! ## Reference, don't select +//! +//! A composed candidate needs a child plan to compose *with* — the inner +//! quantile's own summary readout, say. This strategy deliberately does +//! **not** pick that child itself (the way `construct_summary_agg`'s +//! `realize_child` takes the head of the child's own ranking): a +//! [`Replacement::ExactComposition`] carries only the child *target* +//! (`ExactComposition::child_target`, the same `Rc` whose +//! `MemoGroup` in `PlanSpace` already holds every candidate for it). It is +//! [`PlanSpace::global_selection`](crate::replacement::PlanSpace::global_selection) +//! that commits the compatible parent/child pair — so the child's own +//! cost-model ranking, workload-wide effective consumer count, and shared +//! `Rc` identity (one inner summary serving two outer folds) all stay +//! correct, and a child that is also shared by an unrelated consumer is +//! maintained exactly once. `GlobalSelection::materialize` then links the +//! committed pair into one validated post-ASAP DAG. +//! +//! ## Proposal conditions +//! +//! A candidate is proposed only when all of these hold: +//! +//! - the target is a single-measure, `HAVING`-free exact aggregate; +//! - post-process: the child is a bindable aggregate that has at least one +//! readout-producing summary implementation (a sketch/sample/wavelet/ +//! model — the shapes a maintained accumulator can't sit above), and the +//! target's grouping keys resolve in the child's output schema; +//! transform: the target is a per-entity exact transform with no +//! accumulator form (its only implementation is `PassThrough`); +//! - the exact operator consumes only `Plain` values at its phase — checked +//! again, structurally, when the pair is composed; +//! - the plugged-in [`CostModel`] advertises the matching +//! [`MixedExecutionCapabilities`](crate::cost_model::MixedExecutionCapabilities). +//! +//! `avg` gets a post-process candidate *and* keeps +//! [`crate::rewrite::AvgToSumOverCountStrategy`]'s rewrite in the same +//! group; the cost model picks between them, nothing here hard-codes one. +//! +//! ## What this strategy never does +//! +//! - Propose an `ExactPostProcess` for a position beneath a maintained +//! summary — phase validation at composition rejects it as a typed +//! `ImplementError` regardless. +//! - Decide whether a composition is *worth it*: that is +//! `global_selection`'s job, using the issue's cost-units-per-second +//! formulas (see `crate::cost_model::postprocess_plan_cost_rate` and +//! siblings). Missing statistics keep the conservative `KeepPreAsap`. + +use std::rc::Rc; + +use asap_types::post_asap::phase::validate_execution_phases_at; +use asap_types::post_asap::{ + exact_operator_output_schema, produced_availability, CompositionOperator, ExactOperator, + ExecutionAvailability, SummaryExpr, SummaryNode, SummarySchema, +}; +use asap_types::pre_asap::agg_intent::AggIntent; +use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; +use asap_types::types::AccuracyTarget; + +use crate::cost_model::CostModel; +use crate::replacement::{ + bindable_intent, describe_intent, implementations_for_with, ImplementError, Implementation, + Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, +}; +use crate::{AccuracyModel, DefaultAccuracyModel, PropagationStats}; + +/// Which side of the update/readout boundary an [`ExactComposition`]'s +/// exact operator executes on — selects the `SummaryExpr` variant +/// [`ExactComposition::compose`] builds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CompositionPhase { + /// [`SummaryExpr::ExactPostProcess`]: after the child's readout. + PostProcess, + /// [`SummaryExpr::ExactTransform`]: on the update path, feeding + /// maintained state above. + Transform, +} + +impl CompositionPhase { + /// The availability the composed operator consumes and produces. + pub fn availability(self) -> ExecutionAvailability { + match self { + Self::PostProcess => ExecutionAvailability::ReadoutValue, + Self::Transform => ExecutionAvailability::UpdateValue, + } + } + + pub fn provenance(self) -> ReplacementProvenance { + match self { + Self::PostProcess => ReplacementProvenance::ExactPostProcess, + Self::Transform => ReplacementProvenance::ExactTransform, + } + } +} + +/// The payload of a [`Replacement::ExactComposition`] candidate: an exact +/// operator, the phase it runs at, and a *reference* to the child target +/// it composes over — never an already-selected child plan (see the module +/// docs' "Reference, don't select"). +#[derive(Debug, Clone)] +pub struct ExactComposition { + pub phase: CompositionPhase, + pub op: ExactOperator, + /// The pre-ASAP child the operator consumes; its `MemoGroup` holds the + /// candidates `global_selection` may commit this composition with. + pub child_target: Rc, + /// The composed node's output schema — the target's own pre-ASAP + /// output schema, lifted with every column `Plain` (an exact operator + /// only ever produces plain values). + pub schema: SummarySchema, +} + +impl ExactComposition { + /// Can `child` legally be this composition's input? Phase legality + /// (the child's produced availability — a `KeepPreAsap` leaf takes the + /// phase this edge assigns) plus the plain-operand rule, checked + /// through the same schema derivation [`Self::compose`] uses. + pub fn accepts_child(&self, child: &SummaryNode) -> bool { + let phase_ok = match produced_availability(&child.expr) { + None => true, + Some(avail) => avail == self.phase.availability(), + }; + phase_ok && exact_operator_output_schema(&self.op, &child.schema).is_ok() + } + + /// Build the composed, phase-validated node over `child`. Every edge of + /// the result (including everything beneath `child`) is checked by + /// `asap_types::post_asap::validate_execution_phases`; an illegal + /// placement is a typed [`ImplementError::Phase`], never deferred to a + /// runtime. + pub fn compose(&self, child: Rc) -> Result, ImplementError> { + self.compose_with_accuracy(child, &DefaultAccuracyModel) + } + + /// Compose using the caller's accuracy algebra. Exact operators do not + /// erase an approximate child's error: supported folds propagate it; + /// unsupported folds fail closed with a typed accuracy error. + pub fn compose_with_accuracy( + &self, + child: Rc, + accuracy_model: &dyn AccuracyModel, + ) -> Result, ImplementError> { + let schema = exact_operator_output_schema(&self.op, &child.schema)?; + let guarantee = match &child.guarantee { + None => None, + Some(input) => { + let operator = match &self.op { + ExactOperator::Aggregate { measures, .. } => match measures.as_slice() { + [AggIntent::Sum { .. }] => CompositionOperator::ExactSum, + [AggIntent::Min { .. } | AggIntent::Max { .. } | AggIntent::Avg { .. }] => { + CompositionOperator::ExactExtremum + } + // This placeholder is only used by the model's exact-input + // fast path. Approximate inputs correctly fail closed. + [_] => CompositionOperator::Lipschitz { constant: 1.0 }, + _ => CompositionOperator::Lipschitz { constant: 1.0 }, + }, + _ => CompositionOperator::Lipschitz { constant: 1.0 }, + }; + Some(accuracy_model.propagate( + &operator, + std::slice::from_ref(input), + None, + &PropagationStats::default(), + )?) + } + }; + let expr = match self.phase { + CompositionPhase::PostProcess => SummaryExpr::ExactPostProcess { + child, + op: self.op.clone(), + }, + CompositionPhase::Transform => SummaryExpr::ExactTransform { + child, + op: self.op.clone(), + }, + }; + let node = Rc::new(SummaryNode { + expr, + schema, + guarantee, + }); + validate_execution_phases_at(&node, self.phase.availability())?; + Ok(node) + } + + /// Structural identity for `MemoGroup` dedup: same phase, same + /// operator, same child `Rc`. + pub(crate) fn same_as(&self, other: &Self) -> bool { + self.phase == other.phase + && self.op == other.op + && Rc::ptr_eq(&self.child_target, &other.child_target) + } +} + +/// Which exact reducers may run as a query-time fold over readout rows. +/// `Count` only at `Exact` accuracy (an approximate count is a sketch +/// target, not an exact fold). +fn is_post_process_reducer(intent: &AggIntent) -> bool { + matches!( + intent, + AggIntent::Sum { .. } + | AggIntent::Min { .. } + | AggIntent::Max { .. } + | AggIntent::Avg { .. } + | AggIntent::StdDev { .. } + | AggIntent::Variance { .. } + | AggIntent::Count { + accuracy: AccuracyTarget::Exact + } + ) +} + +/// Does `implementation` need a `SummaryEstimate` readout to yield a value +/// — i.e. is it a shape a maintained accumulator can't legally sit above? +fn needs_readout(implementation: &Implementation) -> bool { + matches!( + implementation, + Implementation::Sketch(_) + | Implementation::Sample { .. } + | Implementation::Wavelet { .. } + | Implementation::StatModel { .. } + ) +} + +/// The `(op, child)` of a post-process-shaped target, or `None`. +fn post_process_shape( + root: &QueryExpr, + cost_model: &dyn CostModel, +) -> Option<(ExactOperator, Rc, AggIntent)> { + let QueryExpr::Aggregate { + reduction, + measures, + output_names, + having: None, + child, + } = root + else { + return None; + }; + let Reduction::Reduce(by) = reduction else { + return None; + }; + if by.is_without() { + return None; + } + let [intent] = measures.as_slice() else { + return None; + }; + if !is_post_process_reducer(intent) { + return None; + } + let child_intent = bindable_intent(child)?; + if !implementations_for_with(child_intent, cost_model) + .iter() + .any(needs_readout) + { + return None; + } + // Grouping keys must resolve in the child's output schema — the same + // derivation the composed node's own schema will use. + root.output_schema().ok()?; + Some(( + ExactOperator::Aggregate { + reduction: reduction.clone(), + measures: measures.clone(), + output_names: output_names.clone(), + having: None, + }, + Rc::clone(child), + intent.clone(), + )) +} + +/// The `(op, child)` of a transform-shaped target — a per-entity exact +/// transform with no accumulator form — or `None`. +fn transform_shape( + root: &QueryExpr, + cost_model: &dyn CostModel, +) -> Option<(ExactOperator, Rc, AggIntent)> { + let QueryExpr::Aggregate { + reduction: Reduction::PerEntity, + measures, + output_names, + having: None, + child, + } = root + else { + return None; + }; + let [intent] = measures.as_slice() else { + return None; + }; + if !intent.is_per_series() { + return None; + } + // Exact accumulators (`Rate`/`Increase`) are already directly nestable + // as `SummaryAgg(ExactAggregate)`; only a pass-through transform needs + // an explicit update-path node. + if implementations_for_with(intent, cost_model) + .iter() + .any(|i| *i != Implementation::PassThrough) + { + return None; + } + root.output_schema().ok()?; + Some(( + ExactOperator::Aggregate { + reduction: Reduction::PerEntity, + measures: measures.clone(), + output_names: output_names.clone(), + having: None, + }, + Rc::clone(child), + intent.clone(), + )) +} + +/// Proposes [`Replacement::ExactComposition`] candidates — see the module +/// docs. Holds a [`CostModel`] only to ask it which mixed-execution shapes +/// the runtime advertises and which implementations the child has; it +/// never uses it to *rank* anything. +pub struct ExactCompositionStrategy<'a> { + cost_model: &'a dyn CostModel, +} + +static DEFAULT_COST_MODEL: crate::cost_model::DefaultCostModel = + crate::cost_model::DefaultCostModel; + +impl ExactCompositionStrategy<'static> { + /// A strategy consulting the built-in [`DefaultCostModel`](crate::cost_model::DefaultCostModel). + pub fn default_cost_model() -> Self { + Self { + cost_model: &DEFAULT_COST_MODEL, + } + } +} + +impl<'a> ExactCompositionStrategy<'a> { + pub fn new(cost_model: &'a dyn CostModel) -> Self { + Self { cost_model } + } + + fn candidates(&self, target: &TargetSubDAG<'_>) -> Vec { + let capabilities = self.cost_model.mixed_execution_capabilities(); + let Ok(schema) = target.root.output_schema() else { + return Vec::new(); + }; + let schema = asap_types::post_asap::phase::lift_plain(&schema); + let mut out = Vec::new(); + + if capabilities.supports(CompositionPhase::PostProcess) { + if let Some((op, child, intent)) = post_process_shape(target.root, self.cost_model) { + let child_desc = describe_intent( + bindable_intent(&child).expect("checked by post_process_shape"), + ); + out.push(ReplacementSubDAG { + strategy: "ExactCompositionStrategy", + replacement: Replacement::ExactComposition(ExactComposition { + phase: CompositionPhase::PostProcess, + op, + child_target: child, + schema: schema.clone(), + }), + provenance: ReplacementProvenance::ExactPostProcess, + rationale: format!( + "{} is an exact fold whose input is the readout of {} — a maintained \ + accumulator cannot consume query-time values, so instead of collapsing \ + the whole tree into KeepPreAsap this applies the fold as an \ + ExactPostProcess over whichever summary readout global_selection \ + commits for the child target (asap_aware_mapping::exact_composition)", + describe_intent(&intent), + child_desc + ), + }); + } + } + + if capabilities.supports(CompositionPhase::Transform) { + if let Some((op, child, intent)) = transform_shape(target.root, self.cost_model) { + out.push(ReplacementSubDAG { + strategy: "ExactCompositionStrategy", + replacement: Replacement::ExactComposition(ExactComposition { + phase: CompositionPhase::Transform, + op, + child_target: child, + schema, + }), + provenance: ReplacementProvenance::ExactTransform, + rationale: format!( + "{} is an exact per-entity transform with no accumulator form; as an \ + explicit ExactTransform on the update path its output can feed a \ + maintained summary above it instead of being handed over as an opaque \ + raw KeepPreAsap blob (asap_aware_mapping::exact_composition)", + describe_intent(&intent) + ), + }); + } + } + out + } +} + +impl ReplacementStrategy for ExactCompositionStrategy<'_> { + fn matches(&self, target: &TargetSubDAG<'_>) -> bool { + !self.candidates(target).is_empty() + } + + fn replacements(&self, target: &TargetSubDAG<'_>) -> Vec { + self.candidates(target) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cost_model::{DefaultCostModel, MixedExecutionCapabilities}; + use crate::replacement::keep_pre_asap; + use asap_types::post_asap::{PhaseError, SketchAlgorithm, SummaryFamilyType}; + use asap_types::pre_asap::agg_intent::default_quantile; + use asap_types::pre_asap::query_expr::Source; + use asap_types::pre_asap::schema::{Column, DataType, Schema}; + + fn metric_scan(labels: &[&str]) -> QueryExpr { + let mut columns = vec![ + Column::new("ts", DataType::Timestamp, false), + Column::new("value", DataType::Float64, false), + ]; + columns.extend(labels.iter().map(|n| Column::new(*n, DataType::Utf8, true))); + QueryExpr::Scan { + source: Source::TimeSeries { metric: "m".into() }, + predicates: vec![], + schema: Schema::with_time_index(columns, 0, vec![]), + } + } + + fn agg(by: Vec, intent: AggIntent, child: QueryExpr) -> QueryExpr { + QueryExpr::Aggregate { + reduction: Reduction::by(by), + measures: vec![intent], + output_names: vec![], + having: None, + child: Rc::new(child), + } + } + + fn per_entity(intent: AggIntent, child: QueryExpr) -> QueryExpr { + QueryExpr::Aggregate { + reduction: Reduction::PerEntity, + measures: vec![intent], + output_names: vec![], + having: None, + child: Rc::new(child), + } + } + + /// `max by (zone) (quantile by (zone, host) (m))`. + fn max_over_quantile() -> Rc { + let inner = agg( + vec![2, 3], + default_quantile(0.99), + metric_scan(&["zone", "host"]), + ); + Rc::new(agg(vec![0], AggIntent::Max { col: None }, inner)) + } + + #[test] + fn proposes_post_process_for_max_over_quantile() { + let root = max_over_quantile(); + let target = TargetSubDAG::new(&root); + let strategy = ExactCompositionStrategy::default_cost_model(); + assert!(strategy.matches(&target)); + let candidates = strategy.replacements(&target); + assert_eq!(candidates.len(), 1); + let Replacement::ExactComposition(comp) = &candidates[0].replacement else { + panic!( + "expected a composition, got {:?}", + candidates[0].replacement + ); + }; + assert_eq!(comp.phase, CompositionPhase::PostProcess); + assert_eq!( + candidates[0].provenance, + ReplacementProvenance::ExactPostProcess + ); + let QueryExpr::Aggregate { child, .. } = root.as_ref() else { + unreachable!() + }; + assert!( + Rc::ptr_eq(&comp.child_target, child), + "the candidate references the child target's own Rc — nothing selected" + ); + let names: Vec<_> = comp.schema.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, vec!["zone", "max"]); + } + + #[test] + fn proposes_post_process_for_avg_over_quantile_alongside_the_rewrite() { + let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); + let root = Rc::new(agg(vec![0], AggIntent::Avg { col: None }, inner)); + let target = TargetSubDAG::new(&root); + assert_eq!( + ExactCompositionStrategy::default_cost_model() + .replacements(&target) + .len(), + 1 + ); + // `avg` competes with AvgToSumOverCountStrategy in the same group. + assert!(crate::rewrite::AvgToSumOverCountStrategy.matches(&target)); + } + + #[test] + fn proposes_transform_for_a_per_entity_pass_through_over_raw_input() { + let root = Rc::new(per_entity(AggIntent::Deriv, metric_scan(&["zone"]))); + let target = TargetSubDAG::new(&root); + let candidates = ExactCompositionStrategy::default_cost_model().replacements(&target); + assert_eq!(candidates.len(), 1); + assert_eq!( + candidates[0].provenance, + ReplacementProvenance::ExactTransform + ); + } + + #[test] + fn does_not_propose_for_shapes_already_covered_by_accumulators() { + // sum by (zone) over an exact Sum child: the child has no readout, + // so SummaryAgg(Sum) over SummaryAgg(Sum) is already legal. + let inner = agg( + vec![2, 3], + AggIntent::Sum { col: None }, + metric_scan(&["zone", "host"]), + ); + let root = Rc::new(agg(vec![0], AggIntent::Sum { col: None }, inner)); + assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&root))); + // rate is an exact accumulator — directly nestable, no transform. + let rate = Rc::new(per_entity(AggIntent::Rate, metric_scan(&[]))); + assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&rate))); + // A sketch-capable outer intent is not an exact fold. + let inner = agg(vec![2], default_quantile(0.5), metric_scan(&["zone"])); + let root = Rc::new(agg(vec![0], default_quantile(0.99), inner)); + assert!(!ExactCompositionStrategy::default_cost_model().matches(&TargetSubDAG::new(&root))); + } + + struct NoMixedExecution; + impl CostModel for NoMixedExecution { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + fn mixed_execution_capabilities(&self) -> MixedExecutionCapabilities { + MixedExecutionCapabilities::NONE + } + } + + #[test] + fn a_runtime_without_the_capability_gets_no_candidate() { + let root = max_over_quantile(); + let target = TargetSubDAG::new(&root); + let strategy = ExactCompositionStrategy::new(&NoMixedExecution); + assert!(!strategy.matches(&target)); + assert!(strategy.replacements(&target).is_empty()); + let deriv = Rc::new(per_entity(AggIntent::Deriv, metric_scan(&[]))); + assert!(!strategy.matches(&TargetSubDAG::new(&deriv))); + } + + #[test] + fn compose_rejects_a_maintained_state_child_for_a_post_process() { + let root = max_over_quantile(); + let target = TargetSubDAG::new(&root); + let candidates = ExactCompositionStrategy::default_cost_model().replacements(&target); + let Replacement::ExactComposition(comp) = &candidates[0].replacement else { + unreachable!() + }; + // A bare SummaryAgg (state, no readout) is not a legal post-process + // input — the operator would be consuming sketch state. + let state_child = + crate::replacement::realize_child(&comp.child_target, &DefaultCostModel).unwrap(); + let SummaryExpr::SummaryEstimate { summary_input, .. } = &state_child.expr else { + panic!("expected the child to realize to a readout"); + }; + assert!(!comp.accepts_child(summary_input)); + assert!(matches!( + comp.compose(Rc::clone(summary_input)), + Err(ImplementError::ExactOperatorSchema(_)) + )); + // The readout itself is accepted and composes to a plain schema. + assert!(comp.accepts_child(&state_child)); + let composed = comp.compose(state_child).unwrap(); + assert!(matches!( + composed.expr, + SummaryExpr::ExactPostProcess { .. } + )); + assert!(composed + .schema + .fields + .iter() + .all(|f| matches!(f.dtype, SummaryFamilyType::Plain(_)))); + } + + #[test] + fn compose_rejects_a_readout_child_for_a_transform() { + let inner = agg(vec![2], default_quantile(0.99), metric_scan(&["zone"])); + let root = Rc::new(per_entity(AggIntent::Deriv, inner)); + let candidates = + ExactCompositionStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + let Replacement::ExactComposition(comp) = &candidates[0].replacement else { + unreachable!() + }; + let readout = + crate::replacement::realize_child(&comp.child_target, &DefaultCostModel).unwrap(); + assert!(!comp.accepts_child(&readout)); + assert!(matches!( + comp.compose(readout), + Err(ImplementError::Phase(PhaseError::IllegalChildPhase { .. })) + )); + // Raw update input is fine. + let raw = keep_pre_asap(&comp.child_target).unwrap(); + assert!(comp.accepts_child(&raw)); + assert!(matches!( + comp.compose(raw).unwrap().expr, + SummaryExpr::ExactTransform { .. } + )); + } +} diff --git a/crates/asap-aware-mapping/src/explanation.rs b/crates/asap-aware-mapping/src/explanation.rs index bcfee70a..3900c729 100644 --- a/crates/asap-aware-mapping/src/explanation.rs +++ b/crates/asap-aware-mapping/src/explanation.rs @@ -216,6 +216,13 @@ pub enum ExplanationKind { /// cross-subpopulation reuse entries, all the same underlying structural /// fact. CommonSubexpressionReuse, + /// A `TargetSubDAG`'s candidate list contains at least one + /// [`Replacement::ExactComposition`] — + /// [`crate::exact_composition::ExactCompositionStrategy`] found an exact + /// operator that can be composed with a summary plan across an explicit + /// update/readout boundary instead of collapsing the whole tree into + /// `KeepPreAsap` (issue #171). + ExactComposition, } /// Why a [`Replacement`] of `kind` exists at `location` (a human-readable @@ -319,6 +326,15 @@ fn findings_from_plan_space(space: &PlanSpace) -> Vec) -> Vec Option { + let reasons: Vec<&str> = group + .candidates + .iter() + .filter(|c| matches!(c.replacement, Replacement::ExactComposition(_))) + .map(|c| c.rationale.as_str()) + .collect(); + if reasons.is_empty() { + None + } else { + Some(reasons.join("; ")) + } +} + /// Does `group`'s candidate list contain a genuine sketch-family realization? /// If so, the finding's `reason` is every such candidate's own `rationale`, /// joined — this module does not invent new prose to restate why a candidate diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 613d5061..a2b565d9 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -184,6 +184,7 @@ pub mod accuracy; pub mod accuracy_reconciliation; pub mod cost_model; +pub mod exact_composition; pub mod explanation; pub mod grouping; pub mod recurrence; @@ -198,7 +199,12 @@ pub use accuracy::{ PropagationStats, }; pub use accuracy_reconciliation::AccuracyReconciliationStrategy; -pub use cost_model::{CostModel, DefaultCostModel}; +pub use cost_model::{ + postprocess_plan_cost_rate, pretransform_plan_cost_rate, raw_recompute_cost_rate, CostModel, + CostProvenance, CostRate, CostUnit, DefaultCostModel, EvaluationRate, + ExactCompositionCostInputs, ExactCompositionCostRequest, MixedExecutionCapabilities, +}; +pub use exact_composition::{CompositionPhase, ExactComposition, ExactCompositionStrategy}; pub use explanation::{ explain_replacements, explain_replacements_with, ExplanationKind, ReplacementExplanation, }; @@ -210,11 +216,11 @@ pub use recurrence::{ }; pub use replacement::{ default_strategies, default_strategies_with, search_workload, search_workload_with, - search_workload_with_targets, summary_candidates, GlobalSelection, ImplementError, - Implementation, Matcher, MemoGroup, PlanSpace, Proposals, RankedGroup, RecurrenceProfileMap, - RejectedCandidate, Replacement, ReplacementProvenance, ReplacementStrategy, ReplacementSubDAG, - SelectedGroup, SharedSubtreeStrategy, SketchAlgorithmStrategy, TargetSubDAG, - MAX_SEARCH_ITERATIONS, + search_workload_with_targets, summary_candidates, CompositionDecision, GlobalSelection, + ImplementError, Implementation, Matcher, MemoGroup, PlanSpace, Proposals, RankedGroup, + RecurrenceProfileMap, RejectedCandidate, Replacement, ReplacementProvenance, + ReplacementStrategy, ReplacementSubDAG, SelectedGroup, SharedSubtreeStrategy, + SketchAlgorithmStrategy, TargetSubDAG, MAX_SEARCH_ITERATIONS, }; pub use rewrite::AvgToSumOverCountStrategy; pub use topk_reuse::TopKLimitReuseStrategy; diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 5835adac..b9730e7a 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -345,15 +345,17 @@ //! multi-group joint optimization beyond this per-site recurrence is left //! for whenever that changes. +use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; -use asap_types::post_asap::{AccuracyError, CompositionOperator, GuaranteeSource, ResultGuarantee}; use asap_types::post_asap::{ - ExactKind, ExactParams, GroupingStrategy, SamplingKind, SamplingParams, SketchAlgorithm, - SketchKind, SketchParams, SketchQuery as PostAsapSketchQuery, StatModelKind, StatModelParams, - SummaryExpr, SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, WaveletKind, - WaveletParams, + validate_execution_phases_at, ExactKind, ExactOperatorSchemaError, ExactParams, + ExecutionAvailability, GroupingStrategy, PhaseError, SamplingKind, SamplingParams, + SketchAlgorithm, SketchKind, SketchParams, SketchQuery as PostAsapSketchQuery, StatModelKind, + StatModelParams, SummaryExpr, SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, + WaveletKind, WaveletParams, }; +use asap_types::post_asap::{AccuracyError, CompositionOperator, GuaranteeSource, ResultGuarantee}; use asap_types::pre_asap::agg_intent::{agg_is_mergeable, AggIntent}; use asap_types::pre_asap::cse::{share_common_subtrees, structural_hash, HashCache}; use asap_types::pre_asap::expr_ir::ColumnRef; @@ -370,7 +372,11 @@ use crate::accuracy::{ KLL_RANK_ERROR_EXPONENT_99, }; use crate::accuracy_reconciliation::AccuracyReconciliationStrategy; -use crate::cost_model::{CostModel, CseCandidate, DefaultCostModel, ShareDecision}; +use crate::cost_model::{ + raw_recompute_cost_rate, CostModel, CostRate, CseCandidate, DefaultCostModel, + ExactCompositionCostInputs, ExactCompositionCostRequest, ShareDecision, +}; +use crate::exact_composition::{CompositionPhase, ExactComposition, ExactCompositionStrategy}; use crate::grouping::HydraGroupingStrategy; use crate::recurrence::{ evaluation_rate_of, Horizon, RecurrenceError, RecurrenceProfile, RootRecurrence, UpdateRate, @@ -396,6 +402,15 @@ pub enum ImplementError { /// records it as a [`RejectedCandidate`] instead of a candidate. #[error("accuracy-illegal candidate: {0}")] Accuracy(#[from] AccuracyError), + /// A constructed plan violates the update/readout phase contract + /// (issue #171) — e.g. a summary readout placed beneath a maintained + /// `SummaryAgg`. Detected at construction, never at runtime. + #[error("execution-phase violation in post-ASAP plan: {0}")] + Phase(#[from] PhaseError), + /// An `ExactOperator`'s output schema could not be derived over its + /// child — the child carries summary state the operator can't read. + #[error("exact operator schema derivation failed: {0}")] + ExactOperatorSchema(#[from] ExactOperatorSchemaError), } /// A pre-ASAP sub-DAG a [`ReplacementStrategy`] knows how to replace. @@ -455,6 +470,15 @@ pub enum Replacement { /// different from the target's own `root` (e.g. sharing vs. not sharing /// a subtree) but semantically equivalent to it. Rewrite(Rc), + /// An exact operator composed over another target's *own* selected + /// decision across an explicit update/readout boundary (issue #171): + /// `ExactPostProcess` over a child's summary readout, or + /// `ExactTransform` feeding a maintained summary above. Carries only a + /// reference to the child target — [`PlanSpace::global_selection`] + /// commits the compatible parent/child pair and + /// [`GlobalSelection::materialize`] links it into one validated + /// `SummaryNode`. See [`crate::exact_composition`]. + ExactComposition(ExactComposition), } /// One candidate replacement for a [`TargetSubDAG`], plus a human-readable @@ -496,6 +520,12 @@ pub enum ReplacementProvenance { /// regardless, so pricing it like a full independent rebuild would be /// the wrong shape of cost, not just the wrong number. AccuracyReconciliation, + /// [`Replacement::ExactComposition`] with + /// [`CompositionPhase::PostProcess`] (issue #171). + ExactPostProcess, + /// [`Replacement::ExactComposition`] with + /// [`CompositionPhase::Transform`] (issue #171). + ExactTransform, } /// A candidate a strategy considered for a target but refused to propose on @@ -521,6 +551,7 @@ pub struct RejectedCandidate { pub struct Proposals { pub candidates: Vec, pub rejected: Vec, + phase_error: Option, } /// A replacement strategy: given a [`TargetSubDAG`], does this strategy have @@ -566,6 +597,7 @@ pub trait ReplacementStrategy { Proposals { candidates: self.replacements(target), rejected: Vec::new(), + phase_error: None, } } } @@ -1369,6 +1401,22 @@ impl<'a> SketchAlgorithmStrategy<'a> { ); } } + if proposals.candidates.is_empty() { + if let Some(error) = &proposals.phase_error { + if let Ok(node) = keep_pre_asap(root) { + proposals.candidates.push(ReplacementSubDAG { + strategy: "SketchAlgorithmStrategy", + replacement: Replacement::Summary(node), + provenance: ReplacementProvenance::SummaryImplementation, + rationale: format!( + "{} stays pre-ASAP because summary construction crosses an illegal \ + execution-phase boundary ({error})", + describe_intent(intent) + ), + }); + } + } + } proposals } } @@ -1390,7 +1438,10 @@ impl Proposals { description: rationale, error, }), - Err(ImplementError::Schema(_)) => {} + Err(ImplementError::Phase(error)) => { + self.phase_error.get_or_insert(error); + } + Err(ImplementError::Schema(_) | ImplementError::ExactOperatorSchema(_)) => {} } } } @@ -1538,10 +1589,10 @@ pub(crate) fn realize_child_with( .. }) => Ok(node), Some(ReplacementSubDAG { - replacement: Replacement::Rewrite(_), + replacement: Replacement::Rewrite(_) | Replacement::ExactComposition(_), .. }) => { - unreachable!("SketchAlgorithmStrategy never returns a Rewrite candidate") + unreachable!("SketchAlgorithmStrategy never returns a Rewrite/composition candidate") } // No candidate at all: `root` isn't `bindable_intent` shape (or its // intent has no realization `implementations_for_with` can't @@ -1762,6 +1813,10 @@ fn construct_summary_agg( // finalized value does. An exact accumulator's state is its value. guarantee: if estimate { None } else { guarantee.clone() }, }); + // Phase contract (issue #171): a maintained summary consumes update-path + // values or exact accumulator state — never a query-time readout. A + // typed error here, at construction; the caller decides the fallback. + validate_execution_phases_at(&agg, ExecutionAvailability::SummaryState)?; match query { // The readout: downstream of the estimate the schema is the plain // pre-ASAP row shape again (the summary-state type does not @@ -2079,10 +2134,13 @@ impl MemoGroup { (Replacement::Summary(existing_node), Replacement::Summary(node)) => { is_duplicate_summary(existing_node, node) } - // A `Rewrite` and a `Summary` are never the same candidate — - // they're different `Replacement` variants entirely. - (Replacement::Rewrite(_), Replacement::Summary(_)) - | (Replacement::Summary(_), Replacement::Rewrite(_)) => false, + ( + Replacement::ExactComposition(existing), + Replacement::ExactComposition(candidate), + ) => existing.same_as(candidate), + // Different `Replacement` variants are never the same + // candidate. + _ => false, } }); if is_duplicate { @@ -2618,7 +2676,7 @@ fn rank_group<'a>(group: &'a MemoGroup, cost_model: &dyn CostModel) -> Vec<&'a R .iter() .map(|c| match &c.replacement { Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) => None, + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, }) .collect(); if let Some(kinds) = kinds { @@ -2626,7 +2684,7 @@ fn rank_group<'a>(group: &'a MemoGroup, cost_model: &dyn CostModel) -> Vec<&'a R ranked.sort_by_key(|c| { let kind = match &c.replacement { Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) => None, + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, }; kind.and_then(|k| order.iter().position(|o| *o == k)) .unwrap_or(usize::MAX) @@ -2754,6 +2812,32 @@ pub struct SelectedGroup<'a> { /// registered strategy proposed anything for (mirrors /// [`MemoGroup::candidates`] being possibly empty). pub chosen: Option<&'a ReplacementSubDAG>, + /// When `chosen` is a [`Replacement::ExactComposition`]: the child + /// decision it was committed together with, and the cost comparison + /// that justified it — the explicit target-to-decision provenance + /// chain (issue #171). + pub composition: Option>, +} + +/// Why [`PlanSpace::global_selection`] committed an exact composition at a +/// site: which child candidate it composes with, and the +/// cost-units-per-second comparison against the raw fallback that it won. +#[derive(Debug)] +pub struct CompositionDecision<'a> { + /// The child target the composed operator consumes. + pub child_target: &'a Rc, + /// For a post-process: the child's own candidate committed alongside + /// (the summary readout the operator folds). `None` for an update-path + /// transform, whose input is raw update data — its cost is charged to + /// the maintained summary *above* it instead. + pub child_candidate: Option<&'a ReplacementSubDAG>, + /// The composed plan's recurring rate — `postprocess_plan_cost_rate` + /// or `pretransform_plan_cost_rate`. + pub cost_rate: CostRate, + /// `raw_recompute_cost_rate` — the `KeepPreAsap` baseline it beat. + pub baseline_rate: CostRate, + /// The statistics (and their provenance) both rates were computed from. + pub inputs: ExactCompositionCostInputs, } /// [`PlanSpace::global_selection`]'s result: one [`SelectedGroup`] per @@ -2763,6 +2847,10 @@ pub struct SelectedGroup<'a> { pub struct GlobalSelection<'a> { order: Vec<*const QueryExpr>, groups: HashMap<*const QueryExpr, SelectedGroup<'a>>, + /// [`Self::materialize`]'s memo — one bound node per target for the + /// life of this selection, so two parents composing over one shared + /// child get the *same* `Rc`. + materialized: RefCell>>, } impl<'a> GlobalSelection<'a> { @@ -2777,6 +2865,269 @@ impl<'a> GlobalSelection<'a> { pub fn for_target(&self, target: &Rc) -> Option<&SelectedGroup<'a>> { self.groups.get(&Rc::as_ptr(target)) } + + /// Link this selection's per-site decisions into one phase-validated + /// post-ASAP DAG rooted at `target` — the one place a committed + /// composition's child *reference* becomes an actual `Rc` + /// edge (issue #171). `None` if `target` is not a discovered site. + /// + /// Per site: a [`Replacement::ExactComposition`] composes over its + /// child target's own materialization; a [`Replacement::Summary`] is + /// re-linked so its `SummaryAgg` child is the child target's own + /// materialization whenever that is phase-legal beneath maintenance + /// (so a child that chose an `ExactTransform` actually ends up under + /// the summary); a [`Replacement::Rewrite`] or an unmatched site stays + /// the conservative `KeepPreAsap`. Memoized by target identity, so a + /// shared inner summary is one `Rc` no matter how many roots reach it. + pub fn materialize( + &self, + target: &Rc, + ) -> Result>, ImplementError> { + if !self.groups.contains_key(&Rc::as_ptr(target)) { + return Ok(None); + } + self.materialize_inner(target).map(Some) + } + + fn materialize_inner(&self, target: &Rc) -> Result, ImplementError> { + let ptr = Rc::as_ptr(target); + if let Some(node) = self.materialized.borrow().get(&ptr) { + return Ok(Rc::clone(node)); + } + let node = match self + .groups + .get(&ptr) + .and_then(|sel| sel.chosen) + .map(|c| &c.replacement) + { + None => keep_pre_asap(target)?, + Some(Replacement::Rewrite(rewritten)) => keep_pre_asap(rewritten)?, + Some(Replacement::Summary(node)) => self.relink_summary(node, target)?, + Some(Replacement::ExactComposition(composition)) => { + let child = self.materialize_inner(&composition.child_target)?; + let child = if composition.accepts_child(&child) { + child + } else { + keep_pre_asap(&composition.child_target)? + }; + composition.compose(child)? + } + }; + self.materialized.borrow_mut().insert(ptr, Rc::clone(&node)); + Ok(node) + } + + /// Re-link a bound `Summary` candidate's `SummaryAgg` child to the + /// child target's own materialization when that is legal beneath + /// maintenance; otherwise keep the candidate exactly as constructed. + fn relink_summary( + &self, + node: &Rc, + target: &Rc, + ) -> Result, ImplementError> { + let QueryExpr::Aggregate { + child: pre_child, .. + } = target.as_ref() + else { + return Ok(Rc::clone(node)); + }; + if !self.groups.contains_key(&Rc::as_ptr(pre_child)) { + return Ok(Rc::clone(node)); + } + let new_child = self.materialize_inner(pre_child)?; + Ok(relink_agg_child(node, &new_child)) + } +} + +/// Rebuild `node` (a `SummaryAgg`, possibly under a `SummaryEstimate`) with +/// `new_child` as the `SummaryAgg`'s child, if the result still validates +/// as maintained state; otherwise return `node` unchanged. +fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc { + match &node.expr { + SummaryExpr::SummaryEstimate { + summary_input, + query, + } => { + let inner = relink_agg_child(summary_input, new_child); + if Rc::ptr_eq(&inner, summary_input) { + return Rc::clone(node); + } + Rc::new(SummaryNode { + expr: SummaryExpr::SummaryEstimate { + summary_input: inner, + query: query.clone(), + }, + schema: node.schema.clone(), + guarantee: node.guarantee.clone(), + }) + } + SummaryExpr::SummaryAgg { + child, + family, + col, + reduction, + grouping, + } => { + if Rc::ptr_eq(child, new_child) { + return Rc::clone(node); + } + let rebuilt = Rc::new(SummaryNode { + expr: SummaryExpr::SummaryAgg { + child: Rc::clone(new_child), + family: family.clone(), + col: col.clone(), + reduction: reduction.clone(), + grouping: grouping.clone(), + }, + schema: node.schema.clone(), + guarantee: node.guarantee.clone(), + }); + match validate_execution_phases_at(&rebuilt, ExecutionAvailability::SummaryState) { + Ok(_) => rebuilt, + Err(_) => Rc::clone(node), + } + } + _ => Rc::clone(node), + } +} + +/// The maintained `SummaryAgg` a bound `Summary` candidate builds (under +/// its `SummaryEstimate` readout, if any) — the summary an `ExactTransform` +/// beneath it feeds, for `pretransform_plan_cost_rate`. +fn maintained_summary(node: &Rc) -> Option<&Rc> { + match &node.expr { + SummaryExpr::SummaryEstimate { summary_input, .. } => maintained_summary(summary_input), + SummaryExpr::SummaryAgg { .. } => Some(node), + _ => None, + } +} + +fn is_composition_candidate(candidate: &ReplacementSubDAG) -> bool { + matches!(candidate.replacement, Replacement::ExactComposition(_)) +} + +/// Everything [`PlanSpace::global_selection`] threads between sites for +/// exact compositions (issue #171): child candidates already committed by +/// an earlier parent, and the maintained summary above each site. +#[derive(Default)] +struct CompositionContext { + /// child target ptr → the child's candidate an ancestor's composition + /// already committed to (a later parent must compose with the *same* + /// one, and the child's own selection is forced to it). + committed_child: HashMap<*const QueryExpr, *const ReplacementSubDAG>, + /// site ptr → the maintained `SummaryAgg` directly above it, when its + /// parent chose a bound `Summary` — what an `ExactTransform` here feeds. + maintaining_parent: HashMap<*const QueryExpr, Rc>, +} + +/// One eligible composed alternative at a site, before the cheapest wins. +struct CompositionOption<'a> { + candidate: &'a ReplacementSubDAG, + decision: CompositionDecision<'a>, +} + +/// Every [`Replacement::ExactComposition`] candidate of `group` whose +/// composed-plan rate is *known* and beats the raw-recompute baseline — +/// costed against each compatible child candidate already in `PlanSpace` +/// (or the one an earlier parent committed). Unknown statistics yield no +/// option at all: the conservative `KeepPreAsap` path stays. +fn composition_options<'a>( + group: &'a MemoGroup, + groups: &'a HashMap<*const QueryExpr, MemoGroup>, + effective: usize, + cost_model: &dyn CostModel, + context: &CompositionContext, +) -> Vec> { + let mut options = Vec::new(); + for candidate in &group.candidates { + let Replacement::ExactComposition(composition) = &candidate.replacement else { + continue; + }; + let child_ptr = Rc::as_ptr(&composition.child_target); + let Some(child_group) = groups.get(&child_ptr) else { + continue; + }; + let already_committed = context.committed_child.get(&child_ptr).copied(); + let cost = |summary: &SummaryNode, shared: bool| { + let request = ExactCompositionCostRequest { + target: &group.target, + composition, + summary, + effective_consumer_count: effective, + }; + let mut inputs = cost_model.exact_composition_cost_inputs(&request); + if shared { + // Shared state is counted once: an earlier parent already + // pays this child's maintenance, so the marginal cost here + // is zero — a *known* zero, unlike an unknown input. + if let Some(maintenance) = inputs.summary_maintenance_cost_per_update.as_mut() { + *maintenance = 0.0; + } + } + let rate = inputs.composed_plan_cost_rate(composition.phase)?; + let baseline = raw_recompute_cost_rate(&inputs)?; + (rate < baseline).then_some((rate, baseline, inputs)) + }; + match composition.phase { + CompositionPhase::PostProcess => { + let child_candidates: Vec<&'a ReplacementSubDAG> = match already_committed { + // SAFETY-free: the pointer was taken from `groups`'s own + // candidate storage, which outlives this borrow. + Some(ptr) => child_group + .candidates + .iter() + .filter(|c| std::ptr::eq(*c, ptr)) + .collect(), + None => child_group.candidates.iter().collect(), + }; + for child_candidate in child_candidates { + let Replacement::Summary(summary) = &child_candidate.replacement else { + continue; + }; + if !composition.accepts_child(summary) { + continue; + } + let Some((rate, baseline, inputs)) = cost(summary, already_committed.is_some()) + else { + continue; + }; + options.push(CompositionOption { + candidate, + decision: CompositionDecision { + child_target: &composition.child_target, + child_candidate: Some(child_candidate), + cost_rate: rate, + baseline_rate: baseline, + inputs, + }, + }); + } + } + CompositionPhase::Transform => { + // An update-path transform only pays off beneath a + // maintained summary; with nothing above it, its output is + // never read and the raw fallback is the same computation. + let Some(parent) = context.maintaining_parent.get(&Rc::as_ptr(&group.target)) + else { + continue; + }; + let Some((rate, baseline, inputs)) = cost(parent, false) else { + continue; + }; + options.push(CompositionOption { + candidate, + decision: CompositionDecision { + child_target: &composition.child_target, + child_candidate: None, + cost_rate: rate, + baseline_rate: baseline, + inputs, + }, + }); + } + } + } + options } impl PlanSpace { @@ -2817,6 +3168,7 @@ impl PlanSpace { let mut effective_uses = graph.external_root_uses.clone(); let mut chosen_share: HashMap<*const QueryExpr, ShareDecision> = HashMap::new(); let mut groups: HashMap<*const QueryExpr, SelectedGroup<'_>> = HashMap::new(); + let mut context = CompositionContext::default(); for ptr in &topo { let group = &self.groups[ptr]; @@ -2824,7 +3176,55 @@ impl PlanSpace { let effective = effective_uses.get(ptr).copied().unwrap_or(0); effective_uses.insert(*ptr, effective); - let chosen = if effective >= 2 && cse_candidate_pair(group).is_some() { + // ── Exact compositions (issue #171) ───────────────────────── + // A child an earlier parent's composition committed to is + // forced to exactly that candidate — the parent/child pair is + // one decision. Otherwise, a composition here wins only when + // its cost-units-per-second rate is *known* and beats the raw + // recompute baseline; missing statistics keep the conservative + // path below. + let mut composition_decision = None; + let forced = context + .committed_child + .get(ptr) + .and_then(|&cptr| group.candidates.iter().find(|c| std::ptr::eq(*c, cptr))); + let composed = if forced.is_some() { + None + } else { + composition_options(group, &self.groups, effective, cost_model, &context) + .into_iter() + .min_by(|a, b| { + a.decision + .cost_rate + .units_per_second + .total_cmp(&b.decision.cost_rate.units_per_second) + }) + }; + if let Some(option) = &composed { + if let Some(child_candidate) = option.decision.child_candidate { + context.committed_child.insert( + Rc::as_ptr(option.decision.child_target), + child_candidate as *const ReplacementSubDAG, + ); + } + if let Replacement::ExactComposition(composition) = &option.candidate.replacement { + if composition.phase == CompositionPhase::Transform { + // A chain of transforms feeds the same summary. + if let Some(parent) = context.maintaining_parent.get(ptr).cloned() { + context + .maintaining_parent + .insert(Rc::as_ptr(&composition.child_target), parent); + } + } + } + } + + let chosen = if let Some(forced) = forced { + Some(forced) + } else if let Some(option) = composed { + composition_decision = Some(option.decision); + Some(option.candidate) + } else if effective >= 2 && cse_candidate_pair(group).is_some() { let decision = if let Some(profiles) = profiles { decide_group_with_recurrence( group, @@ -2844,7 +3244,9 @@ impl PlanSpace { let logical = group .candidates .iter() - .filter(|candidate| !is_cse_candidate(candidate)) + .filter(|candidate| { + !is_cse_candidate(candidate) && !is_composition_candidate(candidate) + }) .min_by(|a, b| { cost_model .estimate_cost(a, &effective_target) @@ -2875,15 +3277,32 @@ impl PlanSpace { // valid answer, just not a cross-group-aware one; this // group also contributes no Share collapse to its own // children (see `multiplier`'s `_ => effective` arm). - None => rank_group(group, cost_model).into_iter().next(), + None => rank_group(group, cost_model) + .into_iter() + .find(|candidate| !is_composition_candidate(candidate)), } } else { rank_group(group, cost_model) .into_iter() - .find(|candidate| !is_cse_candidate(candidate)) + .find(|candidate| { + !is_cse_candidate(candidate) && !is_composition_candidate(candidate) + }) .or_else(|| cse_candidate_pair(group).map(|(share, _)| share)) }; + // Record the maintained summary this site's bound candidate + // builds, for a child that may compose an `ExactTransform` + // beneath it. + if let (Some(Replacement::Summary(node)), QueryExpr::Aggregate { child, .. }) = + (chosen.map(|c| &c.replacement), group.target.as_ref()) + { + if let Some(summary) = maintained_summary(node) { + context + .maintaining_parent + .insert(Rc::as_ptr(child), Rc::clone(summary)); + } + } + let outgoing_multiplier = multiplier(*ptr, &effective_uses, &chosen_share); match chosen { Some(ReplacementSubDAG { @@ -2901,7 +3320,9 @@ impl PlanSpace { _ => { let selected_rewrite = match chosen.map(|candidate| &candidate.replacement) { Some(Replacement::Rewrite(rewrite)) => rewrite, - Some(Replacement::Summary(_)) | None => &group.target, + Some(Replacement::Summary(_) | Replacement::ExactComposition(_)) | None => { + &group.target + } }; for (child, edge_count) in direct_child_counts(selected_rewrite) { *effective_uses.entry(child).or_insert(0) += @@ -2917,6 +3338,7 @@ impl PlanSpace { consumer_count: group.consumer_count, effective_consumer_count: effective, chosen, + composition: composition_decision, }, ); } @@ -2924,6 +3346,7 @@ impl PlanSpace { Ok(GlobalSelection { order: self.order.clone(), groups, + materialized: RefCell::new(HashMap::new()), }) } } @@ -3299,6 +3722,7 @@ pub fn default_strategies() -> Vec> { Box::new(HydraGroupingStrategy::default_cost_model()), Box::new(SharedSubtreeStrategy), Box::new(crate::rewrite::AvgToSumOverCountStrategy), + Box::new(ExactCompositionStrategy::default_cost_model()), ] } @@ -3313,6 +3737,7 @@ pub fn default_strategies_with<'a>( Box::new(HydraGroupingStrategy::new(cost_model)), Box::new(SharedSubtreeStrategy), Box::new(crate::rewrite::AvgToSumOverCountStrategy), + Box::new(ExactCompositionStrategy::new(cost_model)), ] } @@ -3406,6 +3831,7 @@ pub fn search_workload_with_targets<'s, Id>( .as_ref() .is_some_and(|g| accuracy_model.satisfies(g, &target)), Replacement::Rewrite(_) => true, + Replacement::ExactComposition(_) => false, }); group.candidates = legal; group.rejected.extend(illegal.into_iter().map(|candidate| { @@ -3426,6 +3852,11 @@ pub fn search_workload_with_targets<'s, Id>( None, )), Replacement::Rewrite(_) => unreachable!("rewrites are never rejected here"), + Replacement::ExactComposition(_) => ( + asap_types::post_asap::ErrorMetric::AbsoluteValue, + None, + None, + ), }; RejectedCandidate { strategy: candidate.strategy, @@ -4379,7 +4810,9 @@ mod tests { .iter() .map(|r| match &r.replacement { Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) => panic!("expected a Summary replacement"), + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } }) .collect(); assert!(kinds.contains(&SketchAlgorithm::Kll), "{kinds:?}"); @@ -4399,7 +4832,9 @@ mod tests { .iter() .map(|r| match &r.replacement { Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) => panic!("expected a Summary replacement"), + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } }) .collect(); assert_eq!( @@ -4427,7 +4862,9 @@ mod tests { .iter() .map(|r| match &r.replacement { Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) => panic!("expected a Summary replacement"), + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } }) .collect(); assert_eq!(kinds, vec![SketchAlgorithm::Theta, SketchAlgorithm::Kmv]); @@ -4505,7 +4942,9 @@ mod tests { .iter() .map(|r| match &r.replacement { Replacement::Summary(node) => summary_family_algorithm(node), - Replacement::Rewrite(_) => panic!("expected a Summary replacement"), + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => { + panic!("expected a Summary replacement") + } }) .collect(); assert!(kinds.contains(&SketchAlgorithm::Kll)); @@ -4513,13 +4952,13 @@ mod tests { assert_eq!(kinds.len(), 2); } - /// Enumerating candidates for the *target* node must only steer that - /// node's own decision — a nested aggregate underneath it still gets its - /// own independent (`cost_model`-ranked) enumeration, not whatever the - /// caller happened to pick for the outer target. This is the behavior - /// [`construct_summary`]'s recursion (via [`realize_child`]) - /// gets for free: only the top node's `Implementation` is ever forced - /// from outside; the child is always re-enumerated fresh. + /// Constructing the outer target's candidates never leaks the outer + /// choice into the nested aggregate — and, since issue #171's phase + /// contract, a maintained outer sketch can no longer sit above the + /// inner sketch's *readout* at all: the outer target degrades to the + /// conservative `KeepPreAsap` fallback (reported once, not once per + /// dropped family), while the inner quantile keeps its own, + /// independently cost-ranked candidates in its own `MemoGroup`. #[test] fn enumerating_the_targets_candidates_does_not_leak_into_a_nested_aggregate() { // outer: quantile(0.99, ...) over inner: quantile(0.5, m) — both @@ -4539,35 +4978,41 @@ mod tests { ) .replacements(&target); - let ddsketch = replacements - .iter() - .find(|r| { - matches!(&r.replacement, Replacement::Summary(node) - if summary_family_algorithm(node) == SketchAlgorithm::DDSketch) - }) - .expect("the outer target's DDSketch candidate must be present"); - let Replacement::Summary(node) = &ddsketch.replacement else { - unreachable!("filtered on Replacement::Summary above"); + assert_eq!(replacements.len(), 1, "{replacements:?}"); + let Replacement::Summary(node) = &replacements[0].replacement else { + unreachable!("SketchAlgorithmStrategy only returns Summary candidates"); }; - assert_eq!( - summary_family_algorithm(node), - SketchAlgorithm::DDSketch, - "the outer (target) node must be the DDSketch candidate" + assert!( + matches!(node.expr, SummaryExpr::KeepPreAsap(ref e) if Rc::ptr_eq(e, &outer)), + "a sketch over a sketch readout is phase-illegal; expected the conservative \ + fallback, got {:?}", + node.expr ); - - let asap_types::post_asap::SummaryExpr::SummaryEstimate { summary_input, .. } = &node.expr - else { - panic!("expected SummaryEstimate root, got {:?}", node.expr); - }; - let asap_types::post_asap::SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr - else { - panic!("expected SummaryAgg, got {:?}", summary_input.expr); + assert!(replacements[0].rationale.contains("readout")); + + // The inner target is still independently enumerated and ranked — + // a custom cost model that prefers DDSketch for it is honored, and + // nothing about the outer target's choice reaches it. + let space = search_workload_with( + vec![("q", Rc::clone(&outer))], + &default_strategies_with(&PreferDDSketchViaCostModel), + ); + let QueryExpr::Aggregate { child, .. } = space.roots[0].1.as_ref() else { + unreachable!() }; + let inner_group = space.group_for(child).expect("inner quantile is a target"); + let inner_kinds: Vec = inner_group + .candidates + .iter() + .filter_map(|c| match &c.replacement { + Replacement::Summary(node) => sketch_kind_of(node), + _ => None, + }) + .collect(); assert_eq!( - summary_family_algorithm(child), - SketchAlgorithm::Kll, - "the nested inner aggregate must still get the cost-model-ranked \ - default (Kll), not inherit the outer target's DDSketch candidate" + inner_kinds, + vec![SketchAlgorithm::DDSketch, SketchAlgorithm::Kll], + "the nested inner aggregate keeps its own cost-model-ranked candidates" ); } @@ -5094,7 +5539,7 @@ mod tests { assert_eq!(rewrites.len(), 2); let first_shares_target = match &rewrites[0].replacement { Replacement::Rewrite(rc) => Rc::ptr_eq(rc, &group.target), - Replacement::Summary(_) => false, + Replacement::Summary(_) | Replacement::ExactComposition(_) => false, }; assert!( first_shares_target, @@ -5130,7 +5575,7 @@ mod tests { assert_eq!(agg_group.candidates.len(), 2); let first_kind = match &agg_group.candidates[0].replacement { Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) => None, + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, }; assert_eq!(first_kind, Some(SketchAlgorithm::DDSketch)); } @@ -5326,7 +5771,7 @@ mod tests { .unwrap(); let kind = match &agg_group.chosen.unwrap().replacement { Replacement::Summary(node) => sketch_kind_of(node), - Replacement::Rewrite(_) => None, + Replacement::Rewrite(_) | Replacement::ExactComposition(_) => None, }; assert_eq!(kind, Some(SketchAlgorithm::DDSketch)); } @@ -6741,6 +7186,7 @@ mod tests { DefaultAccuracyModel.satisfies(g, &AccuracyTarget::Epsilon(0.1)) }), Replacement::Rewrite(_) => false, + Replacement::ExactComposition(_) => false, })); let ranked = space.cost_sorted(&DefaultCostModel); let root_ranked = ranked.iter().find(|g| Rc::ptr_eq(g.target, root)).unwrap(); @@ -6808,6 +7254,7 @@ mod tests { .as_ref() .is_some_and(ResultGuarantee::is_exact), Replacement::Rewrite(_) => true, + Replacement::ExactComposition(_) => false, })); } diff --git a/crates/asap-aware-mapping/src/rollup.rs b/crates/asap-aware-mapping/src/rollup.rs index 5dc3c81e..a5abae8a 100644 --- a/crates/asap-aware-mapping/src/rollup.rs +++ b/crates/asap-aware-mapping/src/rollup.rs @@ -690,7 +690,7 @@ mod tests { .iter() .find_map(|candidate| match &candidate.replacement { Replacement::Rewrite(rewrite) => Some(rewrite), - Replacement::Summary(_) => None, + Replacement::Summary(_) | Replacement::ExactComposition(_) => None, }) .expect("default search must include the roll-up rewrite"); let QueryExpr::Aggregate { child, .. } = rewrite.as_ref() else { diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index afb910e5..0c266e6c 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -251,6 +251,56 @@ struct Winner<'a> { target: &'a Rc, candidate: &'a ReplacementSubDAG, cost: f64, + /// The unit `cost` is in — `None` for the legacy unitless structural + /// estimate, `Some("cost_units_per_second")` for a composed decision + /// `global_selection` costed as a recurring rate (issue #171). + cost_unit: Option<&'static str>, + /// For a `Replacement::ExactComposition` winner: the composed node + /// `GlobalSelection::materialize` linked over the child's own committed + /// decision — the shape actually exported. `None` otherwise. + materialized: Option>, + /// For a composed winner: the child target it was committed with. + child_target: Option<&'a Rc>, +} + +impl Winner<'_> { + /// The post-ASAP node to export for this winner, if it is a bound + /// (`Summary` or materialized composition) shape. + fn summary_node(&self) -> Option> { + match &self.candidate.replacement { + Replacement::Summary(node) => Some(Rc::clone(node)), + Replacement::ExactComposition(_) => self.materialized.clone(), + Replacement::Rewrite(_) => None, + } + } +} + +/// The `DagDecision` for winner `i`, with explicit provenance/unit and — +/// for a composed winner — the child decision it was committed with, so a +/// viewer never infers composition from graph shape (issue #171). +fn decision_for(i: usize, winners: &[Winner<'_>], role: &'static str) -> DagDecision { + let winner = &winners[i]; + let child_decisions = winner + .child_target + .into_iter() + .filter_map(|child| { + winners + .iter() + .position(|w| Rc::ptr_eq(w.target, child)) + .map(|j| j as u32) + }) + .collect(); + DagDecision { + id: i as u32, + strategy: winner.candidate.strategy.to_string(), + rationale: decision_rationale(winner), + rank: 0, + cost: winner.cost, + role, + provenance: Some(format!("{:?}", winner.candidate.provenance)), + cost_unit: winner.cost_unit.map(str::to_string), + child_decisions, + } } /// Short explanation intended for a selected winner in node-level UI. The @@ -280,6 +330,18 @@ fn decision_rationale(winner: &Winner<'_>) -> String { "Derives this smaller top-k from a compatible larger top-k result shared by the workload." .to_string() } + "ExactCompositionStrategy" => match winner.candidate.provenance { + asap_aware_mapping::replacement::ReplacementProvenance::ExactPostProcess => { + "Applies the exact fold at query time over the child's committed summary readout." + .to_string() + } + asap_aware_mapping::replacement::ReplacementProvenance::ExactTransform => { + "Runs the exact row transform on the update path, feeding the maintained summary above it." + .to_string() + } + _ => "Composes an exact operator with a summary plan across an explicit phase boundary." + .to_string(), + }, _ => { let summary = winner .candidate @@ -325,13 +387,14 @@ fn target_replacement( ) -> TargetReplacement { let strategy = winner.candidate.strategy.to_string(); let before = dag_export::export(winner.target); - let after = match &winner.candidate.replacement { - Replacement::Summary(node) => { - TargetReplacementAfter::Summary(dag_export::export_summary(node)) - } - Replacement::Rewrite(rewritten) => { + let after = match (&winner.candidate.replacement, winner.summary_node()) { + (Replacement::Rewrite(rewritten), _) => { TargetReplacementAfter::Rewrite(dag_export::export(rewritten)) } + (_, Some(node)) => TargetReplacementAfter::Summary(dag_export::export_summary(&node)), + (_, None) => { + unreachable!("a composed winner always carries its materialized node") + } }; TargetReplacement { decision_id, @@ -428,6 +491,10 @@ fn run_post_asap_with_progress( .collect(); let space = search_workload(roots); let ranked_groups = space.cost_sorted(&DefaultCostModel); + // Composed decisions (issue #171) are only ever committed by + // `global_selection`, together with the child decision they compose + // over — never by per-group `cost_sorted` ranking. + let selection = space.global_selection(&DefaultCostModel); // A group's top candidate can be `keep_pre_asap`'s own conservative // fallback — `Replacement::Summary(SummaryNode { expr: @@ -454,7 +521,26 @@ fn run_post_asap_with_progress( let winners: Vec> = ranked_groups .iter() .filter_map(|group| { - let candidate = group.candidates.first()?; + if let Some(selected) = selection.for_target(group.target) { + if let (Some(chosen), Some(decision)) = (selected.chosen, &selected.composition) { + let materialized = selection.materialize(group.target).ok().flatten()?; + return Some(Winner { + target: group.target, + candidate: chosen, + cost: decision.cost_rate.units_per_second, + cost_unit: Some(decision.inputs.unit.as_str()), + materialized: Some(materialized), + child_target: Some(decision.child_target), + }); + } + } + // A composition not committed by `global_selection` is never a + // winner on its own — it has no child to compose over. + let (i, candidate) = group + .candidates + .iter() + .enumerate() + .find(|(_, c)| !matches!(c.replacement, Replacement::ExactComposition(_)))?; if matches!( &candidate.replacement, Replacement::Summary(node) if matches!(node.expr, SummaryExpr::KeepPreAsap(_)) @@ -464,7 +550,10 @@ fn run_post_asap_with_progress( Some(Winner { target: group.target, candidate, - cost: group.costs[0], + cost: group.costs[i], + cost_unit: None, + materialized: None, + child_target: None, }) }) .collect(); @@ -507,27 +596,20 @@ fn run_post_asap_with_progress( let mut find_winner = |expr: &QueryExpr| -> Option { let i = lookup_winner(&by_hash, &winners, &mut post_graph_cache, expr)?; let winner = &winners[i]; - let decision = DagDecision { - id: i as u32, - strategy: winner.candidate.strategy.to_string(), - rationale: decision_rationale(winner), - rank: 0, - cost: winner.cost, - role: "replacement_region", - provenance: None, - cost_unit: None, - child_decisions: Vec::new(), - }; - Some(match &winners[i].candidate.replacement { - Replacement::Rewrite(rc) => PostAsapSubstitution::Rewrite { - replacement: Rc::clone(rc), - decision, + let decision = decision_for(i, &winners, "replacement_region"); + Some( + match (&winner.candidate.replacement, winner.summary_node()) { + (Replacement::Rewrite(rc), _) => PostAsapSubstitution::Rewrite { + replacement: Rc::clone(rc), + decision, + }, + (_, Some(node)) => PostAsapSubstitution::Summary { + replacement: node, + decision, + }, + (_, None) => unreachable!("a composed winner always carries its materialized node"), }, - Replacement::Summary(rc) => PostAsapSubstitution::Summary { - replacement: Rc::clone(rc), - decision, - }, - }) + ) }; let post_graphs: Vec<(String, DagGraph)> = lowered_queries .iter() diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs new file mode 100644 index 00000000..643fb52e --- /dev/null +++ b/crates/integration-tests/tests/exact_composition.rs @@ -0,0 +1,676 @@ +//! Issue #171 — composing exact operators with summary plans across +//! explicit update/readout boundaries, end to end through +//! `search_workload_with` → `PlanSpace::global_selection` → +//! `GlobalSelection::materialize` → `dag_export`. +//! +//! Covers the issue's integration matrix: both nesting directions, grouped +//! fine-to-coarse and identity folds, one inner summary shared by several +//! queries, illegal readout-under-maintenance rejection, a runtime without +//! the capability, a cost model without statistics, and pre/post-ASAP +//! schemas plus shared `Rc` identity — along with pins for every +//! already-supported exact-accumulator nesting. + +use std::rc::Rc; + +use asap_aware_mapping::cost_model::{ + CostProvenance, CostUnit, EvaluationRate, ExactCompositionCostInputs, + ExactCompositionCostRequest, MixedExecutionCapabilities, +}; +use asap_aware_mapping::replacement::{ + default_strategies_with, search_workload_with, ImplementError, Replacement, + ReplacementProvenance, ReplacementStrategy, SketchAlgorithmStrategy, TargetSubDAG, +}; +use asap_aware_mapping::{CompositionPhase, CostModel, DefaultCostModel, ExplanationKind}; +use asap_frontend_promql::lower_promql; +use asap_types::dag_export; +use asap_types::post_asap::{ + validate_execution_phases, ExactKind, ExecutionAvailability, PhaseError, SketchAlgorithm, + SummaryExpr, SummaryFamilyType, SummaryNode, +}; +use asap_types::pre_asap::agg_intent::{default_quantile, AggIntent}; +use asap_types::pre_asap::query_expr::{QueryExpr, Reduction, Source}; +use asap_types::pre_asap::schema::{Column, DataType, Schema}; +use asap_types::types::AccuracyTarget; + +// ── fixtures ──────────────────────────────────────────────────────────── + +fn metric_scan(labels: &[&str]) -> QueryExpr { + let mut columns = vec![ + Column::new("ts", DataType::Timestamp, false), + Column::new("value", DataType::Float64, false), + ]; + columns.extend(labels.iter().map(|n| Column::new(*n, DataType::Utf8, true))); + QueryExpr::Scan { + source: Source::TimeSeries { + metric: "latency".into(), + }, + predicates: vec![], + schema: Schema::with_time_index(columns, 0, vec![]), + } +} + +fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { + Rc::new(QueryExpr::Aggregate { + reduction: Reduction::by(by), + measures: vec![intent], + output_names: vec![], + having: None, + child, + }) +} + +fn per_entity(intent: AggIntent, child: Rc) -> Rc { + Rc::new(QueryExpr::Aggregate { + reduction: Reduction::PerEntity, + measures: vec![intent], + output_names: vec![], + having: None, + child, + }) +} + +/// `quantile by (zone, host) (latency)` — the fine-grained inner summary. +fn fine_quantile() -> Rc { + agg( + vec![2, 3], + default_quantile(0.99), + Rc::new(metric_scan(&["zone", "host"])), + ) +} + +/// A deployment cost model that supplies every statistic the issue's +/// formulas need, so a composition can actually win — and advertises both +/// mixed-execution shapes. +struct StatsModel; + +impl CostModel for StatsModel { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + fn exact_composition_cost_inputs( + &self, + _request: &ExactCompositionCostRequest<'_>, + ) -> ExactCompositionCostInputs { + ExactCompositionCostInputs { + exact_cost_per_row: Some(0.1), + expected_input_rows: Some(50.0), + expected_output_rows: Some(10.0), + summary_maintenance_cost_per_update: Some(0.01), + summary_read_cost: Some(1.0), + update_rate: Some(100.0), + evaluation_rate: EvaluationRate::from_intervals(&[std::time::Duration::from_secs(1)]), + raw_recompute_cost: Some(100.0), + unit: CostUnit::CostUnitsPerSecond, + provenance: CostProvenance { + model: "StatsModel".into(), + version: "test-1".into(), + }, + } + } +} + +/// Same statistics, but the runtime advertises no mixed-execution shape. +struct NoCapabilityModel; + +impl CostModel for NoCapabilityModel { + fn rank_candidates( + &self, + _intent: &AggIntent, + candidates: &[SketchAlgorithm], + ) -> Vec { + candidates.to_vec() + } + fn mixed_execution_capabilities(&self) -> MixedExecutionCapabilities { + MixedExecutionCapabilities::NONE + } + fn exact_composition_cost_inputs( + &self, + request: &ExactCompositionCostRequest<'_>, + ) -> ExactCompositionCostInputs { + StatsModel.exact_composition_cost_inputs(request) + } +} + +fn plan( + roots: Vec<(&'static str, Rc)>, + cost_model: &dyn CostModel, +) -> asap_aware_mapping::PlanSpace<&'static str> { + search_workload_with(roots, &default_strategies_with(cost_model)) +} + +fn is_plain(node: &SummaryNode) -> bool { + node.schema + .fields + .iter() + .all(|f| matches!(f.dtype, SummaryFamilyType::Plain(_))) +} + +fn names(node: &SummaryNode) -> Vec<&str> { + node.schema.fields.iter().map(|f| f.name.as_str()).collect() +} + +// ── step 1: pin every already-supported exact-accumulator nesting ─────── + +#[test] +fn every_exact_accumulator_nests_directly_under_an_outer_sketch() { + use std::time::Duration; + let cases: Vec<(Rc, ExactKind)> = vec![ + ( + agg( + vec![2], + AggIntent::Sum { col: None }, + Rc::new(metric_scan(&["zone"])), + ), + ExactKind::Sum, + ), + ( + agg( + vec![2], + AggIntent::Count { + accuracy: AccuracyTarget::Exact, + }, + Rc::new(metric_scan(&["zone"])), + ), + ExactKind::Count, + ), + ( + agg( + vec![2], + AggIntent::Min { col: None }, + Rc::new(metric_scan(&["zone"])), + ), + ExactKind::MinMax, + ), + ( + agg( + vec![2], + AggIntent::Max { col: None }, + Rc::new(metric_scan(&["zone"])), + ), + ExactKind::MinMax, + ), + ( + per_entity( + AggIntent::Rate, + Rc::new(QueryExpr::TimeRange { + range: Duration::from_secs(300), + child: Rc::new(metric_scan(&["zone"])), + }), + ), + ExactKind::Rate, + ), + ( + per_entity( + AggIntent::Increase, + Rc::new(QueryExpr::TimeRange { + range: Duration::from_secs(300), + child: Rc::new(metric_scan(&["zone"])), + }), + ), + ExactKind::Increase, + ), + ]; + for (inner, kind) in cases { + let outer = agg(vec![], default_quantile(0.9), inner); + let target = TargetSubDAG::new(&outer); + let candidates = SketchAlgorithmStrategy::default_cost_model().replacements(&target); + let Replacement::Summary(root) = &candidates[0].replacement else { + unreachable!() + }; + let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { + panic!("expected KLL readout, got {:?}", root.expr); + }; + let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { + panic!("expected outer SummaryAgg"); + }; + assert!( + matches!( + &child.expr, + SummaryExpr::SummaryAgg { family: SummaryFamilyType::ExactAggregate(k, _), .. } if *k == kind + ), + "{kind:?}: expected the exact accumulator directly under the outer sketch, got {:?}", + child.expr + ); + validate_execution_phases(root).expect("accumulator state composes under maintenance"); + } +} + +// ── direction 1: outer exact fold over an inner summary readout ──────── + +/// Before this PR both `max`/`avg` over a quantile collapsed into one +/// opaque `KeepPreAsap`. Now: the outer group holds an `ExactPostProcess` +/// candidate referencing the inner target, the inner group keeps its own +/// sketch candidates, and with statistics the pair is committed and +/// materializes as `ExactPostProcess → SummaryEstimate → SummaryAgg`. +#[test] +fn max_and_avg_over_quantile_compose_as_post_process_with_statistics() { + for intent in [AggIntent::Max { col: None }, AggIntent::Avg { col: None }] { + let root = agg(vec![0], intent.clone(), fine_quantile()); + let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); + let root = Rc::clone(&space.roots[0].1); + let QueryExpr::Aggregate { child: inner, .. } = root.as_ref() else { + unreachable!() + }; + + let outer_group = space.group_for(&root).unwrap(); + assert!( + outer_group + .candidates + .iter() + .any(|c| c.provenance == ReplacementProvenance::ExactPostProcess), + "{intent:?}: outer group must hold an ExactPostProcess candidate" + ); + let inner_group = space.group_for(inner).unwrap(); + assert!( + inner_group + .candidates + .iter() + .any(|c| matches!(&c.replacement, Replacement::Summary(n) + if matches!(n.expr, SummaryExpr::SummaryEstimate { .. }))), + "{intent:?}: the inner quantile keeps its own readout candidates" + ); + + let selection = space.global_selection(&StatsModel); + let selected = selection.for_target(&root).unwrap(); + let chosen = selected.chosen.expect("a decision"); + assert_eq!(chosen.provenance, ReplacementProvenance::ExactPostProcess); + let decision = selected + .composition + .as_ref() + .expect("composition provenance"); + assert!(Rc::ptr_eq(decision.child_target, inner)); + assert!(decision.cost_rate < decision.baseline_rate); + assert_eq!(decision.inputs.unit, CostUnit::CostUnitsPerSecond); + assert_eq!(decision.inputs.provenance.model, "StatsModel"); + // The child was committed to a compatible candidate *from its own + // group* — the same candidate its own selection reports. + let child_candidate = decision.child_candidate.expect("post-process child"); + let inner_selected = selection.for_target(inner).unwrap(); + assert!(std::ptr::eq( + inner_selected.chosen.unwrap(), + child_candidate + )); + + let composed = selection.materialize(&root).unwrap().unwrap(); + let SummaryExpr::ExactPostProcess { child, .. } = &composed.expr else { + panic!( + "{intent:?}: expected ExactPostProcess root, got {:?}", + composed.expr + ); + }; + assert!(matches!(child.expr, SummaryExpr::SummaryEstimate { .. })); + let child_guarantee = child.guarantee.as_ref().expect("child guarantee"); + let composed_guarantee = composed + .guarantee + .as_ref() + .expect("exact post-process must propagate the child's guarantee"); + assert_eq!(composed_guarantee.metric, child_guarantee.metric); + assert_eq!( + composed_guarantee.bound.evaluate(), + child_guarantee.bound.evaluate(), + "an exact max/average fold retains the modeled error magnitude" + ); + assert!(is_plain(&composed)); + assert_eq!( + names(&composed), + root.output_schema() + .unwrap() + .columns + .iter() + .map(|c| c.name.as_str()) + .collect::>(), + "the composed plan's schema is the pre-ASAP target's own" + ); + validate_execution_phases(&composed).unwrap(); + } +} + +/// `avg` keeps competing with `AvgToSumOverCountStrategy`: both candidates +/// live in the same group; nothing hard-codes the winner. +#[test] +fn avg_over_quantile_keeps_the_sum_over_count_rewrite_as_a_competitor() { + // `by (zone)` over `by (zone)`: the averaged column resolves to the + // non-null quantile output, which is what the rewrite requires. + let inner = agg( + vec![2], + default_quantile(0.99), + Rc::new(metric_scan(&["zone"])), + ); + let root = agg(vec![0], AggIntent::Avg { col: None }, inner); + let space = plan(vec![("q", root)], &StatsModel); + let group = space.group_for(&space.roots[0].1).unwrap(); + let provenances: Vec<_> = group.candidates.iter().map(|c| c.provenance).collect(); + assert!(provenances.contains(&ReplacementProvenance::LogicalRewrite)); + assert!(provenances.contains(&ReplacementProvenance::ExactPostProcess)); +} + +/// Grouped fine-to-coarse fold (`by (zone)` over `by (zone, host)`) and the +/// identity fold (`by (zone)` over `by (zone)`) both compose; the operator +/// is the same, only the fold's row multiplicity differs. +#[test] +fn identity_and_genuine_multi_row_folds_both_compose() { + let identity_inner = agg( + vec![2], + default_quantile(0.99), + Rc::new(metric_scan(&["zone"])), + ); + for (label, inner) in [ + ("identity", identity_inner), + ("fine-to-coarse", fine_quantile()), + ] { + let root = agg(vec![0], AggIntent::Max { col: None }, inner); + let space = plan(vec![("q", root)], &StatsModel); + let root = &space.roots[0].1; + let composed = space + .global_selection(&StatsModel) + .materialize(root) + .unwrap() + .unwrap(); + assert!( + matches!(composed.expr, SummaryExpr::ExactPostProcess { .. }), + "{label}: {:?}", + composed.expr + ); + assert_eq!(names(&composed), vec!["zone", "max"], "{label}"); + } +} + +/// One inner quantile consumed by two outer folds in two queries: CSE +/// collapses the inner target onto one `Rc`, both compositions commit to +/// the *same* child candidate, and both materializations share one +/// `Rc` for it — the summary is maintained once. +#[test] +fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { + let max = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); + let min = agg(vec![0], AggIntent::Min { col: None }, fine_quantile()); + let space = plan(vec![("max", max), ("min", min)], &StatsModel); + let selection = space.global_selection(&StatsModel); + + let roots: Vec> = space.roots.iter().map(|(_, r)| Rc::clone(r)).collect(); + let inner_of = |r: &Rc| match r.as_ref() { + QueryExpr::Aggregate { child, .. } => Rc::clone(child), + _ => unreachable!(), + }; + assert!( + Rc::ptr_eq(&inner_of(&roots[0]), &inner_of(&roots[1])), + "CSE must intern the shared inner quantile" + ); + let inner = inner_of(&roots[0]); + assert_eq!(space.group_for(&inner).unwrap().consumer_count, 2); + + let decisions: Vec<_> = roots + .iter() + .map(|r| { + selection + .for_target(r) + .unwrap() + .composition + .as_ref() + .expect("both roots compose") + }) + .collect(); + assert!(std::ptr::eq( + decisions[0].child_candidate.unwrap(), + decisions[1].child_candidate.unwrap() + )); + // Shared state counted once: the second parent sees zero marginal + // maintenance, so its rate is strictly lower than the first's. + assert!(decisions[1].cost_rate < decisions[0].cost_rate); + + let composed: Vec<_> = roots + .iter() + .map(|r| selection.materialize(r).unwrap().unwrap()) + .collect(); + let child_of = |n: &Rc| match &n.expr { + SummaryExpr::ExactPostProcess { child, .. } => Rc::clone(child), + other => panic!("expected ExactPostProcess, got {other:?}"), + }; + assert!( + Rc::ptr_eq(&child_of(&composed[0]), &child_of(&composed[1])), + "both folds compose over the same Rc" + ); +} + +// ── direction 2: outer summary over an inner exact update-path transform ─ + +/// `quantile(0.99, deriv(latency[5m]))`: `deriv` has no accumulator form. +/// The transform target gets an `ExactTransform` candidate; with a +/// maintained summary above it and statistics, it is committed, and the +/// outer summary's materialization is re-linked over it. +#[test] +fn outer_summary_over_an_exact_transform_composes_on_the_update_path() { + use std::time::Duration; + let deriv = per_entity( + AggIntent::Deriv, + Rc::new(QueryExpr::TimeRange { + range: Duration::from_secs(300), + child: Rc::new(metric_scan(&["zone"])), + }), + ); + let root = agg(vec![], default_quantile(0.99), deriv); + let space = plan(vec![("q", root)], &StatsModel); + let root = Rc::clone(&space.roots[0].1); + let QueryExpr::Aggregate { child: deriv, .. } = root.as_ref() else { + unreachable!() + }; + assert!(space + .group_for(deriv) + .unwrap() + .candidates + .iter() + .any(|c| c.provenance == ReplacementProvenance::ExactTransform)); + + let selection = space.global_selection(&StatsModel); + let deriv_sel = selection.for_target(deriv).unwrap(); + assert_eq!( + deriv_sel.chosen.unwrap().provenance, + ReplacementProvenance::ExactTransform + ); + let decision = deriv_sel.composition.as_ref().unwrap(); + assert!(decision.child_candidate.is_none(), "transform input is raw"); + assert!(decision.cost_rate < decision.baseline_rate); + + let composed = selection.materialize(&root).unwrap().unwrap(); + let SummaryExpr::SummaryEstimate { summary_input, .. } = &composed.expr else { + panic!("expected readout root, got {:?}", composed.expr); + }; + let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { + panic!("expected SummaryAgg"); + }; + let SummaryExpr::ExactTransform { child: raw, .. } = &child.expr else { + panic!( + "expected ExactTransform under the maintained summary, got {:?}", + child.expr + ); + }; + assert!(matches!(raw.expr, SummaryExpr::KeepPreAsap(_))); + let assignment = validate_execution_phases(&composed).unwrap(); + assert_eq!( + assignment.stage_of(child), + Some(ExecutionAvailability::UpdateValue) + ); + assert_eq!( + assignment.stage_of(raw), + Some(ExecutionAvailability::UpdateValue) + ); +} + +// ── rejection, capability, statistics ─────────────────────────────────── + +/// A maintained summary above a query-time readout is a typed plan-time +/// error, both for the construction path and for a hand-built plan. +#[test] +fn readout_under_maintenance_is_rejected_at_construction() { + let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); + let candidates = + SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); + // The MinMax accumulator over the quantile readout is not constructible; + // the strategy reports the conservative fallback once instead. + assert_eq!(candidates.len(), 1); + let Replacement::Summary(node) = &candidates[0].replacement else { + unreachable!() + }; + assert!(matches!(node.expr, SummaryExpr::KeepPreAsap(_))); + + // ExactPostProcess can never be placed under a SummaryAgg: compose a + // post-process, then try to maintain a summary over it. + let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); + let post = space + .global_selection(&StatsModel) + .materialize(&space.roots[0].1) + .unwrap() + .unwrap(); + let illegal = Rc::new(SummaryNode { + expr: SummaryExpr::SummaryAgg { + child: post, + family: SummaryFamilyType::ExactAggregate( + ExactKind::MinMax, + asap_types::post_asap::ExactParams::MinMax, + ), + col: asap_types::pre_asap::ColumnRef::SampleValue, + reduction: Reduction::by(vec![]), + grouping: Default::default(), + }, + schema: asap_types::post_asap::SummarySchema { + fields: vec![], + time_index: None, + }, + guarantee: None, + }); + assert!(matches!( + validate_execution_phases(&illegal), + Err(PhaseError::ReadoutUnderMaintenance { .. }) + )); + let err: ImplementError = validate_execution_phases(&illegal).unwrap_err().into(); + assert!(matches!(err, ImplementError::Phase(_))); +} + +#[test] +fn a_runtime_without_mixed_execution_gets_no_composition_candidates() { + let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); + let space = plan(vec![("q", root)], &NoCapabilityModel); + let root = Rc::clone(&space.roots[0].1); + let group = space.group_for(&root).unwrap(); + assert!(group + .candidates + .iter() + .all(|c| !matches!(c.replacement, Replacement::ExactComposition(_)))); + let selection = space.global_selection(&NoCapabilityModel); + assert!(selection.for_target(&root).unwrap().composition.is_none()); + let node = selection.materialize(&root).unwrap().unwrap(); + assert!(matches!(node.expr, SummaryExpr::KeepPreAsap(_))); + // The inner quantile is still independently selectable. + let QueryExpr::Aggregate { child, .. } = root.as_ref() else { + unreachable!() + }; + assert!(selection.for_target(child).unwrap().chosen.is_some()); +} + +/// Without statistics (the built-in model) the composition is *proposed* +/// — visible in `PlanSpace` and explanations — but never *selected*: the +/// site keeps the conservative `KeepPreAsap`, and the inner summary stays +/// independently selectable. +#[test] +fn missing_cost_statistics_preserve_the_conservative_keep_pre_asap() { + let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); + let space = plan(vec![("q", root)], &DefaultCostModel); + let root = Rc::clone(&space.roots[0].1); + assert!(space + .group_for(&root) + .unwrap() + .candidates + .iter() + .any(|c| c.provenance == ReplacementProvenance::ExactPostProcess)); + let selection = space.global_selection(&DefaultCostModel); + let selected = selection.for_target(&root).unwrap(); + assert!(selected.composition.is_none()); + assert!(!matches!( + selected.chosen.map(|c| &c.replacement), + Some(Replacement::ExactComposition(_)) + )); + let node = selection.materialize(&root).unwrap().unwrap(); + assert!(matches!(node.expr, SummaryExpr::KeepPreAsap(_))); + + let explanations = asap_aware_mapping::explain_replacements(vec![("q", (*root).clone())]); + assert!(explanations + .iter() + .any(|e| e.kind == ExplanationKind::ExactComposition)); +} + +// ── DAG export: explicit stage, schema, provenance ─────────────────────── + +#[test] +fn dag_export_carries_explicit_stage_and_plain_schema_for_a_composed_plan() { + let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); + let space = plan(vec![("q", root)], &StatsModel); + let root = &space.roots[0].1; + let composed = space + .global_selection(&StatsModel) + .materialize(root) + .unwrap() + .unwrap(); + let graph = dag_export::export_summary(&composed); + let node = &graph.nodes[graph.root as usize]; + assert_eq!(node.kind, "ExactPostProcess"); + assert_eq!(node.detail["stage"], "readout_value"); + assert_eq!(node.detail["op"], "Aggregate"); + let stages: Vec<(&str, String)> = graph + .nodes + .iter() + .map(|n| (n.kind, n.detail["stage"].as_str().unwrap().to_string())) + .collect(); + assert!(stages.contains(&("SummaryEstimate", "readout_value".into()))); + assert!(stages.contains(&("SummaryAgg", "summary_state".into()))); + assert!(stages.contains(&("KeepPreAsap", "update_value".into()))); + + // Pre-ASAP export of the same target still describes the same columns. + let pre = dag_export::export(root); + let pre_root = &pre.nodes[pre.root as usize]; + let pre_cols: Vec = pre_root.schema.as_ref().unwrap()["columns"] + .as_array() + .unwrap() + .iter() + .map(|c| c["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(pre_cols, names(&composed)); +} + +/// The PromQL front end produces the exact issue shape and it composes. +#[test] +fn promql_max_by_zone_over_quantile_over_time_composes() { + let expr = lower_promql( + "max by (zone) (quantile_over_time(0.99, latency[5m]))", + AccuracyTarget::Epsilon(0.01), + ) + .unwrap(); + let space = plan(vec![("q", Rc::new(expr))], &StatsModel); + let root = &space.roots[0].1; + let selection = space.global_selection(&StatsModel); + let selected = selection.for_target(root).unwrap(); + assert_eq!( + selected.chosen.map(|c| c.provenance), + Some(ReplacementProvenance::ExactPostProcess), + "{:?}", + space + .group_for(root) + .unwrap() + .candidates + .iter() + .map(|c| (c.strategy, c.provenance)) + .collect::>() + ); + let composed = selection.materialize(root).unwrap().unwrap(); + assert!(matches!( + composed.expr, + SummaryExpr::ExactPostProcess { .. } + )); + assert_eq!( + selected.composition.as_ref().map(|d| d.inputs.unit), + Some(CostUnit::CostUnitsPerSecond) + ); + let _ = CompositionPhase::PostProcess; +} From 82f03e33d83adfa5d848b3e4e8c6dd053532d00c Mon Sep 17 00:00:00 2001 From: zz_y Date: Sat, 29 Aug 2026 11:59:05 -0600 Subject: [PATCH 08/15] refactor(ir): generalize phase-aware value operations --- .../src/exact_composition.rs | 34 +++++++++++++------ .../tests/exact_composition.rs | 12 +++---- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index 086e65ab..330c7e7a 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -21,7 +21,7 @@ //! only consume it as an opaque raw `KeepPreAsap` blob today, with no //! explicit "this row transform runs on the update path" node. //! -//! [`SummaryExpr::ExactPostProcess`] and [`SummaryExpr::ExactTransform`] +//! [`SummaryExpr::ReadoutPostProcess`] and [`SummaryExpr::UpdateTransform`] //! are the two phase-explicit representations; this strategy is what //! proposes them. //! @@ -77,7 +77,7 @@ use std::rc::Rc; use asap_types::post_asap::phase::validate_execution_phases_at; use asap_types::post_asap::{ exact_operator_output_schema, produced_availability, CompositionOperator, ExactOperator, - ExecutionAvailability, SummaryExpr, SummaryNode, SummarySchema, + ExecutionAvailability, PhaseError, SummaryExpr, SummaryNode, SummarySchema, ValueOperator, }; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; @@ -95,9 +95,9 @@ use crate::{AccuracyModel, DefaultAccuracyModel, PropagationStats}; /// [`ExactComposition::compose`] builds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CompositionPhase { - /// [`SummaryExpr::ExactPostProcess`]: after the child's readout. + /// [`SummaryExpr::ReadoutPostProcess`]: after the child's readout. PostProcess, - /// [`SummaryExpr::ExactTransform`]: on the update path, feeding + /// [`SummaryExpr::UpdateTransform`]: on the update path, feeding /// maintained state above. Transform, } @@ -166,6 +166,18 @@ impl ExactComposition { child: Rc, accuracy_model: &dyn AccuracyModel, ) -> Result, ImplementError> { + if let Some(produced) = produced_availability(&child.expr) { + if produced != self.phase.availability() { + let edge = match self.phase { + CompositionPhase::Transform => "UpdateTransform.child", + CompositionPhase::PostProcess => "ReadoutPostProcess.child", + }; + return Err(ImplementError::Phase(PhaseError::IllegalChildPhase { + edge, + child: produced, + })); + } + } let schema = exact_operator_output_schema(&self.op, &child.schema)?; let guarantee = match &child.guarantee { None => None, @@ -192,13 +204,13 @@ impl ExactComposition { } }; let expr = match self.phase { - CompositionPhase::PostProcess => SummaryExpr::ExactPostProcess { + CompositionPhase::PostProcess => SummaryExpr::ReadoutPostProcess { child, - op: self.op.clone(), + op: ValueOperator::Exact(self.op.clone()), }, - CompositionPhase::Transform => SummaryExpr::ExactTransform { + CompositionPhase::Transform => SummaryExpr::UpdateTransform { child, - op: self.op.clone(), + op: ValueOperator::Exact(self.op.clone()), }, }; let node = Rc::new(SummaryNode { @@ -610,14 +622,14 @@ mod tests { assert!(!comp.accepts_child(summary_input)); assert!(matches!( comp.compose(Rc::clone(summary_input)), - Err(ImplementError::ExactOperatorSchema(_)) + Err(ImplementError::Phase(PhaseError::IllegalChildPhase { .. })) )); // The readout itself is accepted and composes to a plain schema. assert!(comp.accepts_child(&state_child)); let composed = comp.compose(state_child).unwrap(); assert!(matches!( composed.expr, - SummaryExpr::ExactPostProcess { .. } + SummaryExpr::ReadoutPostProcess { .. } )); assert!(composed .schema @@ -647,7 +659,7 @@ mod tests { assert!(comp.accepts_child(&raw)); assert!(matches!( comp.compose(raw).unwrap().expr, - SummaryExpr::ExactTransform { .. } + SummaryExpr::UpdateTransform { .. } )); } } diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index 643fb52e..b21fad95 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -296,7 +296,7 @@ fn max_and_avg_over_quantile_compose_as_post_process_with_statistics() { )); let composed = selection.materialize(&root).unwrap().unwrap(); - let SummaryExpr::ExactPostProcess { child, .. } = &composed.expr else { + let SummaryExpr::ReadoutPostProcess { child, .. } = &composed.expr else { panic!( "{intent:?}: expected ExactPostProcess root, got {:?}", composed.expr @@ -371,7 +371,7 @@ fn identity_and_genuine_multi_row_folds_both_compose() { .unwrap() .unwrap(); assert!( - matches!(composed.expr, SummaryExpr::ExactPostProcess { .. }), + matches!(composed.expr, SummaryExpr::ReadoutPostProcess { .. }), "{label}: {:?}", composed.expr ); @@ -426,7 +426,7 @@ fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { .map(|r| selection.materialize(r).unwrap().unwrap()) .collect(); let child_of = |n: &Rc| match &n.expr { - SummaryExpr::ExactPostProcess { child, .. } => Rc::clone(child), + SummaryExpr::ReadoutPostProcess { child, .. } => Rc::clone(child), other => panic!("expected ExactPostProcess, got {other:?}"), }; assert!( @@ -481,7 +481,7 @@ fn outer_summary_over_an_exact_transform_composes_on_the_update_path() { let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { panic!("expected SummaryAgg"); }; - let SummaryExpr::ExactTransform { child: raw, .. } = &child.expr else { + let SummaryExpr::UpdateTransform { child: raw, .. } = &child.expr else { panic!( "expected ExactTransform under the maintained summary, got {:?}", child.expr @@ -615,7 +615,7 @@ fn dag_export_carries_explicit_stage_and_plain_schema_for_a_composed_plan() { .unwrap(); let graph = dag_export::export_summary(&composed); let node = &graph.nodes[graph.root as usize]; - assert_eq!(node.kind, "ExactPostProcess"); + assert_eq!(node.kind, "ReadoutPostProcess"); assert_eq!(node.detail["stage"], "readout_value"); assert_eq!(node.detail["op"], "Aggregate"); let stages: Vec<(&str, String)> = graph @@ -666,7 +666,7 @@ fn promql_max_by_zone_over_quantile_over_time_composes() { let composed = selection.materialize(root).unwrap().unwrap(); assert!(matches!( composed.expr, - SummaryExpr::ExactPostProcess { .. } + SummaryExpr::ReadoutPostProcess { .. } )); assert_eq!( selected.composition.as_ref().map(|d| d.inputs.unit), From 125e1925fc94b10796c052562c6c77f56a370896 Mon Sep 17 00:00:00 2001 From: zz_y Date: Sat, 29 Aug 2026 14:41:41 -0600 Subject: [PATCH 09/15] fix(cost): reuse canonical recurrence rate types --- crates/asap-aware-mapping/src/cost_model.rs | 100 +++--------------- crates/asap-aware-mapping/src/lib.rs | 4 +- crates/asap-aware-mapping/src/replacement.rs | 12 +-- .../tests/exact_composition.rs | 10 +- 4 files changed, 24 insertions(+), 102 deletions(-) diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index bb256b47..d9ff87fe 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -58,7 +58,8 @@ use asap_types::pre_asap::query_expr::QueryExpr; use crate::exact_composition::{CompositionPhase, ExactComposition}; use crate::recurrence::{ - self, Horizon, RecurrenceCostExplanation, RecurrenceError, RecurrenceProfile, + self, CostRate, EvaluationRate, Horizon, RecurrenceCostExplanation, RecurrenceError, + RecurrenceProfile, }; use crate::replacement::{ realize_child, Implementation, Replacement, ReplacementProvenance, ReplacementSubDAG, @@ -88,54 +89,6 @@ impl CostUnit { } } -/// A recurring cost in [`CostUnit::CostUnitsPerSecond`]. Distinct from the -/// unitless one-shot [`Cost`] so the two can never be added or compared by -/// accident. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct CostRate { - pub units_per_second: f64, -} - -impl CostRate { - pub const UNIT: CostUnit = CostUnit::CostUnitsPerSecond; - - /// `total_cost(H) = recurring_cost_rate * H + one_shot_cost` — the cost - /// of running this rate for a finite horizon of `horizon_seconds`, - /// plus any one-shot work (`Cost` is unitless and treated as the same - /// abstract cost unit). - pub fn total_over_horizon(self, horizon_seconds: f64, one_shot: Cost) -> f64 { - self.units_per_second * horizon_seconds + one_shot.0 - } -} - -/// How often a plan is evaluated, in evaluations per second. For a shared -/// plan serving several repeating consumers, -/// `evaluation_rate = Σ 1 / query_interval_i` — see -/// [`EvaluationRate::from_intervals`]. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct EvaluationRate { - pub per_second: f64, -} - -impl EvaluationRate { - /// `Σ 1 / interval_i` over every consumer's own evaluation interval. - /// Non-positive/non-finite intervals contribute nothing (they describe - /// no repeating consumer). Returns `None` for an empty consumer set — - /// an unknown rate stays unknown, never zero. - pub fn from_intervals(intervals: &[std::time::Duration]) -> Option { - let mut per_second = 0.0; - let mut any = false; - for interval in intervals { - let secs = interval.as_secs_f64(); - if secs.is_finite() && secs > 0.0 { - per_second += 1.0 / secs; - any = true; - } - } - any.then_some(Self { per_second }) - } -} - /// Who produced a set of [`ExactCompositionCostInputs`], and under which /// model version — carried into every composed decision's explanation and /// DAG export so a reviewer can tell a deployment's measured numbers from @@ -278,7 +231,7 @@ pub fn postprocess_plan_cost_rate(inputs: &ExactCompositionCostInputs) -> Option let maintenance = inputs.update_rate? * inputs.summary_maintenance_cost_per_update?; let per_eval = inputs.summary_read_cost? + inputs.expected_output_rows? * inputs.exact_cost_per_row?; - let evaluation = inputs.evaluation_rate?.per_second * per_eval; + let evaluation = inputs.evaluation_rate?.0 * per_eval; finite_rate(maintenance + evaluation) } @@ -295,7 +248,7 @@ pub fn postprocess_plan_cost_rate(inputs: &ExactCompositionCostInputs) -> Option pub fn pretransform_plan_cost_rate(inputs: &ExactCompositionCostInputs) -> Option { let per_update = inputs.exact_cost_per_row? + inputs.summary_maintenance_cost_per_update?; let maintenance = inputs.update_rate? * per_update; - let evaluation = inputs.evaluation_rate?.per_second * inputs.summary_read_cost?; + let evaluation = inputs.evaluation_rate?.0 * inputs.summary_read_cost?; finite_rate(maintenance + evaluation) } @@ -307,13 +260,13 @@ pub fn pretransform_plan_cost_rate(inputs: &ExactCompositionCostInputs) -> Optio /// /// `None` if either input is unknown — see [`ExactCompositionCostInputs`]. pub fn raw_recompute_cost_rate(inputs: &ExactCompositionCostInputs) -> Option { - finite_rate(inputs.evaluation_rate?.per_second * inputs.raw_recompute_cost?) + finite_rate(inputs.evaluation_rate?.0 * inputs.raw_recompute_cost?) } fn finite_rate(units_per_second: f64) -> Option { units_per_second .is_finite() - .then_some(CostRate { units_per_second }) + .then_some(CostRate(units_per_second)) } /// A CSE-detected, legality-gated shared subtree with two or more consumers @@ -1114,7 +1067,7 @@ mod tests { summary_maintenance_cost_per_update: Some(0.01), summary_read_cost: Some(1.0), update_rate: Some(100.0), - evaluation_rate: Some(EvaluationRate { per_second: 2.0 }), + evaluation_rate: Some(EvaluationRate(2.0)), raw_recompute_cost: Some(100.0), unit: CostUnit::CostUnitsPerSecond, provenance: CostProvenance { @@ -1128,32 +1081,14 @@ mod tests { fn composition_formulas_match_the_issue_definitions() { let inputs = known_inputs(); // 100 * 0.01 + 2 * (1 + 10 * 0.1) = 1 + 4 = 5 - assert_eq!( - postprocess_plan_cost_rate(&inputs) - .unwrap() - .units_per_second, - 5.0 - ); + assert_eq!(postprocess_plan_cost_rate(&inputs).unwrap().0, 5.0); // 100 * (0.1 + 0.01) + 2 * 1 = 11 + 2 = 13 - assert!( - (pretransform_plan_cost_rate(&inputs) - .unwrap() - .units_per_second - - 13.0) - .abs() - < 1e-9 - ); + assert!((pretransform_plan_cost_rate(&inputs).unwrap().0 - 13.0).abs() < 1e-9); // 2 * 100 + assert_eq!(raw_recompute_cost_rate(&inputs).unwrap().0, 200.0); assert_eq!( - raw_recompute_cost_rate(&inputs).unwrap().units_per_second, - 200.0 - ); - assert_eq!( - CostRate { - units_per_second: 5.0 - } - .total_over_horizon(10.0, Cost(3.0)), - 53.0 + crate::recurrence::total_cost(CostRate(5.0), Horizon(10.0), Cost(3.0)), + Cost(53.0) ); } @@ -1169,17 +1104,6 @@ mod tests { assert_eq!(raw_recompute_cost_rate(&unknown), None); } - #[test] - fn evaluation_rate_sums_reciprocal_intervals() { - use std::time::Duration; - let rate = - EvaluationRate::from_intervals(&[Duration::from_secs(10), Duration::from_secs(5)]) - .unwrap(); - assert!((rate.per_second - 0.3).abs() < 1e-12); - assert_eq!(EvaluationRate::from_intervals(&[]), None); - assert_eq!(EvaluationRate::from_intervals(&[Duration::ZERO]), None); - } - #[test] fn default_model_advertises_capabilities_but_no_statistics() { assert_eq!( diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index a2b565d9..4f323aca 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -201,8 +201,8 @@ pub use accuracy::{ pub use accuracy_reconciliation::AccuracyReconciliationStrategy; pub use cost_model::{ postprocess_plan_cost_rate, pretransform_plan_cost_rate, raw_recompute_cost_rate, CostModel, - CostProvenance, CostRate, CostUnit, DefaultCostModel, EvaluationRate, - ExactCompositionCostInputs, ExactCompositionCostRequest, MixedExecutionCapabilities, + CostProvenance, CostUnit, DefaultCostModel, ExactCompositionCostInputs, + ExactCompositionCostRequest, MixedExecutionCapabilities, }; pub use exact_composition::{CompositionPhase, ExactComposition, ExactCompositionStrategy}; pub use explanation::{ diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index b9730e7a..451094a9 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -373,11 +373,12 @@ use crate::accuracy::{ }; use crate::accuracy_reconciliation::AccuracyReconciliationStrategy; use crate::cost_model::{ - raw_recompute_cost_rate, CostModel, CostRate, CseCandidate, DefaultCostModel, - ExactCompositionCostInputs, ExactCompositionCostRequest, ShareDecision, + raw_recompute_cost_rate, CostModel, CseCandidate, DefaultCostModel, ExactCompositionCostInputs, + ExactCompositionCostRequest, ShareDecision, }; use crate::exact_composition::{CompositionPhase, ExactComposition, ExactCompositionStrategy}; use crate::grouping::HydraGroupingStrategy; +use crate::recurrence::CostRate; use crate::recurrence::{ evaluation_rate_of, Horizon, RecurrenceError, RecurrenceProfile, RootRecurrence, UpdateRate, }; @@ -3193,12 +3194,7 @@ impl PlanSpace { } else { composition_options(group, &self.groups, effective, cost_model, &context) .into_iter() - .min_by(|a, b| { - a.decision - .cost_rate - .units_per_second - .total_cmp(&b.decision.cost_rate.units_per_second) - }) + .min_by(|a, b| a.decision.cost_rate.0.total_cmp(&b.decision.cost_rate.0)) }; if let Some(option) = &composed { if let Some(child_candidate) = option.decision.child_candidate { diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index b21fad95..f47dbc0c 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -13,14 +13,16 @@ use std::rc::Rc; use asap_aware_mapping::cost_model::{ - CostProvenance, CostUnit, EvaluationRate, ExactCompositionCostInputs, - ExactCompositionCostRequest, MixedExecutionCapabilities, + CostProvenance, CostUnit, ExactCompositionCostInputs, ExactCompositionCostRequest, + MixedExecutionCapabilities, }; use asap_aware_mapping::replacement::{ default_strategies_with, search_workload_with, ImplementError, Replacement, ReplacementProvenance, ReplacementStrategy, SketchAlgorithmStrategy, TargetSubDAG, }; -use asap_aware_mapping::{CompositionPhase, CostModel, DefaultCostModel, ExplanationKind}; +use asap_aware_mapping::{ + CompositionPhase, CostModel, DefaultCostModel, EvaluationRate, ExplanationKind, +}; use asap_frontend_promql::lower_promql; use asap_types::dag_export; use asap_types::post_asap::{ @@ -102,7 +104,7 @@ impl CostModel for StatsModel { summary_maintenance_cost_per_update: Some(0.01), summary_read_cost: Some(1.0), update_rate: Some(100.0), - evaluation_rate: EvaluationRate::from_intervals(&[std::time::Duration::from_secs(1)]), + evaluation_rate: Some(EvaluationRate(1.0)), raw_recompute_cost: Some(100.0), unit: CostUnit::CostUnitsPerSecond, provenance: CostProvenance { From 24c2d41d5c66986f6340de75feadfb65a7efae16 Mon Sep 17 00:00:00 2001 From: zz_y Date: Sat, 29 Aug 2026 16:42:14 -0600 Subject: [PATCH 10/15] fix(export): use canonical recurrence cost rate --- crates/devtools/src/bin/dag_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index 0c266e6c..5ed515da 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -527,7 +527,7 @@ fn run_post_asap_with_progress( return Some(Winner { target: group.target, candidate: chosen, - cost: decision.cost_rate.units_per_second, + cost: decision.cost_rate.0, cost_unit: Some(decision.inputs.unit.as_str()), materialized: Some(materialized), child_target: Some(decision.child_target), From 779f1d3f21fa88d75525843df5b766d1f0ab1861 Mon Sep 17 00:00:00 2001 From: zz_y Date: Sat, 29 Aug 2026 16:46:29 -0600 Subject: [PATCH 11/15] chore(test): defer exact composition E2E coverage --- .../tests/exact_composition.rs | 678 ------------------ 1 file changed, 678 deletions(-) delete mode 100644 crates/integration-tests/tests/exact_composition.rs diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs deleted file mode 100644 index f47dbc0c..00000000 --- a/crates/integration-tests/tests/exact_composition.rs +++ /dev/null @@ -1,678 +0,0 @@ -//! Issue #171 — composing exact operators with summary plans across -//! explicit update/readout boundaries, end to end through -//! `search_workload_with` → `PlanSpace::global_selection` → -//! `GlobalSelection::materialize` → `dag_export`. -//! -//! Covers the issue's integration matrix: both nesting directions, grouped -//! fine-to-coarse and identity folds, one inner summary shared by several -//! queries, illegal readout-under-maintenance rejection, a runtime without -//! the capability, a cost model without statistics, and pre/post-ASAP -//! schemas plus shared `Rc` identity — along with pins for every -//! already-supported exact-accumulator nesting. - -use std::rc::Rc; - -use asap_aware_mapping::cost_model::{ - CostProvenance, CostUnit, ExactCompositionCostInputs, ExactCompositionCostRequest, - MixedExecutionCapabilities, -}; -use asap_aware_mapping::replacement::{ - default_strategies_with, search_workload_with, ImplementError, Replacement, - ReplacementProvenance, ReplacementStrategy, SketchAlgorithmStrategy, TargetSubDAG, -}; -use asap_aware_mapping::{ - CompositionPhase, CostModel, DefaultCostModel, EvaluationRate, ExplanationKind, -}; -use asap_frontend_promql::lower_promql; -use asap_types::dag_export; -use asap_types::post_asap::{ - validate_execution_phases, ExactKind, ExecutionAvailability, PhaseError, SketchAlgorithm, - SummaryExpr, SummaryFamilyType, SummaryNode, -}; -use asap_types::pre_asap::agg_intent::{default_quantile, AggIntent}; -use asap_types::pre_asap::query_expr::{QueryExpr, Reduction, Source}; -use asap_types::pre_asap::schema::{Column, DataType, Schema}; -use asap_types::types::AccuracyTarget; - -// ── fixtures ──────────────────────────────────────────────────────────── - -fn metric_scan(labels: &[&str]) -> QueryExpr { - let mut columns = vec![ - Column::new("ts", DataType::Timestamp, false), - Column::new("value", DataType::Float64, false), - ]; - columns.extend(labels.iter().map(|n| Column::new(*n, DataType::Utf8, true))); - QueryExpr::Scan { - source: Source::TimeSeries { - metric: "latency".into(), - }, - predicates: vec![], - schema: Schema::with_time_index(columns, 0, vec![]), - } -} - -fn agg(by: Vec, intent: AggIntent, child: Rc) -> Rc { - Rc::new(QueryExpr::Aggregate { - reduction: Reduction::by(by), - measures: vec![intent], - output_names: vec![], - having: None, - child, - }) -} - -fn per_entity(intent: AggIntent, child: Rc) -> Rc { - Rc::new(QueryExpr::Aggregate { - reduction: Reduction::PerEntity, - measures: vec![intent], - output_names: vec![], - having: None, - child, - }) -} - -/// `quantile by (zone, host) (latency)` — the fine-grained inner summary. -fn fine_quantile() -> Rc { - agg( - vec![2, 3], - default_quantile(0.99), - Rc::new(metric_scan(&["zone", "host"])), - ) -} - -/// A deployment cost model that supplies every statistic the issue's -/// formulas need, so a composition can actually win — and advertises both -/// mixed-execution shapes. -struct StatsModel; - -impl CostModel for StatsModel { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn exact_composition_cost_inputs( - &self, - _request: &ExactCompositionCostRequest<'_>, - ) -> ExactCompositionCostInputs { - ExactCompositionCostInputs { - exact_cost_per_row: Some(0.1), - expected_input_rows: Some(50.0), - expected_output_rows: Some(10.0), - summary_maintenance_cost_per_update: Some(0.01), - summary_read_cost: Some(1.0), - update_rate: Some(100.0), - evaluation_rate: Some(EvaluationRate(1.0)), - raw_recompute_cost: Some(100.0), - unit: CostUnit::CostUnitsPerSecond, - provenance: CostProvenance { - model: "StatsModel".into(), - version: "test-1".into(), - }, - } - } -} - -/// Same statistics, but the runtime advertises no mixed-execution shape. -struct NoCapabilityModel; - -impl CostModel for NoCapabilityModel { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn mixed_execution_capabilities(&self) -> MixedExecutionCapabilities { - MixedExecutionCapabilities::NONE - } - fn exact_composition_cost_inputs( - &self, - request: &ExactCompositionCostRequest<'_>, - ) -> ExactCompositionCostInputs { - StatsModel.exact_composition_cost_inputs(request) - } -} - -fn plan( - roots: Vec<(&'static str, Rc)>, - cost_model: &dyn CostModel, -) -> asap_aware_mapping::PlanSpace<&'static str> { - search_workload_with(roots, &default_strategies_with(cost_model)) -} - -fn is_plain(node: &SummaryNode) -> bool { - node.schema - .fields - .iter() - .all(|f| matches!(f.dtype, SummaryFamilyType::Plain(_))) -} - -fn names(node: &SummaryNode) -> Vec<&str> { - node.schema.fields.iter().map(|f| f.name.as_str()).collect() -} - -// ── step 1: pin every already-supported exact-accumulator nesting ─────── - -#[test] -fn every_exact_accumulator_nests_directly_under_an_outer_sketch() { - use std::time::Duration; - let cases: Vec<(Rc, ExactKind)> = vec![ - ( - agg( - vec![2], - AggIntent::Sum { col: None }, - Rc::new(metric_scan(&["zone"])), - ), - ExactKind::Sum, - ), - ( - agg( - vec![2], - AggIntent::Count { - accuracy: AccuracyTarget::Exact, - }, - Rc::new(metric_scan(&["zone"])), - ), - ExactKind::Count, - ), - ( - agg( - vec![2], - AggIntent::Min { col: None }, - Rc::new(metric_scan(&["zone"])), - ), - ExactKind::MinMax, - ), - ( - agg( - vec![2], - AggIntent::Max { col: None }, - Rc::new(metric_scan(&["zone"])), - ), - ExactKind::MinMax, - ), - ( - per_entity( - AggIntent::Rate, - Rc::new(QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["zone"])), - }), - ), - ExactKind::Rate, - ), - ( - per_entity( - AggIntent::Increase, - Rc::new(QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["zone"])), - }), - ), - ExactKind::Increase, - ), - ]; - for (inner, kind) in cases { - let outer = agg(vec![], default_quantile(0.9), inner); - let target = TargetSubDAG::new(&outer); - let candidates = SketchAlgorithmStrategy::default_cost_model().replacements(&target); - let Replacement::Summary(root) = &candidates[0].replacement else { - unreachable!() - }; - let SummaryExpr::SummaryEstimate { summary_input, .. } = &root.expr else { - panic!("expected KLL readout, got {:?}", root.expr); - }; - let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { - panic!("expected outer SummaryAgg"); - }; - assert!( - matches!( - &child.expr, - SummaryExpr::SummaryAgg { family: SummaryFamilyType::ExactAggregate(k, _), .. } if *k == kind - ), - "{kind:?}: expected the exact accumulator directly under the outer sketch, got {:?}", - child.expr - ); - validate_execution_phases(root).expect("accumulator state composes under maintenance"); - } -} - -// ── direction 1: outer exact fold over an inner summary readout ──────── - -/// Before this PR both `max`/`avg` over a quantile collapsed into one -/// opaque `KeepPreAsap`. Now: the outer group holds an `ExactPostProcess` -/// candidate referencing the inner target, the inner group keeps its own -/// sketch candidates, and with statistics the pair is committed and -/// materializes as `ExactPostProcess → SummaryEstimate → SummaryAgg`. -#[test] -fn max_and_avg_over_quantile_compose_as_post_process_with_statistics() { - for intent in [AggIntent::Max { col: None }, AggIntent::Avg { col: None }] { - let root = agg(vec![0], intent.clone(), fine_quantile()); - let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); - let root = Rc::clone(&space.roots[0].1); - let QueryExpr::Aggregate { child: inner, .. } = root.as_ref() else { - unreachable!() - }; - - let outer_group = space.group_for(&root).unwrap(); - assert!( - outer_group - .candidates - .iter() - .any(|c| c.provenance == ReplacementProvenance::ExactPostProcess), - "{intent:?}: outer group must hold an ExactPostProcess candidate" - ); - let inner_group = space.group_for(inner).unwrap(); - assert!( - inner_group - .candidates - .iter() - .any(|c| matches!(&c.replacement, Replacement::Summary(n) - if matches!(n.expr, SummaryExpr::SummaryEstimate { .. }))), - "{intent:?}: the inner quantile keeps its own readout candidates" - ); - - let selection = space.global_selection(&StatsModel); - let selected = selection.for_target(&root).unwrap(); - let chosen = selected.chosen.expect("a decision"); - assert_eq!(chosen.provenance, ReplacementProvenance::ExactPostProcess); - let decision = selected - .composition - .as_ref() - .expect("composition provenance"); - assert!(Rc::ptr_eq(decision.child_target, inner)); - assert!(decision.cost_rate < decision.baseline_rate); - assert_eq!(decision.inputs.unit, CostUnit::CostUnitsPerSecond); - assert_eq!(decision.inputs.provenance.model, "StatsModel"); - // The child was committed to a compatible candidate *from its own - // group* — the same candidate its own selection reports. - let child_candidate = decision.child_candidate.expect("post-process child"); - let inner_selected = selection.for_target(inner).unwrap(); - assert!(std::ptr::eq( - inner_selected.chosen.unwrap(), - child_candidate - )); - - let composed = selection.materialize(&root).unwrap().unwrap(); - let SummaryExpr::ReadoutPostProcess { child, .. } = &composed.expr else { - panic!( - "{intent:?}: expected ExactPostProcess root, got {:?}", - composed.expr - ); - }; - assert!(matches!(child.expr, SummaryExpr::SummaryEstimate { .. })); - let child_guarantee = child.guarantee.as_ref().expect("child guarantee"); - let composed_guarantee = composed - .guarantee - .as_ref() - .expect("exact post-process must propagate the child's guarantee"); - assert_eq!(composed_guarantee.metric, child_guarantee.metric); - assert_eq!( - composed_guarantee.bound.evaluate(), - child_guarantee.bound.evaluate(), - "an exact max/average fold retains the modeled error magnitude" - ); - assert!(is_plain(&composed)); - assert_eq!( - names(&composed), - root.output_schema() - .unwrap() - .columns - .iter() - .map(|c| c.name.as_str()) - .collect::>(), - "the composed plan's schema is the pre-ASAP target's own" - ); - validate_execution_phases(&composed).unwrap(); - } -} - -/// `avg` keeps competing with `AvgToSumOverCountStrategy`: both candidates -/// live in the same group; nothing hard-codes the winner. -#[test] -fn avg_over_quantile_keeps_the_sum_over_count_rewrite_as_a_competitor() { - // `by (zone)` over `by (zone)`: the averaged column resolves to the - // non-null quantile output, which is what the rewrite requires. - let inner = agg( - vec![2], - default_quantile(0.99), - Rc::new(metric_scan(&["zone"])), - ); - let root = agg(vec![0], AggIntent::Avg { col: None }, inner); - let space = plan(vec![("q", root)], &StatsModel); - let group = space.group_for(&space.roots[0].1).unwrap(); - let provenances: Vec<_> = group.candidates.iter().map(|c| c.provenance).collect(); - assert!(provenances.contains(&ReplacementProvenance::LogicalRewrite)); - assert!(provenances.contains(&ReplacementProvenance::ExactPostProcess)); -} - -/// Grouped fine-to-coarse fold (`by (zone)` over `by (zone, host)`) and the -/// identity fold (`by (zone)` over `by (zone)`) both compose; the operator -/// is the same, only the fold's row multiplicity differs. -#[test] -fn identity_and_genuine_multi_row_folds_both_compose() { - let identity_inner = agg( - vec![2], - default_quantile(0.99), - Rc::new(metric_scan(&["zone"])), - ); - for (label, inner) in [ - ("identity", identity_inner), - ("fine-to-coarse", fine_quantile()), - ] { - let root = agg(vec![0], AggIntent::Max { col: None }, inner); - let space = plan(vec![("q", root)], &StatsModel); - let root = &space.roots[0].1; - let composed = space - .global_selection(&StatsModel) - .materialize(root) - .unwrap() - .unwrap(); - assert!( - matches!(composed.expr, SummaryExpr::ReadoutPostProcess { .. }), - "{label}: {:?}", - composed.expr - ); - assert_eq!(names(&composed), vec!["zone", "max"], "{label}"); - } -} - -/// One inner quantile consumed by two outer folds in two queries: CSE -/// collapses the inner target onto one `Rc`, both compositions commit to -/// the *same* child candidate, and both materializations share one -/// `Rc` for it — the summary is maintained once. -#[test] -fn a_shared_inner_summary_is_materialized_once_for_several_outer_folds() { - let max = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let min = agg(vec![0], AggIntent::Min { col: None }, fine_quantile()); - let space = plan(vec![("max", max), ("min", min)], &StatsModel); - let selection = space.global_selection(&StatsModel); - - let roots: Vec> = space.roots.iter().map(|(_, r)| Rc::clone(r)).collect(); - let inner_of = |r: &Rc| match r.as_ref() { - QueryExpr::Aggregate { child, .. } => Rc::clone(child), - _ => unreachable!(), - }; - assert!( - Rc::ptr_eq(&inner_of(&roots[0]), &inner_of(&roots[1])), - "CSE must intern the shared inner quantile" - ); - let inner = inner_of(&roots[0]); - assert_eq!(space.group_for(&inner).unwrap().consumer_count, 2); - - let decisions: Vec<_> = roots - .iter() - .map(|r| { - selection - .for_target(r) - .unwrap() - .composition - .as_ref() - .expect("both roots compose") - }) - .collect(); - assert!(std::ptr::eq( - decisions[0].child_candidate.unwrap(), - decisions[1].child_candidate.unwrap() - )); - // Shared state counted once: the second parent sees zero marginal - // maintenance, so its rate is strictly lower than the first's. - assert!(decisions[1].cost_rate < decisions[0].cost_rate); - - let composed: Vec<_> = roots - .iter() - .map(|r| selection.materialize(r).unwrap().unwrap()) - .collect(); - let child_of = |n: &Rc| match &n.expr { - SummaryExpr::ReadoutPostProcess { child, .. } => Rc::clone(child), - other => panic!("expected ExactPostProcess, got {other:?}"), - }; - assert!( - Rc::ptr_eq(&child_of(&composed[0]), &child_of(&composed[1])), - "both folds compose over the same Rc" - ); -} - -// ── direction 2: outer summary over an inner exact update-path transform ─ - -/// `quantile(0.99, deriv(latency[5m]))`: `deriv` has no accumulator form. -/// The transform target gets an `ExactTransform` candidate; with a -/// maintained summary above it and statistics, it is committed, and the -/// outer summary's materialization is re-linked over it. -#[test] -fn outer_summary_over_an_exact_transform_composes_on_the_update_path() { - use std::time::Duration; - let deriv = per_entity( - AggIntent::Deriv, - Rc::new(QueryExpr::TimeRange { - range: Duration::from_secs(300), - child: Rc::new(metric_scan(&["zone"])), - }), - ); - let root = agg(vec![], default_quantile(0.99), deriv); - let space = plan(vec![("q", root)], &StatsModel); - let root = Rc::clone(&space.roots[0].1); - let QueryExpr::Aggregate { child: deriv, .. } = root.as_ref() else { - unreachable!() - }; - assert!(space - .group_for(deriv) - .unwrap() - .candidates - .iter() - .any(|c| c.provenance == ReplacementProvenance::ExactTransform)); - - let selection = space.global_selection(&StatsModel); - let deriv_sel = selection.for_target(deriv).unwrap(); - assert_eq!( - deriv_sel.chosen.unwrap().provenance, - ReplacementProvenance::ExactTransform - ); - let decision = deriv_sel.composition.as_ref().unwrap(); - assert!(decision.child_candidate.is_none(), "transform input is raw"); - assert!(decision.cost_rate < decision.baseline_rate); - - let composed = selection.materialize(&root).unwrap().unwrap(); - let SummaryExpr::SummaryEstimate { summary_input, .. } = &composed.expr else { - panic!("expected readout root, got {:?}", composed.expr); - }; - let SummaryExpr::SummaryAgg { child, .. } = &summary_input.expr else { - panic!("expected SummaryAgg"); - }; - let SummaryExpr::UpdateTransform { child: raw, .. } = &child.expr else { - panic!( - "expected ExactTransform under the maintained summary, got {:?}", - child.expr - ); - }; - assert!(matches!(raw.expr, SummaryExpr::KeepPreAsap(_))); - let assignment = validate_execution_phases(&composed).unwrap(); - assert_eq!( - assignment.stage_of(child), - Some(ExecutionAvailability::UpdateValue) - ); - assert_eq!( - assignment.stage_of(raw), - Some(ExecutionAvailability::UpdateValue) - ); -} - -// ── rejection, capability, statistics ─────────────────────────────────── - -/// A maintained summary above a query-time readout is a typed plan-time -/// error, both for the construction path and for a hand-built plan. -#[test] -fn readout_under_maintenance_is_rejected_at_construction() { - let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let candidates = - SketchAlgorithmStrategy::default_cost_model().replacements(&TargetSubDAG::new(&root)); - // The MinMax accumulator over the quantile readout is not constructible; - // the strategy reports the conservative fallback once instead. - assert_eq!(candidates.len(), 1); - let Replacement::Summary(node) = &candidates[0].replacement else { - unreachable!() - }; - assert!(matches!(node.expr, SummaryExpr::KeepPreAsap(_))); - - // ExactPostProcess can never be placed under a SummaryAgg: compose a - // post-process, then try to maintain a summary over it. - let space = plan(vec![("q", Rc::clone(&root))], &StatsModel); - let post = space - .global_selection(&StatsModel) - .materialize(&space.roots[0].1) - .unwrap() - .unwrap(); - let illegal = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child: post, - family: SummaryFamilyType::ExactAggregate( - ExactKind::MinMax, - asap_types::post_asap::ExactParams::MinMax, - ), - col: asap_types::pre_asap::ColumnRef::SampleValue, - reduction: Reduction::by(vec![]), - grouping: Default::default(), - }, - schema: asap_types::post_asap::SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }); - assert!(matches!( - validate_execution_phases(&illegal), - Err(PhaseError::ReadoutUnderMaintenance { .. }) - )); - let err: ImplementError = validate_execution_phases(&illegal).unwrap_err().into(); - assert!(matches!(err, ImplementError::Phase(_))); -} - -#[test] -fn a_runtime_without_mixed_execution_gets_no_composition_candidates() { - let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &NoCapabilityModel); - let root = Rc::clone(&space.roots[0].1); - let group = space.group_for(&root).unwrap(); - assert!(group - .candidates - .iter() - .all(|c| !matches!(c.replacement, Replacement::ExactComposition(_)))); - let selection = space.global_selection(&NoCapabilityModel); - assert!(selection.for_target(&root).unwrap().composition.is_none()); - let node = selection.materialize(&root).unwrap().unwrap(); - assert!(matches!(node.expr, SummaryExpr::KeepPreAsap(_))); - // The inner quantile is still independently selectable. - let QueryExpr::Aggregate { child, .. } = root.as_ref() else { - unreachable!() - }; - assert!(selection.for_target(child).unwrap().chosen.is_some()); -} - -/// Without statistics (the built-in model) the composition is *proposed* -/// — visible in `PlanSpace` and explanations — but never *selected*: the -/// site keeps the conservative `KeepPreAsap`, and the inner summary stays -/// independently selectable. -#[test] -fn missing_cost_statistics_preserve_the_conservative_keep_pre_asap() { - let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &DefaultCostModel); - let root = Rc::clone(&space.roots[0].1); - assert!(space - .group_for(&root) - .unwrap() - .candidates - .iter() - .any(|c| c.provenance == ReplacementProvenance::ExactPostProcess)); - let selection = space.global_selection(&DefaultCostModel); - let selected = selection.for_target(&root).unwrap(); - assert!(selected.composition.is_none()); - assert!(!matches!( - selected.chosen.map(|c| &c.replacement), - Some(Replacement::ExactComposition(_)) - )); - let node = selection.materialize(&root).unwrap().unwrap(); - assert!(matches!(node.expr, SummaryExpr::KeepPreAsap(_))); - - let explanations = asap_aware_mapping::explain_replacements(vec![("q", (*root).clone())]); - assert!(explanations - .iter() - .any(|e| e.kind == ExplanationKind::ExactComposition)); -} - -// ── DAG export: explicit stage, schema, provenance ─────────────────────── - -#[test] -fn dag_export_carries_explicit_stage_and_plain_schema_for_a_composed_plan() { - let root = agg(vec![0], AggIntent::Max { col: None }, fine_quantile()); - let space = plan(vec![("q", root)], &StatsModel); - let root = &space.roots[0].1; - let composed = space - .global_selection(&StatsModel) - .materialize(root) - .unwrap() - .unwrap(); - let graph = dag_export::export_summary(&composed); - let node = &graph.nodes[graph.root as usize]; - assert_eq!(node.kind, "ReadoutPostProcess"); - assert_eq!(node.detail["stage"], "readout_value"); - assert_eq!(node.detail["op"], "Aggregate"); - let stages: Vec<(&str, String)> = graph - .nodes - .iter() - .map(|n| (n.kind, n.detail["stage"].as_str().unwrap().to_string())) - .collect(); - assert!(stages.contains(&("SummaryEstimate", "readout_value".into()))); - assert!(stages.contains(&("SummaryAgg", "summary_state".into()))); - assert!(stages.contains(&("KeepPreAsap", "update_value".into()))); - - // Pre-ASAP export of the same target still describes the same columns. - let pre = dag_export::export(root); - let pre_root = &pre.nodes[pre.root as usize]; - let pre_cols: Vec = pre_root.schema.as_ref().unwrap()["columns"] - .as_array() - .unwrap() - .iter() - .map(|c| c["name"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(pre_cols, names(&composed)); -} - -/// The PromQL front end produces the exact issue shape and it composes. -#[test] -fn promql_max_by_zone_over_quantile_over_time_composes() { - let expr = lower_promql( - "max by (zone) (quantile_over_time(0.99, latency[5m]))", - AccuracyTarget::Epsilon(0.01), - ) - .unwrap(); - let space = plan(vec![("q", Rc::new(expr))], &StatsModel); - let root = &space.roots[0].1; - let selection = space.global_selection(&StatsModel); - let selected = selection.for_target(root).unwrap(); - assert_eq!( - selected.chosen.map(|c| c.provenance), - Some(ReplacementProvenance::ExactPostProcess), - "{:?}", - space - .group_for(root) - .unwrap() - .candidates - .iter() - .map(|c| (c.strategy, c.provenance)) - .collect::>() - ); - let composed = selection.materialize(root).unwrap().unwrap(); - assert!(matches!( - composed.expr, - SummaryExpr::ReadoutPostProcess { .. } - )); - assert_eq!( - selected.composition.as_ref().map(|d| d.inputs.unit), - Some(CostUnit::CostUnitsPerSecond) - ); - let _ = CompositionPhase::PostProcess; -} From 5ad2feead528a10c5aa3986617345129ebeef612 Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 12:10:04 -0600 Subject: [PATCH 12/15] refactor(planner): use value domains for exact composition --- crates/asap-aware-mapping/src/cost_model.rs | 28 +++--- .../src/exact_composition.rs | 88 ++++++++++--------- crates/asap-aware-mapping/src/lib.rs | 2 +- crates/asap-aware-mapping/src/replacement.rs | 50 +++++------ crates/devtools/src/bin/dag_export.rs | 2 +- 5 files changed, 87 insertions(+), 83 deletions(-) diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index d9ff87fe..3967e50e 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -56,7 +56,7 @@ use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::expr_ir::ColumnRef; use asap_types::pre_asap::query_expr::QueryExpr; -use crate::exact_composition::{CompositionPhase, ExactComposition}; +use crate::exact_composition::{CompositionPlacement, ExactComposition}; use crate::recurrence::{ self, CostRate, EvaluationRate, Horizon, RecurrenceCostExplanation, RecurrenceError, RecurrenceProfile, @@ -129,10 +129,10 @@ impl MixedExecutionCapabilities { exact_update_transform: true, }; - pub fn supports(self, phase: CompositionPhase) -> bool { - match phase { - CompositionPhase::PostProcess => self.exact_post_process, - CompositionPhase::Transform => self.exact_update_transform, + pub fn supports(self, placement: CompositionPlacement) -> bool { + match placement { + CompositionPlacement::PostProcess => self.exact_post_process, + CompositionPlacement::Transform => self.exact_update_transform, } } } @@ -144,11 +144,11 @@ impl MixedExecutionCapabilities { pub struct ExactCompositionCostRequest<'a> { /// The pre-ASAP target the composed candidate replaces. pub target: &'a QueryExpr, - /// The composition itself — phase, operator, child target. + /// The composition itself — placement, operator, child target. pub composition: &'a ExactComposition, - /// For [`CompositionPhase::PostProcess`]: the child target's *selected* + /// For [`CompositionPlacement::PostProcess`]: the child target's *selected* /// summary readout candidate the exact operator consumes. For - /// [`CompositionPhase::Transform`]: the maintained summary *above* the + /// [`CompositionPlacement::Transform`]: the maintained summary *above* the /// transform that consumes its output (the `SummaryAgg` this transform /// feeds). Either way, the summary whose maintenance/read cost the /// formula charges. @@ -207,12 +207,12 @@ impl ExactCompositionCostInputs { } } - /// The rate for whichever phase `phase` names — + /// The rate for whichever composition placement is requested — /// [`postprocess_plan_cost_rate`] or [`pretransform_plan_cost_rate`]. - pub fn composed_plan_cost_rate(&self, phase: CompositionPhase) -> Option { - match phase { - CompositionPhase::PostProcess => postprocess_plan_cost_rate(self), - CompositionPhase::Transform => pretransform_plan_cost_rate(self), + pub fn composed_plan_cost_rate(&self, placement: CompositionPlacement) -> Option { + match placement { + CompositionPlacement::PostProcess => postprocess_plan_cost_rate(self), + CompositionPlacement::Transform => pretransform_plan_cost_rate(self), } } } @@ -1111,7 +1111,7 @@ mod tests { MixedExecutionCapabilities::ALL ); assert!(MixedExecutionCapabilities::NONE - .supports(CompositionPhase::PostProcess) + .supports(CompositionPlacement::PostProcess) .not()); } diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index 330c7e7a..46326487 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -11,8 +11,8 @@ //! //! - `max by (zone) (quantile_over_time(0.99, latency[5m]))` — the outer //! `max` is an exact fold over the inner summary's *readout*. A `MinMax` -//! accumulator over that readout is phase-illegal (a maintained summary -//! can't consume query-time values — see `asap_types::post_asap::phase`), +//! accumulator over that readout is domain-illegal (a maintained summary +//! can't consume query-time values — see `asap_types::post_asap::value_domain`), //! and `avg` has no accumulator at all, so today either shape collapses //! into one opaque `KeepPreAsap` that swallows the realizable inner //! quantile. @@ -22,7 +22,7 @@ //! explicit "this row transform runs on the update path" node. //! //! [`SummaryExpr::ReadoutPostProcess`] and [`SummaryExpr::UpdateTransform`] -//! are the two phase-explicit representations; this strategy is what +//! are the two domain-explicit representations; this strategy is what //! proposes them. //! //! ## Reference, don't select @@ -53,7 +53,7 @@ //! target's grouping keys resolve in the child's output schema; //! transform: the target is a per-entity exact transform with no //! accumulator form (its only implementation is `PassThrough`); -//! - the exact operator consumes only `Plain` values at its phase — checked +//! - the exact operator consumes only `Plain` values in its domain — checked //! again, structurally, when the pair is composed; //! - the plugged-in [`CostModel`] advertises the matching //! [`MixedExecutionCapabilities`](crate::cost_model::MixedExecutionCapabilities). @@ -65,7 +65,7 @@ //! ## What this strategy never does //! //! - Propose an `ExactPostProcess` for a position beneath a maintained -//! summary — phase validation at composition rejects it as a typed +//! summary — domain validation at composition rejects it as a typed //! `ImplementError` regardless. //! - Decide whether a composition is *worth it*: that is //! `global_selection`'s job, using the issue's cost-units-per-second @@ -74,10 +74,10 @@ use std::rc::Rc; -use asap_types::post_asap::phase::validate_execution_phases_at; +use asap_types::post_asap::value_domain::validate_execution_domains_at; use asap_types::post_asap::{ - exact_operator_output_schema, produced_availability, CompositionOperator, ExactOperator, - ExecutionAvailability, PhaseError, SummaryExpr, SummaryNode, SummarySchema, ValueOperator, + exact_operator_output_schema, produced_domain, CompositionOperator, DomainError, ExactOperator, + SummaryExpr, SummaryNode, SummarySchema, ValueDomain, ValueOperator, }; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; @@ -94,7 +94,7 @@ use crate::{AccuracyModel, DefaultAccuracyModel, PropagationStats}; /// exact operator executes on — selects the `SummaryExpr` variant /// [`ExactComposition::compose`] builds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CompositionPhase { +pub enum CompositionPlacement { /// [`SummaryExpr::ReadoutPostProcess`]: after the child's readout. PostProcess, /// [`SummaryExpr::UpdateTransform`]: on the update path, feeding @@ -102,12 +102,12 @@ pub enum CompositionPhase { Transform, } -impl CompositionPhase { +impl CompositionPlacement { /// The availability the composed operator consumes and produces. - pub fn availability(self) -> ExecutionAvailability { + pub fn domain(self) -> ValueDomain { match self { - Self::PostProcess => ExecutionAvailability::ReadoutValue, - Self::Transform => ExecutionAvailability::UpdateValue, + Self::PostProcess => ValueDomain::READ_ROWS, + Self::Transform => ValueDomain::MAINTENANCE_ROWS, } } @@ -120,12 +120,12 @@ impl CompositionPhase { } /// The payload of a [`Replacement::ExactComposition`] candidate: an exact -/// operator, the phase it runs at, and a *reference* to the child target +/// operator, the placement it runs at, and a *reference* to the child target /// it composes over — never an already-selected child plan (see the module /// docs' "Reference, don't select"). #[derive(Debug, Clone)] pub struct ExactComposition { - pub phase: CompositionPhase, + pub placement: CompositionPlacement, pub op: ExactOperator, /// The pre-ASAP child the operator consumes; its `MemoGroup` holds the /// candidates `global_selection` may commit this composition with. @@ -138,21 +138,21 @@ pub struct ExactComposition { impl ExactComposition { /// Can `child` legally be this composition's input? Phase legality - /// (the child's produced availability — a `KeepPreAsap` leaf takes the + /// (the child's produced domain — a `KeepPreAsap` leaf takes the /// phase this edge assigns) plus the plain-operand rule, checked /// through the same schema derivation [`Self::compose`] uses. pub fn accepts_child(&self, child: &SummaryNode) -> bool { - let phase_ok = match produced_availability(&child.expr) { + let phase_ok = match produced_domain(&child.expr) { None => true, - Some(avail) => avail == self.phase.availability(), + Some(avail) => avail == self.placement.domain(), }; phase_ok && exact_operator_output_schema(&self.op, &child.schema).is_ok() } - /// Build the composed, phase-validated node over `child`. Every edge of + /// Build the composed, domain-validated node over `child`. Every edge of /// the result (including everything beneath `child`) is checked by - /// `asap_types::post_asap::validate_execution_phases`; an illegal - /// placement is a typed [`ImplementError::Phase`], never deferred to a + /// `asap_types::post_asap::validate_execution_domains`; an illegal + /// placement is a typed [`ImplementError::Domain`], never deferred to a /// runtime. pub fn compose(&self, child: Rc) -> Result, ImplementError> { self.compose_with_accuracy(child, &DefaultAccuracyModel) @@ -166,13 +166,13 @@ impl ExactComposition { child: Rc, accuracy_model: &dyn AccuracyModel, ) -> Result, ImplementError> { - if let Some(produced) = produced_availability(&child.expr) { - if produced != self.phase.availability() { - let edge = match self.phase { - CompositionPhase::Transform => "UpdateTransform.child", - CompositionPhase::PostProcess => "ReadoutPostProcess.child", + if let Some(produced) = produced_domain(&child.expr) { + if produced != self.placement.domain() { + let edge = match self.placement { + CompositionPlacement::Transform => "UpdateTransform.child", + CompositionPlacement::PostProcess => "ReadoutPostProcess.child", }; - return Err(ImplementError::Phase(PhaseError::IllegalChildPhase { + return Err(ImplementError::Domain(DomainError::IllegalChildPhase { edge, child: produced, })); @@ -203,12 +203,12 @@ impl ExactComposition { )?) } }; - let expr = match self.phase { - CompositionPhase::PostProcess => SummaryExpr::ReadoutPostProcess { + let expr = match self.placement { + CompositionPlacement::PostProcess => SummaryExpr::ReadoutPostProcess { child, op: ValueOperator::Exact(self.op.clone()), }, - CompositionPhase::Transform => SummaryExpr::UpdateTransform { + CompositionPlacement::Transform => SummaryExpr::UpdateTransform { child, op: ValueOperator::Exact(self.op.clone()), }, @@ -218,14 +218,14 @@ impl ExactComposition { schema, guarantee, }); - validate_execution_phases_at(&node, self.phase.availability())?; + validate_execution_domains_at(&node, self.placement.domain())?; Ok(node) } - /// Structural identity for `MemoGroup` dedup: same phase, same + /// Structural identity for `MemoGroup` dedup: same placement, same /// operator, same child `Rc`. pub(crate) fn same_as(&self, other: &Self) -> bool { - self.phase == other.phase + self.placement == other.placement && self.op == other.op && Rc::ptr_eq(&self.child_target, &other.child_target) } @@ -384,10 +384,10 @@ impl<'a> ExactCompositionStrategy<'a> { let Ok(schema) = target.root.output_schema() else { return Vec::new(); }; - let schema = asap_types::post_asap::phase::lift_plain(&schema); + let schema = asap_types::post_asap::value_domain::lift_plain(&schema); let mut out = Vec::new(); - if capabilities.supports(CompositionPhase::PostProcess) { + if capabilities.supports(CompositionPlacement::PostProcess) { if let Some((op, child, intent)) = post_process_shape(target.root, self.cost_model) { let child_desc = describe_intent( bindable_intent(&child).expect("checked by post_process_shape"), @@ -395,7 +395,7 @@ impl<'a> ExactCompositionStrategy<'a> { out.push(ReplacementSubDAG { strategy: "ExactCompositionStrategy", replacement: Replacement::ExactComposition(ExactComposition { - phase: CompositionPhase::PostProcess, + placement: CompositionPlacement::PostProcess, op, child_target: child, schema: schema.clone(), @@ -414,12 +414,12 @@ impl<'a> ExactCompositionStrategy<'a> { } } - if capabilities.supports(CompositionPhase::Transform) { + if capabilities.supports(CompositionPlacement::Transform) { if let Some((op, child, intent)) = transform_shape(target.root, self.cost_model) { out.push(ReplacementSubDAG { strategy: "ExactCompositionStrategy", replacement: Replacement::ExactComposition(ExactComposition { - phase: CompositionPhase::Transform, + placement: CompositionPlacement::Transform, op, child_target: child, schema, @@ -454,7 +454,7 @@ mod tests { use super::*; use crate::cost_model::{DefaultCostModel, MixedExecutionCapabilities}; use crate::replacement::keep_pre_asap; - use asap_types::post_asap::{PhaseError, SketchAlgorithm, SummaryFamilyType}; + use asap_types::post_asap::{DomainError, SketchAlgorithm, SummaryFamilyType}; use asap_types::pre_asap::agg_intent::default_quantile; use asap_types::pre_asap::query_expr::Source; use asap_types::pre_asap::schema::{Column, DataType, Schema}; @@ -516,7 +516,7 @@ mod tests { candidates[0].replacement ); }; - assert_eq!(comp.phase, CompositionPhase::PostProcess); + assert_eq!(comp.placement, CompositionPlacement::PostProcess); assert_eq!( candidates[0].provenance, ReplacementProvenance::ExactPostProcess @@ -622,7 +622,9 @@ mod tests { assert!(!comp.accepts_child(summary_input)); assert!(matches!( comp.compose(Rc::clone(summary_input)), - Err(ImplementError::Phase(PhaseError::IllegalChildPhase { .. })) + Err(ImplementError::Domain( + DomainError::IllegalChildPhase { .. } + )) )); // The readout itself is accepted and composes to a plain schema. assert!(comp.accepts_child(&state_child)); @@ -652,7 +654,9 @@ mod tests { assert!(!comp.accepts_child(&readout)); assert!(matches!( comp.compose(readout), - Err(ImplementError::Phase(PhaseError::IllegalChildPhase { .. })) + Err(ImplementError::Domain( + DomainError::IllegalChildPhase { .. } + )) )); // Raw update input is fine. let raw = keep_pre_asap(&comp.child_target).unwrap(); diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 4f323aca..1a9c7d1d 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -204,7 +204,7 @@ pub use cost_model::{ CostProvenance, CostUnit, DefaultCostModel, ExactCompositionCostInputs, ExactCompositionCostRequest, MixedExecutionCapabilities, }; -pub use exact_composition::{CompositionPhase, ExactComposition, ExactCompositionStrategy}; +pub use exact_composition::{CompositionPlacement, ExactComposition, ExactCompositionStrategy}; pub use explanation::{ explain_replacements, explain_replacements_with, ExplanationKind, ReplacementExplanation, }; diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 451094a9..8df3de87 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -349,11 +349,11 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use asap_types::post_asap::{ - validate_execution_phases_at, ExactKind, ExactOperatorSchemaError, ExactParams, - ExecutionAvailability, GroupingStrategy, PhaseError, SamplingKind, SamplingParams, - SketchAlgorithm, SketchKind, SketchParams, SketchQuery as PostAsapSketchQuery, StatModelKind, - StatModelParams, SummaryExpr, SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, - WaveletKind, WaveletParams, + validate_execution_domains_at, DomainError, ExactKind, ExactOperatorSchemaError, ExactParams, + GroupingStrategy, SamplingKind, SamplingParams, SketchAlgorithm, SketchKind, SketchParams, + SketchQuery as PostAsapSketchQuery, StatModelKind, StatModelParams, SummaryExpr, + SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, ValueDomain, WaveletKind, + WaveletParams, }; use asap_types::post_asap::{AccuracyError, CompositionOperator, GuaranteeSource, ResultGuarantee}; use asap_types::pre_asap::agg_intent::{agg_is_mergeable, AggIntent}; @@ -376,7 +376,7 @@ use crate::cost_model::{ raw_recompute_cost_rate, CostModel, CseCandidate, DefaultCostModel, ExactCompositionCostInputs, ExactCompositionCostRequest, ShareDecision, }; -use crate::exact_composition::{CompositionPhase, ExactComposition, ExactCompositionStrategy}; +use crate::exact_composition::{CompositionPlacement, ExactComposition, ExactCompositionStrategy}; use crate::grouping::HydraGroupingStrategy; use crate::recurrence::CostRate; use crate::recurrence::{ @@ -406,8 +406,8 @@ pub enum ImplementError { /// A constructed plan violates the update/readout phase contract /// (issue #171) — e.g. a summary readout placed beneath a maintained /// `SummaryAgg`. Detected at construction, never at runtime. - #[error("execution-phase violation in post-ASAP plan: {0}")] - Phase(#[from] PhaseError), + #[error("execution-domain violation in post-ASAP plan: {0}")] + Domain(#[from] DomainError), /// An `ExactOperator`'s output schema could not be derived over its /// child — the child carries summary state the operator can't read. #[error("exact operator schema derivation failed: {0}")] @@ -522,10 +522,10 @@ pub enum ReplacementProvenance { /// the wrong shape of cost, not just the wrong number. AccuracyReconciliation, /// [`Replacement::ExactComposition`] with - /// [`CompositionPhase::PostProcess`] (issue #171). + /// [`CompositionPlacement::PostProcess`] (issue #171). ExactPostProcess, /// [`Replacement::ExactComposition`] with - /// [`CompositionPhase::Transform`] (issue #171). + /// [`CompositionPlacement::Transform`] (issue #171). ExactTransform, } @@ -552,7 +552,7 @@ pub struct RejectedCandidate { pub struct Proposals { pub candidates: Vec, pub rejected: Vec, - phase_error: Option, + domain_error: Option, } /// A replacement strategy: given a [`TargetSubDAG`], does this strategy have @@ -598,7 +598,7 @@ pub trait ReplacementStrategy { Proposals { candidates: self.replacements(target), rejected: Vec::new(), - phase_error: None, + domain_error: None, } } } @@ -1403,7 +1403,7 @@ impl<'a> SketchAlgorithmStrategy<'a> { } } if proposals.candidates.is_empty() { - if let Some(error) = &proposals.phase_error { + if let Some(error) = &proposals.domain_error { if let Ok(node) = keep_pre_asap(root) { proposals.candidates.push(ReplacementSubDAG { strategy: "SketchAlgorithmStrategy", @@ -1411,7 +1411,7 @@ impl<'a> SketchAlgorithmStrategy<'a> { provenance: ReplacementProvenance::SummaryImplementation, rationale: format!( "{} stays pre-ASAP because summary construction crosses an illegal \ - execution-phase boundary ({error})", + execution-domain boundary ({error})", describe_intent(intent) ), }); @@ -1439,8 +1439,8 @@ impl Proposals { description: rationale, error, }), - Err(ImplementError::Phase(error)) => { - self.phase_error.get_or_insert(error); + Err(ImplementError::Domain(error)) => { + self.domain_error.get_or_insert(error); } Err(ImplementError::Schema(_) | ImplementError::ExactOperatorSchema(_)) => {} } @@ -1817,7 +1817,7 @@ fn construct_summary_agg( // Phase contract (issue #171): a maintained summary consumes update-path // values or exact accumulator state — never a query-time readout. A // typed error here, at construction; the caller decides the fallback. - validate_execution_phases_at(&agg, ExecutionAvailability::SummaryState)?; + validate_execution_domains_at(&agg, ValueDomain::MAINTENANCE_SUMMARY)?; match query { // The readout: downstream of the estimate the schema is the plain // pre-ASAP row shape again (the summary-state type does not @@ -2867,7 +2867,7 @@ impl<'a> GlobalSelection<'a> { self.groups.get(&Rc::as_ptr(target)) } - /// Link this selection's per-site decisions into one phase-validated + /// Link this selection's per-site decisions into one domain-validated /// post-ASAP DAG rooted at `target` — the one place a committed /// composition's child *reference* becomes an actual `Rc` /// edge (issue #171). `None` if `target` is not a discovered site. @@ -2983,7 +2983,7 @@ fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc rebuilt, Err(_) => Rc::clone(node), } @@ -3065,12 +3065,12 @@ fn composition_options<'a>( *maintenance = 0.0; } } - let rate = inputs.composed_plan_cost_rate(composition.phase)?; + let rate = inputs.composed_plan_cost_rate(composition.placement)?; let baseline = raw_recompute_cost_rate(&inputs)?; (rate < baseline).then_some((rate, baseline, inputs)) }; - match composition.phase { - CompositionPhase::PostProcess => { + match composition.placement { + CompositionPlacement::PostProcess => { let child_candidates: Vec<&'a ReplacementSubDAG> = match already_committed { // SAFETY-free: the pointer was taken from `groups`'s own // candidate storage, which outlives this borrow. @@ -3104,7 +3104,7 @@ fn composition_options<'a>( }); } } - CompositionPhase::Transform => { + CompositionPlacement::Transform => { // An update-path transform only pays off beneath a // maintained summary; with nothing above it, its output is // never read and the raw fallback is the same computation. @@ -3204,7 +3204,7 @@ impl PlanSpace { ); } if let Replacement::ExactComposition(composition) = &option.candidate.replacement { - if composition.phase == CompositionPhase::Transform { + if composition.placement == CompositionPlacement::Transform { // A chain of transforms feeds the same summary. if let Some(parent) = context.maintaining_parent.get(ptr).cloned() { context @@ -4980,7 +4980,7 @@ mod tests { }; assert!( matches!(node.expr, SummaryExpr::KeepPreAsap(ref e) if Rc::ptr_eq(e, &outer)), - "a sketch over a sketch readout is phase-illegal; expected the conservative \ + "a sketch over a sketch readout is domain-illegal; expected the conservative \ fallback, got {:?}", node.expr ); diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index 5ed515da..589e5d93 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -339,7 +339,7 @@ fn decision_rationale(winner: &Winner<'_>) -> String { "Runs the exact row transform on the update path, feeding the maintained summary above it." .to_string() } - _ => "Composes an exact operator with a summary plan across an explicit phase boundary." + _ => "Composes an exact operator with a summary plan across an explicit domain boundary." .to_string(), }, _ => { From 657cf3bd237531d1a617433f37ac4a07b8a7a9cd Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 12:28:40 -0600 Subject: [PATCH 13/15] refactor(planner): use execution data state name --- crates/asap-aware-mapping/src/exact_composition.rs | 8 ++++---- crates/asap-aware-mapping/src/replacement.rs | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index 46326487..0a6dfa69 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -77,7 +77,7 @@ use std::rc::Rc; use asap_types::post_asap::value_domain::validate_execution_domains_at; use asap_types::post_asap::{ exact_operator_output_schema, produced_domain, CompositionOperator, DomainError, ExactOperator, - SummaryExpr, SummaryNode, SummarySchema, ValueDomain, ValueOperator, + ExecutionDataState, SummaryExpr, SummaryNode, SummarySchema, ValueOperator, }; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; @@ -104,10 +104,10 @@ pub enum CompositionPlacement { impl CompositionPlacement { /// The availability the composed operator consumes and produces. - pub fn domain(self) -> ValueDomain { + pub fn domain(self) -> ExecutionDataState { match self { - Self::PostProcess => ValueDomain::READ_ROWS, - Self::Transform => ValueDomain::MAINTENANCE_ROWS, + Self::PostProcess => ExecutionDataState::READ_ROWS, + Self::Transform => ExecutionDataState::MAINTENANCE_ROWS, } } diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 8df3de87..69d85f54 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -350,9 +350,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use asap_types::post_asap::{ validate_execution_domains_at, DomainError, ExactKind, ExactOperatorSchemaError, ExactParams, - GroupingStrategy, SamplingKind, SamplingParams, SketchAlgorithm, SketchKind, SketchParams, - SketchQuery as PostAsapSketchQuery, StatModelKind, StatModelParams, SummaryExpr, - SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, ValueDomain, WaveletKind, + ExecutionDataState, GroupingStrategy, SamplingKind, SamplingParams, SketchAlgorithm, + SketchKind, SketchParams, SketchQuery as PostAsapSketchQuery, StatModelKind, StatModelParams, + SummaryExpr, SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, WaveletKind, WaveletParams, }; use asap_types::post_asap::{AccuracyError, CompositionOperator, GuaranteeSource, ResultGuarantee}; @@ -1817,7 +1817,7 @@ fn construct_summary_agg( // Phase contract (issue #171): a maintained summary consumes update-path // values or exact accumulator state — never a query-time readout. A // typed error here, at construction; the caller decides the fallback. - validate_execution_domains_at(&agg, ValueDomain::MAINTENANCE_SUMMARY)?; + validate_execution_domains_at(&agg, ExecutionDataState::MAINTENANCE_SUMMARY)?; match query { // The readout: downstream of the estimate the schema is the plain // pre-ASAP row shape again (the summary-state type does not @@ -2983,7 +2983,7 @@ fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc rebuilt, Err(_) => Rc::clone(node), } From 1bf215ce9e2011292666e6f336cd55c1bb219ab8 Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 12:35:02 -0600 Subject: [PATCH 14/15] refactor(planner): use execution data state module --- crates/asap-aware-mapping/src/exact_composition.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index 0a6dfa69..ce89af14 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -12,7 +12,8 @@ //! - `max by (zone) (quantile_over_time(0.99, latency[5m]))` — the outer //! `max` is an exact fold over the inner summary's *readout*. A `MinMax` //! accumulator over that readout is domain-illegal (a maintained summary -//! can't consume query-time values — see `asap_types::post_asap::value_domain`), +//! can't consume query-time values — see +//! `asap_types::post_asap::execution_data_state`), //! and `avg` has no accumulator at all, so today either shape collapses //! into one opaque `KeepPreAsap` that swallows the realizable inner //! quantile. @@ -74,7 +75,7 @@ use std::rc::Rc; -use asap_types::post_asap::value_domain::validate_execution_domains_at; +use asap_types::post_asap::execution_data_state::validate_execution_domains_at; use asap_types::post_asap::{ exact_operator_output_schema, produced_domain, CompositionOperator, DomainError, ExactOperator, ExecutionDataState, SummaryExpr, SummaryNode, SummarySchema, ValueOperator, @@ -384,7 +385,7 @@ impl<'a> ExactCompositionStrategy<'a> { let Ok(schema) = target.root.output_schema() else { return Vec::new(); }; - let schema = asap_types::post_asap::value_domain::lift_plain(&schema); + let schema = asap_types::post_asap::execution_data_state::lift_plain(&schema); let mut out = Vec::new(); if capabilities.supports(CompositionPlacement::PostProcess) { From aa8353decd8a59a4eaba3818fcc3a93c4ad8cccd Mon Sep 17 00:00:00 2001 From: zz_y Date: Sun, 30 Aug 2026 13:02:34 -0600 Subject: [PATCH 15/15] refactor: use execution data state terminology --- .../src/exact_composition.rs | 55 ++++++++++--------- crates/asap-aware-mapping/src/replacement.rs | 31 ++++++----- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index ce89af14..5b23d854 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -11,7 +11,7 @@ //! //! - `max by (zone) (quantile_over_time(0.99, latency[5m]))` — the outer //! `max` is an exact fold over the inner summary's *readout*. A `MinMax` -//! accumulator over that readout is domain-illegal (a maintained summary +//! accumulator over that readout is data_state-illegal (a maintained summary //! can't consume query-time values — see //! `asap_types::post_asap::execution_data_state`), //! and `avg` has no accumulator at all, so today either shape collapses @@ -23,7 +23,7 @@ //! explicit "this row transform runs on the update path" node. //! //! [`SummaryExpr::ReadoutPostProcess`] and [`SummaryExpr::UpdateTransform`] -//! are the two domain-explicit representations; this strategy is what +//! are the two data_state-explicit representations; this strategy is what //! proposes them. //! //! ## Reference, don't select @@ -54,7 +54,7 @@ //! target's grouping keys resolve in the child's output schema; //! transform: the target is a per-entity exact transform with no //! accumulator form (its only implementation is `PassThrough`); -//! - the exact operator consumes only `Plain` values in its domain — checked +//! - the exact operator consumes only `Plain` values in its data_state — checked //! again, structurally, when the pair is composed; //! - the plugged-in [`CostModel`] advertises the matching //! [`MixedExecutionCapabilities`](crate::cost_model::MixedExecutionCapabilities). @@ -66,7 +66,7 @@ //! ## What this strategy never does //! //! - Propose an `ExactPostProcess` for a position beneath a maintained -//! summary — domain validation at composition rejects it as a typed +//! summary — data_state validation at composition rejects it as a typed //! `ImplementError` regardless. //! - Decide whether a composition is *worth it*: that is //! `global_selection`'s job, using the issue's cost-units-per-second @@ -75,10 +75,11 @@ use std::rc::Rc; -use asap_types::post_asap::execution_data_state::validate_execution_domains_at; +use asap_types::post_asap::execution_data_state::validate_execution_data_states_at; use asap_types::post_asap::{ - exact_operator_output_schema, produced_domain, CompositionOperator, DomainError, ExactOperator, - ExecutionDataState, SummaryExpr, SummaryNode, SummarySchema, ValueOperator, + exact_operator_output_schema, produced_data_state, CompositionOperator, ExactOperator, + ExecutionDataState, ExecutionDataStateError, SummaryExpr, SummaryNode, SummarySchema, + ValueOperator, }; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::query_expr::{QueryExpr, Reduction}; @@ -105,7 +106,7 @@ pub enum CompositionPlacement { impl CompositionPlacement { /// The availability the composed operator consumes and produces. - pub fn domain(self) -> ExecutionDataState { + pub fn data_state(self) -> ExecutionDataState { match self { Self::PostProcess => ExecutionDataState::READ_ROWS, Self::Transform => ExecutionDataState::MAINTENANCE_ROWS, @@ -139,21 +140,21 @@ pub struct ExactComposition { impl ExactComposition { /// Can `child` legally be this composition's input? Phase legality - /// (the child's produced domain — a `KeepPreAsap` leaf takes the + /// (the child's produced data_state — a `KeepPreAsap` leaf takes the /// phase this edge assigns) plus the plain-operand rule, checked /// through the same schema derivation [`Self::compose`] uses. pub fn accepts_child(&self, child: &SummaryNode) -> bool { - let phase_ok = match produced_domain(&child.expr) { + let phase_ok = match produced_data_state(&child.expr) { None => true, - Some(avail) => avail == self.placement.domain(), + Some(avail) => avail == self.placement.data_state(), }; phase_ok && exact_operator_output_schema(&self.op, &child.schema).is_ok() } - /// Build the composed, domain-validated node over `child`. Every edge of + /// Build the composed, data_state-validated node over `child`. Every edge of /// the result (including everything beneath `child`) is checked by - /// `asap_types::post_asap::validate_execution_domains`; an illegal - /// placement is a typed [`ImplementError::Domain`], never deferred to a + /// `asap_types::post_asap::validate_execution_data_states`; an illegal + /// placement is a typed [`ImplementError::ExecutionDataState`], never deferred to a /// runtime. pub fn compose(&self, child: Rc) -> Result, ImplementError> { self.compose_with_accuracy(child, &DefaultAccuracyModel) @@ -167,16 +168,18 @@ impl ExactComposition { child: Rc, accuracy_model: &dyn AccuracyModel, ) -> Result, ImplementError> { - if let Some(produced) = produced_domain(&child.expr) { - if produced != self.placement.domain() { + if let Some(produced) = produced_data_state(&child.expr) { + if produced != self.placement.data_state() { let edge = match self.placement { CompositionPlacement::Transform => "UpdateTransform.child", CompositionPlacement::PostProcess => "ReadoutPostProcess.child", }; - return Err(ImplementError::Domain(DomainError::IllegalChildPhase { - edge, - child: produced, - })); + return Err(ImplementError::ExecutionDataState( + ExecutionDataStateError::IllegalChildPhase { + edge, + child: produced, + }, + )); } } let schema = exact_operator_output_schema(&self.op, &child.schema)?; @@ -219,7 +222,7 @@ impl ExactComposition { schema, guarantee, }); - validate_execution_domains_at(&node, self.placement.domain())?; + validate_execution_data_states_at(&node, self.placement.data_state())?; Ok(node) } @@ -455,7 +458,7 @@ mod tests { use super::*; use crate::cost_model::{DefaultCostModel, MixedExecutionCapabilities}; use crate::replacement::keep_pre_asap; - use asap_types::post_asap::{DomainError, SketchAlgorithm, SummaryFamilyType}; + use asap_types::post_asap::{ExecutionDataStateError, SketchAlgorithm, SummaryFamilyType}; use asap_types::pre_asap::agg_intent::default_quantile; use asap_types::pre_asap::query_expr::Source; use asap_types::pre_asap::schema::{Column, DataType, Schema}; @@ -623,8 +626,8 @@ mod tests { assert!(!comp.accepts_child(summary_input)); assert!(matches!( comp.compose(Rc::clone(summary_input)), - Err(ImplementError::Domain( - DomainError::IllegalChildPhase { .. } + Err(ImplementError::ExecutionDataState( + ExecutionDataStateError::IllegalChildPhase { .. } )) )); // The readout itself is accepted and composes to a plain schema. @@ -655,8 +658,8 @@ mod tests { assert!(!comp.accepts_child(&readout)); assert!(matches!( comp.compose(readout), - Err(ImplementError::Domain( - DomainError::IllegalChildPhase { .. } + Err(ImplementError::ExecutionDataState( + ExecutionDataStateError::IllegalChildPhase { .. } )) )); // Raw update input is fine. diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 69d85f54..89860123 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -349,11 +349,11 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use asap_types::post_asap::{ - validate_execution_domains_at, DomainError, ExactKind, ExactOperatorSchemaError, ExactParams, - ExecutionDataState, GroupingStrategy, SamplingKind, SamplingParams, SketchAlgorithm, - SketchKind, SketchParams, SketchQuery as PostAsapSketchQuery, StatModelKind, StatModelParams, - SummaryExpr, SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, WaveletKind, - WaveletParams, + validate_execution_data_states_at, ExactKind, ExactOperatorSchemaError, ExactParams, + ExecutionDataState, ExecutionDataStateError, GroupingStrategy, SamplingKind, SamplingParams, + SketchAlgorithm, SketchKind, SketchParams, SketchQuery as PostAsapSketchQuery, StatModelKind, + StatModelParams, SummaryExpr, SummaryFamilyType, SummaryField, SummaryNode, SummarySchema, + WaveletKind, WaveletParams, }; use asap_types::post_asap::{AccuracyError, CompositionOperator, GuaranteeSource, ResultGuarantee}; use asap_types::pre_asap::agg_intent::{agg_is_mergeable, AggIntent}; @@ -406,8 +406,8 @@ pub enum ImplementError { /// A constructed plan violates the update/readout phase contract /// (issue #171) — e.g. a summary readout placed beneath a maintained /// `SummaryAgg`. Detected at construction, never at runtime. - #[error("execution-domain violation in post-ASAP plan: {0}")] - Domain(#[from] DomainError), + #[error("execution-data_state violation in post-ASAP plan: {0}")] + ExecutionDataState(#[from] ExecutionDataStateError), /// An `ExactOperator`'s output schema could not be derived over its /// child — the child carries summary state the operator can't read. #[error("exact operator schema derivation failed: {0}")] @@ -552,7 +552,7 @@ pub struct RejectedCandidate { pub struct Proposals { pub candidates: Vec, pub rejected: Vec, - domain_error: Option, + domain_error: Option, } /// A replacement strategy: given a [`TargetSubDAG`], does this strategy have @@ -1411,7 +1411,7 @@ impl<'a> SketchAlgorithmStrategy<'a> { provenance: ReplacementProvenance::SummaryImplementation, rationale: format!( "{} stays pre-ASAP because summary construction crosses an illegal \ - execution-domain boundary ({error})", + execution-data_state boundary ({error})", describe_intent(intent) ), }); @@ -1439,7 +1439,7 @@ impl Proposals { description: rationale, error, }), - Err(ImplementError::Domain(error)) => { + Err(ImplementError::ExecutionDataState(error)) => { self.domain_error.get_or_insert(error); } Err(ImplementError::Schema(_) | ImplementError::ExactOperatorSchema(_)) => {} @@ -1817,7 +1817,7 @@ fn construct_summary_agg( // Phase contract (issue #171): a maintained summary consumes update-path // values or exact accumulator state — never a query-time readout. A // typed error here, at construction; the caller decides the fallback. - validate_execution_domains_at(&agg, ExecutionDataState::MAINTENANCE_SUMMARY)?; + validate_execution_data_states_at(&agg, ExecutionDataState::MAINTENANCE_SUMMARY)?; match query { // The readout: downstream of the estimate the schema is the plain // pre-ASAP row shape again (the summary-state type does not @@ -2867,7 +2867,7 @@ impl<'a> GlobalSelection<'a> { self.groups.get(&Rc::as_ptr(target)) } - /// Link this selection's per-site decisions into one domain-validated + /// Link this selection's per-site decisions into one data_state-validated /// post-ASAP DAG rooted at `target` — the one place a committed /// composition's child *reference* becomes an actual `Rc` /// edge (issue #171). `None` if `target` is not a discovered site. @@ -2983,7 +2983,10 @@ fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc rebuilt, Err(_) => Rc::clone(node), } @@ -4980,7 +4983,7 @@ mod tests { }; assert!( matches!(node.expr, SummaryExpr::KeepPreAsap(ref e) if Rc::ptr_eq(e, &outer)), - "a sketch over a sketch readout is domain-illegal; expected the conservative \ + "a sketch over a sketch readout is data_state-illegal; expected the conservative \ fallback, got {:?}", node.expr );