diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index 071c4a40..dcbbc56a 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -49,22 +49,458 @@ // --post-asap --epsilon 0.01 \ // --sql "SELECT quantile(0.95, latency) FROM metrics" --name q1 +use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use std::time::Instant; +use asap_aware_mapping::analytical_cost::{AnalyticalCostError, ResourceCalibration}; +use asap_aware_mapping::analytical_lowering::{ + PhysicalDag, PhysicalNodeEvidence, PhysicalNodeRequest, +}; +use asap_aware_mapping::analytical_planner::{ + AnalyticalPlannerCostModel, PlannerPhysicalPlanProvider, +}; +use asap_aware_mapping::analytical_statistics::ComparisonScope; +#[cfg(test)] use asap_aware_mapping::cost_model::DefaultCostModel; -use asap_aware_mapping::replacement::{search_workload, Replacement, ReplacementSubDAG}; +use asap_aware_mapping::cost_model::{Cost, CostModel}; +#[cfg(test)] +use asap_aware_mapping::replacement::search_workload; +use asap_aware_mapping::replacement::{ + default_strategies_with, default_strategies_with_evidence, search_workload_with, Replacement, + ReplacementSubDAG, +}; +use asap_aware_mapping::{AccuracyEvidenceProvider, PropagationStats}; +use asap_types::cost::{BaselineRef, CostAnnotation, CostInput, CostSource, CostUnit}; use asap_types::dag_export::{ self, DagDecision, DagGraph, DagNote, NamedGraph, PostAsapSubstitution, TargetRejection, TargetReplacement, TargetReplacementAfter, WorkloadGraph, }; use asap_types::post_asap::SummaryExpr; +use asap_types::post_asap::SummaryNode; +use asap_types::post_asap::{CompositionOperator, SketchQuery, SummaryFamilyType}; use asap_types::pre_asap::cse::{structural_hash, HashCache}; use asap_types::pre_asap::query_expr::QueryExpr; use asap_types::pre_asap::schema::{Column, DataType, Schema}; use asap_types::types::AccuracyTarget; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct PlannerCostDocument { + calibration: ResourceCalibration, + targets: Vec, +} + +fn parse_planner_cost_document(raw: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("planner cost evidence is invalid JSON: {error}"))?; + if value.get("targets").is_none() { + return Err("the compact analytical-cost payload is no longer supported; migrate to complete per-operator physical DAG evidence".into()); + } + let mut document: PlannerCostDocument = serde_json::from_value(value) + .map_err(|error| format!("planner cost evidence is invalid: {error}"))?; + for target in &mut document.targets { + for candidate in &mut target.candidates { + normalize_replacement_identity(candidate.plan_mut()); + } + } + Ok(document) +} + +fn normalize_replacement_identity(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(fields) => { + // Accuracy guarantees are derived from the selected summary and + // target and may round-trip through decimal JSON by one ULP. They + // are validated before costing, but are not physical identity. + fields.remove("guarantee"); + for child in fields.values_mut() { + normalize_replacement_identity(child); + } + } + serde_json::Value::Array(values) => { + for child in values { + normalize_replacement_identity(child); + } + } + _ => {} + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct TargetPhysicalEvidence { + target: QueryExpr, + scope: ComparisonScopeEvidence, + query_nodes: Vec, + candidates: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ComparisonScopeEvidence { + data_arrival: asap_types::workload::DataArrival, + planning_time_ms: u64, + horizon_ms: u64, + evaluation_count: u64, + time_scope: String, + lookback_ms: Option, + as_of_ms: Option, + sources: Vec, +} + +impl ComparisonScopeEvidence { + fn resolve(&self) -> Result { + use asap_types::workload::{ + DurationMs, QueryRecurrence, QueryTimeScope, TimeSelection, TimestampMs, + }; + let scope = match self.time_scope.as_str() { + "real_time" => QueryTimeScope::RealTime, + "longitudinal" => QueryTimeScope::Longitudinal, + "mixed" => QueryTimeScope::Mixed, + "unknown" => QueryTimeScope::Unknown, + _ => return Err(AnalyticalCostError::MissingComparisonScope("time_scope")), + }; + let comparison = ComparisonScope { + data_arrival: self.data_arrival, + planning_time: TimestampMs(self.planning_time_ms), + horizon: DurationMs(self.horizon_ms), + recurrence: QueryRecurrence::OneTime { + invocations: self.evaluation_count, + execute_at: None, + }, + time_selection: TimeSelection { + scope, + lookback: self.lookback_ms.map(DurationMs), + as_of: self.as_of_ms.map(TimestampMs), + }, + sources: self.sources.clone(), + }; + comparison.validate()?; + Ok(comparison) + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct QueryNodePhysicalEvidence { + logical_node: QueryExpr, + operator: asap_aware_mapping::analytical_cost::PhysicalOperator, + occurrence: usize, + synthetic: bool, + evidence: PhysicalNodeEvidence, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum CandidatePhysicalEvidence { + Rewrite { + plan: serde_json::Value, + }, + Summary { + plan: serde_json::Value, + physical_dag: PhysicalDag, + }, +} + +impl CandidatePhysicalEvidence { + fn plan(&self) -> &serde_json::Value { + match self { + Self::Rewrite { plan } | Self::Summary { plan, .. } => plan, + } + } + + fn plan_mut(&mut self) -> &mut serde_json::Value { + match self { + Self::Rewrite { plan } | Self::Summary { plan, .. } => plan, + } + } + + fn matches(&self, candidate: &ReplacementSubDAG) -> bool { + let actual = match (self, &candidate.replacement) { + (Self::Summary { .. }, Replacement::Summary(summary)) => { + serde_json::to_value(dag_export::export_summary(summary)) + } + (Self::Rewrite { .. }, Replacement::Rewrite(query)) => { + serde_json::to_value(dag_export::export(query)) + } + _ => return false, + }; + actual.is_ok_and(|mut actual| { + normalize_replacement_identity(&mut actual); + actual == *self.plan() + }) + } + + fn summary_dag(&self) -> Option<&PhysicalDag> { + match self { + Self::Summary { physical_dag, .. } => Some(physical_dag), + Self::Rewrite { .. } => None, + } + } +} + +struct ExportPhysicalProvider<'a> { + target: &'a TargetPhysicalEvidence, + candidate: &'a CandidatePhysicalEvidence, + used_query_nodes: RefCell>, +} + +impl ExportPhysicalProvider<'_> { + fn all_query_evidence_used(&self) -> bool { + self.used_query_nodes.borrow().len() == self.target.query_nodes.len() + } +} + +impl PlannerPhysicalPlanProvider for ExportPhysicalProvider<'_> { + fn comparison_scope( + &self, + _target: &asap_aware_mapping::replacement::TargetSubDAG<'_>, + ) -> Result { + self.target.scope.resolve() + } + + fn query_node_evidence( + &self, + request: PhysicalNodeRequest<'_>, + ) -> Result { + let mut matches = self + .target + .query_nodes + .iter() + .enumerate() + .filter(|(_, entry)| { + entry.logical_node == *request.logical_node + && entry.operator == request.operator + && entry.occurrence == request.occurrence + && entry.synthetic == request.synthetic + }); + let (index, evidence) = matches.next().ok_or_else(|| { + AnalyticalCostError::MissingOperatorStatistics(format!( + "logical occurrence {}", + request.occurrence + )) + })?; + if matches.next().is_some() { + return Err(AnalyticalCostError::InvalidPhysicalDag( + "duplicate query-node evidence key", + )); + } + self.used_query_nodes.borrow_mut().insert(index); + Ok(evidence.evidence.clone()) + } + + fn summary_physical_dag( + &self, + _summary: &Rc, + _target: &asap_aware_mapping::replacement::TargetSubDAG<'_>, + _scope: &ComparisonScope, + ) -> Result { + self.candidate + .summary_dag() + .cloned() + .ok_or(AnalyticalCostError::InvalidPhysicalDag( + "summary candidate is missing its physical DAG", + )) + } +} + +struct ExportPlannerCostModel<'a> { + document: &'a PlannerCostDocument, + used_targets: RefCell>, + used_candidates: RefCell>, +} + +impl ExportPlannerCostModel<'_> { + fn new(document: &PlannerCostDocument) -> ExportPlannerCostModel<'_> { + ExportPlannerCostModel { + document, + used_targets: RefCell::new(std::collections::HashSet::new()), + used_candidates: RefCell::new(std::collections::HashSet::new()), + } + } + + fn bound<'a>( + &'a self, + candidate: &ReplacementSubDAG, + target: &asap_aware_mapping::replacement::TargetSubDAG<'_>, + ) -> Option<(ExportPhysicalProvider<'a>, &'a ResourceCalibration)> { + let mut targets = self + .document + .targets + .iter() + .enumerate() + .filter(|(_, entry)| entry.target == **target.root); + let (target_index, target_evidence) = targets.next()?; + if targets.next().is_some() { + return None; + } + let mut candidates = target_evidence + .candidates + .iter() + .enumerate() + .filter(|(_, entry)| entry.matches(candidate)); + let (candidate_index, candidate_evidence) = candidates.next()?; + if candidates.next().is_some() { + return None; + } + self.used_targets.borrow_mut().insert(target_index); + self.used_candidates + .borrow_mut() + .insert((target_index, candidate_index)); + Some(( + ExportPhysicalProvider { + target: target_evidence, + candidate: candidate_evidence, + used_query_nodes: RefCell::new(std::collections::HashSet::new()), + }, + &self.document.calibration, + )) + } + + fn all_document_evidence_used(&self) -> bool { + let used_targets = self.used_targets.borrow(); + let used_candidates = self.used_candidates.borrow(); + self.document + .targets + .iter() + .enumerate() + .all(|(target_index, target)| { + used_targets.contains(&target_index) + && target + .candidates + .iter() + .enumerate() + .all(|(candidate_index, _)| { + used_candidates.contains(&(target_index, candidate_index)) + }) + }) + } + + fn annotations( + &self, + candidate: &ReplacementSubDAG, + target: &Rc, + ) -> (CostAnnotation, CostAnnotation, CostAnnotation) { + let target = asap_aware_mapping::replacement::TargetSubDAG::new(target); + let Some((provider, calibration)) = self.bound(candidate, &target) else { + return winner_cost_annotations(); + }; + let Ok(model) = AnalyticalPlannerCostModel::new(&provider, calibration.clone()) else { + return winner_cost_annotations(); + }; + let Ok(estimate) = model.estimate_candidate(candidate, &target) else { + return winner_cost_annotations(); + }; + if !provider.all_query_evidence_used() { + return winner_cost_annotations(); + } + let version = format!("physical-dag+{}", calibration.version); + let inputs = |resources: asap_aware_mapping::analytical_cost::ResourceEstimate| { + vec![ + CostInput { + name: "estimated_cpu_ops".into(), + value: resources.cpu_ops, + unit: Some("operations".into()), + }, + CostInput { + name: "estimated_peak_memory".into(), + value: resources.peak_memory_bytes as f64, + unit: Some("bytes".into()), + }, + CostInput { + name: "estimated_scan".into(), + value: resources.scan_bytes as f64, + unit: Some("bytes".into()), + }, + ] + }; + let baseline = CostAnnotation::modeled( + estimate.raw_cost.0, + CostUnit::CostUnits, + &version, + inputs(estimate.resources.raw), + ); + let selected = CostAnnotation::modeled( + estimate.candidate_cost.0, + CostUnit::CostUnits, + &version, + inputs(estimate.resources.candidate), + ) + .with_baseline(BaselineRef::PreAsapRecomputation, estimate.raw_cost.0); + let benefit = CostAnnotation { + value: selected.delta, + unit: CostUnit::CostUnits, + source: CostSource::Modeled, + baseline: Some(BaselineRef::PreAsapRecomputation), + delta: None, + benefit_ratio: selected.benefit_ratio, + model_version: Some(version), + benchmark_id: None, + inputs: Vec::new(), + }; + (baseline, selected, benefit) + } +} + +impl CostModel for ExportPlannerCostModel<'_> { + fn candidate_cost_covers_complete_plan(&self) -> bool { + true + } + + fn candidate_cost( + &self, + candidate: &ReplacementSubDAG, + target: &asap_aware_mapping::replacement::TargetSubDAG<'_>, + ) -> Option { + let (provider, calibration) = self.bound(candidate, target)?; + let cost = AnalyticalPlannerCostModel::new(&provider, calibration.clone()) + .ok()? + .candidate_cost(candidate, target)?; + provider.all_query_evidence_used().then_some(cost) + } + + fn rank_candidates( + &self, + _intent: &asap_types::pre_asap::AggIntent, + candidates: &[asap_types::post_asap::SketchAlgorithm], + ) -> Vec { + // Candidate generation must not reintroduce the legacy structural + // cost model before complete physical alternatives are compared. + candidates.to_vec() + } + + fn estimate_cost( + &self, + candidate: &ReplacementSubDAG, + target: &asap_aware_mapping::replacement::TargetSubDAG<'_>, + ) -> f64 { + self.candidate_cost(candidate, target) + .map_or(f64::NAN, |cost| cost.0) + } +} + +/// Baseline/selected/benefit [`CostAnnotation`]s for one [`Winner`] — issue +/// #286's "replacement-region baseline cost, selected cost, and benefit" +/// granularity item, reused verbatim for [`TargetReplacement`] and for the +/// [`DagDecision`] carried by every node the winning candidate produced or +/// carried. +/// +/// `dag_export` has no deployment-owned physical evidence provider. It must +/// therefore expose costs as unavailable instead of guessing operator +/// statistics or falling back to structural node counts. Callers that have +/// complete evidence use `AnalyticalPlannerCostModel` before export and may +/// attach its dimensional comparison to these fields. +#[allow(dead_code)] +fn winner_cost_annotations() -> (CostAnnotation, CostAnnotation, CostAnnotation) { + ( + CostAnnotation::unavailable(CostUnit::CostUnits), + CostAnnotation::unavailable(CostUnit::CostUnits), + CostAnnotation::unavailable(CostUnit::CostUnits), + ) +} + use asap_devtools::{lower_promql, lower_sql, SqlCatalog}; enum Lang { @@ -152,6 +588,53 @@ struct ParsedArgs { post_asap: bool, progress: bool, table_schemas: Vec, + planner_cost: Option, + topk_margin: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct TopKMarginEvidence { + selected_lower_bound: f64, + excluded_upper_bound: f64, + interval_failure_probability: f64, +} + +impl TopKMarginEvidence { + fn validate(&self) -> Result<(), &'static str> { + if !self.selected_lower_bound.is_finite() || !self.excluded_upper_bound.is_finite() { + return Err("Top-K bounds must be finite"); + } + if self.selected_lower_bound <= self.excluded_upper_bound { + return Err("selected_lower_bound must exceed excluded_upper_bound"); + } + if !(self.interval_failure_probability.is_finite() + && self.interval_failure_probability >= 0.0 + && self.interval_failure_probability < 1.0) + { + return Err("interval_failure_probability must be in [0, 1)"); + } + Ok(()) + } +} + +impl AccuracyEvidenceProvider for TopKMarginEvidence { + fn propagation_stats( + &self, + op: &CompositionOperator, + _family: &SummaryFamilyType, + _query: Option<&SketchQuery>, + ) -> PropagationStats { + if matches!(op, CompositionOperator::TopKSelection) { + PropagationStats { + topk_selected_lower_bound: Some(self.selected_lower_bound), + topk_excluded_upper_bound: Some(self.excluded_upper_bound), + topk_interval_failure_probability: Some(self.interval_failure_probability), + ..Default::default() + } + } else { + PropagationStats::default() + } + } } fn parse_args() -> ParsedArgs { @@ -161,6 +644,8 @@ fn parse_args() -> ParsedArgs { let mut post_asap = false; let mut progress = false; let mut table_schemas = Vec::new(); + let mut planner_cost_json = None; + let mut topk_margin_json = None; let mut args = std::env::args().skip(1); fn flush(entries: &mut Vec<(String, Lang, String)>, pending: &mut Option<(Lang, String)>) { @@ -204,16 +689,40 @@ fn parse_args() -> ParsedArgs { "--table-schema" => { table_schemas.push(args.next().expect("--table-schema requires JSON")); } + "--planner-cost-json" | "--analytical-cost-json" => { + planner_cost_json = Some( + args.next() + .expect("planner cost evidence requires a JSON document"), + ); + } + "--topk-margin-json" => { + topk_margin_json = Some( + args.next() + .expect("--topk-margin-json requires a JSON object"), + ); + } other => panic!("unrecognized argument: {other}"), } } flush(&mut entries, &mut pending); + let planner_cost = planner_cost_json + .map(|raw| parse_planner_cost_document(&raw).unwrap_or_else(|error| panic!("{error}"))); + let topk_margin = topk_margin_json.map(|raw| { + let evidence: TopKMarginEvidence = serde_json::from_str(&raw) + .unwrap_or_else(|error| panic!("--topk-margin-json is invalid: {error}")); + evidence + .validate() + .unwrap_or_else(|error| panic!("invalid Top-K margin evidence: {error}")); + evidence + }); ParsedArgs { entries, accuracy, post_asap, progress, table_schemas, + planner_cost, + topk_margin, } } @@ -244,19 +753,21 @@ fn annotate_with_explanations( } /// One `MemoGroup`'s best-ranked candidate, kept alongside its own `target` -/// and `cost` — the unit both [`PostAsapResults::replacements`] and +/// — the unit both [`PostAsapResults::replacements`] and /// [`PostAsapResults::post_graphs`] are built from, so the two outputs can /// never disagree about which candidate won for a given target. +#[allow(dead_code)] struct Winner<'a> { target: &'a Rc, candidate: &'a ReplacementSubDAG, - cost: f64, + costs: (CostAnnotation, CostAnnotation, CostAnnotation), } /// Short explanation intended for a selected winner in node-level UI. The /// candidate's full rationale remains available in pre-ASAP applicability /// notes; repeating that exhaustive prose on every post-ASAP region node /// obscures the actual decision. +#[allow(dead_code)] fn decision_rationale(winner: &Winner<'_>) -> String { match winner.candidate.strategy { "AvgToSumOverCountStrategy" => { @@ -302,6 +813,7 @@ fn decision_rationale(winner: &Winner<'_>) -> String { /// index rather than a `&Winner` directly so a caller can both use the /// match and record it (e.g. `matched[i] = true`) without juggling a second /// way to name the same winner. +#[allow(dead_code)] fn lookup_winner( by_hash: &HashMap>, winners: &[Winner<'_>], @@ -316,8 +828,34 @@ fn lookup_winner( .find(|&i| expr == winners[i].target.as_ref()) } +/// One `(decision.id, baseline_cost, selected_cost)` triple per *distinct* +/// [`DagDecision`] carried anywhere in `graph` — collapsing every node that +/// shares one `decision.id` (a replacement region can span many nodes, all +/// carrying an identical clone of the same decision) down to a single +/// entry, so a caller summing these never counts one decision's cost once +/// per node it happens to touch. +fn decision_cost_entries(graph: &DagGraph) -> Vec<(u32, CostAnnotation, CostAnnotation)> { + let mut seen = std::collections::HashSet::new(); + let mut entries = Vec::new(); + for node in &graph.nodes { + let Some(decision) = &node.decision else { + continue; + }; + if !seen.insert(decision.id) { + continue; + } + let (Some(baseline), Some(selected)) = (&decision.baseline_cost, &decision.selected_cost) + else { + continue; + }; + entries.push((decision.id, baseline.clone(), selected.clone())); + } + entries +} + /// Build a [`TargetReplacement`] for `winner`, matching this file's own /// per-target `before`/`after` construction. +#[allow(dead_code)] fn target_replacement( decision_id: u32, target_pre_id: u32, @@ -333,15 +871,22 @@ fn target_replacement( TargetReplacementAfter::Rewrite(dag_export::export(rewritten)) } }; + let (baseline_cost, selected_cost, benefit) = winner.costs.clone(); + // Derived from `selected_cost` so the legacy scalar field and the + // structured annotation can never drift apart. + let cost = selected_cost.value.unwrap_or(f64::NAN); TargetReplacement { decision_id, target_pre_id, strategy, rationale: decision_rationale(winner), rank: 0, - cost: winner.cost, + cost, before, after, + baseline_cost: Some(baseline_cost), + selected_cost: Some(selected_cost), + benefit: Some(benefit), } } @@ -364,6 +909,14 @@ struct PostAsapResults { rejections: Vec<(String, TargetRejection)>, } +fn raw_only_post_asap_results() -> PostAsapResults { + PostAsapResults { + replacements: Vec::new(), + post_graphs: Vec::new(), + rejections: Vec::new(), + } +} + /// Assign collision-free, explicit identities to structurally equal nodes /// across a set of exported query graphs. The full canonical subtree string /// is the equality key; the compact integer is what JSON consumers receive. @@ -406,17 +959,18 @@ fn assign_workload_node_ids(graphs: &mut [&mut DagGraph]) { } } -/// Run `asap_aware_mapping::replacement::search_workload` (its own -/// `default_strategies()` — which includes `AvgToSumOverCountStrategy` as of -/// #282 — is exactly the strategy set this binary wants; no custom list -/// needed) over every lowered query, rank each discovered `MemoGroup` via -/// `PlanSpace::cost_sorted`, and build both `--post-asap` outputs from the -/// exact same set of winning candidates (see [`Winner`]), so the flat -/// `replacements` list and the merged `post_graph` can never disagree about -/// which candidate won for a given target. +/// Search every lowered query with strategies bound to the supplied cost +/// model, rank each discovered `MemoGroup` via `PlanSpace::global_selection`, +/// and build both `--post-asap` outputs from the exact same set of winning +/// candidates (see [`Winner`]). The flat `replacements` list and merged +/// `post_graph` therefore cannot disagree about which candidate won. +#[allow(dead_code)] fn run_post_asap_with_progress( lowered_queries: &[(String, String, QueryExpr)], progress: bool, + cost_model: &dyn CostModel, + export_model: Option<&ExportPlannerCostModel<'_>>, + evidence: Option<&dyn AccuracyEvidenceProvider>, ) -> PostAsapResults { let mapping_started = Instant::now(); if progress { @@ -426,8 +980,18 @@ fn run_post_asap_with_progress( .iter() .map(|(name, _, qe)| (name.clone(), Rc::new(qe.clone()))) .collect(); - let space = search_workload(roots); - let ranked_groups = space.cost_sorted(&DefaultCostModel); + let strategies; + let space = if let Some(evidence) = evidence { + strategies = default_strategies_with_evidence(cost_model, evidence); + search_workload_with(roots, &strategies) + } else { + strategies = default_strategies_with(cost_model); + search_workload_with(roots, &strategies) + }; + let selection = space.global_selection(cost_model); + if export_model.is_some_and(|model| !model.all_document_evidence_used()) { + return raw_only_post_asap_results(); + } // A group's top candidate can be `keep_pre_asap`'s own conservative // fallback — `Replacement::Summary(SummaryNode { expr: @@ -451,10 +1015,10 @@ fn run_post_asap_with_progress( // winner again, forever. Treating this candidate as "no winner" (same // as an empty candidate list) avoids ever handing `export_post_asap` a // winner that can't help but recurse into itself. - let winners: Vec> = ranked_groups - .iter() + let winners: Vec> = selection + .groups() .filter_map(|group| { - let candidate = group.candidates.first()?; + let candidate = group.chosen?; if matches!( &candidate.replacement, Replacement::Summary(node) if matches!(node.expr, SummaryExpr::KeepPreAsap(_)) @@ -464,7 +1028,9 @@ fn run_post_asap_with_progress( Some(Winner { target: group.target, candidate, - cost: group.costs[0], + costs: export_model.map_or_else(winner_cost_annotations, |model| { + model.annotations(candidate, group.target) + }), }) }) .collect(); @@ -507,13 +1073,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 (baseline_cost, selected_cost, benefit) = winner.costs.clone(); + // Derived from `selected_cost`; see `target_replacement`'s identical + // derivation. + let cost = selected_cost.value.unwrap_or(f64::NAN); let decision = DagDecision { id: i as u32, strategy: winner.candidate.strategy.to_string(), rationale: decision_rationale(winner), rank: 0, - cost: winner.cost, + cost, role: "replacement_region", + baseline_cost: Some(baseline_cost), + selected_cost: Some(selected_cost), + benefit: Some(benefit), }; Some(match &winners[i].candidate.replacement { Replacement::Rewrite(rc) => PostAsapSubstitution::Rewrite { @@ -640,9 +1213,10 @@ fn run_post_asap_with_progress( } } +#[cfg(test)] #[cfg(test)] fn run_post_asap(lowered_queries: &[(String, String, QueryExpr)]) -> PostAsapResults { - run_post_asap_with_progress(lowered_queries, false) + run_post_asap_with_progress(lowered_queries, false, &DefaultCostModel, None, None) } #[tokio::main] @@ -653,6 +1227,8 @@ async fn main() { post_asap, progress, table_schemas, + planner_cost, + topk_margin, } = parse_args(); let sql_catalog = catalog(&table_schemas); let planner_started = Instant::now(); @@ -710,6 +1286,7 @@ async fn main() { graph, replacements: Vec::new(), post_graph: None, + workload_cost: None, rejections: Vec::new(), }); } @@ -729,7 +1306,23 @@ async fn main() { } if post_asap { - let results = run_post_asap_with_progress(&lowered_queries, progress); + let results = if let Some(document) = planner_cost.as_ref() { + let model = ExportPlannerCostModel::new(document); + run_post_asap_with_progress( + &lowered_queries, + progress, + &model, + Some(&model), + topk_margin + .as_ref() + .map(|evidence| evidence as &dyn AccuracyEvidenceProvider), + ) + } else { + eprintln!( + "dag_export: --post-asap requires complete deployment-owned physical-plan evidence; exporting the raw plan only" + ); + raw_only_post_asap_results() + }; for (query_name, replacement) in results.replacements { if let Some(named) = queries.iter_mut().find(|q| q.name == query_name) { named.replacements.push(replacement); @@ -759,7 +1352,59 @@ async fn main() { assign_workload_node_ids(&mut post_graphs); } - let workload = WorkloadGraph { queries }; + // Whole selected-workload cost/benefit (issue #286) — per query, and + // for the whole selected workload. `decision.id` (== the winning + // `Winner`'s own index — see `run_post_asap_with_progress`) is already + // a collision-free dedup key for a decision shared across multiple + // nodes (a replacement region spans several nodes, all carrying the + // same `decision.id`) and across multiple queries (a CSE-shared target + // reachable from more than one query's root) alike, so it's reused + // directly as `sum_workload_costs`'s dedup key — no separate lookup + // needed. + let mut workload_entries = Vec::new(); + for query in &mut queries { + let Some(post_graph) = &query.post_graph else { + continue; + }; + let entries = decision_cost_entries(post_graph); + if entries.is_empty() { + continue; + } + match asap_types::cost::workload_cost_summary( + entries + .iter() + .map(|(id, baseline, selected)| (Some(*id), baseline, selected)), + "dag_export-workload-cost-v1", + ) { + Ok(summary) => query.workload_cost = Some(summary), + Err(mismatch) => eprintln!( + "dag_export: workload cost aggregation for {:?} skipped — {mismatch}", + query.name + ), + } + workload_entries.extend(entries); + } + let workload_cost = if workload_entries.is_empty() { + None + } else { + match asap_types::cost::workload_cost_summary( + workload_entries + .iter() + .map(|(id, baseline, selected)| (Some(*id), baseline, selected)), + "dag_export-workload-cost-v1", + ) { + Ok(summary) => Some(summary), + Err(mismatch) => { + eprintln!("dag_export: workload-wide cost aggregation skipped — {mismatch}"); + None + } + } + }; + + let workload = WorkloadGraph { + queries, + workload_cost, + }; if progress { eprintln!( "Total planner time: {:.2} ms", @@ -773,6 +1418,475 @@ async fn main() { mod tests { use super::*; + use asap_aware_mapping::analytical_cost::{ + ExecutionMultiplicity, PhysicalDagNode, PhysicalOperator, + }; + use asap_aware_mapping::analytical_lowering::lower_query_physical_dag; + use asap_aware_mapping::analytical_statistics::{ + EdgeStatistics, OperatorStatistics, SourceCoverage, + }; + use asap_types::pre_asap::{Column, DataType, Reduction, Schema, Source}; + + fn non_topk_query() -> QueryExpr { + QueryExpr::Aggregate { + reduction: Reduction::by(vec![]), + measures: vec![asap_types::pre_asap::AggIntent::Count { + accuracy: AccuracyTarget::Epsilon(0.1), + }], + output_names: vec![], + having: None, + child: Rc::new(QueryExpr::Scan { + source: Source::Table { + table_ref: "events".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("v", DataType::Int64, false)]), + }), + } + } + + fn test_scope() -> ComparisonScopeEvidence { + ComparisonScopeEvidence { + data_arrival: asap_types::workload::DataArrival::AtRest, + planning_time_ms: 1_000, + horizon_ms: 10_000, + evaluation_count: 10, + time_scope: "longitudinal".into(), + lookback_ms: Some(10_000), + as_of_ms: Some(1_000), + sources: vec![SourceCoverage { + source: Source::Table { + table_ref: "events".into(), + }, + snapshot_id: "snapshot-1".into(), + predicates: vec![], + }], + } + } + + fn edge(rows: u64, bytes: u64) -> EdgeStatistics { + EdgeStatistics { rows, bytes } + } + + fn query_evidence(query: &QueryExpr) -> Vec { + let entries = RefCell::new(Vec::new()); + let scope = test_scope().resolve().unwrap(); + let provider = |request: PhysicalNodeRequest<'_>| { + let (statistics, output_buffer_bytes) = match request.operator { + PhysicalOperator::Scan => ( + OperatorStatistics { + source_scan_bytes: 64_000, + inputs: vec![edge(1_000, 64_000)], + output: edge(1_000, 64_000), + group_count: None, + key_bytes: None, + aggregate_value_bytes: None, + k: None, + topk_output_offset: None, + limit_rows_consumed: None, + hash_join_build_side: None, + promql: None, + }, + 64_000, + ), + PhysicalOperator::HashAggregate => ( + OperatorStatistics { + source_scan_bytes: 0, + inputs: vec![edge(1_000, 64_000)], + output: edge(100, 2_400), + group_count: Some(100), + key_bytes: Some(8), + aggregate_value_bytes: Some(8), + k: None, + topk_output_offset: None, + limit_rows_consumed: None, + hash_join_build_side: None, + promql: None, + }, + 2_400, + ), + _ => return Err(AnalyticalCostError::UnsupportedQueryOperator), + }; + let evidence = PhysicalNodeEvidence { + physical_id: format!("{:?}-{}", request.operator, request.occurrence), + statistics, + output_buffer_bytes, + }; + entries.borrow_mut().push(QueryNodePhysicalEvidence { + logical_node: request.logical_node.clone(), + operator: request.operator, + occurrence: request.occurrence, + synthetic: request.synthetic, + evidence: evidence.clone(), + }); + Ok(evidence) + }; + lower_query_physical_dag(&Rc::new(query.clone()), &scope, &provider).unwrap(); + entries.into_inner() + } + + fn cheap_candidate_dag() -> PhysicalDag { + let coverage = test_scope().sources[0].clone(); + let statistics = OperatorStatistics { + source_scan_bytes: 64, + inputs: vec![edge(100, 2_400)], + output: edge(100, 2_400), + group_count: None, + key_bytes: None, + aggregate_value_bytes: None, + k: None, + topk_output_offset: None, + limit_rows_consumed: None, + hash_join_build_side: None, + promql: None, + }; + PhysicalDag { + nodes: vec![PhysicalDagNode { + id: "summary-read".into(), + operator: PhysicalOperator::Scan, + children: vec![], + source_coverage: Some(coverage), + output_buffer_bytes: 2_400, + retained_bytes: 0, + execution: ExecutionMultiplicity::PerEvaluation, + }], + root: "summary-read".into(), + evidence: [( + "summary-read".into(), + PhysicalNodeEvidence { + physical_id: "summary-read".into(), + statistics, + output_buffer_bytes: 2_400, + }, + )] + .into_iter() + .collect(), + } + } + + fn candidate_plan(candidate: &ReplacementSubDAG) -> serde_json::Value { + match &candidate.replacement { + Replacement::Summary(summary) => { + serde_json::to_value(dag_export::export_summary(summary.as_ref())).unwrap() + } + Replacement::Rewrite(rewrite) => { + serde_json::to_value(dag_export::export(rewrite.as_ref())).unwrap() + } + } + } + + fn cost_fixture() -> (QueryExpr, ReplacementSubDAG, PlannerCostDocument) { + let query = non_topk_query(); + let root = Rc::new(query.clone()); + let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]); + let group = space + .groups() + .find(|group| *group.target == query) + .expect("aggregate memo group"); + let candidate = group + .candidates + .iter() + .find(|candidate| { + !matches!( + &candidate.replacement, + Replacement::Summary(node) + if matches!(node.expr, SummaryExpr::KeepPreAsap(_)) + ) + }) + .expect("summary candidate") + .clone(); + let plan = match &candidate.replacement { + Replacement::Summary(summary) => { + serde_json::to_value(dag_export::export_summary(summary)).unwrap() + } + Replacement::Rewrite(rewrite) => { + serde_json::to_value(dag_export::export(rewrite)).unwrap() + } + }; + let document = PlannerCostDocument { + calibration: ResourceCalibration { + cost_per_cpu_op: 1.0, + cost_per_scan_byte: 1.0, + cost_per_retained_byte: 1.0, + version: "test".into(), + }, + targets: vec![TargetPhysicalEvidence { + target: query.clone(), + scope: test_scope(), + query_nodes: query_evidence(&query), + candidates: vec![match &candidate.replacement { + Replacement::Summary(_) => CandidatePhysicalEvidence::Summary { + plan, + physical_dag: cheap_candidate_dag(), + }, + Replacement::Rewrite(_) => CandidatePhysicalEvidence::Rewrite { plan }, + }], + }], + }; + (query, candidate, document) + } + + #[test] + fn planner_json_costs_and_exports_a_generic_non_topk_winner() { + let (query, candidate, document) = cost_fixture(); + let json = serde_json::to_string(&document).unwrap(); + let parsed = parse_planner_cost_document(&json).unwrap(); + assert_eq!(parsed.targets[0].target, query); + assert!(parsed.targets[0].candidates[0].matches(&candidate)); + let model = ExportPlannerCostModel::new(&parsed); + let target_rc = Rc::new(query.clone()); + let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); + let (provider, calibration) = model.bound(&candidate, &target).expect("exact binding"); + let estimate = AnalyticalPlannerCostModel::new(&provider, calibration.clone()) + .unwrap() + .estimate_candidate(&candidate, &target) + .unwrap(); + assert!(estimate.candidate_cost < estimate.raw_cost); + let (baseline, selected, benefit) = model.annotations(&candidate, &Rc::new(query)); + assert!(baseline.value.is_some()); + assert!(selected.value.is_some()); + assert!(benefit.value.is_some()); + assert!(selected + .inputs + .iter() + .any(|input| input.name == "estimated_scan")); + assert!(!selected.inputs.iter().any(|input| input.name == "topk_k")); + } + + #[test] + fn duplicate_target_candidate_and_query_evidence_each_fail_closed() { + let (query, candidate, document) = cost_fixture(); + let target_rc = Rc::new(query); + let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); + + let mut duplicate_target = document.clone(); + duplicate_target + .targets + .push(duplicate_target.targets[0].clone()); + assert!(ExportPlannerCostModel::new(&duplicate_target) + .candidate_cost(&candidate, &target) + .is_none()); + + let mut duplicate_candidate = document.clone(); + let candidate_entry = duplicate_candidate.targets[0].candidates[0].clone(); + duplicate_candidate.targets[0] + .candidates + .push(candidate_entry); + assert!(ExportPlannerCostModel::new(&duplicate_candidate) + .candidate_cost(&candidate, &target) + .is_none()); + + let mut duplicate_query = document; + let query_entry = duplicate_query.targets[0].query_nodes[0].clone(); + duplicate_query.targets[0].query_nodes.push(query_entry); + assert!(ExportPlannerCostModel::new(&duplicate_query) + .candidate_cost(&candidate, &target) + .is_none()); + } + + #[test] + fn incomplete_or_unused_json_evidence_fails_closed() { + let (query, candidate, document) = cost_fixture(); + let target_rc = Rc::new(query); + let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); + + let mut missing = document.clone(); + missing.targets[0].query_nodes.pop(); + let missing = parse_planner_cost_document(&serde_json::to_string(&missing).unwrap()) + .expect("well-formed but incomplete evidence document"); + assert!(ExportPlannerCostModel::new(&missing) + .candidate_cost(&candidate, &target) + .is_none()); + + let mut unused = document; + let mut extra = unused.targets[0].query_nodes[0].clone(); + extra.occurrence = usize::MAX; + unused.targets[0].query_nodes.push(extra); + let unused = parse_planner_cost_document(&serde_json::to_string(&unused).unwrap()) + .expect("well-formed document with unused evidence"); + assert!(ExportPlannerCostModel::new(&unused) + .candidate_cost(&candidate, &target) + .is_none()); + } + + #[test] + fn invalid_candidate_dag_from_json_fails_closed() { + let (query, candidate, mut document) = cost_fixture(); + let CandidatePhysicalEvidence::Summary { physical_dag, .. } = + &mut document.targets[0].candidates[0] + else { + panic!("fixture must use a summary candidate"); + }; + physical_dag.nodes.push(physical_dag.nodes[0].clone()); + let document = parse_planner_cost_document(&serde_json::to_string(&document).unwrap()) + .expect("invalid physical semantics are checked by the estimator"); + let target_rc = Rc::new(query); + let target = asap_aware_mapping::replacement::TargetSubDAG::new(&target_rc); + assert!(ExportPlannerCostModel::new(&document) + .candidate_cost(&candidate, &target) + .is_none()); + } + + #[test] + fn global_selection_uses_the_cheapest_complete_physical_candidate() { + let query = non_topk_query(); + let root = Rc::new(query.clone()); + let space = search_workload(vec![(String::from("q"), Rc::clone(&root))]); + let group = space + .groups() + .find(|group| *group.target == query) + .expect("aggregate memo group"); + let candidates: Vec<_> = group + .candidates + .iter() + .filter(|candidate| { + matches!(candidate.replacement, Replacement::Summary(ref node) + if !matches!(node.expr, SummaryExpr::KeepPreAsap(_))) + }) + .take(2) + .collect(); + assert_eq!( + candidates.len(), + 2, + "fixture needs two physical alternatives" + ); + + let mut first_dag = cheap_candidate_dag(); + first_dag + .evidence + .get_mut("summary-read") + .unwrap() + .statistics + .source_scan_bytes = 1_024; + let second_dag = cheap_candidate_dag(); + let document = PlannerCostDocument { + calibration: ResourceCalibration { + cost_per_cpu_op: 1.0, + cost_per_scan_byte: 1.0, + cost_per_retained_byte: 1.0, + version: "test".into(), + }, + targets: vec![TargetPhysicalEvidence { + target: query.clone(), + scope: test_scope(), + query_nodes: query_evidence(&query), + candidates: vec![ + CandidatePhysicalEvidence::Summary { + plan: candidate_plan(candidates[0]), + physical_dag: first_dag, + }, + CandidatePhysicalEvidence::Summary { + plan: candidate_plan(candidates[1]), + physical_dag: second_dag, + }, + ], + }], + }; + let document = parse_planner_cost_document(&serde_json::to_string(&document).unwrap()) + .expect("complete physical evidence"); + let model = ExportPlannerCostModel::new(&document); + let selection = space.global_selection(&model); + let chosen = selection + .groups() + .find(|selected| selected.target.as_ref() == &query) + .and_then(|selected| selected.chosen) + .expect("one complete physical candidate should win"); + assert!(document.targets[0].candidates[1].matches(chosen)); + assert!(!document.targets[0].candidates[0].matches(chosen)); + } + + #[test] + fn cost_annotations_fail_closed_without_physical_evidence() { + let (baseline, selected, benefit) = winner_cost_annotations(); + for annotation in [baseline, selected, benefit] { + assert!(annotation.value.is_none()); + assert_eq!(annotation.source, asap_types::cost::CostSource::Unavailable); + assert_eq!(annotation.unit, CostUnit::CostUnits); + } + } + + #[test] + fn missing_physical_evidence_keeps_the_export_raw_only() { + let results = raw_only_post_asap_results(); + assert!(results.replacements.is_empty()); + assert!(results.post_graphs.is_empty()); + } + + #[test] + fn compact_analytical_payload_has_an_explicit_migration_error() { + let error = parse_planner_cost_document(r#"{"inputs":{"group_count":10}}"#) + .expect_err("legacy payload must fail"); + assert!(error.contains("compact analytical-cost payload")); + } + + #[test] + fn planner_cost_json_rejects_unknown_fields() { + let (_, _, document) = cost_fixture(); + let mut value = serde_json::to_value(document).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("unexpected".into(), serde_json::Value::Bool(true)); + let error = parse_planner_cost_document(&value.to_string()) + .expect_err("unknown evidence fields must not be ignored"); + assert!(error.contains("unknown field")); + } + + #[test] + fn unused_target_or_candidate_evidence_keeps_export_raw_only() { + let (query, _, document) = cost_fixture(); + let lowered = vec![("q".into(), "fixture".into(), query.clone())]; + + let mut extra_candidate = document.clone(); + let mut unused = extra_candidate.targets[0].candidates[0].clone(); + *unused.plan_mut() = serde_json::json!({"unmatched": true}); + extra_candidate.targets[0].candidates.push(unused); + let extra_candidate = + parse_planner_cost_document(&serde_json::to_string(&extra_candidate).unwrap()).unwrap(); + let model = ExportPlannerCostModel::new(&extra_candidate); + let results = run_post_asap_with_progress(&lowered, false, &model, Some(&model), None); + assert!(results.replacements.is_empty()); + + let mut extra_target = document; + let mut unused = extra_target.targets[0].clone(); + unused.target = QueryExpr::Scan { + source: asap_types::pre_asap::Source::Table { + table_ref: "unused".into(), + }, + predicates: vec![], + schema: Schema::new(vec![Column::new("v", DataType::Int64, false)]), + }; + extra_target.targets.push(unused); + let extra_target = + parse_planner_cost_document(&serde_json::to_string(&extra_target).unwrap()).unwrap(); + let model = ExportPlannerCostModel::new(&extra_target); + let results = run_post_asap_with_progress(&lowered, false, &model, Some(&model), None); + assert!(results.replacements.is_empty()); + } + + #[test] + fn exact_candidate_selector_does_not_confuse_the_same_strategy() { + let selected_query = lower_promql("up", AccuracyTarget::Exact).unwrap(); + let other_query = lower_promql("process_cpu_seconds_total", AccuracyTarget::Exact).unwrap(); + let selected = ReplacementSubDAG { + replacement: Replacement::Rewrite(Rc::new(selected_query.clone())), + strategy: "same-strategy", + provenance: asap_aware_mapping::replacement::ReplacementProvenance::LogicalRewrite, + rationale: String::new(), + }; + let other = ReplacementSubDAG { + replacement: Replacement::Rewrite(Rc::new(other_query)), + strategy: "same-strategy", + provenance: asap_aware_mapping::replacement::ReplacementProvenance::LogicalRewrite, + rationale: String::new(), + }; + let selector = CandidatePhysicalEvidence::Rewrite { + plan: serde_json::to_value(dag_export::export(&selected_query)).unwrap(), + }; + assert!(selector.matches(&selected)); + assert!(!selector.matches(&other)); + } + #[test] fn workload_wide_explanations_annotate_cross_query_reuse() { let query = "sum by (job) (rate(http_requests_total[5m]))"; diff --git a/crates/types/src/cost.rs b/crates/types/src/cost.rs new file mode 100644 index 00000000..332e1c9e --- /dev/null +++ b/crates/types/src/cost.rs @@ -0,0 +1,569 @@ +//! Structured cost/benefit annotations for [`dag_export`](crate::dag_export) +//! output — issue #286. +//! +//! The issue's cost/benefit semantics are defined in **cost units per +//! second** for recurring costs: +//! +//! ```text +//! maintained_cost_rate = +//! update_rate * maintenance_cost_per_update +//! + evaluation_rate * summary_read_cost +//! +//! recompute_cost_rate = evaluation_rate * raw_recompute_cost +//! evaluation_rate = sum(1 / query_interval_i) +//! +//! estimated_benefit_rate = baseline_cost_rate - selected_cost_rate +//! estimated_benefit_ratio = estimated_benefit_rate / baseline_cost_rate +//! ``` +//! +//! Today's cost model (`asap_aware_mapping::cost_model`) has no notion of +//! `update_rate`/`evaluation_rate`/`query_interval` at all — issue #287 +//! ("recurrence-aware modeled inputs") is what's expected to supply those. +//! Rather than inventing rate numbers this crate has no basis for, every +//! annotation this module produces from today's cost model is honestly +//! unit-tagged [`CostUnit::RelativeStructuralUnits`] — the *same* underlying +//! numbers `asap_aware_mapping::cost_model::CostModel::estimate_cost` and +//! `default_cse_recompute_cost`/`default_cse_shared_maintenance_cost` +//! already compute (a structural-size proxy, not a real cost-per-second +//! rate) — so a renderer can never mistake a structural proxy for a +//! measured or rate-modeled cost. The formulas above still hold shape for +//! shape (`benefit = baseline - selected`, `ratio = benefit / baseline` +//! guarded at `baseline <= 0`), just over whichever [`CostUnit`] the inputs +//! actually carry; when #287 lands real rates, a caller can construct a +//! [`CostAnnotation`] with `unit: CostUnit::CostUnitsPerSecond` through the +//! exact same type with no further plumbing change needed here. +//! +//! Nothing in this module ever fabricates a number: a hook with no basis to +//! estimate returns [`CostAnnotation::unavailable`] (`value: None, source: +//! CostSource::Unavailable`), never `0.0` or another synthetic placeholder. + +use serde::{Deserialize, Serialize}; + +/// The unit one [`CostAnnotation::value`] (and its `delta`) is expressed in. +/// See the module doc for why today's `dag_export` output uses +/// [`RelativeStructuralUnits`](CostUnit::RelativeStructuralUnits) rather than +/// claiming a real rate it can't back up. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CostUnit { + /// A recurring cost expressed per second (`cost_units / s`) — the + /// `*_cost_rate` quantities in the issue's formulas. Only a + /// [`CostSource::Modeled`] annotation backed by real recurrence inputs + /// (issue #287) or a [`CostSource::Measured`] benchmark (issue #288) + /// may use this unit. + CostUnitsPerSecond, + /// A finite-run or one-shot total at some horizon `H` — `total_cost(H)` + /// in the issue's formulas, or a standalone one-shot addend. Never + /// aggregated with [`CostUnitsPerSecond`](CostUnit::CostUnitsPerSecond) + /// except through [`total_cost`], which keeps the two terms explicit + /// rather than silently adding a rate to a total. + CostUnits, + /// A dimensionless structural-size proxy (e.g. unique-DAG-node count, + /// the same magnitude + /// `asap_aware_mapping::cost_model::default_cse_recompute_cost` already + /// returns) — used wherever the underlying cost model has no rate or + /// absolute-cost estimate at all yet (issue #287). Never comparable to + /// [`CostUnitsPerSecond`](CostUnit::CostUnitsPerSecond) or + /// [`CostUnits`](CostUnit::CostUnits): a renderer must show it as its + /// own kind of number, and [`sum_workload_costs`] refuses to aggregate + /// mismatched units rather than silently mixing them. + RelativeStructuralUnits, +} + +impl std::fmt::Display for CostUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CostUnit::CostUnitsPerSecond => write!(f, "cost units/s"), + CostUnit::CostUnits => write!(f, "cost units"), + CostUnit::RelativeStructuralUnits => write!(f, "relative structural units"), + } + } +} + +/// Provenance of one [`CostAnnotation`]'s value — the issue's three states. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CostSource { + /// Generated by a named/versioned cost model + /// ([`CostAnnotation::model_version`]). + Modeled, + /// Loaded from a reproducible benchmark artifact + /// ([`CostAnnotation::benchmark_id`]) — issue #288. + Measured, + /// No value. `value` is always `None` for this source — never encode an + /// unknown value as `0` or another synthetic number. + Unavailable, +} + +/// What a [`CostAnnotation`]'s `baseline`/`delta`/`benefit_ratio` are +/// measured against. The issue names two examples explicitly; both are +/// first-class variants here rather than opaque strings so a renderer can +/// display them without guessing. [`Named`](BaselineRef::Named) covers any +/// other explicitly-chosen baseline a future caller introduces. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "detail")] +pub enum BaselineRef { + /// Recomputing the pre-ASAP target independently at every consumer site + /// — "do nothing" (never apply ASAP-aware replacement at all). + PreAsapRecomputation, + /// The best-ranked *non-selected* legal candidate for the same target + /// (`rank` into that target's own `PlanSpace::cost_sorted` ordering, + /// `0` = best; a baseline referencing this variant is always `rank >= + /// 1`, since `rank 0` is what got selected). + HighestRankedNonSelectedCandidate { rank: usize }, + /// Any other explicitly-named baseline. + Named(String), +} + +/// One raw input that fed a [`CostAnnotation`]'s `value` — surfaced so the +/// viewer's sidebar can show *why* a modeled number is what it is, not just +/// the number itself. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CostInput { + pub name: String, + pub value: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +impl CostInput { + pub fn new(name: impl Into, value: f64) -> Self { + CostInput { + name: name.into(), + value, + unit: None, + } + } +} + +/// A structured, optional cost/benefit annotation — issue #286's schema. +/// Every numeric value carries units and provenance; a missing value is +/// [`CostSource::Unavailable`] with `value: None`, never a fabricated +/// number. See the module doc for the exact formulas this backs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CostAnnotation { + pub value: Option, + pub unit: CostUnit, + pub source: CostSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline: Option, + /// `baseline_value - value` under `baseline`, when both are known — the + /// issue's `estimated_benefit_rate` (or its non-rate structural + /// analogue; see the module doc). + #[serde(skip_serializing_if = "Option::is_none")] + pub delta: Option, + /// `delta / baseline_value`, when `baseline_value > 0` — the issue's + /// `estimated_benefit_ratio`. Not part of the issue's own sketch schema + /// verbatim (the issue only asks that ratio be *derivable*), but kept + /// as its own explicit field rather than pushed onto the caller to + /// recompute from `delta` plus a `baseline` it would otherwise have no + /// value for. + #[serde(skip_serializing_if = "Option::is_none")] + pub benefit_ratio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub benchmark_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub inputs: Vec, +} + +impl CostAnnotation { + /// No value at all — `value: None`, `source: Unavailable`. `unit` is + /// still required: it states what unit a value *would* have been in, + /// had one been available, so a renderer can group "not estimated" + /// alongside the kind of number it's missing. + pub fn unavailable(unit: CostUnit) -> Self { + CostAnnotation { + value: None, + unit, + source: CostSource::Unavailable, + baseline: None, + delta: None, + benefit_ratio: None, + model_version: None, + benchmark_id: None, + inputs: Vec::new(), + } + } + + /// A modeled value with no baseline comparison attached yet — chain + /// [`with_baseline`](Self::with_baseline) to add one. + pub fn modeled( + value: f64, + unit: CostUnit, + model_version: impl Into, + inputs: Vec, + ) -> Self { + CostAnnotation { + value: Some(value), + unit, + source: CostSource::Modeled, + baseline: None, + delta: None, + benefit_ratio: None, + model_version: Some(model_version.into()), + benchmark_id: None, + inputs, + } + } + + /// Attach `baseline` plus its own value, computing `delta` and + /// [`benefit_ratio`](Self::benefit_ratio) per the issue's formulas. + /// `self.value` must already be `Some` (an + /// [`unavailable`](Self::unavailable) annotation has nothing to + /// subtract a baseline from and is returned unchanged). + pub fn with_baseline(mut self, baseline: BaselineRef, baseline_value: f64) -> Self { + let Some(value) = self.value else { + return self; + }; + let delta = baseline_value - value; + self.delta = Some(delta); + self.benefit_ratio = benefit_ratio(baseline_value, delta); + self.baseline = Some(baseline); + self + } +} + +/// `delta / baseline_value`, or `None` when `baseline_value <= 0` — the +/// issue's explicit "ratio unavailable" guard (a zero or negative baseline +/// makes a ratio meaningless, not just numerically awkward). +pub fn benefit_ratio(baseline_value: f64, delta: f64) -> Option { + if baseline_value > 0.0 { + Some(delta / baseline_value) + } else { + None + } +} + +/// `total_cost(H) = recurring_cost_rate * H + one_shot_cost` — finite-run or +/// one-shot totals require an explicit horizon. Returns `None` (never a +/// fabricated or poisoned total) when both terms are `None`, when `horizon` +/// isn't a finite, non-negative number for a `Some(rate)`, or when either +/// `recurring_cost_rate` or `one_shot_cost` is itself non-finite (`NaN` or +/// infinite) — a non-finite input must never silently produce `Some(NaN)`. +/// Rate and one-shot costs are passed as separate arguments specifically so +/// they can never be silently added by a caller before this function ever +/// sees them. +pub fn total_cost( + recurring_cost_rate: Option, + horizon: f64, + one_shot_cost: Option, +) -> Option { + // A non-finite input anywhere here (NaN/±inf — e.g. from a future #287 + // caller upstream) must never quietly poison the total into `Some(NaN)`: + // that would violate this module's own "never fabricate, never a + // poisoned total" rule as much as inventing a number from nothing would. + if let Some(rate) = recurring_cost_rate { + if !rate.is_finite() { + return None; + } + } + if let Some(one_shot) = one_shot_cost { + if !one_shot.is_finite() { + return None; + } + } + let recurring = match recurring_cost_rate { + Some(rate) => { + if !horizon.is_finite() || horizon < 0.0 { + return None; + } + rate * horizon + } + None => 0.0, + }; + match (recurring_cost_rate, one_shot_cost) { + (None, None) => None, + _ => Some(recurring + one_shot_cost.unwrap_or(0.0)), + } +} + +/// Two [`CostAnnotation`]s were summed by [`sum_workload_costs`] despite +/// disagreeing on [`CostUnit`] — unit-incompatible aggregation is rejected +/// rather than silently mixed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnitMismatch { + pub first: CostUnit, + pub second: CostUnit, +} + +impl std::fmt::Display for UnitMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "cannot aggregate cost annotations with mismatched units: {} vs {}", + self.first, self.second + ) + } +} +impl std::error::Error for UnitMismatch {} + +/// Sum a workload's per-node cost annotations into one workload-wide total, +/// counting each distinct `workload_node_id` exactly once — the "shared +/// nodes must be counted once in workload totals" requirement. `entries` is +/// `(workload_node_id, annotation)` pairs; an entry with `workload_node_id: +/// None` is never deduplicated against anything else (each is its own, +/// always-unique contribution). If any distinct entry has no value, the +/// aggregate is unavailable as well: returning a numeric subtotal would +/// misrepresent it as the whole workload's cost. +/// +/// Rejects the sum with [`UnitMismatch`] the moment two distinct entries +/// disagree on [`CostUnit`] — "unit-incompatible aggregation is rejected". +pub fn sum_workload_costs<'a, I>(entries: I) -> Result +where + I: IntoIterator, &'a CostAnnotation)>, +{ + let mut seen_ids = std::collections::HashSet::new(); + let mut unit: Option = None; + let mut total = 0.0_f64; + let mut counted_any = false; + let mut missing_any = false; + let mut model_versions: Vec = Vec::new(); + + for (workload_node_id, annotation) in entries { + if let Some(id) = workload_node_id { + if !seen_ids.insert(id) { + continue; // already counted this shared node once + } + } + match unit { + None => unit = Some(annotation.unit), + Some(existing) if existing != annotation.unit => { + return Err(UnitMismatch { + first: existing, + second: annotation.unit, + }); + } + _ => {} + } + let Some(value) = annotation.value else { + missing_any = true; + continue; + }; + total += value; + counted_any = true; + if let Some(version) = &annotation.model_version { + if !model_versions.contains(version) { + model_versions.push(version.clone()); + } + } + } + + Ok(if counted_any && !missing_any { + CostAnnotation { + value: Some(total), + unit: unit.expect("counted_any implies unit was set"), + source: CostSource::Modeled, + baseline: None, + delta: None, + benefit_ratio: None, + model_version: if model_versions.len() == 1 { + Some(model_versions.remove(0)) + } else { + None + }, + benchmark_id: None, + inputs: Vec::new(), + } + } else { + CostAnnotation::unavailable(unit.unwrap_or(CostUnit::RelativeStructuralUnits)) + }) +} + +/// Whole-selected-workload cost/benefit — one query's (or one workload +/// batch's) aggregate baseline, selected, and benefit, built from +/// [`sum_workload_costs`] over that scope's own per-decision node +/// annotations. See [`crate::dag_export::NamedGraph::workload_cost`] / +/// [`crate::dag_export::WorkloadGraph::workload_cost`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WorkloadCostSummary { + pub baseline_cost: CostAnnotation, + pub selected_cost: CostAnnotation, + pub benefit: CostAnnotation, +} + +/// Build a [`WorkloadCostSummary`] from one `(workload_node_id, baseline, +/// selected)` triple per decision node in scope — see +/// [`WorkloadCostSummary`]'s own doc. `model_version` labels the resulting +/// `benefit` annotation. +pub fn workload_cost_summary<'a, I>( + entries: I, + model_version: impl Into, +) -> Result +where + I: IntoIterator, &'a CostAnnotation, &'a CostAnnotation)> + Clone, +{ + let baseline_cost = sum_workload_costs( + entries + .clone() + .into_iter() + .map(|(id, baseline, _)| (id, baseline)), + )?; + let selected_cost = + sum_workload_costs(entries.into_iter().map(|(id, _, selected)| (id, selected)))?; + + let benefit = match (baseline_cost.value, selected_cost.value) { + (Some(baseline_value), Some(selected_value)) + if baseline_cost.unit == selected_cost.unit => + { + let delta = baseline_value - selected_value; + CostAnnotation { + value: Some(delta), + unit: selected_cost.unit, + source: CostSource::Modeled, + baseline: Some(BaselineRef::PreAsapRecomputation), + delta: None, + benefit_ratio: benefit_ratio(baseline_value, delta), + model_version: Some(model_version.into()), + benchmark_id: None, + inputs: Vec::new(), + } + } + (Some(_), Some(_)) => { + return Err(UnitMismatch { + first: baseline_cost.unit, + second: selected_cost.unit, + }) + } + _ => CostAnnotation::unavailable(selected_cost.unit), + }; + + Ok(WorkloadCostSummary { + baseline_cost, + selected_cost, + benefit, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unavailable_has_no_value_and_no_synthetic_number() { + let a = CostAnnotation::unavailable(CostUnit::RelativeStructuralUnits); + assert_eq!(a.value, None); + assert_eq!(a.source, CostSource::Unavailable); + } + + #[test] + fn with_baseline_computes_delta_and_ratio() { + let a = CostAnnotation::modeled(3.0, CostUnit::RelativeStructuralUnits, "v1", vec![]) + .with_baseline(BaselineRef::PreAsapRecomputation, 10.0); + assert_eq!(a.delta, Some(7.0)); + assert_eq!(a.benefit_ratio, Some(0.7)); + assert_eq!(a.baseline, Some(BaselineRef::PreAsapRecomputation)); + } + + #[test] + fn benefit_ratio_unavailable_when_baseline_not_positive() { + assert_eq!(benefit_ratio(0.0, 5.0), None); + assert_eq!(benefit_ratio(-1.0, 5.0), None); + assert_eq!(benefit_ratio(2.0, 1.0), Some(0.5)); + } + + #[test] + fn with_baseline_on_unavailable_annotation_is_a_no_op() { + let a = CostAnnotation::unavailable(CostUnit::CostUnits) + .with_baseline(BaselineRef::PreAsapRecomputation, 10.0); + assert_eq!(a.value, None); + assert_eq!(a.delta, None); + assert_eq!(a.baseline, None); + } + + #[test] + fn total_cost_requires_a_horizon_for_a_recurring_rate() { + assert_eq!(total_cost(Some(2.0), 5.0, None), Some(10.0)); + assert_eq!(total_cost(Some(2.0), 5.0, Some(1.0)), Some(11.0)); + assert_eq!(total_cost(None, 5.0, Some(4.0)), Some(4.0)); + assert_eq!(total_cost(None, 5.0, None), None); + } + + #[test] + fn total_cost_rejects_a_non_finite_or_negative_horizon() { + assert_eq!(total_cost(Some(2.0), f64::NAN, None), None); + assert_eq!(total_cost(Some(2.0), f64::INFINITY, None), None); + assert_eq!(total_cost(Some(2.0), -1.0, None), None); + } + + /// A non-finite `recurring_cost_rate` (e.g. a stray `NaN` from a future + /// #287 caller) must never silently produce `Some(NaN)` — that's a + /// poisoned total, exactly the kind of fabricated-looking value this + /// module's "never fabricate" rule exists to prevent. + #[test] + fn total_cost_rejects_a_non_finite_recurring_rate() { + assert_eq!(total_cost(Some(f64::NAN), 5.0, None), None); + assert_eq!(total_cost(Some(f64::INFINITY), 5.0, None), None); + assert_eq!(total_cost(Some(f64::NEG_INFINITY), 5.0, Some(1.0)), None); + } + + /// Same guard on the one-shot addend — a `NaN`/infinite one-shot cost + /// must not poison the total either, even when the recurring side is + /// perfectly well-formed. + #[test] + fn total_cost_rejects_a_non_finite_one_shot_cost() { + assert_eq!(total_cost(Some(2.0), 5.0, Some(f64::NAN)), None); + assert_eq!(total_cost(None, 5.0, Some(f64::INFINITY)), None); + } + + fn ann(value: f64, unit: CostUnit) -> CostAnnotation { + CostAnnotation::modeled(value, unit, "v1", vec![]) + } + + #[test] + fn sum_workload_costs_counts_a_shared_node_once() { + let a = ann(5.0, CostUnit::RelativeStructuralUnits); + let b = ann(5.0, CostUnit::RelativeStructuralUnits); + let c = ann(2.0, CostUnit::RelativeStructuralUnits); + // Node id 1 shared by two queries (same decision, same cost) must + // only be counted once; node id 2 is a distinct contribution. + let total = sum_workload_costs(vec![(Some(1), &a), (Some(1), &b), (Some(2), &c)]).unwrap(); + assert_eq!(total.value, Some(7.0), "5.0 (once) + 2.0, not 5+5+2"); + } + + #[test] + fn sum_workload_costs_never_deduplicates_entries_with_no_workload_id() { + let a = ann(3.0, CostUnit::RelativeStructuralUnits); + let b = ann(3.0, CostUnit::RelativeStructuralUnits); + let total = sum_workload_costs(vec![(None, &a), (None, &b)]).unwrap(); + assert_eq!(total.value, Some(6.0)); + } + + #[test] + fn sum_workload_costs_is_unavailable_when_any_distinct_entry_is_unavailable() { + let known = ann(4.0, CostUnit::RelativeStructuralUnits); + let unavailable = CostAnnotation::unavailable(CostUnit::RelativeStructuralUnits); + let total = sum_workload_costs(vec![(None, &known), (None, &unavailable)]).unwrap(); + assert_eq!(total.value, None); + assert_eq!(total.source, CostSource::Unavailable); + } + + #[test] + fn sum_workload_costs_rejects_mismatched_units() { + let rate = ann(1.0, CostUnit::CostUnitsPerSecond); + let structural = ann(1.0, CostUnit::RelativeStructuralUnits); + let err = sum_workload_costs(vec![(None, &rate), (None, &structural)]).unwrap_err(); + assert_eq!(err.first, CostUnit::CostUnitsPerSecond); + assert_eq!(err.second, CostUnit::RelativeStructuralUnits); + } + + #[test] + fn workload_cost_summary_computes_benefit_from_deduplicated_totals() { + let baseline1 = ann(10.0, CostUnit::RelativeStructuralUnits); + let selected1 = ann(3.0, CostUnit::RelativeStructuralUnits); + let baseline2 = ann(10.0, CostUnit::RelativeStructuralUnits); // same shared node + let selected2 = ann(3.0, CostUnit::RelativeStructuralUnits); + let baseline3 = ann(4.0, CostUnit::RelativeStructuralUnits); + let selected3 = ann(1.0, CostUnit::RelativeStructuralUnits); + + let entries = vec![ + (Some(1_u32), &baseline1, &selected1), + (Some(1_u32), &baseline2, &selected2), // duplicate of node 1 — must not double count + (Some(2_u32), &baseline3, &selected3), + ]; + let summary = workload_cost_summary(entries, "test-v1").unwrap(); + assert_eq!(summary.baseline_cost.value, Some(14.0), "10 (once) + 4"); + assert_eq!(summary.selected_cost.value, Some(4.0), "3 (once) + 1"); + assert_eq!(summary.benefit.value, Some(10.0)); + assert_eq!(summary.benefit.benefit_ratio, Some(10.0 / 14.0)); + } +} diff --git a/crates/types/src/dag_export.rs b/crates/types/src/dag_export.rs index 5df2ed8c..d15790d4 100644 --- a/crates/types/src/dag_export.rs +++ b/crates/types/src/dag_export.rs @@ -46,8 +46,9 @@ use std::rc::Rc; use serde::Serialize; +use crate::cost::{CostAnnotation, CostInput, CostUnit}; use crate::post_asap::{AccuracyError, ResultGuarantee, SummaryExpr, SummaryNode}; -use crate::pre_asap::cse::{structural_hash, HashCache}; +use crate::pre_asap::cse::{dag_node_count, structural_hash, HashCache}; use crate::pre_asap::query_expr::{QueryExpr, Source}; /// One flattened IR node. `detail` holds this node's own scalar fields @@ -152,6 +153,41 @@ 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, + /// Structured counterpart of `cost` above — see [`CostAnnotation`] + /// (issue #286). `None` for the same reason `cost` can be `f64::NAN`: + /// the plugged-in cost model doesn't estimate a number for this + /// candidate shape. Additive: every existing reader of `cost` keeps + /// working unchanged; a reader that wants units, provenance, and an + /// explicit baseline comparison reads this instead. + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_cost: Option, + /// `baseline_cost.value - selected_cost.value` under `baseline_cost`'s + /// own baseline — for a winning `SharedSubtreeStrategy`/`CseShare` + /// decision this *is* "avoided recomputation for a shared sub-DAG" (one + /// of `dag_export`'s issue #286 granularity items): the baseline is + /// exactly the cost of recomputing this subtree independently at every + /// consumer, so the benefit is exactly what sharing avoided. + #[serde(skip_serializing_if = "Option::is_none")] + pub benefit: Option, +} + +/// A cost/benefit annotation attributed to one specific graph edge (`from` +/// -> `to`, in [`DagNode::children`]'s direction) rather than to a node — +/// issue #286's "edge cost only when genuinely attributable to the edge" +/// granularity item. `dag_export`'s own producer +/// ([`crates/devtools/src/bin/dag_export.rs`](../../../devtools/src/bin/dag_export.rs)) +/// only ever populates this for a `post_graph` edge whose target node is a +/// genuine DAG merge point (in-degree > 1, from +/// `deduplicate_pointer_shared_nodes`) — the materialization/read cost of +/// consuming an already-shared result along that one specific edge, never a +/// guessed multi-hop path cost (explicitly out of scope per the issue). +#[derive(Debug, Clone, Serialize)] +pub struct EdgeCostAnnotation { + pub from: u32, + pub to: u32, + pub cost: CostAnnotation, } /// One query's exported graph. `nodes[root as usize]` is the tree's root. @@ -159,6 +195,13 @@ pub struct DagDecision { pub struct DagGraph { pub nodes: Vec, pub root: u32, + /// See [`EdgeCostAnnotation`]. Always empty unless a higher layer + /// explicitly populated it (same layering rule as [`DagNode::notes`]); + /// omitted from JSON entirely when empty, so every existing producer of + /// [`DagGraph`] (every call to [`export`]/[`export_summary`]) is + /// unaffected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edge_annotations: Vec, } /// A single named query within a multi-query export. @@ -199,6 +242,24 @@ pub struct NamedGraph { /// `NamedGraph` is unaffected. #[serde(default, skip_serializing_if = "Option::is_none")] pub post_graph: Option, + /// This query's own selected-workload cost/benefit — one of issue + /// #286's granularity items. Built by summing *this query's own* + /// `post_graph` decision-node cost annotations, deduplicated by + /// `decision.id` **within this one query only** (a decision spanning + /// several nodes in this query's own replacement region is still + /// counted once here). `None` unless a higher layer built one (same + /// `--post-asap`-gated pattern as `post_graph`); omitted from JSON when + /// absent. + /// + /// This does **not** dedupe across queries: a target shared by two + /// queries (e.g. a common `Scan` after workload-wide CSE) is counted + /// once in *each* query's own `workload_cost` — summing several + /// `NamedGraph.workload_cost` values by hand double-counts any decision + /// shared between them. For a cross-query total that dedupes correctly, + /// use [`WorkloadGraph::workload_cost`] instead, which is built + /// specifically to cover every query in one pass. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workload_cost: Option, /// Accuracy-illegal candidates a higher layer's search refused for /// targets in this query (issue #172) — see [`TargetRejection`]. Always /// empty coming out of this module; omitted from the JSON when empty, @@ -214,6 +275,14 @@ pub struct NamedGraph { #[derive(Debug, Clone, Serialize)] pub struct WorkloadGraph { pub queries: Vec, + /// The selected multi-query workload's own cost/benefit, deduplicated + /// across every query in `queries` (not just within one) — the + /// "Selecting ... multiple queries ... display correct Pre/Post-ASAP + /// annotations" / "workload totals count shared nodes once" acceptance + /// criteria for the batch/union case. `None` unless a higher layer + /// built one; omitted from JSON when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workload_cost: Option, } // ── Post-ASAP replacement export — a second, layering-seam-shaped feature ── @@ -526,6 +595,21 @@ pub struct TargetReplacement { /// `export(target)` for the `MemoGroup`'s own `target`, reused as-is. pub before: DagGraph, pub after: TargetReplacementAfter, + /// Structured baseline/selected/benefit cost annotations for this one + /// replacement region — issue #286's "replacement-region baseline + /// cost, selected cost, and benefit" granularity item. Always + /// consistent with `cost` above: `selected_cost.value == Some(cost)` + /// whenever `cost` is finite, `None`/`Unavailable` whenever it's + /// `NaN`. `baseline_cost` is always populated (the pre-ASAP + /// independent-recomputation baseline is computable from the target + /// alone, unlike a candidate's own estimated cost); `benefit` is + /// `Unavailable` exactly when `selected_cost` is. + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub benefit: Option, } /// What a [`TargetReplacement`] became — either a genuine post-ASAP binding @@ -562,7 +646,11 @@ pub fn export(expr: &QueryExpr) -> DagGraph { // callback regardless (so `export_post_asap` can share this exact // per-variant traversal instead of duplicating it). let root = build(expr, &mut nodes, &mut cache, &mut |_| None); - DagGraph { nodes, root } + DagGraph { + nodes, + root, + edge_annotations: Vec::new(), + } } /// What a higher layer found for one specific pre-ASAP node when building a @@ -641,6 +729,18 @@ fn deduplicate_pointer_shared_nodes(nodes: Vec, root: u32) -> DagGraph let mut by_source_ptr = HashMap::::new(); let mut old_to_new = vec![0_u32; nodes.len()]; let mut deduplicated = Vec::with_capacity(nodes.len()); + // Built in the same pass as `deduplicated` itself, rather than by a + // second full walk over `deduplicated` afterward (see + // `shared_node_edge_annotations`'s own doc for why one pass suffices): + // `HashSet`, not `Vec`, per child — a *distinct* consuming node is what + // "consumer" means here. A single parent can reference the same shared + // child from two of its own operand slots at once (e.g. a `Join` whose + // left and right are the same `Rc` post pointer-dedup) — that's one + // downstream consumer reading the child twice, structurally, not two + // separate consumers, and it must not inflate `consumer_count` (which + // would understate `per_edge_cost`) or produce two colliding `(from, + // to)` `EdgeCostAnnotation` entries for the exact same edge. + let mut parents_of: HashMap> = HashMap::new(); for mut node in nodes { node.children = node @@ -662,15 +762,90 @@ fn deduplicate_pointer_shared_nodes(nodes: Vec, root: u32) -> DagGraph by_source_ptr.insert(source_ptr, new_id); } old_to_new[old_id as usize] = new_id; + // `node.children` is already remapped to final new-id space above, + // and post-order guarantees every child was already pushed (with + // its own final id) before this parent is reached — so this is safe + // to record right here, once, rather than re-deriving it from + // `deduplicated` in a later pass. A node this loop *doesn't* push + // (the dedup-hit `continue` above) never reaches this line, but that + // never loses information: its first-pushed duplicate already had + // its own (identical) children recorded when *it* was processed. + for &child_new_id in &node.children { + parents_of.entry(child_new_id).or_default().insert(new_id); + } deduplicated.push(node); } + let edge_annotations = shared_node_edge_annotations(&deduplicated, &parents_of); DagGraph { nodes: deduplicated, root: old_to_new[root as usize], + edge_annotations, } } +/// One [`EdgeCostAnnotation`] per edge running *into* a genuine DAG merge +/// point in `nodes` — a node id referenced as a child by more than one +/// parent, the exact shape [`deduplicate_pointer_shared_nodes`] produces +/// for a subtree two or more consumers share by `Rc` pointer identity +/// (whether or not a `SharedSubtreeStrategy` decision is what caused it — +/// ordinary `Rc`-shared pre-ASAP structure merges here too). This is the +/// "edge cost only when genuinely attributable to the edge" granularity +/// item from issue #286: the materialization/read cost of one consumer +/// reading the already-computed shared result along its own specific edge, +/// evenly divided across every consuming edge — never a guessed multi-hop +/// path cost (explicitly out of scope per the issue). +/// +/// `parents_of` is [`deduplicate_pointer_shared_nodes`]'s own +/// already-built child -> distinct-parents map, passed in rather than +/// rebuilt here by a second walk over `nodes`'s edges — that caller has +/// already visited every edge exactly once while assigning final node ids, +/// so redoing the same walk here would just re-derive what it already +/// knows. +/// +/// Uses [`dag_node_count`] (the same structural-size proxy +/// `asap_aware_mapping::cost_model::default_cse_recompute_cost` computes; +/// duplicated here in plain terms since `asap_types` may not depend on that +/// higher crate) rather than a real per-byte transfer cost — honestly +/// unit-tagged [`CostUnit::RelativeStructuralUnits`], not a rate. +fn shared_node_edge_annotations( + nodes: &[DagNode], + parents_of: &HashMap>, +) -> Vec { + let mut annotations = Vec::new(); + for (child_id, parents) in parents_of { + if parents.len() < 2 { + continue; + } + let Some(child) = nodes.get(*child_id as usize) else { + continue; + }; + let Some(source_expr) = child.source_expr.as_ref() else { + continue; // no QueryExpr to size (e.g. a post-ASAP-originated node) + }; + let consumer_count = parents.len(); + let materialized_size = dag_node_count(source_expr) as f64; + let per_edge_cost = materialized_size / consumer_count as f64; + for &parent in parents { + annotations.push(EdgeCostAnnotation { + from: *child_id, + to: parent, + cost: CostAnnotation::modeled( + per_edge_cost, + CostUnit::RelativeStructuralUnits, + "dag_export-shared-edge-v1", + vec![ + CostInput::new("materialized_subtree_size", materialized_size), + CostInput::new("consumer_count", consumer_count as f64), + ], + ), + }); + } + } + annotations.sort_by_key(|edge| (edge.from, edge.to)); + annotations +} + /// Push one flattened node for `expr`. `expr` is the *whole* subtree this /// node represents (not just its own fields) — `hash` is /// [`structural_hash(expr)`](structural_hash), the identical function and @@ -1303,6 +1478,7 @@ mod tests { assert!(graph.nodes[0].notes.is_empty()); assert!(graph.nodes[0].decision.is_none()); assert!(graph.nodes[0].schema.is_some()); + assert!(graph.edge_annotations.is_empty()); let json = serde_json::to_string(&graph.nodes[0]).unwrap(); assert!( !json.contains("notes"), @@ -1312,6 +1488,123 @@ mod tests { !json.contains("decision"), "empty `decision` must be skipped, not serialized as `null`: {json}" ); + let graph_json = serde_json::to_string(&graph).unwrap(); + assert!( + !graph_json.contains("edge_annotations"), + "empty `edge_annotations` must be skipped, not serialized as `[]`: {graph_json}" + ); + } + + // ── Issue #286: edge cost annotations for genuine DAG merge points ──── + + #[test] + fn export_post_asap_annotates_edges_into_a_genuinely_shared_node() { + // Two *distinct* parents (a Dedup and a Limit, each with their own + // single child slot) share the exact same `Rc` Scan — + // `export_post_asap`'s `deduplicate_pointer_shared_nodes` must merge + // them onto one node id, and (issue #286) attach an + // `EdgeCostAnnotation` on each of the two edges running into it. + let shared_scan = Rc::new(scan("metrics", value_col())); + let left_branch = QueryExpr::Dedup { + cols: vec![0], + child: Rc::clone(&shared_scan), + }; + let right_branch = QueryExpr::Limit { + n: 5, + offset: 0, + child: Rc::clone(&shared_scan), + }; + let root = QueryExpr::Concat { + children: vec![left_branch, right_branch], + }; + let graph = export_post_asap(&root, &mut |_| None); + + assert_eq!( + graph.nodes.iter().filter(|n| n.kind == "Scan").count(), + 1, + "the shared Scan must be merged onto one node, not duplicated" + ); + let scan_id = graph.nodes.iter().find(|n| n.kind == "Scan").unwrap().id; + let edges_into_scan: Vec<_> = graph + .edge_annotations + .iter() + .filter(|edge| edge.from == scan_id) + .collect(); + assert_eq!( + edges_into_scan.len(), + 2, + "one annotation per distinct consuming parent" + ); + let distinct_parents: std::collections::HashSet<_> = + edges_into_scan.iter().map(|edge| edge.to).collect(); + assert_eq!( + distinct_parents.len(), + 2, + "the two parents (Dedup, Limit) must be distinct" + ); + for edge in &edges_into_scan { + assert_eq!( + edge.cost.value, + Some(0.5), + "1 unique node / 2 distinct consumers" + ); + assert_eq!( + edge.cost.unit, + crate::cost::CostUnit::RelativeStructuralUnits + ); + assert_eq!(edge.cost.source, crate::cost::CostSource::Modeled); + } + } + + /// Regression test: a single parent referencing the same shared child + /// from two of its own operand slots at once (a `Join` whose left and + /// right sides are the exact same `Rc`, post pointer-dedup) is *one* + /// downstream consumer, not two — this must not inflate + /// `consumer_count`, must not halve the reported per-edge cost, and + /// must not produce two colliding `(from, to)` `EdgeCostAnnotation` + /// entries for what is structurally a single edge. Since there is only + /// one *distinct* consumer here, this shape isn't "genuinely shared" at + /// all in the `>= 2 distinct consumers` sense `shared_node_edge_annotations` + /// requires, so no annotation should be produced for it. + #[test] + fn a_single_parent_referencing_a_shared_child_twice_is_one_consumer_not_two() { + let shared_scan = Rc::new(scan("metrics", value_col())); + let root = QueryExpr::Join { + kind: crate::pre_asap::query_expr::JoinKind::Inner, + pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), + left: Rc::clone(&shared_scan), + right: Rc::clone(&shared_scan), + }; + let graph = export_post_asap(&root, &mut |_| None); + + assert_eq!( + graph.nodes.iter().filter(|n| n.kind == "Scan").count(), + 1, + "the shared Scan must be merged onto one node, not duplicated" + ); + assert!( + graph.edge_annotations.is_empty(), + "a single parent referencing the same child twice is one consumer, not a genuine \ + multi-consumer share — got: {:?}", + graph.edge_annotations + ); + } + + #[test] + fn export_never_produces_edge_annotations_since_it_never_shares_nodes() { + // Plain `export` (no `export_post_asap`) never deduplicates by `Rc` + // pointer identity — even a workload-level shared subtree renders as + // two independent tree nodes here, so there is nothing to annotate. + let shared_scan = Rc::new(scan("metrics", value_col())); + let root = QueryExpr::Join { + kind: crate::pre_asap::query_expr::JoinKind::Inner, + pred: Predicate(Rc::new(QueryExpr::Literal(ScalarValue::Boolean(true)))), + left: Rc::clone(&shared_scan), + right: Rc::clone(&shared_scan), + }; + let graph = export(&root); + assert_eq!(graph.nodes.iter().filter(|n| n.kind == "Scan").count(), 2); + assert!(graph.edge_annotations.is_empty()); } #[test] @@ -1566,6 +1859,7 @@ mod tests { graph: export(&leaf), replacements: vec![], post_graph: None, + workload_cost: None, rejections: vec![TargetRejection { target_pre_id: 0, strategy: "SketchAlgorithmStrategy".into(), diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 1308b976..dbc1665c 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -19,6 +19,7 @@ //! runtime's readout path can call directly — see that module's docs //! for the planning-time/execution-time boundary and why it's unwired //! today. +pub mod cost; pub mod dag_export; pub mod post_asap; pub mod pre_asap; diff --git a/tools/dag-viewer/README.md b/tools/dag-viewer/README.md index ba8bad70..e6d5bdcd 100644 --- a/tools/dag-viewer/README.md +++ b/tools/dag-viewer/README.md @@ -13,6 +13,11 @@ The viewer has one visualization mode: **Pre/Post-ASAP**. target operation derives its output schema in the details panel. - The details panel shows the selected workload's bound table/metric schemas and can be resized by dragging its left edge. +- A post-ASAP node whose winning decision carries a cost/benefit annotation + shows a concise `▼NN%`/`▲NN%` badge next to its label; the sidebar and the + workload-scope summary show the full baseline/selected/benefit breakdown, + with units and provenance, wherever the export provides one — see "Cost/ + benefit annotations" below. There are no separate Single, Compare, or Union modes. @@ -52,12 +57,20 @@ shell command. ```sh cargo run -p asap-devtools --bin dag_export -- \ --post-asap --epsilon 0.01 \ + --planner-cost-json "$PLANNER_PHYSICAL_EVIDENCE" \ --sql "SELECT service, COUNT(*) FROM metrics GROUP BY service" --name q1 \ > /tmp/dag.json ``` -Load the JSON with the page's file picker. A post-ASAP visualization requires -`--post-asap`; ordinary exports intentionally omit `post_graph`. +Load the JSON with the page's file picker. `--planner-cost-json` is a complete +physical-evidence document: calibration plus target records containing the +exact target `QueryExpr`, comparison scope, exact logical query nodes with +`PhysicalNodeEvidence`, and exact exported replacement DAGs with their bound +`PhysicalDag`. Matching uses full structural equality, never a hash or strategy +name. Duplicate, conflicting, or missing records fail closed. Without this +document, `--post-asap` exports the raw graph only. The old +`--analytical-cost-json` spelling accepts the new document as an alias; its old +compact aggregation payload is rejected with a migration error. The viewer also accepts the JSON produced by `export_summary_maintenance_plan`. It renders the materialized summary DAG as @@ -90,8 +103,11 @@ a selected replacement directly contains: "strategy": "SketchAlgorithmStrategy", "rationale": "count realizes as a Cms sketch", "rank": 0, - "cost": 3.0, - "role": "replacement_root" + "cost": 5.64051088, + "role": "replacement_root", + "baseline_cost": { "value": 164.000000016, "unit": "CostUnits", "source": "Modeled", "model_version": "analytical-resource-v1+demo-calibration-v1" }, + "selected_cost": { "value": 5.64051088, "unit": "CostUnits", "source": "Modeled", "baseline": {"kind": "PreAsapRecomputation"}, "delta": 158.359489136, "benefit_ratio": 0.965606640979 }, + "benefit": { "value": 158.359489136, "unit": "CostUnits", "source": "Modeled", "baseline": {"kind": "PreAsapRecomputation"}, "benefit_ratio": 0.965606640979 } } } ``` @@ -106,6 +122,32 @@ filter predicates, projections, sources, summary families, and readout queries. Category icons are deliberately omitted so they cannot be confused with IR text. +### Cost/benefit annotations (issue #286) + +`decision.baseline_cost` / `.selected_cost` / `.benefit` are structured +[`CostAnnotation`](../../crates/types/src/cost.rs)s: `value` + `unit` + +`source` (`Modeled` / `Measured` / `Unavailable`), optionally `baseline` + +`delta` + `benefit_ratio`, and `model_version`/`benchmark_id`/`inputs` for +provenance. A missing `value` (`source: "Unavailable"`) always renders as +**Not estimated** — the viewer never fabricates a number. A complete physical +planner export keeps CPU operations, peak memory, scan bytes, coefficients, +and workload statistics in `inputs`. Without complete physical evidence, the +annotation is `Unavailable`; structural node counts are never substituted. +See the [analytical model design](../../docs/design_docs/asap-aware-mapping/analytical-resource-cost.md). + +The same three fields also appear on `TargetReplacement` +(replacement-region baseline/selected/benefit), `NamedGraph.workload_cost` / +`WorkloadGraph.workload_cost` (whole selected-workload cost/benefit, shared +nodes counted once via `decision.id` dedup), and `DagGraph.edge_annotations` +(materialization/read cost on an edge into a genuine DAG merge point — never +a guessed multi-hop path cost). The sidebar shows the full breakdown +(value, unit, provenance, baseline, ratio, inputs) on node/edge click and in +the workload-scope summary; a post-ASAP node with a costed decision also +gets a concise on-graph `▼NN%`/`▲NN%` badge next to its label. + +All of this is additive and optional: an export with none of these fields +(anything produced before issue #286) renders exactly as before. + ## Tests ```sh diff --git a/tools/dag-viewer/cost-benefit-annotations.png b/tools/dag-viewer/cost-benefit-annotations.png new file mode 100644 index 00000000..2e556507 Binary files /dev/null and b/tools/dag-viewer/cost-benefit-annotations.png differ diff --git a/tools/dag-viewer/cost-benefit-positive-savings.png b/tools/dag-viewer/cost-benefit-positive-savings.png new file mode 100644 index 00000000..0e6827ab Binary files /dev/null and b/tools/dag-viewer/cost-benefit-positive-savings.png differ diff --git a/tools/dag-viewer/dag.example.json b/tools/dag-viewer/dag.example.json index 401aa0a8..d52f9af8 100644 --- a/tools/dag-viewer/dag.example.json +++ b/tools/dag-viewer/dag.example.json @@ -149,7 +149,7 @@ "notes": [ { "kind": "SketchApproximation", - "reason": "count realizes as a Cms sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with); count realizes as a CountSketch sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with); count realizes as a shared HydraCms structure over Cms serving every subpopulation of this grouped aggregate, instead of one Cms instance per distinct `by` key — legal because this aggregate has a non-empty subpopulation concept and Cms has a modeled Hydra variant (asap_types::post_asap::hydra_kind_for); whether it's *worth* the shared/independent trade-off for the actual subpopulation cardinality is a CostModel's call, not this strategy's; count realizes as a shared HydraCountSketch structure over CountSketch serving every subpopulation of this grouped aggregate, instead of one CountSketch instance per distinct `by` key — legal because this aggregate has a non-empty subpopulation concept and CountSketch has a modeled Hydra variant (asap_types::post_asap::hydra_kind_for); whether it's *worth* the shared/independent trade-off for the actual subpopulation cardinality is a CostModel's call, not this strategy's" + "reason": "count realizes as a Cms sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with); count realizes as a CountSketch sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with)" } ] }, @@ -205,302 +205,12 @@ } ], "root": 2 - }, - "replacements": [ - { - "decision_id": 0, - "target_pre_id": 1, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 3.0, - "before": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(metrics)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 2606922452740434172 - }, - { - "id": 1, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "accuracy": { - "Epsilon": 0.01 - }, - "kind": "count" - } - ], - "output_names": [ - "count(*)" - ], - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "count(*)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 0 - ], - "hash": 12396162568747900801 - } - ], - "root": 1 - }, - "after": { - "kind": "Summary", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "KeepPreAsap", - "label": "KeepPreAsap(Scan)", - "detail": { - "pre_asap_subgraph": { - "nodes": [ - { - "children": [], - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "hash": 2606922452740434172, - "id": 0, - "kind": "Scan", - "label": "Scan(metrics)", - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - } - } - ], - "root": 0 - } - }, - "children": [] - }, - { - "id": 1, - "kind": "SummaryAgg", - "label": "SummaryAgg(Sketch(Cms))", - "detail": { - "col": "SampleValue", - "family": "Sketch(SketchKind { category: Frequency, algorithm: Cms, params: Cms { width: 272, depth: 5 } }, PerSubpopulationInstance)", - "grouping": "PerSubpopulationInstance", - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "children": [ - 0 - ] - }, - { - "id": 2, - "kind": "SummaryEstimate", - "label": "SummaryEstimate(PointCount { key: SampleValue, value: None })", - "detail": { - "query": "PointCount { key: SampleValue, value: None }" - }, - "children": [ - 1 - ] - } - ], - "root": 2 - } - } - } - ], - "post_graph": { + } + }, + { + "name": "q2", + "source": "SELECT service, AVG(latency) FROM metrics GROUP BY service", + "graph": { "nodes": [ { "id": 0, @@ -590,24 +300,23 @@ }, "children": [], "workload_node_id": 0, - "hash": 2606922452740434172, - "decision": { - "id": 0, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 3.0, - "role": "replacement_region" - } + "hash": 2606922452740434172 }, { "id": 1, - "kind": "SummaryAgg", - "label": "SummaryAgg(Sketch(Cms))", + "kind": "Aggregate", + "label": "Aggregate(1 measures)", "detail": { - "col": "SampleValue", - "family": "Sketch(SketchKind { category: Frequency, algorithm: Cms, params: Cms { width: 272, depth: 5 } }, PerSubpopulationInstance)", - "grouping": "PerSubpopulationInstance", + "having": null, + "measures": [ + { + "col": 3, + "kind": "avg" + } + ], + "output_names": [ + "avg(metrics.latency)" + ], "reduction": { "Reduce": [ 1 @@ -615,70 +324,36 @@ } }, "schema": { - "fields": [ + "closed": true, + "columns": [ { - "dtype": "Plain(Utf8)", + "dtype": "utf8", "name": "service", - "nullable": false + "nullable": false, + "table": "metrics" }, { - "dtype": "Sketch(SketchKind { category: Frequency, algorithm: Cms, params: Cms { width: 272, depth: 5 } }, PerSubpopulationInstance)", - "name": "count(*)", - "nullable": false + "dtype": "float64", + "name": "avg(metrics.latency)", + "nullable": false, + "table": null } ], - "time_index": null + "time_index": null, + "unique_keys": [ + [ + 0 + ] + ] }, "children": [ 0 ], - "workload_node_id": 1, - "decision": { - "id": 0, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 3.0, - "role": "replacement_region" - } + "workload_node_id": 3, + "hash": 5109904469838189862 }, { "id": 2, - "kind": "SummaryEstimate", - "label": "SummaryEstimate(PointCount { key: SampleValue, value: None })", - "detail": { - "query": "PointCount { key: SampleValue, value: None }" - }, - "schema": { - "fields": [ - { - "dtype": "Plain(Utf8)", - "name": "service", - "nullable": false - }, - { - "dtype": "Plain(Int64)", - "name": "count(*)", - "nullable": false - } - ], - "time_index": null - }, - "children": [ - 1 - ], - "workload_node_id": 2, - "decision": { - "id": 0, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 3.0, - "role": "replacement_root" - } - }, - { - "id": 3, "kind": "Project", "label": "Project(2 cols)", "detail": { @@ -708,8 +383,8 @@ "table": null }, { - "dtype": "int64", - "name": "count(*)", + "dtype": "float64", + "name": "avg(metrics.latency)", "nullable": false, "table": null } @@ -722,2947 +397,178 @@ ] }, "children": [ - 2 + 1 ], - "workload_node_id": 3, - "hash": 17063396757587155626 + "workload_node_id": 4, + "hash": 5277207484379927121 } ], - "root": 3 + "root": 2 } }, { - "name": "q2", - "source": "SELECT service, AVG(latency) FROM metrics GROUP BY service", + "name": "q3", + "source": "topk(5, rate(http_requests_total[5m]))", "graph": { "nodes": [ { "id": 0, "kind": "Scan", - "label": "Scan(metrics)", + "label": "Scan(http_requests_total)", "detail": { "predicates": [], "schema": { - "closed": true, + "closed": false, "columns": [ { "dtype": "timestamp", "name": "ts", "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" + "table": null }, { "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", + "name": "value", "nullable": false, - "table": "metrics" + "table": null } ], "time_index": 0, "unique_keys": [] }, "source": { - "Table": { - "table_ref": "metrics" + "TimeSeries": { + "metric": "http_requests_total" } } }, "schema": { - "closed": true, + "closed": false, "columns": [ { "dtype": "timestamp", "name": "ts", "nullable": false, - "table": "metrics" + "table": null }, { - "dtype": "utf8", - "name": "service", + "dtype": "float64", + "name": "value", "nullable": false, - "table": "metrics" - }, + "table": null + } + ], + "time_index": 0, + "unique_keys": [] + }, + "children": [], + "workload_node_id": 5, + "hash": 16569634401303792668 + }, + { + "id": 1, + "kind": "TimeRange", + "label": "TimeRange(300s)", + "detail": { + "range": { + "nanos": 0, + "secs": 300 + } + }, + "schema": { + "closed": false, + "columns": [ { - "dtype": "utf8", - "name": "region", + "dtype": "timestamp", + "name": "ts", "nullable": false, - "table": "metrics" + "table": null }, { "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", + "name": "value", "nullable": false, - "table": "metrics" + "table": null } ], "time_index": 0, "unique_keys": [] }, - "children": [], - "workload_node_id": 0, - "hash": 2606922452740434172 + "children": [ + 0 + ], + "workload_node_id": 6, + "hash": 12007712336004404578 }, { - "id": 1, + "id": 2, "kind": "Aggregate", "label": "Aggregate(1 measures)", "detail": { "having": null, "measures": [ { - "col": 3, - "kind": "avg" + "kind": "rate" } ], "output_names": [ - "avg(metrics.latency)" + "" ], - "reduction": { - "Reduce": [ - 1 - ] - } + "reduction": "PerEntity" }, "schema": { - "closed": true, + "closed": false, "columns": [ { - "dtype": "utf8", - "name": "service", + "dtype": "timestamp", + "name": "ts", "nullable": false, - "table": "metrics" + "table": null }, { "dtype": "float64", - "name": "avg(metrics.latency)", + "name": "value", "nullable": false, "table": null } ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] + "time_index": 0, + "unique_keys": [] }, "children": [ - 0 + 1 ], - "workload_node_id": 3, - "hash": 5109904469838189862 + "workload_node_id": 7, + "hash": 12607242477503010192 }, { - "id": 2, - "kind": "Project", - "label": "Project(2 cols)", + "id": 3, + "kind": "Sort", + "label": "Sort(1 keys)", "detail": { - "cols": [ - { - "alias": null, - "expr": { - "Column": 0 - } - }, + "keys": [ { - "alias": null, + "ascending": false, "expr": { "Column": 1 - } + }, + "nulls_first": false } ], - "qualifier": null + "partition_by": [] }, "schema": { - "closed": true, + "closed": false, "columns": [ { - "dtype": "utf8", - "name": "service", + "dtype": "timestamp", + "name": "ts", "nullable": false, "table": null }, { "dtype": "float64", - "name": "avg(metrics.latency)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 1 - ], - "workload_node_id": 4, - "hash": 5277207484379927121 - } - ], - "root": 2 - }, - "replacements": [ - { - "decision_id": 1, - "target_pre_id": 1, - "strategy": "AvgToSumOverCountStrategy", - "rationale": "Rewrites AVG into SUM and COUNT under the same grouping, then divides SUM by COUNT.", - "rank": 0, - "cost": 2.0, - "before": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(metrics)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 2606922452740434172 - }, - { - "id": 1, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "col": 3, - "kind": "avg" - } - ], - "output_names": [ - "avg(metrics.latency)" - ], - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "avg(metrics.latency)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 0 - ], - "hash": 5109904469838189862 - } - ], - "root": 1 - }, - "after": { - "kind": "Rewrite", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(metrics)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 2606922452740434172 - }, - { - "id": 1, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "col": 3, - "kind": "sum" - } - ], - "output_names": [], - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "sum", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 0 - ], - "hash": 18377191452169790973 - }, - { - "id": 2, - "kind": "Project", - "label": "Project(2 cols)", - "detail": { - "cols": [ - { - "alias": null, - "expr": { - "Column": 0 - } - }, - { - "alias": "avg(metrics.latency)", - "expr": { - "Cast": { - "expr": { - "Column": 1 - }, - "to": "float64", - "try_cast": false - } - } - } - ], - "qualifier": null - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "avg(metrics.latency)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 1 - ], - "hash": 11401885529025699918 - }, - { - "id": 3, - "kind": "Scan", - "label": "Scan(metrics)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 2606922452740434172 - }, - { - "id": 4, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "accuracy": "Exact", - "kind": "count" - } - ], - "output_names": [], - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "count", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 3 - ], - "hash": 6760740959892251122 - }, - { - "id": 5, - "kind": "BinaryOp", - "label": "BinaryOp(/)", - "detail": { - "op": "/", - "vector_match": null - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "avg(metrics.latency)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 2, - 4 - ], - "hash": 16391872732257280252 - } - ], - "root": 5 - } - } - } - ], - "post_graph": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(metrics)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "workload_node_id": 0, - "hash": 2606922452740434172, - "decision": { - "id": 6, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Sum { col: Some(3) } realizes as an exact Sum accumulator", - "rank": 0, - "cost": 3.0, - "role": "replacement_region" - } - }, - { - "id": 1, - "kind": "SummaryAgg", - "label": "SummaryAgg(ExactAggregate(Sum))", - "detail": { - "col": { - "Qualified": { - "name": "latency", - "table": "metrics" - } - }, - "family": "ExactAggregate(Sum, Sum)", - "grouping": "PerSubpopulationInstance", - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "schema": { - "fields": [ - { - "dtype": "Plain(Utf8)", - "name": "service", - "nullable": false - }, - { - "dtype": "ExactAggregate(Sum, Sum)", - "name": "sum", - "nullable": false - } - ], - "time_index": null - }, - "children": [ - 0 - ], - "workload_node_id": 4, - "decision": { - "id": 6, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Sum { col: Some(3) } realizes as an exact Sum accumulator", - "rank": 0, - "cost": 3.0, - "role": "replacement_root" - } - }, - { - "id": 2, - "kind": "Project", - "label": "Project(2 cols)", - "detail": { - "cols": [ - { - "alias": null, - "expr": { - "Column": 0 - } - }, - { - "alias": "avg(metrics.latency)", - "expr": { - "Cast": { - "expr": { - "Column": 1 - }, - "to": "float64", - "try_cast": false - } - } - } - ], - "qualifier": null - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "avg(metrics.latency)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 1 - ], - "workload_node_id": 5, - "hash": 11401885529025699918, - "decision": { - "id": 1, - "strategy": "AvgToSumOverCountStrategy", - "rationale": "Rewrites AVG into SUM and COUNT under the same grouping, then divides SUM by COUNT.", - "rank": 0, - "cost": 2.0, - "role": "replacement_region" - } - }, - { - "id": 3, - "kind": "SummaryAgg", - "label": "SummaryAgg(ExactAggregate(Count))", - "detail": { - "col": "SampleValue", - "family": "ExactAggregate(Count, Count)", - "grouping": "PerSubpopulationInstance", - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "schema": { - "fields": [ - { - "dtype": "Plain(Utf8)", - "name": "service", - "nullable": false - }, - { - "dtype": "ExactAggregate(Count, Count)", - "name": "count", - "nullable": false - } - ], - "time_index": null - }, - "children": [ - 0 - ], - "workload_node_id": 6, - "decision": { - "id": 7, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as an exact Count accumulator", - "rank": 0, - "cost": 3.0, - "role": "replacement_root" - } - }, - { - "id": 4, - "kind": "BinaryOp", - "label": "BinaryOp(/)", - "detail": { - "op": "/", - "vector_match": null - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "avg(metrics.latency)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 2, - 3 - ], - "workload_node_id": 7, - "hash": 16391872732257280252, - "decision": { - "id": 1, - "strategy": "AvgToSumOverCountStrategy", - "rationale": "Rewrites AVG into SUM and COUNT under the same grouping, then divides SUM by COUNT.", - "rank": 0, - "cost": 2.0, - "role": "replacement_root" - } - }, - { - "id": 5, - "kind": "Project", - "label": "Project(2 cols)", - "detail": { - "cols": [ - { - "alias": null, - "expr": { - "Column": 0 - } - }, - { - "alias": null, - "expr": { - "Column": 1 - } - } - ], - "qualifier": null - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "avg(metrics.latency)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 4 - ], - "workload_node_id": 8, - "hash": 5277207484379927121 - } - ], - "root": 5 - } - }, - { - "name": "q3", - "source": "topk(5, rate(http_requests_total[5m]))", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "workload_node_id": 5, - "hash": 16569634401303792668 - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "workload_node_id": 6, - "hash": 12007712336004404578 - }, - { - "id": 2, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "kind": "rate" - } - ], - "output_names": [ - "" - ], - "reduction": "PerEntity" - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 1 - ], - "workload_node_id": 7, - "hash": 12607242477503010192 - }, - { - "id": 3, - "kind": "Sort", - "label": "Sort(1 keys)", - "detail": { - "keys": [ - { - "ascending": false, - "expr": { - "Column": 1 - }, - "nulls_first": false - } - ], - "partition_by": [] - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 2 - ], - "workload_node_id": 8, - "hash": 3760917937445997010 - }, - { - "id": 4, - "kind": "Limit", - "label": "Limit(5)", - "detail": { - "n": 5, - "offset": 0 - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 3 - ], - "workload_node_id": 9, - "hash": 7672123412300577486 - } - ], - "root": 4 - }, - "replacements": [ - { - "decision_id": 3, - "target_pre_id": 2, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "before": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 16569634401303792668 - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "hash": 12007712336004404578 - }, - { - "id": 2, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "kind": "rate" - } - ], - "output_names": [ - "" - ], - "reduction": "PerEntity" - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 1 - ], - "hash": 12607242477503010192 - } - ], - "root": 2 - }, - "after": { - "kind": "Summary", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "KeepPreAsap", - "label": "KeepPreAsap(TimeRange)", - "detail": { - "pre_asap_subgraph": { - "nodes": [ - { - "children": [], - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "hash": 16569634401303792668, - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - } - }, - { - "children": [ - 0 - ], - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "hash": 12007712336004404578, - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - } - } - ], - "root": 1 - } - }, - "children": [] - }, - { - "id": 1, - "kind": "SummaryAgg", - "label": "SummaryAgg(ExactAggregate(Rate))", - "detail": { - "col": "SampleValue", - "family": "ExactAggregate(Rate, Rate)", - "grouping": "PerSubpopulationInstance", - "reduction": "PerEntity" - }, - "children": [ - 0 - ] - } - ], - "root": 1 - } - } - }, - { - "decision_id": 2, - "target_pre_id": 4, - "strategy": "TopKLimitReuseStrategy", - "rationale": "Derives this smaller top-k from a compatible larger top-k result shared by the workload.", - "rank": 0, - "cost": 5.0, - "before": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 16569634401303792668 - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "hash": 12007712336004404578 - }, - { - "id": 2, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "kind": "rate" - } - ], - "output_names": [ - "" - ], - "reduction": "PerEntity" - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 1 - ], - "hash": 12607242477503010192 - }, - { - "id": 3, - "kind": "Sort", - "label": "Sort(1 keys)", - "detail": { - "keys": [ - { - "ascending": false, - "expr": { - "Column": 1 - }, - "nulls_first": false - } - ], - "partition_by": [] - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 2 - ], - "hash": 3760917937445997010 - }, - { - "id": 4, - "kind": "Limit", - "label": "Limit(5)", - "detail": { - "n": 5, - "offset": 0 - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 3 - ], - "hash": 7672123412300577486 - } - ], - "root": 4 - }, - "after": { - "kind": "Rewrite", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 16569634401303792668 - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "hash": 12007712336004404578 - }, - { - "id": 2, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "kind": "rate" - } - ], - "output_names": [ - "" - ], - "reduction": "PerEntity" - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 1 - ], - "hash": 12607242477503010192 - }, - { - "id": 3, - "kind": "Sort", - "label": "Sort(1 keys)", - "detail": { - "keys": [ - { - "ascending": false, - "expr": { - "Column": 1 - }, - "nulls_first": false - } - ], - "partition_by": [] - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 2 - ], - "hash": 3760917937445997010 - }, - { - "id": 4, - "kind": "Limit", - "label": "Limit(10)", - "detail": { - "n": 10, - "offset": 0 - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 3 - ], - "hash": 10822644126224715622 - }, - { - "id": 5, - "kind": "Limit", - "label": "Limit(5)", - "detail": { - "n": 5, - "offset": 0 - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 4 - ], - "hash": 5925942169603452483 - } - ], - "root": 5 - } - } - } - ], - "post_graph": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "workload_node_id": 9, - "hash": 16569634401303792668, - "decision": { - "id": 3, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "role": "replacement_region" - } - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "workload_node_id": 10, - "hash": 12007712336004404578, - "decision": { - "id": 3, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "role": "replacement_region" - } - }, - { - "id": 2, - "kind": "SummaryAgg", - "label": "SummaryAgg(ExactAggregate(Rate))", - "detail": { - "col": "SampleValue", - "family": "ExactAggregate(Rate, Rate)", - "grouping": "PerSubpopulationInstance", - "reduction": "PerEntity" - }, - "schema": { - "fields": [ - { - "dtype": "Plain(Timestamp)", - "name": "ts", - "nullable": false - }, - { - "dtype": "ExactAggregate(Rate, Rate)", - "name": "value", - "nullable": false - } - ], - "time_index": 0 - }, - "children": [ - 1 - ], - "workload_node_id": 11, - "decision": { - "id": 3, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "role": "replacement_root" - } - }, - { - "id": 3, - "kind": "Sort", - "label": "Sort(1 keys)", - "detail": { - "keys": [ - { - "ascending": false, - "expr": { - "Column": 1 - }, - "nulls_first": false - } - ], - "partition_by": [] - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 2 - ], - "workload_node_id": 12, - "hash": 3760917937445997010, - "decision": { - "id": 2, - "strategy": "TopKLimitReuseStrategy", - "rationale": "Derives this smaller top-k from a compatible larger top-k result shared by the workload.", - "rank": 0, - "cost": 5.0, - "role": "replacement_region" - } - }, - { - "id": 4, - "kind": "Limit", - "label": "Limit(10)", - "detail": { - "n": 10, - "offset": 0 - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 3 - ], - "workload_node_id": 13, - "hash": 10822644126224715622, - "decision": { - "id": 2, - "strategy": "TopKLimitReuseStrategy", - "rationale": "Derives this smaller top-k from a compatible larger top-k result shared by the workload.", - "rank": 0, - "cost": 5.0, - "role": "replacement_region" - } - }, - { - "id": 5, - "kind": "Limit", - "label": "Limit(5)", - "detail": { - "n": 5, - "offset": 0 - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 4 - ], - "workload_node_id": 14, - "hash": 5925942169603452483, - "decision": { - "id": 2, - "strategy": "TopKLimitReuseStrategy", - "rationale": "Derives this smaller top-k from a compatible larger top-k result shared by the workload.", - "rank": 0, - "cost": 5.0, - "role": "replacement_root" - } - } - ], - "root": 5 - } - }, - { - "name": "q4", - "source": "topk(10, rate(http_requests_total[5m]))", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "workload_node_id": 5, - "hash": 16569634401303792668 - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "workload_node_id": 6, - "hash": 12007712336004404578 - }, - { - "id": 2, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "kind": "rate" - } - ], - "output_names": [ - "" - ], - "reduction": "PerEntity" - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 1 - ], - "workload_node_id": 7, - "hash": 12607242477503010192 - }, - { - "id": 3, - "kind": "Sort", - "label": "Sort(1 keys)", - "detail": { - "keys": [ - { - "ascending": false, - "expr": { - "Column": 1 - }, - "nulls_first": false - } - ], - "partition_by": [] - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 2 - ], - "workload_node_id": 8, - "hash": 3760917937445997010 - }, - { - "id": 4, - "kind": "Limit", - "label": "Limit(10)", - "detail": { - "n": 10, - "offset": 0 - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 3 - ], - "workload_node_id": 10, - "hash": 10822644126224715622 - } - ], - "root": 4 - }, - "replacements": [ - { - "decision_id": 3, - "target_pre_id": 2, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "before": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 16569634401303792668 - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "hash": 12007712336004404578 - }, - { - "id": 2, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "kind": "rate" - } - ], - "output_names": [ - "" - ], - "reduction": "PerEntity" - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 1 - ], - "hash": 12607242477503010192 - } - ], - "root": 2 - }, - "after": { - "kind": "Summary", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "KeepPreAsap", - "label": "KeepPreAsap(TimeRange)", - "detail": { - "pre_asap_subgraph": { - "nodes": [ - { - "children": [], - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "hash": 16569634401303792668, - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - } - }, - { - "children": [ - 0 - ], - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "hash": 12007712336004404578, - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - } - } - ], - "root": 1 - } - }, - "children": [] - }, - { - "id": 1, - "kind": "SummaryAgg", - "label": "SummaryAgg(ExactAggregate(Rate))", - "detail": { - "col": "SampleValue", - "family": "ExactAggregate(Rate, Rate)", - "grouping": "PerSubpopulationInstance", - "reduction": "PerEntity" - }, - "children": [ - 0 - ] - } - ], - "root": 1 - } - } - } - ], - "post_graph": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(http_requests_total)", - "detail": { - "predicates": [], - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "TimeSeries": { - "metric": "http_requests_total" - } - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "workload_node_id": 9, - "hash": 16569634401303792668, - "decision": { - "id": 3, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "role": "replacement_region" - } - }, - { - "id": 1, - "kind": "TimeRange", - "label": "TimeRange(300s)", - "detail": { - "range": { - "nanos": 0, - "secs": 300 - } - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", - "nullable": false, - "table": null - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0 - ], - "workload_node_id": 10, - "hash": 12007712336004404578, - "decision": { - "id": 3, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "role": "replacement_region" - } - }, - { - "id": 2, - "kind": "SummaryAgg", - "label": "SummaryAgg(ExactAggregate(Rate))", - "detail": { - "col": "SampleValue", - "family": "ExactAggregate(Rate, Rate)", - "grouping": "PerSubpopulationInstance", - "reduction": "PerEntity" - }, - "schema": { - "fields": [ - { - "dtype": "Plain(Timestamp)", - "name": "ts", - "nullable": false - }, - { - "dtype": "ExactAggregate(Rate, Rate)", - "name": "value", - "nullable": false - } - ], - "time_index": 0 - }, - "children": [ - 1 - ], - "workload_node_id": 11, - "decision": { - "id": 3, - "strategy": "SketchAlgorithmStrategy", - "rationale": "Rate realizes as an exact Rate accumulator", - "rank": 0, - "cost": 4.0, - "role": "replacement_root" - } - }, - { - "id": 3, - "kind": "Sort", - "label": "Sort(1 keys)", - "detail": { - "keys": [ - { - "ascending": false, - "expr": { - "Column": 1 - }, - "nulls_first": false - } - ], - "partition_by": [] - }, - "schema": { - "closed": false, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": null - }, - { - "dtype": "float64", - "name": "value", + "name": "value", "nullable": false, "table": null } @@ -3673,15 +579,15 @@ "children": [ 2 ], - "workload_node_id": 12, + "workload_node_id": 8, "hash": 3760917937445997010 }, { "id": 4, "kind": "Limit", - "label": "Limit(10)", + "label": "Limit(5)", "detail": { - "n": 10, + "n": 5, "offset": 0 }, "schema": { @@ -3706,894 +612,230 @@ "children": [ 3 ], - "workload_node_id": 13, - "hash": 10822644126224715622 + "workload_node_id": 9, + "hash": 7672123412300577486 } ], "root": 4 } }, { - "name": "q6", - "source": "SELECT metrics.service, COUNT(*) FROM metrics JOIN hosts ON metrics.service = hosts.service GROUP BY metrics.service", + "name": "q4", + "source": "topk(10, rate(http_requests_total[5m]))", "graph": { "nodes": [ { "id": 0, "kind": "Scan", - "label": "Scan(metrics)", + "label": "Scan(http_requests_total)", "detail": { "predicates": [], "schema": { - "closed": true, + "closed": false, "columns": [ { "dtype": "timestamp", "name": "ts", "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" + "table": null }, { "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", + "name": "value", "nullable": false, - "table": "metrics" + "table": null } ], "time_index": 0, "unique_keys": [] }, "source": { - "Table": { - "table_ref": "metrics" + "TimeSeries": { + "metric": "http_requests_total" } } }, "schema": { - "closed": true, + "closed": false, "columns": [ { "dtype": "timestamp", "name": "ts", "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" + "table": null }, { "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", + "name": "value", "nullable": false, - "table": "metrics" + "table": null } ], "time_index": 0, "unique_keys": [] }, "children": [], - "workload_node_id": 0, - "hash": 2606922452740434172 + "workload_node_id": 5, + "hash": 16569634401303792668 }, { "id": 1, - "kind": "Scan", - "label": "Scan(hosts)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": null, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "hosts" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": null, - "unique_keys": [] - }, - "children": [], - "workload_node_id": 11, - "hash": 7077183977966848076 - }, - { - "id": 2, - "kind": "Join", - "label": "Join(Inner)", + "kind": "TimeRange", + "label": "TimeRange(300s)", "detail": { - "kind": "Inner", - "pred": { - "Compare": { - "left": { - "Column": 1 - }, - "op": "Eq", - "right": { - "Column": 5 - } - } + "range": { + "nanos": 0, + "secs": 300 } }, "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, + "closed": false, + "columns": [ { - "dtype": "utf8", - "name": "region", + "dtype": "timestamp", + "name": "ts", "nullable": false, - "table": "metrics" + "table": null }, { "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", + "name": "value", "nullable": false, - "table": "hosts" + "table": null } ], "time_index": 0, "unique_keys": [] }, "children": [ - 0, - 1 + 0 ], - "workload_node_id": 12, - "hash": 1121596767830819524 + "workload_node_id": 6, + "hash": 12007712336004404578 }, { - "id": 3, + "id": 2, "kind": "Aggregate", "label": "Aggregate(1 measures)", "detail": { "having": null, "measures": [ { - "accuracy": { - "Epsilon": 0.01 - }, - "kind": "count" + "kind": "rate" } ], "output_names": [ - "count(*)" + "" ], - "reduction": { - "Reduce": [ - 1 - ] - } + "reduction": "PerEntity" }, "schema": { - "closed": true, + "closed": false, "columns": [ { - "dtype": "utf8", - "name": "service", + "dtype": "timestamp", + "name": "ts", "nullable": false, - "table": "metrics" + "table": null }, { - "dtype": "int64", - "name": "count(*)", + "dtype": "float64", + "name": "value", "nullable": false, "table": null } ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] + "time_index": 0, + "unique_keys": [] }, "children": [ - 2 + 1 ], - "workload_node_id": 13, - "hash": 4243737280010465780, - "notes": [ - { - "kind": "SketchApproximation", - "reason": "count realizes as a Cms sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with); count realizes as a CountSketch sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with); count realizes as a shared HydraCms structure over Cms serving every subpopulation of this grouped aggregate, instead of one Cms instance per distinct `by` key — legal because this aggregate has a non-empty subpopulation concept and Cms has a modeled Hydra variant (asap_types::post_asap::hydra_kind_for); whether it's *worth* the shared/independent trade-off for the actual subpopulation cardinality is a CostModel's call, not this strategy's; count realizes as a shared HydraCountSketch structure over CountSketch serving every subpopulation of this grouped aggregate, instead of one CountSketch instance per distinct `by` key — legal because this aggregate has a non-empty subpopulation concept and CountSketch has a modeled Hydra variant (asap_types::post_asap::hydra_kind_for); whether it's *worth* the shared/independent trade-off for the actual subpopulation cardinality is a CostModel's call, not this strategy's" - } - ] + "workload_node_id": 7, + "hash": 12607242477503010192 }, { - "id": 4, - "kind": "Project", - "label": "Project(2 cols)", + "id": 3, + "kind": "Sort", + "label": "Sort(1 keys)", "detail": { - "cols": [ - { - "alias": null, - "expr": { - "Column": 0 - } - }, + "keys": [ { - "alias": null, + "ascending": false, "expr": { "Column": 1 - } + }, + "nulls_first": false } ], - "qualifier": null + "partition_by": [] }, "schema": { - "closed": true, + "closed": false, "columns": [ { - "dtype": "utf8", - "name": "service", + "dtype": "timestamp", + "name": "ts", "nullable": false, "table": null }, { - "dtype": "int64", - "name": "count(*)", + "dtype": "float64", + "name": "value", "nullable": false, "table": null } ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] + "time_index": 0, + "unique_keys": [] }, "children": [ - 3 - ], - "workload_node_id": 14, - "hash": 16310199685311002920 - } - ], - "root": 4 - }, - "replacements": [ - { - "decision_id": 5, - "target_pre_id": 3, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 5.0, - "before": { - "nodes": [ - { - "id": 0, - "kind": "Scan", - "label": "Scan(metrics)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [], - "hash": 2606922452740434172 - }, - { - "id": 1, - "kind": "Scan", - "label": "Scan(hosts)", - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": null, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "hosts" - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": null, - "unique_keys": [] - }, - "children": [], - "hash": 7077183977966848076 - }, - { - "id": 2, - "kind": "Join", - "label": "Join(Inner)", - "detail": { - "kind": "Inner", - "pred": { - "Compare": { - "left": { - "Column": 1 - }, - "op": "Eq", - "right": { - "Column": 5 - } - } - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "children": [ - 0, - 1 - ], - "hash": 1121596767830819524 - }, - { - "id": 3, - "kind": "Aggregate", - "label": "Aggregate(1 measures)", - "detail": { - "having": null, - "measures": [ - { - "accuracy": { - "Epsilon": 0.01 - }, - "kind": "count" - } - ], - "output_names": [ - "count(*)" - ], - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "count(*)", - "nullable": false, - "table": null - } - ], - "time_index": null, - "unique_keys": [ - [ - 0 - ] - ] - }, - "children": [ - 2 - ], - "hash": 4243737280010465780 - } + 2 ], - "root": 3 + "workload_node_id": 8, + "hash": 3760917937445997010 }, - "after": { - "kind": "Summary", - "graph": { - "nodes": [ - { - "id": 0, - "kind": "KeepPreAsap", - "label": "KeepPreAsap(Join)", - "detail": { - "pre_asap_subgraph": { - "nodes": [ - { - "children": [], - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "metrics" - } - } - }, - "hash": 2606922452740434172, - "id": 0, - "kind": "Scan", - "label": "Scan(metrics)", - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - } - ], - "time_index": 0, - "unique_keys": [] - } - }, - { - "children": [], - "detail": { - "predicates": [], - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": null, - "unique_keys": [] - }, - "source": { - "Table": { - "table_ref": "hosts" - } - } - }, - "hash": 7077183977966848076, - "id": 1, - "kind": "Scan", - "label": "Scan(hosts)", - "schema": { - "closed": true, - "columns": [ - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": null, - "unique_keys": [] - } - }, - { - "children": [ - 0, - 1 - ], - "detail": { - "kind": "Inner", - "pred": { - "Compare": { - "left": { - "Column": 1 - }, - "op": "Eq", - "right": { - "Column": 5 - } - } - } - }, - "hash": 1121596767830819524, - "id": 2, - "kind": "Join", - "label": "Join(Inner)", - "schema": { - "closed": true, - "columns": [ - { - "dtype": "timestamp", - "name": "ts", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "float64", - "name": "latency", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "int64", - "name": "bytes", - "nullable": false, - "table": "metrics" - }, - { - "dtype": "utf8", - "name": "service", - "nullable": false, - "table": "hosts" - }, - { - "dtype": "utf8", - "name": "region", - "nullable": false, - "table": "hosts" - } - ], - "time_index": 0, - "unique_keys": [] - } - } - ], - "root": 2 - } - }, - "children": [] - }, + { + "id": 4, + "kind": "Limit", + "label": "Limit(10)", + "detail": { + "n": 10, + "offset": 0 + }, + "schema": { + "closed": false, + "columns": [ { - "id": 1, - "kind": "SummaryAgg", - "label": "SummaryAgg(Sketch(Cms))", - "detail": { - "col": "SampleValue", - "family": "Sketch(SketchKind { category: Frequency, algorithm: Cms, params: Cms { width: 272, depth: 5 } }, PerSubpopulationInstance)", - "grouping": "PerSubpopulationInstance", - "reduction": { - "Reduce": [ - 1 - ] - } - }, - "children": [ - 0 - ] + "dtype": "timestamp", + "name": "ts", + "nullable": false, + "table": null }, { - "id": 2, - "kind": "SummaryEstimate", - "label": "SummaryEstimate(PointCount { key: SampleValue, value: None })", - "detail": { - "query": "PointCount { key: SampleValue, value: None }" - }, - "children": [ - 1 - ] + "dtype": "float64", + "name": "value", + "nullable": false, + "table": null } ], - "root": 2 - } + "time_index": 0, + "unique_keys": [] + }, + "children": [ + 3 + ], + "workload_node_id": 10, + "hash": 10822644126224715622 } - } - ], - "post_graph": { + ], + "root": 4 + } + }, + { + "name": "q6", + "source": "SELECT metrics.service, COUNT(*) FROM metrics JOIN hosts ON metrics.service = hosts.service GROUP BY metrics.service", + "graph": { "nodes": [ { "id": 0, @@ -4683,15 +925,7 @@ }, "children": [], "workload_node_id": 0, - "hash": 2606922452740434172, - "decision": { - "id": 5, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 5.0, - "role": "replacement_region" - } + "hash": 2606922452740434172 }, { "id": 1, @@ -4744,16 +978,8 @@ "unique_keys": [] }, "children": [], - "workload_node_id": 15, - "hash": 7077183977966848076, - "decision": { - "id": 5, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 5.0, - "role": "replacement_region" - } + "workload_node_id": 11, + "hash": 7077183977966848076 }, { "id": 2, @@ -4826,25 +1052,26 @@ 0, 1 ], - "workload_node_id": 16, - "hash": 1121596767830819524, - "decision": { - "id": 5, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 5.0, - "role": "replacement_region" - } + "workload_node_id": 12, + "hash": 1121596767830819524 }, { "id": 3, - "kind": "SummaryAgg", - "label": "SummaryAgg(Sketch(Cms))", + "kind": "Aggregate", + "label": "Aggregate(1 measures)", "detail": { - "col": "SampleValue", - "family": "Sketch(SketchKind { category: Frequency, algorithm: Cms, params: Cms { width: 272, depth: 5 } }, PerSubpopulationInstance)", - "grouping": "PerSubpopulationInstance", + "having": null, + "measures": [ + { + "accuracy": { + "Epsilon": 0.01 + }, + "kind": "count" + } + ], + "output_names": [ + "count(*)" + ], "reduction": { "Reduce": [ 1 @@ -4852,70 +1079,42 @@ } }, "schema": { - "fields": [ + "closed": true, + "columns": [ { - "dtype": "Plain(Utf8)", + "dtype": "utf8", "name": "service", - "nullable": false + "nullable": false, + "table": "metrics" }, { - "dtype": "Sketch(SketchKind { category: Frequency, algorithm: Cms, params: Cms { width: 272, depth: 5 } }, PerSubpopulationInstance)", + "dtype": "int64", "name": "count(*)", - "nullable": false + "nullable": false, + "table": null } ], - "time_index": null + "time_index": null, + "unique_keys": [ + [ + 0 + ] + ] }, "children": [ 2 ], - "workload_node_id": 17, - "decision": { - "id": 5, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 5.0, - "role": "replacement_region" - } + "workload_node_id": 13, + "hash": 4243737280010465780, + "notes": [ + { + "kind": "SketchApproximation", + "reason": "count realizes as a Cms sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with); count realizes as a CountSketch sketch — one of summary_candidates' alternatives for this intent (asap_aware_mapping::replacement::implementations_for_with)" + } + ] }, { "id": 4, - "kind": "SummaryEstimate", - "label": "SummaryEstimate(PointCount { key: SampleValue, value: None })", - "detail": { - "query": "PointCount { key: SampleValue, value: None }" - }, - "schema": { - "fields": [ - { - "dtype": "Plain(Utf8)", - "name": "service", - "nullable": false - }, - { - "dtype": "Plain(Int64)", - "name": "count(*)", - "nullable": false - } - ], - "time_index": null - }, - "children": [ - 3 - ], - "workload_node_id": 18, - "decision": { - "id": 5, - "strategy": "SketchAlgorithmStrategy", - "rationale": "count realizes as a Cms sketch", - "rank": 0, - "cost": 5.0, - "role": "replacement_root" - } - }, - { - "id": 5, "kind": "Project", "label": "Project(2 cols)", "detail": { @@ -4959,13 +1158,13 @@ ] }, "children": [ - 4 + 3 ], - "workload_node_id": 19, + "workload_node_id": 14, "hash": 16310199685311002920 } ], - "root": 5 + "root": 4 } } ] diff --git a/tools/dag-viewer/generate-sample.sh b/tools/dag-viewer/generate-sample.sh index e916cba5..60cd0a8e 100755 --- a/tools/dag-viewer/generate-sample.sh +++ b/tools/dag-viewer/generate-sample.sh @@ -5,10 +5,10 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/../.." -# --epsilon asks for an approximate accuracy target instead of the default -# Exact, so SketchAlgorithmStrategy actually has a sketch alternative to -# report — without it, no query below would ever pick up a `notes` badge -# (see crates/devtools/src/bin/dag_export.rs's own `--epsilon` doc comment). +# This checked-in sample intentionally omits deployment-owned physical +# evidence. The exporter therefore emits the raw DAG only; costed post-ASAP +# examples must pass a complete --planner-cost-json document and never fall +# back to structural node counts. cargo run -p asap-devtools --bin dag_export -- \ --post-asap --progress \ --epsilon 0.01 \ diff --git a/tools/dag-viewer/index.html b/tools/dag-viewer/index.html index cb1a1d01..ecb36f00 100644 --- a/tools/dag-viewer/index.html +++ b/tools/dag-viewer/index.html @@ -175,6 +175,27 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 15%, transparent); } .scopeRow .scopeMeta { color: var(--muted); font-size: 0.72rem; margin-left: auto; white-space: nowrap; } + .costComparisonGroup { min-width: min(760px, 100%); } + .costComparison { display: flex; align-items: stretch; gap: 0.45rem; flex-wrap: wrap; } + .costStage, .costSaving { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.15rem; + min-width: 180px; + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + } + .costStage span, .costSaving span { color: var(--muted); font-size: 0.68rem; text-transform: uppercase; letter-spacing: .03em; } + .costStage strong, .costSaving strong { font-size: 0.86rem; font-variant-numeric: tabular-nums; } + .costStage--pre { border-left: 4px solid #b45309; } + .costStage--post { border-left: 4px solid var(--accent); } + .costArrow { display: flex; align-items: center; color: var(--muted); font-size: 1.25rem; font-weight: 700; } + .costSaving { border-left: 4px solid #15803d; color: #15803d; } + .costSaving--regression { border-left-color: #b91c1c; color: #b91c1c; } + .costComparisonHint { color: var(--muted); font-size: 0.68rem; } .proxyNote { font-size: 0.72rem; color: var(--muted); @@ -372,6 +393,40 @@ .leg .swatchLabel { font-weight: 650; } .leg .swatchDesc { color: var(--muted); display: block; } .leg .swatch.ring { background: transparent; border-radius: 999px; border-width: 2px; } + + /* Issue #286: structured cost/benefit annotations. */ + .costBlock { + margin: 0.5rem 0; + padding: 0.5rem 0.55rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel2); + } + .costBlock h4 { + margin: 0 0 0.35rem; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: .03em; + color: var(--muted); + } + .costRow { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; margin: 0.15rem 0; } + .costRow .costLabel { color: var(--muted); font-size: 0.72rem; min-width: 72px; } + .costRow .costValue { font-weight: 650; font-variant-numeric: tabular-nums; } + .costBadge { + display: inline-block; + font-size: 0.64rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .02em; + border-radius: 4px; + padding: 0.05rem 0.35rem; + } + .costBadge--modeled { color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); } + .costBadge--measured { color: #15803d; background: color-mix(in srgb, #15803d 16%, transparent); } + .costBadge--unavailable { color: var(--muted); background: color-mix(in srgb, var(--muted) 16%, transparent); } + .costMeta { color: var(--muted); font-size: 0.68rem; margin-top: 0.15rem; } + .costInputs { margin: 0.3rem 0 0; padding: 0; list-style: none; font-size: 0.68rem; color: var(--muted); } + .costInputs li { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.05rem 0; } diff --git a/tools/dag-viewer/render.py b/tools/dag-viewer/render.py index 8fa8b67c..6659561e 100755 --- a/tools/dag-viewer/render.py +++ b/tools/dag-viewer/render.py @@ -20,11 +20,12 @@ `QueryExpr` detail *is* its plan (see the side panel on node click), and shared-hash highlighting *is* what this repo has for CSE today — both a hash-based proxy, not real CSE output; see README.md's "Shared-subtree -highlighting is a proxy" section. Per-node cost isn't in dag_export's output -yet (no cost estimator is wired into pre-ASAP IR), so there's nothing here -to render for it; if `detail` ever grows a `cost` field, the side panel -shows it automatically since it dumps `detail` verbatim, no viewer change -needed. +highlighting is a proxy" section. Structured cost/benefit annotations +(issue #286, `CostAnnotation` in crates/types/src/cost.rs) pass through +this deep-copy untouched, same as everything else `prepare_workload` below +doesn't explicitly rewrite — viewer.js reads `decision.baseline_cost` / +`.selected_cost` / `.benefit`, `NamedGraph.workload_cost`, and +`DagGraph.edge_annotations` directly, with no help needed from this file. Usage: cargo run -p asap-devtools --bin dag_export -- --sql "..." --name q1 \\ diff --git a/tools/dag-viewer/test_render.py b/tools/dag-viewer/test_render.py index ba353d1f..bc78fcfb 100644 --- a/tools/dag-viewer/test_render.py +++ b/tools/dag-viewer/test_render.py @@ -176,6 +176,11 @@ def test_embedded_data_placed_before_viewer_js_body(self): viewer_pos = html.index("cytoscape.use(window.cytoscapeDagre)") # viewer.js's first line self.assertLess(embedded_pos, viewer_pos) + def test_inlined_edge_cost_key_contains_no_literal_nul(self): + html = render({"queries": [named_graph("q1")]}) + self.assertNotIn("\x00", html) + self.assertIn(r"\u0000", html) + def test_angle_bracket_in_query_source_does_not_break_out_of_script_tag(self): # A pathological (but legal JSON) query source containing a literal # "" substring must not prematurely close the embedded diff --git a/tools/dag-viewer/viewer.js b/tools/dag-viewer/viewer.js index 19d19153..66e0dd7d 100644 --- a/tools/dag-viewer/viewer.js +++ b/tools/dag-viewer/viewer.js @@ -20,7 +20,12 @@ cytoscape.use(window.cytoscapeDagre); // like "SummaryAgg" mixed in, and any such node has no `hash` — there's no // corresponding QueryExpr to hash) — left `undefined` when absent (omitted // whenever --post-asap wasn't set, or this query had zero replacements), -// unlike `replacements` which always defaults to an array. +// unlike `replacements` which always defaults to an array. `workload_cost` +// is the optional per-query `NamedGraph.workload_cost` (issue #286), also +// left `undefined` when absent. `sourceBatch` is a viewer-assigned integer +// (never present in the JSON itself) shared by every query loaded from the +// same document — see computeSelectionWorkloadCost's own doc for why it +// exists and how it's used. let queries = []; let activeIndex = -1; let cy = null; @@ -29,6 +34,13 @@ let zoom = 1; // The viewer has one Pre/Post-ASAP mode. One selected query renders its own // two DAGs; multiple selected queries union each stage into one workload DAG. let participants = new Set(); +// Every query pushed from the *same* loaded JSON document (one `dag_export` +// process invocation) shares one `sourceBatch` id, assigned here. Needed +// because `DagDecision.id` is only unique *within* one dag_export run, not +// across independently-generated files — computeSelectionWorkloadCost below +// dedups by `${sourceBatch}:${decision.id}`, never `decision.id` alone, so +// two files that happen to reuse the same small integer id never collide. +let nextSourceBatch = 0; const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('fileInput'); @@ -101,11 +113,14 @@ function loadFiles(fileList) { lifecycle_summary: lifecyclePlanSummary(parsed), }] : []); const existingNames = new Set(queries.map((q) => q.name)); + // One batch id per *file* — every query this one dag_export + // invocation produced shares its decision.id numbering. + const sourceBatch = nextSourceBatch++; incoming.forEach((q) => { let name = q.name; if (existingNames.has(name)) name = `${q.name} (${file.name})`; existingNames.add(name); - queries.push({ name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary }); + queries.push({ name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph, workload_cost: q.workload_cost, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary, sourceBatch }); }); } catch (err) { alert(`Failed to parse ${file.name}: ${err.message}`); @@ -276,6 +291,26 @@ function buildCyStyle() { 'border-color': ringColor, }, }, + { + selector: 'node.costImprovement', + style: { + 'border-color': '#15803d', + 'border-width': 2.5, + 'underlay-color': '#15803d', + 'underlay-opacity': 0.1, + 'underlay-padding': 4, + }, + }, + { + selector: 'node.costRegression', + style: { + 'border-color': '#b91c1c', + 'border-width': 2.5, + 'underlay-color': '#b91c1c', + 'underlay-opacity': 0.1, + 'underlay-padding': 4, + }, + }, { // Pre/Post-ASAP lane container (a compound parent node). selector: 'node.laneParent', @@ -319,6 +354,16 @@ function buildCyStyle() { 'transition-timing-function': 'ease-in-out', }, }, + { + selector: 'edge.costAnnotated', + style: { + 'width': 3, + 'line-color': ringColor, + 'target-arrow-color': ringColor, + 'label': 'data(edgeCostLabel)', + 'font-weight': 700, + }, + }, ]; return style; @@ -433,14 +478,15 @@ function renderPrePostAsap() { ? `Pre/Post-ASAP: ${selected[0].name}` : `Pre/Post-ASAP workload union: ${selected.length} queries`; + const costSummary = selected.length === 1 ? selected[0].workload_cost : computeSelectionWorkloadCost(selected); const elements = selected.length === 1 ? [ - ...laneElements('pre-asap', `${selected[0].name} · pre-ASAP`, selected[0].graph, selected[0], 'pre'), - ...laneElements('post-asap', `${selected[0].name} · post-ASAP`, selected[0].post_graph, selected[0], 'post'), + ...laneElements('pre-asap', `${selected[0].name} · pre-ASAP`, selected[0].graph, selected[0], 'pre', costSummary && costSummary.baseline_cost), + ...laneElements('post-asap', `${selected[0].name} · post-ASAP`, selected[0].post_graph, selected[0], 'post', costSummary && costSummary.selected_cost), ] : [ - ...unionStageLaneElements('pre', chosen), - ...unionStageLaneElements('post', chosen), + ...unionStageLaneElements('pre', chosen, costSummary && costSummary.baseline_cost), + ...unionStageLaneElements('post', chosen, costSummary && costSummary.selected_cost), ]; buildCy(elements); @@ -458,7 +504,7 @@ function renderPrePostAsap() { fitAndSyncZoom(); } -function unionStageLaneElements(stage, chosen) { +function unionStageLaneElements(stage, chosen, laneCost) { // Workload merging is an exporter decision. `workload_node_id` is the // explicit JSON mapping; do not reconstruct identity from node content. const laneId = `${stage}-asap-union`; @@ -484,7 +530,7 @@ function unionStageLaneElements(stage, chosen) { const entries = new Map(); const edges = new Map(); const elements = [ - { data: { id: laneId, label: `${stage === 'pre' ? 'pre-ASAP' : 'post-ASAP'} · workload union`, isLane: true }, classes: 'laneParent', selectable: false, grabbable: false }, + { data: { id: laneId, label: laneCostLabel(`${stage === 'pre' ? 'pre-ASAP' : 'post-ASAP'} · workload union`, laneCost, stage), isLane: true }, classes: 'laneParent', selectable: false, grabbable: false }, ]; chosen.forEach((qIdx) => { @@ -544,18 +590,26 @@ function unionStageLaneElements(stage, chosen) { // both shapes carry id/kind/label/detail/children, which is all a lane // needs; SummaryDagNode's missing `hash`/`notes` fields are simply never // read by this function or by showPrePostDetail below. -function laneElements(laneId, laneLabel, graph, query, stage) { +function laneElements(laneId, laneLabel, graph, query, stage, laneCost) { const nodes = graph.nodes; const byId = new Map(nodes.map((node) => [node.id, node])); + // Keep the delimiter escaped in source. A literal NUL is replaced by the + // HTML tokenizer when viewer.js is embedded by render.py, which otherwise + // makes these keys differ from the lookup below. + const edgeCostByPair = new Map((graph.edge_annotations || []).map((edge) => [`${edge.from}\u0000${edge.to}`, edge.cost])); const elements = [ - { data: { id: laneId, label: laneLabel, isLane: true }, classes: 'laneParent', selectable: false, grabbable: false }, + { data: { id: laneId, label: laneCostLabel(laneLabel, laneCost, stage), isLane: true }, classes: 'laneParent', selectable: false, grabbable: false }, ]; for (const node of nodes) { elements.push({ data: { id: `${laneId}-${node.id}`, parent: laneId, - label: node.label, + // On-graph label carries a concise cost/benefit badge (issue #286) + // when this node's decision has one; `node.label` itself (nested, + // used everywhere else — the sidebar, schema derivation, …) stays + // exactly the plain IR label. + label: node.label + nodeCostBadgeSuffix(node), node, // Flat (not nested under `node`) so buildCyStyle's // `node[kind = "KeepPreAsap"]` selector can actually match it — @@ -571,6 +625,9 @@ function laneElements(laneId, laneLabel, graph, query, stage) { lifecycleSummary: query.lifecycle_summary, translations: translationsForNode(query, node, stage), }, + classes: node.decision && typeof node.decision.benefit?.value === 'number' + ? (node.decision.benefit.value >= 0 ? 'costImprovement' : 'costRegression') + : '', }); } for (const node of nodes) { @@ -583,13 +640,196 @@ function laneElements(laneId, laneLabel, graph, query, stage) { source: `${laneId}-${childId}`, target: `${laneId}-${node.id}`, schemaLabel: formatSchema(byId.get(childId).schema), + // Issue #286 edge cost — only ever present when the exporter + // found this exact (child -> node) edge genuinely attributable + // (a real DAG merge point); `undefined` otherwise, read by + // showEdgeDetail. + edgeCost: edgeCostByPair.get(`${childId}\u0000${node.id}`), + edgeCostLabel: edgeCostByPair.has(`${childId}\u0000${node.id}`) + ? `edge cost ${compactCost(edgeCostByPair.get(`${childId}\u0000${node.id}`))}` + : '', }, + classes: edgeCostByPair.has(`${childId}\u0000${node.id}`) ? 'costAnnotated' : '', }); } } return elements; } +// ── Issue #286: structured cost/benefit annotations ─────────────────────── +// Renders only what a `dag_export` JSON export explicitly carries +// (`CostAnnotation`/`WorkloadCostSummary`/`EdgeCostAnnotation` from +// crates/types/src/cost.rs) — no client-side cost estimation. A value with +// no `source: "Modeled"|"Measured"` (i.e. `Unavailable`, or the field +// simply absent from an older export) always reads "Not estimated", never +// a fabricated number. + +function formatCostUnit(unit) { + switch (unit) { + case 'CostUnitsPerSecond': return 'cost units/s'; + case 'CostUnits': return 'cost units'; + case 'RelativeStructuralUnits': return 'relative structural units'; + default: return unit || 'unknown unit'; + } +} + +function formatBaselineRef(baseline) { + if (!baseline) return ''; + switch (baseline.kind) { + case 'PreAsapRecomputation': return 'pre-ASAP recomputation'; + case 'HighestRankedNonSelectedCandidate': + return `best non-selected candidate (rank ${baseline.detail && baseline.detail.rank})`; + case 'Named': return String(baseline.detail || ''); + default: return baseline.kind || ''; + } +} + +function formatCostNumber(value) { + // Trim to at most 3 decimals without trailing zeros — these are + // structural-proxy magnitudes today (see cost.rs's module doc), not + // precision-sensitive measurements. + return Number(value.toFixed(3)).toString(); +} + +function compactCost(annotation) { + if (!annotation || annotation.value === null || annotation.value === undefined) return 'Not estimated'; + return `${formatCostNumber(annotation.value)} ${formatCostUnit(annotation.unit)}`; +} + +function laneCostLabel(label, annotation, stage) { + if (!annotation) return label; + return `${label}\n${stage === 'pre' ? 'Baseline' : 'Selected'} cost: ${compactCost(annotation)}`; +} + +// One `` block for the sidebar: value + unit + +// Modeled/Measured/Unavailable badge, baseline/delta/ratio when present, +// model/benchmark provenance, and the raw `inputs` the value was built +// from. `annotation` may be `undefined` (an older export with no +// annotation at all) or `null`/missing `value` (an explicit `Unavailable`) +// — both render as "Not estimated", never a number. +function renderCostAnnotation(title, annotation) { + if (!annotation) return ''; + const source = annotation.source || 'Unavailable'; + const badgeClass = source === 'Modeled' ? 'costBadge--modeled' : source === 'Measured' ? 'costBadge--measured' : 'costBadge--unavailable'; + if (annotation.value === null || annotation.value === undefined) { + return `
${escapeHtml(title)}Not estimated${escapeHtml(source)}
`; + } + const unit = formatCostUnit(annotation.unit); + const valueText = `${formatCostNumber(annotation.value)} ${unit}`; + const metaParts = []; + if (annotation.baseline) metaParts.push(`vs ${formatBaselineRef(annotation.baseline)}`); + if (typeof annotation.delta === 'number') metaParts.push(`Δ ${formatCostNumber(annotation.delta)} ${unit}`); + if (typeof annotation.benefit_ratio === 'number') metaParts.push(`ratio ${(annotation.benefit_ratio * 100).toFixed(1)}%`); + const provenanceParts = []; + if (annotation.model_version) provenanceParts.push(`model ${annotation.model_version}`); + if (annotation.benchmark_id) provenanceParts.push(`benchmark ${annotation.benchmark_id}`); + const inputsHtml = (annotation.inputs || []).length + ? `
    ${annotation.inputs.map((input) => `
  • ${escapeHtml(input.name)}${escapeHtml(String(input.value))}${input.unit ? ' ' + escapeHtml(input.unit) : ''}
  • `).join('')}
` + : ''; + return ` +
+ ${escapeHtml(title)} + ${escapeHtml(valueText)} + ${escapeHtml(source)} +
+ ${metaParts.length ? `
${escapeHtml(metaParts.join(' · '))}
` : ''} + ${provenanceParts.length ? `
${escapeHtml(provenanceParts.join(' · '))}
` : ''} + ${inputsHtml} + `; +} + +// Baseline/selected/benefit trio for one replacement decision, matching +// `DagDecision.baseline_cost/selected_cost/benefit` (crates/types/src/dag_export.rs). +function renderDecisionCostBlock(entry) { + if (!entry.baseline_cost && !entry.selected_cost && !entry.benefit) return ''; + return `
+

Cost / benefit

+ ${renderCostAnnotation('Baseline', entry.baseline_cost)} + ${renderCostAnnotation('Selected', entry.selected_cost)} + ${renderCostAnnotation('Benefit', entry.benefit)} +
`; +} + +// Short, on-graph badge text for a post-ASAP node's own winning decision — +// "concise on-graph benefit/cost badges" per issue #286; the full +// breakdown only ever appears in the sidebar (`renderDecisionCostBlock`). +// Empty string whenever there's nothing to show (no decision, or its +// benefit is `Unavailable`) so an un-costed node's label is untouched. +function nodeCostBadgeSuffix(node) { + const benefit = node.decision && node.decision.benefit; + if (!benefit || benefit.value === null || benefit.value === undefined) return ''; + if (typeof benefit.benefit_ratio === 'number') { + const pct = Math.abs(benefit.benefit_ratio * 100); + return `\n${benefit.benefit_ratio >= 0 ? '▼' : '▲'}${pct >= 10 ? Math.round(pct) : pct.toFixed(1)}%`; + } + return `\n${benefit.value >= 0 ? '▼' : '▲'}${formatCostNumber(Math.abs(benefit.value))}`; +} + +// Workload-wide baseline/selected/benefit for the currently selected +// queries, deduplicated by `decision.id` *within one loaded document* — the +// same collision-free key `crates/devtools/src/bin/dag_export.rs`'s own +// `decision_cost_entries` dedupes by (a decision spans every node in its +// replacement region, and a CSE-shared target can appear in more than one +// selected query from the same dag_export run). `decision.id` is only +// unique within the one `dag_export` process invocation that produced it, +// never across independently-generated files — the viewer explicitly +// supports loading and selecting across several files at once — so the +// dedup key is `${query.sourceBatch}:${decision.id}`, not `decision.id` +// alone; two files that happen to reuse the same small integer id must +// never collide and silently drop one file's cost from the total. +// +// This aggregates explicit per-node `CostAnnotation`s already in the +// export; it never estimates a cost itself. Returns `null` when nothing in +// the selection carries a cost annotation, or when selected annotations +// disagree on unit (unit-incompatible aggregation is refused, not mixed). +function computeSelectionWorkloadCost(selected) { + const seenDecisions = new Set(); + let unit = null; + let baselineSum = 0; + let selectedSum = 0; + let any = false; + let unavailable = false; + for (const query of selected) { + const nodes = (query.post_graph && query.post_graph.nodes) || []; + for (const node of nodes) { + const decision = node.decision; + if (!decision) continue; + const dedupKey = `${query.sourceBatch}:${decision.id}`; + if (seenDecisions.has(dedupKey)) continue; + seenDecisions.add(dedupKey); + const baseline = decision.baseline_cost; + const selectedCost = decision.selected_cost; + if (!baseline || !selectedCost) continue; + if (unit === null) unit = baseline.unit; + if (baseline.unit !== unit || selectedCost.unit !== unit) return null; // unit-incompatible aggregation is rejected + any = true; + if (baseline.value === null || baseline.value === undefined || selectedCost.value === null || selectedCost.value === undefined) { + unavailable = true; + continue; + } + baselineSum += baseline.value; + selectedSum += selectedCost.value; + } + } + if (!any) return null; + if (unavailable) { + const missing = { value: null, unit, source: 'Unavailable' }; + return { baseline_cost: missing, selected_cost: missing, benefit: missing }; + } + const delta = baselineSum - selectedSum; + return { + baseline_cost: { value: baselineSum, unit, source: 'Modeled' }, + selected_cost: { value: selectedSum, unit, source: 'Modeled' }, + benefit: { + value: delta, + unit, + source: 'Modeled', + baseline: { kind: 'PreAsapRecomputation' }, + benefit_ratio: baselineSum > 0 ? delta / baselineSum : null, + }, + }; +} + function formatSchema(schema) { if (!schema || typeof schema !== 'object') return 'schema unavailable'; const fields = Array.isArray(schema.columns) ? schema.columns : schema.fields; @@ -637,6 +877,10 @@ function showEdgeDetail(edge) { const sourceNode = source.data('node') || {}; const targetNode = target.data('node') || {}; const edgeSchema = edge.data('schemaLabel') || formatSchema(sourceNode.schema); + const edgeCost = edge.data('edgeCost'); + const edgeCostHtml = edgeCost + ? `

Edge cost

${renderCostAnnotation('Materialization', edgeCost)}
` + : ''; detailSection.innerHTML = `

Selected edge

@@ -644,6 +888,7 @@ function showEdgeDetail(edge) {
From: ${escapeHtml(sourceNode.label || source.id())}
To: ${escapeHtml(targetNode.label || target.id())}
+ ${edgeCostHtml}

Schema carried by this edge

${escapeHtml(edgeSchema || '(schema unavailable)')}

How the schema is produced

@@ -663,7 +908,30 @@ function renderScopeSummary(selected) { })); const scope = selected.length === 1 ? 'Single query' : `Batch workload · ${selected.length} queries`; const strategyText = strategies.size ? `Winning strategies: ${Array.from(strategies).join(', ')}` : 'No selected replacements'; - scopePickerEl.innerHTML = `
View scope
${escapeHtml(scope)}${escapeHtml(strategyText)}
`; + // Single query: use the exporter's own precomputed `NamedGraph.workload_cost` + // directly. Multiple queries: no single precomputed field covers exactly + // this subset, so aggregate the explicit per-decision annotations already + // in the export (dedup by `decision.id`) — see + // computeSelectionWorkloadCost's own doc for why this is aggregation, not + // client-side cost estimation. + const costSummary = selected.length === 1 ? selected[0].workload_cost : computeSelectionWorkloadCost(selected); + const benefitValue = costSummary && costSummary.benefit && costSummary.benefit.value; + const outcomeLabel = typeof benefitValue !== 'number' + ? 'difference' + : benefitValue > 0 ? 'saved' : benefitValue < 0 ? 'additional cost' : 'no change'; + const costHtml = costSummary + ? `
+
Pre/post cost comparison
+
+
pre-ASAP baseline${escapeHtml(compactCost(costSummary.baseline_cost))}
+
+
post-ASAP selected${escapeHtml(compactCost(costSummary.selected_cost))}
+
${escapeHtml(outcomeLabel)}${escapeHtml(compactCost(costSummary.benefit))}${typeof costSummary.benefit?.benefit_ratio === 'number' ? ` (${(costSummary.benefit.benefit_ratio * 100).toFixed(1)}%)` : ''}
+
+
The same values are attached to the lane headers below. Green/red nodes show lower/higher replacement cost; highlighted edges carry inspectable materialization cost.
+
` + : ''; + scopePickerEl.innerHTML = `
View scope
${escapeHtml(scope)}${escapeHtml(strategyText)}
${costHtml}`; } function translationsForNode(query, node, stage) { @@ -730,6 +998,7 @@ function showPrePostDetail(data) {
${entry.role === 'replacement_root' ? 'This node replaces the pre-ASAP target.' : 'This node is generated or carried inside the replacement region.'}
${escapeHtml(entry.rationale || 'No rationale recorded.')}
${entry.target_pre_id === undefined ? `decision #${entry.id}` : `pre-ASAP target node #${entry.target_pre_id}`} · output ${escapeHtml(entry.output_kind || node.kind)}
+ ${renderDecisionCostBlock(entry)} `).join(''); translationHtml = `

Why this post-ASAP translation

${cards}
`; } else if (data.stage === 'post') { @@ -867,7 +1136,9 @@ document.getElementById('resetBtn').addEventListener('click', () => { zoom = 1; function loadWorkload(parsed) { const incoming = (parsed && parsed.queries) || []; - incoming.forEach((q) => queries.push({ name: q.name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary })); + // One batch id for this whole document — see `sourceBatch`'s own doc above. + const sourceBatch = nextSourceBatch++; + incoming.forEach((q) => queries.push({ name: q.name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph, workload_cost: q.workload_cost, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary, sourceBatch })); if (activeIndex === -1 && queries.length > 0) activeIndex = 0; if (participants.size === 0 && activeIndex >= 0) participants.add(activeIndex); }