From 7b65e693dfd2662e63bd7c3dc25e529349dfcdc2 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 10 Nov 2026 17:18:40 -0500 Subject: [PATCH 1/3] feat(devtools): asap_planner text output is terse by default; --verbose prints the details (#1261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename --printing-full-candidates to --verbose. - Each stage starts with a legend, one label per line. - Memo groups show `used by` / `reached by` (from `parents` / `queries`) and, from Pass 2, their evaluation grid; the column-properties line is verbose-only. - Pass 2 prints the stored `LogicalASAPMemoCandidates.edges` as producer ids by α^log; the recomputed per-producer reasons are verbose-only. - Stage 2 prints each logical candidate once with its physical candidates under it, `★` on the plan's, and per input the readable producers and the one the plan uses. - Stage 3's pruned list is counts per cause, as Pass 2's and Stage 2's. Co-Authored-By: Claude Opus 5.5 --- crates/devtools/src/bin/asap_planner.rs | 17 +- crates/devtools/src/planning_stages_text.rs | 378 ++++++++++++++---- crates/devtools/tests/asap_planner.rs | 85 +++- .../devtools/tests/asap_planner_acceptance.rs | 5 +- docs/user_guide_docs/asap-planner.md | 4 +- 5 files changed, 383 insertions(+), 106 deletions(-) diff --git a/crates/devtools/src/bin/asap_planner.rs b/crates/devtools/src/bin/asap_planner.rs index c7cf44720..c527319a6 100644 --- a/crates/devtools/src/bin/asap_planner.rs +++ b/crates/devtools/src/bin/asap_planner.rs @@ -9,10 +9,9 @@ // `stage3`) and writes each stage's output, as text (`--format text`, the default; // `asap_devtools::planning_stages_text`) or as an `asap-stage-pipeline/v2` // document for the Stage Viewer (`--format json`), to `--out` or standard -// output. The text folds each memo group's candidates that differ only in -// θ into one line (their indices, each θ parameter's values, how many Pass -// 2 pruned by cause), printing in full only those Pass 2 kept, and counts -// the pruned lists per cause; `--printing-full-candidates` prints every candidate, edge and +// output. The text is terse by default (a legend per stage, folded θ +// variants, producer ids per input, pruned counts per cause); `--verbose` +// prints every candidate, edge with its reason, group's properties and // pruned candidate. // // The stages run as `run_planning` runs them @@ -136,7 +135,7 @@ use asap_types::workload::{ use serde_json::{json, Value}; const USAGE: &str = "usage: asap_planner (--workload )... \ ---run-until stage0|stage1_pass1|stage1_pass2|stage2|stage3 [--format text|json] [--printing-full-candidates] [--out ] \ +--run-until stage0|stage1_pass1|stage1_pass2|stage2|stage3 [--format text|json] [--verbose] [--out ] \ [--sql-catalog-table ]... [--cost-inputs ] [--deployment-capabilities ] \ [--ignore-accuracy-guarantees]"; @@ -182,7 +181,7 @@ fn run(args: Vec) -> Result<(), Failure> { let mut cost_inputs = CostInputs::default(); let mut deployment_capabilities = DeploymentCapabilities::default(); let mut ignore_accuracy_guarantees = false; - let mut printing_full_candidates = false; + let mut verbose = false; let mut args = args.into_iter(); while let Some(flag) = args.next() { let mut value = || { @@ -206,7 +205,7 @@ fn run(args: Vec) -> Result<(), Failure> { "--cost-inputs" => cost_inputs = read_json(&flag, &value()?)?, "--deployment-capabilities" => deployment_capabilities = read_json(&flag, &value()?)?, "--ignore-accuracy-guarantees" => ignore_accuracy_guarantees = true, - "--printing-full-candidates" => printing_full_candidates = true, + "--verbose" => verbose = true, other => return Err(Failure::Usage(format!("unknown argument {other}"))), } } @@ -288,9 +287,7 @@ fn run(args: Vec) -> Result<(), Failure> { serde_json::to_string_pretty(&document).map_err(|e| e.to_string())? + "\n" } else { let mut text = match result { - Ok(run) => { - planning_stages_text(&run, evidence, plan_cost.as_ref(), printing_full_candidates) - } + Ok(run) => planning_stages_text(&run, evidence, plan_cost.as_ref(), verbose), Err(PlanningStagesError::Pass1(error)) => format!("error: Pass 1: {error}\n"), Err(error) => return Err(error.to_string().into()), }; diff --git a/crates/devtools/src/planning_stages_text.rs b/crates/devtools/src/planning_stages_text.rs index 6b3b7f747..8feaf4cdf 100644 --- a/crates/devtools/src/planning_stages_text.rs +++ b/crates/devtools/src/planning_stages_text.rs @@ -17,7 +17,8 @@ //! `requires` its required accuracy per input. An edge is `✓` when its //! layer's requirement holds, else `✗` with why; a logical edge's producer //! is any candidate of the input group (eliminated ones included), a -//! physical edge's a physical candidate whose logical edge holds: +//! physical edge's a physical candidate whose logical edge holds. With +//! `--verbose`: //! //! ```text //! G4: SummaryEstimate Quantile(q=0.99) <- G3 @@ -46,7 +47,8 @@ use asap_types::physical::PhysicalProperties; use asap_types::stages::{ CandidateForMemoGroup, CandidateForMemoGroupId, ColumnLineage, EvaluationGrid, LogicalASAPMemoCandidates, Memo, MemoGroup, MemoGroupId, PhysicalASAPMemoCandidates, - PhysicalCandidateId, PhysicalEdge, RequiredAccuracy, RootPruneReason, + PhysicalCandidate, PhysicalCandidateId, PhysicalEdge, PrunedCandidate, RequiredAccuracy, + RootPruneReason, }; use crate::candidate_labels::{operator_label, parameters_label}; @@ -58,16 +60,18 @@ use crate::planning_stages::{PlanningStagesRun, RunUntil}; /// `plan_cost` the selected plan's `asap_cost::plan_cost`, printed with /// the plan when given. A failure ends the text with its error. /// -/// Unless `printing_full_candidates`, the candidates of a memo group that differ only in θ -/// are folded ([`write_memo`]): one line gives their indices, each θ -/// parameter's values and how many Pass 2 pruned, by cause; those not -/// pruned are still printed in full with their edges. The pruned lists of -/// Pass 2 and Stage 2 are then counts per cause. +/// Each section starts with a legend of the labels it prints. Unless +/// `verbose`: the candidates of a memo group that differ only in θ are +/// folded ([`write_memo`]); groups omit their properties line; Pass 2's +/// edges are the stored ones as producer ids by $\alpha^{\log}$, without +/// reasons; Stage 2 is printed per logical candidate +/// ([`write_physical_by_logical_candidate`]); and the pruned lists are +/// counts per cause. pub fn planning_stages_text( stages: &PlanningStagesRun, evidence: &dyn AccuracyEvidence, plan_cost: Option<&PlanCost>, - printing_full_candidates: bool, + verbose: bool, ) -> String { let mut out = String::new(); let run = &stages.run; @@ -82,14 +86,16 @@ pub fn planning_stages_text( section( &mut out, "Stage 0 output: the frontends' roots (Vec)", + &["n = node k", "<- = the nodes it reads"], ); write_dag(&mut out, &stages.stage0_query_language_frontends); section( &mut out, "Stage 0 → Stage 1: Memo (the roots hash-consed, without candidates)", + MEMO_LEGEND, ); if let Some(memo) = &stages.stage0_memo_groups { - write_memo(&mut out, memo, None, None, printing_full_candidates); + write_memo(&mut out, memo, None, None, verbose); } if until == RunUntil::Stage0 { return out; @@ -99,14 +105,12 @@ pub fn planning_stages_text( failure(&mut out); return out; }; - section(&mut out, "Stage 1 Pass 1 output: Memo"); - write_memo( + section( &mut out, - pass1, - Some(MemoStep::Pass1), - None, - printing_full_candidates, + "Stage 1 Pass 1 output: Memo", + &[MEMO_LEGEND, CANDIDATE_LEGEND].concat(), ); + write_memo(&mut out, pass1, Some(MemoStep::Pass1), None, verbose); if until == RunUntil::Pass1 { return out; } @@ -118,15 +122,16 @@ pub fn planning_stages_text( section( &mut out, "Stage 1 Pass 2 output = Stage 2 input: LogicalASAPMemoCandidates", + &[MEMO_LEGEND, CANDIDATE_LEGEND, PASS2_LEGEND].concat(), ); write_memo( &mut out, &logical.memo, Some(MemoStep::Pass2 { pass1, logical }), Some(evidence), - printing_full_candidates, + verbose, ); - write_pass2_pruned(&mut out, logical, printing_full_candidates); + write_pass2_pruned(&mut out, logical, verbose); if until == RunUntil::Pass2 { return out; } @@ -138,20 +143,65 @@ pub fn planning_stages_text( section( &mut out, "Stage 2 output = Stage 3 input: PhysicalASAPMemoCandidates", + STAGE2_LEGEND, ); let plan = run.selection.as_ref().ok().map(|(plan, _)| plan); - write_physical(&mut out, physical, plan, printing_full_candidates); + write_physical(&mut out, physical, plan, verbose); if until == RunUntil::Stage2 { return out; } - section(&mut out, "Stage 3 output: PhysicalASAPDAG and Selection"); - write_selection(&mut out, stages, plan_cost); + section( + &mut out, + "Stage 3 output: PhysicalASAPDAG and Selection", + &[ + "n = plan node k", + "C = the physical candidate it runs", + "<- = the nodes it reads", + ], + ); + write_selection(&mut out, stages, plan_cost, verbose); out } -fn section(out: &mut String, title: &str) { +const MEMO_LEGEND: &[&str] = &[ + "G = memo group n", + "<- = the groups it reads", + "used by = the groups that read it", + "reached by = the queries whose roots reach it", + "every

ms = the grid its result is evaluated on (from Pass 2)", +]; +const CANDIDATE_LEGEND: &[&str] = &[ + "[i] = candidate i, then the rule that added it", + "exact = no sketch", + "θ = sketch parameters", + "[a..b] = candidates that differ only in θ, folded (--verbose lists each)", +]; +const PASS2_LEGEND: &[&str] = &[ + "new = added by Pass 2", + "pruned = eliminated by Pass 2", + "requires = the accuracy required per input", + "input i <- G: ✓ / ✗ = the producers in G that do / do not meet the input's requirement (--verbose: every producer, with why ✗)", +]; +const STAGE2_LEGEND: &[&str] = &[ + "G[] = a logical candidate", + "C = one of its physical candidates", + "(timing, lifetime) = when C runs, how long its result is kept", + "reads = the producers C can read on that input (--verbose: also those it cannot, and why)", + "★ = in Stage 3's plan", + "uses = the producer the plan reads", +]; + +/// A section header, then `legend` (the labels the section prints). +fn section(out: &mut String, title: &str, legend: &[&str]) { let _ = writeln!(out, "\n==================== {title} ===================="); + for (index, line) in legend.iter().enumerate() { + let _ = writeln!( + out, + "{}{line}", + if index == 0 { "legend: " } else { " " } + ); + } } /// One line per node of the DAG of `roots` (children before parents), then @@ -250,20 +300,20 @@ fn group_properties_text(group: &MemoGroup, grid: Option true => " · reads only time series", false => " · reads a table", }; - match grid { - Some(Some(grid)) => { - let _ = write!( - text, - " · evaluated every {} ms at {}", - grid.period_ms, grid.phase_ms - ); - } - Some(None) => text += " · no known evaluation grid", - None => {} + if let Some(grid) = grid { + let _ = write!(text, " · {}", grid_text(grid)); } text } +/// `evaluated every 60000 ms at 0`, or `no known evaluation grid`. +fn grid_text(grid: Option<&EvaluationGrid>) -> String { + match grid { + Some(grid) => format!("evaluated every {} ms at {}", grid.period_ms, grid.phase_ms), + None => "no known evaluation grid".to_owned(), + } +} + /// `<- a, b`, or nothing when there is nothing read. fn reads(inputs: &[String]) -> String { if inputs.is_empty() { @@ -293,7 +343,7 @@ enum MemoStep<'a> { /// required accuracy per input; at Pass 2, under each candidate, the /// logical edges into each input with $\alpha^{\log}$ (from `evidence`). /// -/// Unless `printing_full_candidates`, candidates that differ only in θ (same origin, `new`, +/// Unless `verbose`, candidates that differ only in θ (same origin, `new`, /// operator, fused groups and requirements) are folded when there are /// several: at Pass 1 into one line, at Pass 2 when Pass 2 pruned some of /// them, into one line followed by those it kept, in full; their edges @@ -314,10 +364,14 @@ fn write_memo( memo: &Memo, step: Option>, evidence: Option<&dyn AccuracyEvidence>, - printing_full_candidates: bool, + verbose: bool, ) { // The group lines share one set of column widths, with each group's // candidates printed under its line. + let (earlier, logical) = match step { + Some(MemoStep::Pass2 { pass1, logical }) => (Some(pass1), Some(logical)), + _ => (None, None), + }; let group_rows: Vec> = memo .groups() .iter() @@ -329,19 +383,42 @@ fn write_memo( .into_iter() .map(|child| format!("G{}", child.0)) .collect(); + let mut context = Vec::new(); + if !group.parent_groups().is_empty() { + let parents: Vec = group + .parent_groups() + .iter() + .map(|g| format!("G{}", g.0)) + .collect(); + context.push(format!("used by {}", parents.join(", "))); + } + if !group.reached_by_queries().is_empty() { + let queries: Vec = group + .reached_by_queries() + .iter() + .map(|query| format!("q{}", query + 1)) + .collect(); + context.push(format!("reached by {}", queries.join(", "))); + } + // Without `verbose` the properties line is not printed, but the + // grid is: Pass 2 derives it, so it is new at that step. + if let (false, Some(logical)) = (verbose, logical) { + context.push(match &logical.evaluation_grid_of_group[id] { + Some(grid) if grid.phase_ms == 0 => format!("every {} ms", grid.period_ms), + Some(grid) => format!("every {} ms at {}", grid.period_ms, grid.phase_ms), + None => "no known grid".to_owned(), + }); + } vec![ format!("G{id}:"), operator_label(&group.expression), reads(&children), + context.join(" · "), ] }) .collect(); let mut group_lines = String::new(); write_rows(&mut group_lines, "", group_rows); - let (earlier, logical) = match step { - Some(MemoStep::Pass2 { pass1, logical }) => (Some(pass1), Some(logical)), - _ => (None, None), - }; // The metric rule's accepted metrics of Pass 2's memo, once. let accepted = logical.map(|logical| accepted_metrics(&logical.memo)); let pruned: BTreeMap = logical @@ -361,12 +438,14 @@ fn write_memo( .unwrap_or_default(); for (id, line) in group_lines.lines().enumerate() { let _ = writeln!(out, "{line}"); - let grid = logical.map(|logical| logical.evaluation_grid_of_group[id].as_ref()); - let _ = writeln!( - out, - " {}", - group_properties_text(&memo.groups()[id], grid) - ); + if verbose { + let grid = logical.map(|logical| logical.evaluation_grid_of_group[id].as_ref()); + let _ = writeln!( + out, + " {}", + group_properties_text(&memo.groups()[id], grid) + ); + } if step.is_none() { continue; } @@ -388,7 +467,7 @@ fn write_memo( text, ] }; - // The candidates printed in full, in index order; unless `printing_full_candidates`, + // The candidates printed in full, in index order; unless `verbose`, // each class of candidates differing only in θ that is folded // prints its line where its first candidate is. let mut classes: Vec<(String, Vec)> = Vec::new(); @@ -410,9 +489,7 @@ fn write_memo( .iter() .filter(|&&index| pruned.contains_key(&id_of(index))) .count(); - let folded = !printing_full_candidates - && members.len() > 1 - && (logical.is_none() || pruned_members > 0); + let folded = !verbose && members.len() > 1 && (logical.is_none() || pruned_members > 0); if !folded { printed.extend(members.iter().map(|&index| (index, None))); continue; @@ -472,17 +549,12 @@ fn write_memo( ); cells.insert(3, flag(pruned.contains_key(&candidate_id), "pruned")); write_rows(out, " ", vec![cells]); - if let (Some(logical), Some(accepted), Some(evidence)) = (logical, &accepted, evidence) - { - let folded_producers = (!printing_full_candidates).then_some(&pruned); - write_logical_edges( - out, - &logical.memo, - accepted, - candidate_id, - evidence, - folded_producers, - ); + match (logical, &accepted, evidence) { + (Some(logical), Some(accepted), Some(evidence)) if verbose => { + write_logical_edges(out, &logical.memo, accepted, candidate_id, evidence, None) + } + (Some(logical), ..) => write_stored_logical_edges(out, logical, candidate_id), + _ => {} } } } @@ -497,6 +569,46 @@ fn write_memo( let _ = writeln!(out, "query_roots: [{}]", roots.join(", ")); } +/// The logical edges Pass 2 stored into each input of `consumer` +/// (`LogicalASAPMemoCandidates::edges`: between surviving candidates +/// only), as producer indices by $\alpha^{\log}$: +/// `input 0 <- G7: ✓ G7[0..11] ✗ G7[12]`. +fn write_stored_logical_edges( + out: &mut String, + logical: &LogicalASAPMemoCandidates, + consumer: CandidateForMemoGroupId, +) { + let groups = logical + .memo + .candidate(consumer) + .expression + .operator + .children(); + for (input, group) in groups.into_iter().enumerate() { + let (mut satisfied, mut failed) = (Vec::new(), Vec::new()); + // ponytail: scans every edge per input; index by (consumer, input) if large memos print slowly. + for edge in logical.edges.iter().filter(|edge| { + edge.consumer_candidate_id == consumer && edge.input_index_of_consumer == input + }) { + let index = edge.producer_candidate_id.index_in_group; + match edge.satisfies_logical_requirement { + true => satisfied.push(index), + false => failed.push(index), + } + } + let mut line = format!(" input {input} <- G{}:", group.0); + for (mark, indices) in [("✓", satisfied), ("✗", failed)] { + if !indices.is_empty() { + let _ = write!(line, " {mark} G{}[{}]", group.0, index_ranges(&indices)); + } + } + if line.ends_with(':') { + line += " no surviving producer"; + } + let _ = writeln!(out, "{line}"); + } +} + /// The logical edges into each input of `consumer`: each producer of the /// input group with `✓`, or `✗` and why ([`logical_edges_into`]). With /// `folded` (Pass 2's pruned candidates), the edges from those and from @@ -785,15 +897,11 @@ pub fn root_prune_reason_text(reason: &RootPruneReason) -> String { /// The candidates Pass 2 eliminated, each with its θ and one line per /// cause: the failing edge (`at G4[0] input 0`) or none, the eliminated -/// consumers the cause comes through, and why. Unless `printing_full_candidates`, per group +/// consumers the cause comes through, and why. Unless `verbose`, per group /// the count of its pruned candidates by their first cause. -fn write_pass2_pruned( - out: &mut String, - logical: &LogicalASAPMemoCandidates, - printing_full_candidates: bool, -) { +fn write_pass2_pruned(out: &mut String, logical: &LogicalASAPMemoCandidates, verbose: bool) { let _ = writeln!(out, "pruned by Pass 2 ({}):", logical.pruned.len()); - if !printing_full_candidates { + if !verbose { let mut causes: BTreeMap> = BTreeMap::new(); for pruned in &logical.pruned { causes.entry(pruned.candidate.group).or_default().push( @@ -945,12 +1053,12 @@ fn physical_candidate_text( /// candidate and $p^{\mathrm{phys}}_v$; under it, per input $(c, i)$, the /// physical edges whose logical edge holds, with $\alpha^{\mathrm{phys}}$ /// (`✓`, or `✗` and why not) and `selected` for an edge of `plan`; then the -/// candidates Stage 2 pruned (unless `printing_full_candidates`, their count per reason). +/// candidates Stage 2 pruned (unless `verbose`, their count per reason). fn write_physical( out: &mut String, physical: &PhysicalASAPMemoCandidates, plan: Option<&PhysicalASAPDAG>, - printing_full_candidates: bool, + verbose: bool, ) { let memo = &physical.logical_candidates.memo; let selected = selected_edges(plan); @@ -959,8 +1067,16 @@ fn write_physical( "physical candidates ({}):", physical.physical_candidates.len() ); + if !verbose { + write_physical_by_logical_candidate(out, physical, plan, &selected); + } let edges_into = edges_per_input(physical); - for (id, candidate) in physical.physical_candidates.iter().enumerate() { + for (id, candidate) in physical + .physical_candidates + .iter() + .enumerate() + .filter(|_| verbose) + { let operator = &memo .candidate(candidate.logical_candidate_id) .expression @@ -1010,14 +1126,23 @@ fn write_physical( "pruned by Stage 2 ({}):", physical.pruned_candidates.len() ); - if !printing_full_candidates { - let reasons: Vec = physical - .pruned_candidates + write_physical_pruned(out, &physical.pruned_candidates, verbose); +} + +/// Physical candidates Stage 2 or Stage 3 pruned: their count per reason, +/// or with `verbose` each with its logical candidate, properties and reason. +fn write_physical_pruned( + out: &mut String, + pruned: &[PrunedCandidate], + verbose: bool, +) { + if !verbose { + let reasons: Vec = pruned .iter() .map(|pruned| { let reason = format!("{:?}", pruned.reason); reason - .split(['(', ' ']) + .split(['(', ' ', '{']) .next() .unwrap_or_default() .to_owned() @@ -1032,8 +1157,7 @@ fn write_physical( } return; } - let rows = physical - .pruned_candidates + let rows = pruned .iter() .map(|pruned| { vec![ @@ -1046,11 +1170,102 @@ fn write_physical( write_rows(out, " ", rows); } +/// Stage 2's output without `verbose`: each logical candidate once, its +/// physical candidates under it (`★` when in `plan`), and per input the +/// producers it can read ($\alpha^{\log} = \alpha^{\mathrm{phys}} = 1$) and +/// the one the plan reads: +/// +/// ```text +/// G1[0] TimeRange 300s <- G0 exact +/// C4 (QueryTime, None) ★ +/// input 0 <- G0: reads C0..C1, C3 · uses C0 +/// ``` +fn write_physical_by_logical_candidate( + out: &mut String, + physical: &PhysicalASAPMemoCandidates, + plan: Option<&PhysicalASAPDAG>, + selected: &BTreeSet<(PhysicalCandidateId, PhysicalCandidateId, usize)>, +) { + let memo = &physical.logical_candidates.memo; + let in_plan: BTreeSet = plan + .into_iter() + .flat_map(|plan| &plan.nodes) + .filter_map(|node| node.selected_physical_candidate) + .collect(); + let mut previous = None; + for (index, candidate) in physical.physical_candidates.iter().enumerate() { + let id = PhysicalCandidateId(index); + let logical = memo.candidate(candidate.logical_candidate_id); + if previous != Some(candidate.logical_candidate_id) { + let _ = writeln!( + out, + "{} {}", + candidate_id_text(candidate.logical_candidate_id), + candidate_text(logical, true) + ); + previous = Some(candidate.logical_candidate_id); + } + let star = if in_plan.contains(&id) { " ★" } else { "" }; + let _ = writeln!( + out, + " C{index} {}{star}", + physical_properties_text(&candidate.physical_properties) + ); + for (input, group) in logical + .expression + .operator + .children() + .into_iter() + .enumerate() + { + let producers: Vec = physical + .admissible_edges_into(id, input) + .iter() + .map(|&edge| physical.edge(edge).producer_candidate_id.0) + .collect(); + let mut line = match producers.is_empty() { + true => format!(" input {input} <- G{}: reads nothing", group.0), + false => format!( + " input {input} <- G{}: reads {}", + group.0, + physical_ranges(&producers) + ), + }; + if let Some(&(producer, ..)) = selected + .iter() + .find(|&&(_, consumer, selected_input)| consumer == id && selected_input == input) + { + let _ = write!(line, " · uses C{}", producer.0); + } + let _ = writeln!(out, "{line}"); + } + } +} + +/// `C0..C1, C3`: sorted physical candidate ids as ranges. +fn physical_ranges(ids: &[usize]) -> String { + let mut ids = ids.to_vec(); + ids.sort_unstable(); + index_ranges(&ids) + .split(", ") + .map(|range| match range.split_once("..") { + Some((start, end)) => format!("C{start}..C{end}"), + None => format!("C{range}"), + }) + .collect::>() + .join(", ") +} + /// Stage 3's plan, each node with its physical candidate, operator and /// input nodes; the plan's cost (`plan_cost`) with each node's charge; /// each query's accepted accuracy; the selection MILP's size; and the /// candidates Stage 3 pruned. Or why no plan was selected. -fn write_selection(out: &mut String, stages: &PlanningStagesRun, plan_cost: Option<&PlanCost>) { +fn write_selection( + out: &mut String, + stages: &PlanningStagesRun, + plan_cost: Option<&PlanCost>, + verbose: bool, +) { let run = &stages.run; let (plan, selection) = match &run.selection { Ok(selected) => selected, @@ -1135,16 +1350,5 @@ fn write_selection(out: &mut String, stages: &PlanningStagesRun, plan_cost: Opti "pruned by Stage 3 ({}):", selection.pruned_candidates.len() ); - let rows = selection - .pruned_candidates - .iter() - .map(|pruned| { - vec![ - candidate_id_text(pruned.candidate.logical_candidate_id), - physical_properties_text(&pruned.candidate.physical_properties), - format!("{:?}", pruned.reason), - ] - }) - .collect(); - write_rows(out, " ", rows); + write_physical_pruned(out, &selection.pruned_candidates, verbose); } diff --git a/crates/devtools/tests/asap_planner.rs b/crates/devtools/tests/asap_planner.rs index 75db77451..b09680d68 100644 --- a/crates/devtools/tests/asap_planner.rs +++ b/crates/devtools/tests/asap_planner.rs @@ -1762,7 +1762,7 @@ fn cost_input_files_match_their_presets() { /// its θ and a readout its required accuracy; Pass 2's and Stage 2's /// pruned candidates are listed with their causes; every `G` named /// resolves to a printed group. Workload: two windowed quantiles every -/// 10 s within 1 % (the shared-scan workload), printed with `--printing-full-candidates`. +/// 10 s within 1 % (the shared-scan workload), printed with `--verbose`. #[test] fn the_text_memo_after_pass_2_prints_every_group_with_its_properties() { let workload = two_windowed_quantiles_within_1_percent(); @@ -1774,7 +1774,7 @@ fn the_text_memo_after_pass_2_prints_every_group_with_its_properties() { "stage3", "--format", "text", - "--printing-full-candidates", + "--verbose", ]); assert!( output.status.success(), @@ -1873,11 +1873,11 @@ fn two_windowed_quantiles_within_1_percent() -> serde_json::Value { }) } -/// Without `--printing-full-candidates` the text folds the candidates of a group that differ +/// Without `--verbose` the text folds the candidates of a group that differ /// only in θ: a line with their indices (`[0..39]`), one with each θ /// parameter's values (`θ: k ∈ {...}`) and one with how many Pass 2 pruned /// by cause and kept, whose counts add up; the candidates Pass 2 kept are -/// still printed with their θ. The folded text is shorter than `--printing-full-candidates`. +/// still printed with their θ. The folded text is shorter than `--verbose`. #[test] fn the_text_folds_candidates_that_differ_only_in_theta() { let file = TempFile::json(&two_windowed_quantiles_within_1_percent()); @@ -1899,7 +1899,7 @@ fn the_text_folds_candidates_that_differ_only_in_theta() { ); String::from_utf8(output.stdout).unwrap() }; - let (folded, printed_in_full) = (text(&[]), text(&["--printing-full-candidates"])); + let (folded, printed_in_full) = (text(&[]), text(&["--verbose"])); assert!(folded.lines().count() < printed_in_full.lines().count()); let pass2 = folded .split("==================== Stage 1 Pass 2 output") @@ -1938,3 +1938,78 @@ fn the_text_folds_candidates_that_differ_only_in_theta() { } } } + +/// Without `--verbose` the text leaves out what `--verbose` adds: no +/// group-properties line, no per-producer edge rows, no `α^` rejection +/// reasons. Every section starts with a legend; each non-root memo group +/// names the groups that read it and every group the queries reaching it; +/// Stage 2 prints each physical candidate once, under its logical +/// candidate, with `★` on exactly the plan's candidates. +#[test] +fn the_default_text_is_the_terse_form_and_verbose_adds_the_details() { + let file = TempFile::json(&two_windowed_quantiles_within_1_percent()); + let text = |extra: &[&str]| { + let mut args = vec!["--workload", file.path(), "--run-until", "stage3"]; + args.extend(extra); + let output = planner(&args); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() + }; + let (terse, verbose) = (text(&[]), text(&["--verbose"])); + for detail in ["reads only time series", "α^log", "α^phys"] { + assert!(!terse.contains(detail), "terse text has no {detail:?}"); + assert!(verbose.contains(detail), "verbose text has {detail:?}"); + } + let sections: Vec<&str> = terse.split("====================\n").skip(1).collect(); + assert_eq!(sections.len(), 6, "one per stage boundary"); + for section in §ions { + assert!(section.starts_with("legend: "), "{section}"); + } + let pass2 = sections[3]; + for line in pass2.lines().filter(|line| line.starts_with('G')) { + assert!(line.contains("reached by q"), "{line}"); + } + assert!(pass2 + .lines() + .any(|line| line.starts_with('G') && line.contains("used by G"))); + + let stage2 = sections[4]; + let candidates: Vec<&str> = stage2 + .lines() + .filter(|line| line.starts_with(" C")) + .collect(); + let count: usize = stage2 + .lines() + .find_map(|line| line.strip_prefix("physical candidates (")) + .and_then(|rest| rest.strip_suffix("):")) + .unwrap() + .parse() + .unwrap(); + assert_eq!(candidates.len(), count, "each physical candidate once"); + for (index, line) in candidates.iter().enumerate() { + assert!( + line.starts_with(&format!(" C{index} ")), + "in id order: {line}" + ); + } + let starred: Vec = candidates + .iter() + .filter(|line| line.ends_with('★')) + .map(|line| line.split_whitespace().next().unwrap().to_owned()) + .collect(); + let plan_nodes = sections[5] + .lines() + .filter(|line| line.starts_with('n')) + .count(); + assert_eq!(starred.len(), plan_nodes, "★ on the plan's candidates"); + for candidate in &starred { + assert!( + sections[5].contains(&format!(" {candidate} = ")), + "{candidate} is a plan node's candidate" + ); + } +} diff --git a/crates/devtools/tests/asap_planner_acceptance.rs b/crates/devtools/tests/asap_planner_acceptance.rs index e3aecf2af..5e4d091d5 100644 --- a/crates/devtools/tests/asap_planner_acceptance.rs +++ b/crates/devtools/tests/asap_planner_acceptance.rs @@ -134,7 +134,8 @@ fn pass2_holds_tumbling_windows_at_the_gcd_of_ranges_and_steps() { } /// Stage 3 selects among the candidates Stage 2 lists: every candidate a -/// plan node runs (`n: C = ...`) is a line of Stage 2's output. +/// plan node runs (`n: C = ...`) is a line of Stage 2's output +/// (` C (timing, lifetime)`). #[test] fn stage3_selects_candidates_stage2_lists() { let text = stdout(planner(&rate_workload(), &["--run-until", "stage3"])); @@ -149,7 +150,7 @@ fn stage3_selects_candidates_stage2_lists() { assert!(!selected.is_empty(), "{stage3}"); for candidate in selected { assert!( - stage2.contains(&format!("\n {candidate} = ")), + stage2.contains(&format!("\n {candidate} (")), "{candidate} is not in Stage 2's output" ); } diff --git a/docs/user_guide_docs/asap-planner.md b/docs/user_guide_docs/asap-planner.md index 5e52ec364..1eb1e2234 100644 --- a/docs/user_guide_docs/asap-planner.md +++ b/docs/user_guide_docs/asap-planner.md @@ -34,7 +34,7 @@ minutes in a debug build. ```text asap_planner (--workload )... --run-until stage0|stage1_pass1|stage1_pass2|stage2|stage3 - [--format text|json] [--printing-full-candidates] [--out ] [--sql-catalog-table ]... + [--format text|json] [--verbose] [--out ] [--sql-catalog-table ]... [--cost-inputs ] [--deployment-capabilities ] [--ignore-accuracy-guarantees] ``` @@ -44,7 +44,7 @@ asap_planner (--workload )... --run-until stage0|stage1_pass1|stage1_pass2 | `--workload ` | A `PlanningWorkload` JSON file: the queries, their requirements and recurrence, and the data-source facts. Repeat it to plan several files together, including PromQL and SQL files; sources they share must have the same facts. Required. | | `--run-until ` | The last stage to run. Every stage up to and including it is printed. Required. | | `--format text\|json` | `text` (the default) for reading; `json` for an `asap-stage-pipeline/v2` document. | -| `--printing-full-candidates` | Text only: print every candidate, edge and pruned candidate. By default the candidates of a memo group that differ only in θ are folded into one line (their indices, each θ parameter's values, and how many Pass 2 pruned, by cause), the candidates Pass 2 kept are printed in full with their edges, and the pruned lists of Pass 2 and Stage 2 are counts per cause. | +| `--verbose` | Text only: print every candidate, edge and pruned candidate in full. By default each stage starts with a legend; memo groups show the groups that read them, the queries that reach them and (from Pass 2) their evaluation grid, without their column properties; candidates that differ only in θ are folded into one line; each input lists its producers' ids by whether they meet its requirement, without the reasons; Stage 2 lists each logical candidate once with its physical candidates under it (`★` marks the selected plan's); and the pruned lists are counts per cause. | | `--out ` | Write to this file instead of standard output. | | `--sql-catalog-table ` | One SQL table a SQL workload reads, as `{"name", "columns": [{"name", "type", "nullable"}], "time_index"}`. Repeat it per table. | | `--cost-inputs ` | The cost model and its costs (`CostInputs` JSON). The files in `tools/dag-viewer/cost-inputs/` are ready to use. Without it, the built-in usage-over-time model is used. Stage 3 costs only usage-over-time models (`aws-fargate-us-east-1.json` is one); a provisioned- or cluster-capacity model ends Stage 3 with an `UnsupportedCostModel` error. | From f0d4764a80cad04facb6e8212d1800daf267aef2 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 10 Nov 2026 18:15:51 -0500 Subject: [PATCH 2/3] fix(devtools): address #1276 review of asap_planner's terse text output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A pruned Pass 2 candidate lists no inputs (Pass 2 keeps no edges into it) instead of "no surviving producer". - Each terse input line counts the input group's candidates with no edge (`· N others`); the legend says which producers are listed. - Pass 2's and Stage 2's legends describe the rows `--verbose` prints. - Stage 2 groups physical candidates by logical candidate explicitly. - Stored edges are bucketed by (consumer, input) once; the recomputed edges' inputs and Stage 2's per-input edges are built only for `--verbose`. - One grid wording in both modes; `write_logical_edges` loses its dead `folded` parameter. - The terse-text test compares `★` with the set of candidates the plan's nodes run, and checks that pruned candidates list no inputs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq6eHKg6PF5nMxwGhWG --- crates/devtools/src/planning_stages_text.rs | 285 +++++++++++--------- crates/devtools/tests/asap_planner.rs | 47 ++-- 2 files changed, 182 insertions(+), 150 deletions(-) diff --git a/crates/devtools/src/planning_stages_text.rs b/crates/devtools/src/planning_stages_text.rs index 8feaf4cdf..bba67c6a1 100644 --- a/crates/devtools/src/planning_stages_text.rs +++ b/crates/devtools/src/planning_stages_text.rs @@ -46,9 +46,9 @@ use asap_types::ir::QueryRoot; use asap_types::physical::PhysicalProperties; use asap_types::stages::{ CandidateForMemoGroup, CandidateForMemoGroupId, ColumnLineage, EvaluationGrid, - LogicalASAPMemoCandidates, Memo, MemoGroup, MemoGroupId, PhysicalASAPMemoCandidates, - PhysicalCandidate, PhysicalCandidateId, PhysicalEdge, PrunedCandidate, RequiredAccuracy, - RootPruneReason, + LogicalASAPMemoCandidates, LogicalEdge, Memo, MemoGroup, MemoGroupId, + PhysicalASAPMemoCandidates, PhysicalCandidate, PhysicalCandidateId, PhysicalEdge, + PrunedCandidate, RequiredAccuracy, RootPruneReason, }; use crate::candidate_labels::{operator_label, parameters_label}; @@ -122,7 +122,17 @@ pub fn planning_stages_text( section( &mut out, "Stage 1 Pass 2 output = Stage 2 input: LogicalASAPMemoCandidates", - &[MEMO_LEGEND, CANDIDATE_LEGEND, PASS2_LEGEND].concat(), + &[ + MEMO_LEGEND, + CANDIDATE_LEGEND, + PASS2_LEGEND, + if verbose { + PASS2_VERBOSE_EDGES_LEGEND + } else { + PASS2_EDGES_LEGEND + }, + ] + .concat(), ); write_memo( &mut out, @@ -143,7 +153,11 @@ pub fn planning_stages_text( section( &mut out, "Stage 2 output = Stage 3 input: PhysicalASAPMemoCandidates", - STAGE2_LEGEND, + if verbose { + STAGE2_VERBOSE_LEGEND + } else { + STAGE2_LEGEND + }, ); let plan = run.selection.as_ref().ok().map(|(plan, _)| plan); write_physical(&mut out, physical, plan, verbose); @@ -181,7 +195,15 @@ const PASS2_LEGEND: &[&str] = &[ "new = added by Pass 2", "pruned = eliminated by Pass 2", "requires = the accuracy required per input", - "input i <- G: ✓ / ✗ = the producers in G that do / do not meet the input's requirement (--verbose: every producer, with why ✗)", +]; +const PASS2_EDGES_LEGEND: &[&str] = &[ + "input i <- G: ✓ / ✗ = the edges Pass 2 kept, from producers in G that do / do not meet the input's requirement", + "N other(s) = candidates of G with no edge: pruned, or of another θ (--verbose lists each, with why)", + "a pruned candidate lists no inputs: Pass 2 keeps no edges into it", +]; +const PASS2_VERBOSE_EDGES_LEGEND: &[&str] = &[ + "input i <- G: one row per candidate of G, pruned ones included:", + " ✓ = meets the input's requirement · ✗ = does not, and why · no edge = another θ", ]; const STAGE2_LEGEND: &[&str] = &[ "G[] = a logical candidate", @@ -191,6 +213,12 @@ const STAGE2_LEGEND: &[&str] = &[ "★ = in Stage 3's plan", "uses = the producer the plan reads", ]; +const STAGE2_VERBOSE_LEGEND: &[&str] = &[ + "C = G[] (timing, lifetime) = physical candidate n: a logical candidate, when it runs and how long its result is kept", + "input i <- G: one row per producer whose logical edge holds:", + " ✓ = the consumer can read it · ✗ = it cannot, and why", + "selected = the edge Stage 3's plan uses", +]; /// A section header, then `legend` (the labels the section prints). fn section(out: &mut String, title: &str, legend: &[&str]) { @@ -306,10 +334,11 @@ fn group_properties_text(group: &MemoGroup, grid: Option text } -/// `evaluated every 60000 ms at 0`, or `no known evaluation grid`. +/// `every 60000 ms`, `every 60000 ms at 15000`, or `no known evaluation grid`. fn grid_text(grid: Option<&EvaluationGrid>) -> String { match grid { - Some(grid) => format!("evaluated every {} ms at {}", grid.period_ms, grid.phase_ms), + Some(grid) if grid.phase_ms == 0 => format!("every {} ms", grid.period_ms), + Some(grid) => format!("every {} ms at {}", grid.period_ms, grid.phase_ms), None => "no known evaluation grid".to_owned(), } } @@ -346,9 +375,9 @@ enum MemoStep<'a> { /// Unless `verbose`, candidates that differ only in θ (same origin, `new`, /// operator, fused groups and requirements) are folded when there are /// several: at Pass 1 into one line, at Pass 2 when Pass 2 pruned some of -/// them, into one line followed by those it kept, in full; their edges -/// from pruned producers and producers of another θ are counted, not -/// listed: +/// them, into one line followed by those it kept, in full. Unless +/// `verbose`, each kept candidate's inputs are the edges Pass 2 stored +/// ([`write_stored_logical_edges`]), and a pruned one lists none: /// /// ```text /// G9: SummaryAgg CmsWithHeap <- G1 @@ -356,8 +385,7 @@ enum MemoStep<'a> { /// θ: depth ∈ {5, 7, 10, 12}, heap_size ∈ {10..10000} (7 values), width ∈ {28..2719} (10 values) /// 280 candidates: 278 pruned (BelowRequiredAccuracy 270, MetricRule 8), 2 kept /// [57] Pass1 SummaryAgg CmsWithHeap <- G1 θ=CmsWithHeap(depth=7, heap_size=10, width=544) fuses=[G4] -/// input 0 <- G1: -/// G1[0] ✓ +/// input 0 <- G1: ✓ G1[0] /// ``` fn write_memo( out: &mut String, @@ -403,11 +431,7 @@ fn write_memo( // Without `verbose` the properties line is not printed, but the // grid is: Pass 2 derives it, so it is new at that step. if let (false, Some(logical)) = (verbose, logical) { - context.push(match &logical.evaluation_grid_of_group[id] { - Some(grid) if grid.phase_ms == 0 => format!("every {} ms", grid.period_ms), - Some(grid) => format!("every {} ms at {}", grid.period_ms, grid.phase_ms), - None => "no known grid".to_owned(), - }); + context.push(grid_text(logical.evaluation_grid_of_group[id].as_ref())); } vec![ format!("G{id}:"), @@ -419,8 +443,24 @@ fn write_memo( .collect(); let mut group_lines = String::new(); write_rows(&mut group_lines, "", group_rows); - // The metric rule's accepted metrics of Pass 2's memo, once. - let accepted = logical.map(|logical| accepted_metrics(&logical.memo)); + // Only `verbose` recomputes the logical edges, from the metric rule's + // accepted metrics; otherwise the stored edges are printed, by + // (consumer, input). + let accepted = logical + .filter(|_| verbose) + .map(|logical| accepted_metrics(&logical.memo)); + let mut stored_edges: BTreeMap<(CandidateForMemoGroupId, usize), Vec<&LogicalEdge>> = + BTreeMap::new(); + for edge in logical + .filter(|_| !verbose) + .iter() + .flat_map(|logical| &logical.edges) + { + stored_edges + .entry((edge.consumer_candidate_id, edge.input_index_of_consumer)) + .or_default() + .push(edge); + } let pruned: BTreeMap = logical .map(|logical| { logical @@ -550,10 +590,13 @@ fn write_memo( cells.insert(3, flag(pruned.contains_key(&candidate_id), "pruned")); write_rows(out, " ", vec![cells]); match (logical, &accepted, evidence) { - (Some(logical), Some(accepted), Some(evidence)) if verbose => { - write_logical_edges(out, &logical.memo, accepted, candidate_id, evidence, None) + (Some(logical), Some(accepted), Some(evidence)) => { + write_logical_edges(out, &logical.memo, accepted, candidate_id, evidence) + } + // Pass 2 keeps no edges into a pruned candidate. + (Some(logical), ..) if !pruned.contains_key(&candidate_id) => { + write_stored_logical_edges(out, &logical.memo, &stored_edges, candidate_id) } - (Some(logical), ..) => write_stored_logical_edges(out, logical, candidate_id), _ => {} } } @@ -570,56 +613,51 @@ fn write_memo( } /// The logical edges Pass 2 stored into each input of `consumer` -/// (`LogicalASAPMemoCandidates::edges`: between surviving candidates -/// only), as producer indices by $\alpha^{\log}$: -/// `input 0 <- G7: ✓ G7[0..11] ✗ G7[12]`. +/// (`stored_edges`, from `LogicalASAPMemoCandidates::edges` by (consumer, +/// input): between surviving candidates only), as producer indices by +/// $\alpha^{\log}$, then how many candidates of the input group have no +/// edge: `input 0 <- G7: ✓ G7[0..11] ✗ G7[12] · 270 others`. fn write_stored_logical_edges( out: &mut String, - logical: &LogicalASAPMemoCandidates, + memo: &Memo, + stored_edges: &BTreeMap<(CandidateForMemoGroupId, usize), Vec<&LogicalEdge>>, consumer: CandidateForMemoGroupId, ) { - let groups = logical - .memo - .candidate(consumer) - .expression - .operator - .children(); + let groups = memo.candidate(consumer).expression.operator.children(); for (input, group) in groups.into_iter().enumerate() { let (mut satisfied, mut failed) = (Vec::new(), Vec::new()); - // ponytail: scans every edge per input; index by (consumer, input) if large memos print slowly. - for edge in logical.edges.iter().filter(|edge| { - edge.consumer_candidate_id == consumer && edge.input_index_of_consumer == input - }) { + for edge in stored_edges.get(&(consumer, input)).into_iter().flatten() { let index = edge.producer_candidate_id.index_in_group; match edge.satisfies_logical_requirement { true => satisfied.push(index), false => failed.push(index), } } + let others = + memo.groups()[group.0 as usize].candidates.len() - satisfied.len() - failed.len(); let mut line = format!(" input {input} <- G{}:", group.0); for (mark, indices) in [("✓", satisfied), ("✗", failed)] { if !indices.is_empty() { let _ = write!(line, " {mark} G{}[{}]", group.0, index_ranges(&indices)); } } - if line.ends_with(':') { - line += " no surviving producer"; + if others > 0 { + let plural = if others == 1 { "" } else { "s" }; + let _ = write!(line, " · {others} other{plural}"); } let _ = writeln!(out, "{line}"); } } -/// The logical edges into each input of `consumer`: each producer of the -/// input group with `✓`, or `✗` and why ([`logical_edges_into`]). With -/// `folded` (Pass 2's pruned candidates), the edges from those and from -/// producers of another θ are counted on one line, not listed. +/// The logical edges into each input of `consumer`, recomputed +/// (`verbose`): each candidate of the input group with `✓`, or `✗` and why +/// ([`logical_edges_into`]). fn write_logical_edges( out: &mut String, memo: &Memo, accepted: &[Option>], consumer: CandidateForMemoGroupId, evidence: &dyn AccuracyEvidence, - folded: Option<&BTreeMap>, ) { let groups = memo.candidate(consumer).expression.operator.children(); for ((input, group), edges) in groups @@ -627,47 +665,22 @@ fn write_logical_edges( .enumerate() .zip(logical_edges_into(memo, accepted, consumer, evidence)) { - let (mut pruned_producers, mut other_parameters) = (0, 0); let rows: Vec> = edges .into_iter() .enumerate() - .filter_map(|(index_in_group, rejection)| { - let producer = CandidateForMemoGroupId { + .map(|(index_in_group, rejection)| { + let producer = candidate_id_text(CandidateForMemoGroupId { group: *group, index_in_group, - }; - if let Some(folded) = folded { - if folded.contains_key(&producer) { - pruned_producers += 1; - return None; - } - if rejection.as_deref() == Some(NO_EDGE_OTHER_PARAMETERS) { - other_parameters += 1; - return None; - } - } - let producer = candidate_id_text(producer); - Some(match rejection { + }); + match rejection { None => vec![producer, "✓".into()], Some(reason) => vec![producer, format!("✗ {reason}")], - }) + } }) .collect(); let _ = writeln!(out, " input {input} <- G{}:", group.0); - let listed = !rows.is_empty(); write_rows(out, " ", rows); - let counted: Vec = [ - (pruned_producers, "pruned producers"), - (other_parameters, "producers of another θ (no edge)"), - ] - .into_iter() - .filter(|&(count, _)| count > 0) - .map(|(count, what)| format!("{count} {what}")) - .collect(); - if !counted.is_empty() { - let and = if listed { "and " } else { "" }; - let _ = writeln!(out, " {and}{}", counted.join(", ")); - } } } @@ -1060,23 +1073,36 @@ fn write_physical( plan: Option<&PhysicalASAPDAG>, verbose: bool, ) { - let memo = &physical.logical_candidates.memo; let selected = selected_edges(plan); let _ = writeln!( out, "physical candidates ({}):", physical.physical_candidates.len() ); - if !verbose { + if verbose { + write_physical_edges(out, physical, &selected); + } else { write_physical_by_logical_candidate(out, physical, plan, &selected); } + let _ = writeln!( + out, + "pruned by Stage 2 ({}):", + physical.pruned_candidates.len() + ); + write_physical_pruned(out, &physical.pruned_candidates, verbose); +} + +/// Each physical candidate with, per input, every physical edge whose +/// logical edge holds: `✓`, or `✗` and why, and `selected` for an edge of +/// the plan (`selected`). +fn write_physical_edges( + out: &mut String, + physical: &PhysicalASAPMemoCandidates, + selected: &BTreeSet<(PhysicalCandidateId, PhysicalCandidateId, usize)>, +) { + let memo = &physical.logical_candidates.memo; let edges_into = edges_per_input(physical); - for (id, candidate) in physical - .physical_candidates - .iter() - .enumerate() - .filter(|_| verbose) - { + for (id, candidate) in physical.physical_candidates.iter().enumerate() { let operator = &memo .candidate(candidate.logical_candidate_id) .expression @@ -1121,12 +1147,6 @@ fn write_physical( write_rows(out, " ", rows); } } - let _ = writeln!( - out, - "pruned by Stage 2 ({}):", - physical.pruned_candidates.len() - ); - write_physical_pruned(out, &physical.pruned_candidates, verbose); } /// Physical candidates Stage 2 or Stage 3 pruned: their count per reason, @@ -1192,52 +1212,61 @@ fn write_physical_by_logical_candidate( .flat_map(|plan| &plan.nodes) .filter_map(|node| node.selected_physical_candidate) .collect(); - let mut previous = None; + // Grouped explicitly: Stage 2 lists a logical candidate's physical + // candidates together, but `PhysicalASAPMemoCandidates` does not require it. + let mut by_logical: BTreeMap> = BTreeMap::new(); for (index, candidate) in physical.physical_candidates.iter().enumerate() { - let id = PhysicalCandidateId(index); - let logical = memo.candidate(candidate.logical_candidate_id); - if previous != Some(candidate.logical_candidate_id) { - let _ = writeln!( - out, - "{} {}", - candidate_id_text(candidate.logical_candidate_id), - candidate_text(logical, true) - ); - previous = Some(candidate.logical_candidate_id); - } - let star = if in_plan.contains(&id) { " ★" } else { "" }; + by_logical + .entry(candidate.logical_candidate_id) + .or_default() + .push(index); + } + for (logical_id, indices) in by_logical { + let logical = memo.candidate(logical_id); let _ = writeln!( out, - " C{index} {}{star}", - physical_properties_text(&candidate.physical_properties) + "{} {}", + candidate_id_text(logical_id), + candidate_text(logical, true) ); - for (input, group) in logical - .expression - .operator - .children() - .into_iter() - .enumerate() - { - let producers: Vec = physical - .admissible_edges_into(id, input) - .iter() - .map(|&edge| physical.edge(edge).producer_candidate_id.0) - .collect(); - let mut line = match producers.is_empty() { - true => format!(" input {input} <- G{}: reads nothing", group.0), - false => format!( - " input {input} <- G{}: reads {}", - group.0, - physical_ranges(&producers) - ), - }; - if let Some(&(producer, ..)) = selected - .iter() - .find(|&&(_, consumer, selected_input)| consumer == id && selected_input == input) + for index in indices { + let candidate = &physical.physical_candidates[index]; + let id = PhysicalCandidateId(index); + let star = if in_plan.contains(&id) { " ★" } else { "" }; + let _ = writeln!( + out, + " C{index} {}{star}", + physical_properties_text(&candidate.physical_properties) + ); + for (input, group) in logical + .expression + .operator + .children() + .into_iter() + .enumerate() { - let _ = write!(line, " · uses C{}", producer.0); + let producers: Vec = physical + .admissible_edges_into(id, input) + .iter() + .map(|&edge| physical.edge(edge).producer_candidate_id.0) + .collect(); + let mut line = match producers.is_empty() { + true => format!(" input {input} <- G{}: reads nothing", group.0), + false => format!( + " input {input} <- G{}: reads {}", + group.0, + physical_ranges(&producers) + ), + }; + if let Some(&(producer, ..)) = + selected.iter().find(|&&(_, consumer, selected_input)| { + consumer == id && selected_input == input + }) + { + let _ = write!(line, " · uses C{}", producer.0); + } + let _ = writeln!(out, "{line}"); } - let _ = writeln!(out, "{line}"); } } } diff --git a/crates/devtools/tests/asap_planner.rs b/crates/devtools/tests/asap_planner.rs index b09680d68..60f40030e 100644 --- a/crates/devtools/tests/asap_planner.rs +++ b/crates/devtools/tests/asap_planner.rs @@ -1,6 +1,7 @@ //! `asap_planner` writes valid stage documents: each stage's output over //! #509's examples and ad-hoc workloads, the deployment and cost inputs it //! plans with, and the inputs it refuses. +use std::collections::BTreeSet; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -1810,8 +1811,7 @@ fn the_text_memo_after_pass_2_prints_every_group_with_its_properties() { "{properties}" ); assert!( - properties.contains("evaluated every") - || properties.contains("no known evaluation grid"), + properties.contains("every ") || properties.contains("no known evaluation grid"), "{properties}" ); } @@ -1943,8 +1943,9 @@ fn the_text_folds_candidates_that_differ_only_in_theta() { /// group-properties line, no per-producer edge rows, no `α^` rejection /// reasons. Every section starts with a legend; each non-root memo group /// names the groups that read it and every group the queries reaching it; -/// Stage 2 prints each physical candidate once, under its logical -/// candidate, with `★` on exactly the plan's candidates. +/// a pruned Pass 2 candidate lists no inputs; Stage 2 prints each physical +/// candidate once, under its logical candidate, with `★` on exactly the +/// candidates the plan's nodes run. #[test] fn the_default_text_is_the_terse_form_and_verbose_adds_the_details() { let file = TempFile::json(&two_windowed_quantiles_within_1_percent()); @@ -1989,27 +1990,29 @@ fn the_default_text_is_the_terse_form_and_verbose_adds_the_details() { .unwrap() .parse() .unwrap(); - assert_eq!(candidates.len(), count, "each physical candidate once"); - for (index, line) in candidates.iter().enumerate() { - assert!( - line.starts_with(&format!(" C{index} ")), - "in id order: {line}" - ); - } - let starred: Vec = candidates + let name = |line: &&str| line.split_whitespace().next().unwrap().to_owned(); + let listed: BTreeSet = candidates.iter().map(name).collect(); + assert_eq!(listed.len(), count, "each physical candidate once"); + assert_eq!(listed, (0..count).map(|id| format!("C{id}")).collect()); + let starred: BTreeSet = candidates .iter() .filter(|line| line.ends_with('★')) - .map(|line| line.split_whitespace().next().unwrap().to_owned()) + .map(name) .collect(); - let plan_nodes = sections[5] + // `n: C = ...`: the candidate each plan node runs. + let in_plan: BTreeSet = sections[5] .lines() - .filter(|line| line.starts_with('n')) - .count(); - assert_eq!(starred.len(), plan_nodes, "★ on the plan's candidates"); - for candidate in &starred { - assert!( - sections[5].contains(&format!(" {candidate} = ")), - "{candidate} is a plan node's candidate" - ); + .filter(|line| line.starts_with('n') && line.contains(": C")) + .filter_map(|line| line.split_whitespace().nth(1).map(str::to_owned)) + .collect(); + assert!(!in_plan.is_empty()); + assert_eq!(starred, in_plan, "★ on exactly the plan's candidates"); + + let pass2_lines: Vec<&str> = pass2.lines().collect(); + for (index, line) in pass2_lines.iter().enumerate() { + if line.trim_start().starts_with('[') && line.contains(" pruned ") { + let next = pass2_lines.get(index + 1).copied().unwrap_or_default(); + assert!(!next.trim_start().starts_with("input "), "{line}\n{next}"); + } } } From 8f5495f3d66e29cb309ef46eb9777acff9623b54 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 10 Nov 2026 23:20:07 -0500 Subject: [PATCH 3/3] feat(dag-viewer): readable memo (drill-down, Stage 2 grid, plan-only and pruned filters), inputs/outputs layout, Plan Viewer rename (#1262) (#1277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dag-viewer): drill into large memo groups by any candidate dimension (#1262) A memo group with more than 12 candidates used to collapse to one count per origin and expand to every candidate, so a 280-candidate CmsWithHeap group (depth × width × heap_size) was one box or 280 boxes. It now splits by its first varying dimension (origin, sketch family, each θ field, window width), and clicking a count drills into it until at most 12 remain. The group label shows the path, clicking the group goes back a level, and the group's details offer the other varying dimensions to split by. The dimensions come from the candidate's existing `parameters` and `operator`; the document is unchanged. Co-Authored-By: Claude Opus 5.5 * fix(dag-viewer): drilled-in candidates show the dimensions that tell them apart (#1262) Drilled to depth=5 › heap_size=10, a CmsWithHeap group's 7 candidates all read "θ CmsWithHeap(depth=5, heap_s…": the clipped θ hid width, the one dimension that differs. Drilled in, a candidate now shows the dimensions still varying among the shown ones ("θ width=28"). The split-by buttons use the page's .btn style, so the dimension in use shows as pressed. Co-Authored-By: Claude Opus 5.5 * fix(dag-viewer): deployment line names each summary's summary_type, not undefined (#1262) Co-Authored-By: Claude Opus 5.5 * feat(dag-viewer): one memo edge per group when its candidates share inputs, a how-to-read line, light theme by default with a toggle (#1262) Co-Authored-By: Claude Opus 5.5 * feat(dag-viewer): hide Pass 2's pruned memo candidates until asked; theme toggle top right (#1262) Co-Authored-By: Claude Opus 5.5 * docs(dag-viewer): README says pruned candidates are hidden until asked (#1262) Co-Authored-By: Claude Opus 5.5 * feat(dag-viewer): each Pass 2 candidate shows its Stage 2 physical variants as a timing × lifetime grid (#1262) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve * feat(dag-viewer): planner inputs and output as two sections, settings panel with the cost model, no Stage 3 sidebar, short step names, Stage 2 variants toggle (#1262) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve * feat(dag-viewer): zoom in, out and fit buttons on every graph; the memo's how-to-read as a short list (#1262) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve * docs: rename the Stage Viewer to the Plan Viewer (#1262) The page, its server, code comments and every doc that names it; the user guide moves to docs/user_guide_docs/plan-viewer.md. The document format (asap-stage-pipeline/v2) keeps its name. asap_planner.rs's header comment still says Stage Viewer: changing it runs the cargo hooks, where two devtools tests already fail on this branch's base. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve * docs: the rest of the Plan Viewer rename: links, page, server and comments (#1262) bce9c4017 added docs/user_guide_docs/plan-viewer.md; this removes stage-viewer.md and renames the viewer everywhere else. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve * feat(dag-viewer): selected-plan-only memo filter; the queries' DAG shown with the inputs as Input; "error metric not set" (#1262) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve * docs(devtools): asap_planner's header comment names the Plan Viewer (#1262) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve * fix(dag-viewer): the Stage 2 grid image is wide enough for its third column (#1262) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qhwq4QkRexgK7yPwbksve --------- Co-authored-by: Claude Opus 5.5 --- README.md | 2 +- crates/devtools/src/bin/asap_planner.rs | 2 +- docs/README.md | 2 +- docs/design_docs/architecture/README.md | 2 +- .../architecture/input-output-workflow.md | 2 +- .../planner-layering-example2-acceptance.md | 2 +- .../develop_docs/extend-asap-aware-mapping.md | 2 +- docs/develop_docs/library-api.md | 2 +- docs/develop_docs/replacement-explanations.md | 2 +- docs/user_guide_docs/README.md | 2 +- docs/user_guide_docs/asap-planner.md | 4 +- .../{stage-viewer.md => plan-viewer.md} | 4 +- docs/user_guide_docs/run-a-query.md | 4 +- tools/dag-viewer/README.md | 65 +++-- tools/dag-viewer/app.js | 41 +-- tools/dag-viewer/index.html | 104 ++++--- tools/dag-viewer/memo.js | 270 ++++++++++++++---- tools/dag-viewer/memo.test.mjs | 86 +++++- tools/dag-viewer/server.py | 4 +- tools/dag-viewer/stages.js | 64 ++--- tools/dag-viewer/stages.test.mjs | 11 - tools/dag-viewer/test_viewer.py | 101 ++++--- tools/dag-viewer/workload-sets.js | 2 +- 23 files changed, 521 insertions(+), 259 deletions(-) rename docs/user_guide_docs/{stage-viewer.md => plan-viewer.md} (97%) diff --git a/README.md b/README.md index c4ea78f18..3c6c4cc97 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ASAPPlanner turns SQL, PromQL, and MetricsQL query workloads into legal candidat - New to the repository? Read the [planner pipeline](docs/design_docs/concepts/planner-pipeline.md), then the [glossary](docs/design_docs/concepts/glossary.md). - Want to run a query? Follow [Run and inspect a query](docs/user_guide_docs/run-a-query.md). -- Want to see how a workload is planned, stage by stage? Use the [Stage Viewer](docs/user_guide_docs/stage-viewer.md). +- Want to see how a workload is planned, stage by stage? Use the [Plan Viewer](docs/user_guide_docs/plan-viewer.md). - Embedding Planner? Use the [library API guide](docs/develop_docs/library-api.md). - Extending Planner? Read the [planning stages design](docs/design_docs/proposals/planner-layering.md), then the [ASAP-aware mapping architecture](docs/develop_docs/asap-aware-mapping-architecture.md), which maps each stage to its code, then [extend ASAP-aware mapping](docs/develop_docs/extend-asap-aware-mapping.md). - Evaluating a design? Browse the [design documentation](docs/design_docs/README.md), [developer documentation](docs/develop_docs/README.md), and [user guides](docs/user_guide_docs/README.md). diff --git a/crates/devtools/src/bin/asap_planner.rs b/crates/devtools/src/bin/asap_planner.rs index c527319a6..37068f8f4 100644 --- a/crates/devtools/src/bin/asap_planner.rs +++ b/crates/devtools/src/bin/asap_planner.rs @@ -8,7 +8,7 @@ // `--run-until` (`stage0`, `stage1_pass1`, `stage1_pass2`, `stage2` or // `stage3`) and writes each stage's output, as text (`--format text`, the default; // `asap_devtools::planning_stages_text`) or as an `asap-stage-pipeline/v2` -// document for the Stage Viewer (`--format json`), to `--out` or standard +// document for the Plan Viewer (`--format json`), to `--out` or standard // output. The text is terse by default (a legend per stage, folded θ // variants, producer ids per input, pruned counts per cause); `--verbose` // prints every candidate, edge with its reason, group's properties and diff --git a/docs/README.md b/docs/README.md index 4ef582c9b..49c2dcfd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ Choose the path that matches what you need to do. ## Run or inspect a query -Follow [Run and inspect a query](user_guide_docs/run-a-query.md). To see how the planner plans a workload stage by stage, and why it selects its plan, use [the Stage Viewer](user_guide_docs/stage-viewer.md). +Follow [Run and inspect a query](user_guide_docs/run-a-query.md). To see how the planner plans a workload stage by stage, and why it selects its plan, use [the Plan Viewer](user_guide_docs/plan-viewer.md). ## Embed the library diff --git a/docs/design_docs/architecture/README.md b/docs/design_docs/architecture/README.md index a521d7e3f..a7b556916 100644 --- a/docs/design_docs/architecture/README.md +++ b/docs/design_docs/architecture/README.md @@ -51,7 +51,7 @@ target. | Plan selection | `stage3-plan-selection` | Stage 3: accuracy and latency checks, costing and selection; the planning models and the Stage 1 to Stage 3 driver | | Planner facade | `planner` | `e2e_plan`: Stage 0 followed by Stages 1–3 | | Executor | `executor` | Stage 4: runs a `PhysicalASAPDAG` over `asap_sketchlib` summaries; no stage crate depends on it | -| Developer inspection | `devtools` | `asap_planner` and the Stage Viewer documents, corpus coverage tools | +| Developer inspection | `devtools` | `asap_planner` and the Plan Viewer documents, corpus coverage tools | | End-to-end validation | `integration-tests` | Verify behavior across frontends, stages and execution | ## Inputs and outputs diff --git a/docs/design_docs/architecture/input-output-workflow.md b/docs/design_docs/architecture/input-output-workflow.md index a148a9425..da5a8e08e 100644 --- a/docs/design_docs/architecture/input-output-workflow.md +++ b/docs/design_docs/architecture/input-output-workflow.md @@ -304,7 +304,7 @@ PlanningWorkload + frontend dependencies + PlanningModels |---|---|---| | Plan a workload end to end | `asap_planner::e2e_plan` | `PlanOutput` | | Plan already-lowered roots and inspect every stage | `asap_planner::run_planning` | `PlanningRun`, whose `selection` is the `Selection` | -| Inspect a workload's stages as a document | `asap_planner` (devtools) | The stage document the [Stage Viewer](../../user_guide_docs/stage-viewer.md) shows | +| Inspect a workload's stages as a document | `asap_planner` (devtools) | The stage document the [Plan Viewer](../../user_guide_docs/plan-viewer.md) shows | The integration associates each lowered root, by its position, with its workload entry (accuracy target, latency bound, recurrence). See the diff --git a/docs/design_docs/proposals/planner-layering-example2-acceptance.md b/docs/design_docs/proposals/planner-layering-example2-acceptance.md index 5c1d0dec8..454736fce 100644 --- a/docs/design_docs/proposals/planner-layering-example2-acceptance.md +++ b/docs/design_docs/proposals/planner-layering-example2-acceptance.md @@ -159,5 +159,5 @@ strictest size. is the `flows` table of `tools/dag-viewer/workloads/planner-layering/tables.json`, plans the SQL workload with the `flows(ts, src_ip)` catalog (with `--ignore-accuracy-guarantees`, none of the plans below is rejected for its -guarantee). The stage viewer has no tab for it; open the +guarantee). The plan viewer has no tab for it; open the document with **Open stage document…**. diff --git a/docs/develop_docs/extend-asap-aware-mapping.md b/docs/develop_docs/extend-asap-aware-mapping.md index 43248f3e4..306493991 100644 --- a/docs/develop_docs/extend-asap-aware-mapping.md +++ b/docs/develop_docs/extend-asap-aware-mapping.md @@ -105,7 +105,7 @@ tests in `planner_layering_example4.rs`. a stage function takes only the field it reads (Stage 2 takes `query_time_results_retained: bool`, not the whole struct). - Expose it in `asap_planner` and in the - [Stage Viewer guide](../user_guide_docs/stage-viewer.md). + [Plan Viewer guide](../user_guide_docs/plan-viewer.md). Tests: a Stage 3 unit test that the check rejects exactly the candidates it should, and a `crates/devtools/tests/asap_planner.rs` test for the flag. diff --git a/docs/develop_docs/library-api.md b/docs/develop_docs/library-api.md index ef3f59c66..259342ea6 100644 --- a/docs/develop_docs/library-api.md +++ b/docs/develop_docs/library-api.md @@ -295,7 +295,7 @@ let models = PlanningModels::builtin().with_cost_inputs(&settings); The `asap_planner` devtool runs the same stages and writes every stage's output as text or, with `--format json`, as one JSON document; see the -[Stage Viewer guide](../user_guide_docs/stage-viewer.md). +[Plan Viewer guide](../user_guide_docs/plan-viewer.md). ## Workload inputs and defaults diff --git a/docs/develop_docs/replacement-explanations.md b/docs/develop_docs/replacement-explanations.md index 93e69f094..0d29527bd 100644 --- a/docs/develop_docs/replacement-explanations.md +++ b/docs/develop_docs/replacement-explanations.md @@ -5,7 +5,7 @@ > and why each candidate was pruned or rejected, is now in the stages' > output: Pass 1's pruned candidates with their `PruneReason`, Stage 2's > pruned candidates, and Stage 3's `Rejection` reasons, all written by -> `asap_planner` (see the [Stage Viewer guide](../user_guide_docs/stage-viewer.md)). +> `asap_planner` (see the [Plan Viewer guide](../user_guide_docs/plan-viewer.md)). ASAP-aware mapping should make its discovered opportunities visible to users and other components. diff --git a/docs/user_guide_docs/README.md b/docs/user_guide_docs/README.md index 4cee670dd..841b9f765 100644 --- a/docs/user_guide_docs/README.md +++ b/docs/user_guide_docs/README.md @@ -5,4 +5,4 @@ result. - [Run and inspect a query](run-a-query.md) - [Plan a workload with asap_planner](asap-planner.md) -- [See how ASAPPlanner plans a workload: the Stage Viewer](stage-viewer.md) +- [See how ASAPPlanner plans a workload: the Plan Viewer](plan-viewer.md) diff --git a/docs/user_guide_docs/asap-planner.md b/docs/user_guide_docs/asap-planner.md index 1eb1e2234..36990ced3 100644 --- a/docs/user_guide_docs/asap-planner.md +++ b/docs/user_guide_docs/asap-planner.md @@ -2,7 +2,7 @@ `asap_planner` runs the planner's stages on a workload, up to the stage you choose, and prints what each stage produced: as text, or as a JSON document -for the [Stage Viewer](stage-viewer.md). It reports candidates, selection, +for the [Plan Viewer](plan-viewer.md). It reports candidates, selection, modeled costs and accuracy checks; it does not deploy or execute the plan. ## Build and run @@ -100,7 +100,7 @@ SQL examples. To try a single query, write a one-query workload file. ## Inspect the result With `--format json`, open the document in the -[Stage Viewer](stage-viewer.md), or read Stage 3 directly: +[Plan Viewer](plan-viewer.md), or read Stage 3 directly: ```sh cargo run --release -p asap-devtools --bin asap_planner -- \ diff --git a/docs/user_guide_docs/stage-viewer.md b/docs/user_guide_docs/plan-viewer.md similarity index 97% rename from docs/user_guide_docs/stage-viewer.md rename to docs/user_guide_docs/plan-viewer.md index 2d915fba7..5a1c47535 100644 --- a/docs/user_guide_docs/stage-viewer.md +++ b/docs/user_guide_docs/plan-viewer.md @@ -1,6 +1,6 @@ -# See how ASAPPlanner plans a workload: the Stage Viewer +# See how ASAPPlanner plans a workload: the Plan Viewer -The Stage Viewer is a web page that shows what ASAPPlanner does with one +The Plan Viewer is a web page that shows what ASAPPlanner does with one workload at each [planning stage](../design_docs/proposals/planner-layering.md#asapplanner-system-overview-planning-stages), and which plan it selects. It reads one JSON document per workload, written diff --git a/docs/user_guide_docs/run-a-query.md b/docs/user_guide_docs/run-a-query.md index c1c19059c..085330f91 100644 --- a/docs/user_guide_docs/run-a-query.md +++ b/docs/user_guide_docs/run-a-query.md @@ -14,7 +14,7 @@ tool on first use. | Command (`cargo run -p asap-devtools --bin … -- …`) | Input / options | Result | | --- | --- | --- | -| `asap_planner --workload --run-until ` | `PlanningWorkload` JSON files | Runs the planner's stages up to `` and prints each stage's output as text, or with `--format json` as the document the [Stage Viewer](stage-viewer.md) shows | +| `asap_planner --workload --run-until ` | `PlanningWorkload` JSON files | Runs the planner's stages up to `` and prints each stage's output as text, or with `--format json` as the document the [Plan Viewer](plan-viewer.md) shows | | `convert_rqe_atomic_costs ` | sketch-bench's per-operation cost table | Writes a measured table to set as `measured_operation_costs` in an `asap_planner --cost-inputs` file; skipped rows are listed on stderr | | `analyze_corpora --corpora --sample-interval-ms 1000 --out-dir

` | Repository PromQL corpora, output directory | Writes successful/error IR dumps and summary reports | | `analyze_corpora --sql-corpora --out-dir ` | Repository SQL corpora, output directory | Writes SQL corpus reports | @@ -49,7 +49,7 @@ the selected physical candidates, the plan's `cost_per_second` with each node's charge, each query's accepted accuracy, and the search status (`Optimal` when the plan is proven the cheapest). For this example the selected plan is exact for both queries. Add `--format json --out plan.json` -and open the document in the [Stage Viewer](stage-viewer.md) to see every +and open the document in the [Plan Viewer](plan-viewer.md) to see every stage. ## Inspect a query's logical DAG diff --git a/tools/dag-viewer/README.md b/tools/dag-viewer/README.md index d4a7f207b..a13f906e7 100644 --- a/tools/dag-viewer/README.md +++ b/tools/dag-viewer/README.md @@ -1,4 +1,4 @@ -# ASAPPlanner Stage Viewer +# ASAPPlanner Plan Viewer A browser view of what the planner does with one workload, read from an `asap-stage-pipeline/v2` document written by the `asap_planner` devtool @@ -31,24 +31,27 @@ To run it, see [RUNNING.md](RUNNING.md). (deployment capabilities, cost model, accuracy guarantees). Without the server, the rows say they need it. `#workload-sets` in the URL opens the section. -- **Query workload** with each query's accuracy, latency, recurrence, - predictability and range evaluation, and a line that counts the - candidates at each stage: the Pass 2 memo's logical candidates and how +- **Planner inputs** (blue bar), what the planner was given: + - **Query workload** with each query's accuracy (`error metric not set` when + the workload names no error metric), latency, recurrence, + predictability and range evaluation; + - **Data workload**: the arrival and one row per declared source with + exactly the facts it declares (series count, metric type, non-negative + values, sample interval, stream norms, distinct bound, distinct values + per label, distinct values per label set) and the queries whose text + names it; + - **Deployment and planner settings**: the deployment capabilities (raw + data and query-time results retained or not, the summaries it + supports), whether accuracy guarantees were enforced or ignored, and + the cost model Stage 3 priced the plan with (`cost.source`). +- **Planner output** (green bar), everything below: a line that counts the + candidates at each stage (the Pass 2 memo's logical candidates and how many Pass 2 pruned, the physical candidates and how many Stage 2 and - Stage 3 pruned, and whether a plan was selected. -- **Data workload** beside it: the arrival and one row per declared source - with exactly the facts it declares (series count, metric type, - non-negative values, sample interval, stream norms, distinct bound, - distinct values per label, distinct values per label set) and the queries - whose text names it, plus the deployment capabilities the run planned - with. -- **Stage 3 · plan selection**: one row, the selected plan with its cost - per second and the accuracy Stage 3 accepted per query, or why no plan - was selected. The header says why Stage 1 or Stage 2 failed when it did, - shows the selection's warnings, says whether accuracy guarantees were - enforced or ignored, and names the search with its MILP's size and times. -- **Two lanes**, stacked top to bottom so each is as wide as the page: - Stage 0 and Stage 3's plan, with each plan node's timing (also its fill: + Stage 3 pruned) and a line with Stage 3's outcome (plan selected, or why + not), why Stage 1 or Stage 2 failed when it did, the selection's + warnings, and the search with its MILP's size and times. +- **Two lanes**, each as wide as the page: **Input**, the queries as one + logical DAG (Stage 0's output, drawn with the inputs), and Stage 3's plan, with each plan node's timing (also its fill: green at ingestion time, orange at query time) and Stage 3 cost (`× n` when a node that is not materialized is computed once per consumer). Each lane is the whole workload's DAG, so a node shared by several queries @@ -68,13 +71,27 @@ To run it, see [RUNNING.md](RUNNING.md). is not installable says why instead. Clicking a row selects its node in the plan lane. - **Memo**: the memo after each planner step, one tab per step (`Stage 0`, - `Stage 1, Pass 1`, `Stage 1, Pass 2`), opening on the last. Each memo + `Stage 1 · Pass 1`, `Stage 1 · Pass 2`), opening on the last. Each memo group is a box `G ·