From 50d6c458cda73a98db544c7227c7dd4f9dada108 Mon Sep 17 00:00:00 2001 From: Selvomega Date: Thu, 17 Sep 2026 00:15:41 +0000 Subject: [PATCH] feat(devtools): rank dag_export --post-asap without a cost document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--post-asap` without `--planner-cost-json` ran nothing. The gate exists because a post-ASAP export asserts a cost-ranked decision, and dag_export owns no deployment evidence with which to defend one. But a user with no deployment yet still wants to see which replacements the search finds, which one it commits to, and what the merged post-ASAP DAG looks like — and that needs the structure to be real, not the numbers. Add `--default-cost`, mutually exclusive with `--planner-cost-json`. It ranks candidates with `DefaultCostModel` and keeps `export_model` as `None`, so every annotation stays `CostSource::Unavailable` with no `value`, `workload_cost_summary` degrades to an unavailable summary, and the viewer renders "Not estimated". The structural number decides which candidate wins and is then discarded, never serialized. The flag pair is rejected rather than given a precedence order, before the document is parsed, because the two disagree about what the export may claim, not just about which model ranks. `--topk-margin-json` still applies under `--default-cost`: accuracy evidence is orthogonal to cost evidence. Absent both flags the export is byte-identical to before; only the stderr line differs, which now names both cost sources instead of demanding evidence. This is not a revival of what 5dbf4d9 removed. That commit deleted two things: `winner_cost_annotations` emitting a modeled `CostAnnotation` from `AnalyticalCostModel`, and the `analytical.map_or(&default_model, ...)` choice of which model ranks. Only the latter returns. A `DefaultCostModel` number was never an exported cost, and structural node counts remain intentionally unusable as a cost fallback. Refs #431 Co-Authored-By: Claude Opus 5 (1M context) --- crates/devtools/src/bin/dag_export.rs | 202 +++++++++++++++++++++++++- tools/dag-viewer/README.md | 28 +++- 2 files changed, 219 insertions(+), 11 deletions(-) diff --git a/crates/devtools/src/bin/dag_export.rs b/crates/devtools/src/bin/dag_export.rs index 08df63be..354f1962 100644 --- a/crates/devtools/src/bin/dag_export.rs +++ b/crates/devtools/src/bin/dag_export.rs @@ -18,6 +18,10 @@ // cargo run -p asap-lower --bin dag_export -- \ // --epsilon 0.01 --sql "SELECT quantile(0.99, latency) FROM metrics" --name p99 // +// `--post-asap` needs one of two cost sources to rank with, and does +// nothing without either (see `--default-cost` and `--planner-cost-json` +// below). +// // `--post-asap` is optional and off by default. When passed, this binary // additionally runs `asap_aware_mapping::replacement::search_workload` (this // binary took no strategies of its own — `default_strategies()` already @@ -46,8 +50,26 @@ // `post_graph` is `None`, both skipped from the JSON entirely in that // case). E.g.: // cargo run -p asap-lower --bin dag_export -- \ -// --post-asap --epsilon 0.01 \ +// --post-asap --default-cost --epsilon 0.01 \ // --sql "SELECT quantile(0.95, latency) FROM metrics" --name q1 +// +// `--default-cost` and `--planner-cost-json` are the two mutually exclusive +// ways to give `--post-asap` a cost model, and they differ in what the +// export is allowed to claim: +// +// - `--planner-cost-json ` supplies complete deployment-owned +// physical-plan evidence. It both ranks the candidates and is exported: +// every decision carries a calibrated `CostUnits` annotation. +// - `--default-cost` ranks with `asap_aware_mapping::cost_model:: +// DefaultCostModel` — structural node counts, owning no deployment +// evidence. The structure of the result is real (which replacements the +// search found, which one won per group, what the merged post-ASAP DAG +// looks like); the numbers are not exported at all. Every decision's +// cost is `CostSource::Unavailable` with `value: None`, which the viewer +// renders as "Not estimated". Use it to see what ASAPPlanner does with a +// workload before there is a deployment to measure. +// +// Absent both, `--post-asap` exports the raw plan only. use std::cell::RefCell; use std::collections::HashMap; @@ -58,7 +80,6 @@ use asap_aware_mapping::analytical_cost::{ cache_hit_ratios, AnalyticalCostError, EvidenceBackedPhysicalDag as PhysicalDag, PhysicalNodeEvidence, ResourceCalibration, ANALYTICAL_COST_MODEL_VERSION, }; -#[cfg(test)] use asap_aware_mapping::cost_model::DefaultCostModel; use asap_aware_mapping::cost_model::{Cost, CostModel}; use asap_aware_mapping::physical_operator_statistics::ComparisonScope; @@ -765,6 +786,11 @@ struct ParsedArgs { progress: bool, table_schemas: Vec, planner_cost: Option, + /// `--default-cost`: rank the `--post-asap` search with + /// [`DefaultCostModel`] and export no cost at all. Mutually exclusive + /// with `planner_cost`, which both ranks *and* is exported — see this + /// file's top-of-file usage doc for why the two can't be combined. + default_cost: bool, topk_margin: Option, } @@ -814,6 +840,10 @@ impl AccuracyEvidenceProvider for TopKMarginEvidence { } fn parse_args() -> ParsedArgs { + parse_args_from(std::env::args().skip(1)) +} + +fn parse_args_from(argv: impl Iterator) -> ParsedArgs { let mut entries: Vec<(String, Lang, String)> = Vec::new(); let mut pending: Option<(Lang, String)> = None; let mut accuracy = AccuracyTarget::Exact; @@ -821,8 +851,9 @@ fn parse_args() -> ParsedArgs { let mut progress = false; let mut table_schemas = Vec::new(); let mut planner_cost_json = None; + let mut default_cost = false; let mut topk_margin_json = None; - let mut args = std::env::args().skip(1); + let mut args = argv; fn flush(entries: &mut Vec<(String, Lang, String)>, pending: &mut Option<(Lang, String)>) { if let Some((lang, query)) = pending.take() { @@ -871,6 +902,9 @@ fn parse_args() -> ParsedArgs { .expect("planner cost evidence requires a JSON document"), ); } + "--default-cost" => { + default_cost = true; + } "--topk-margin-json" => { topk_margin_json = Some( args.next() @@ -881,6 +915,17 @@ fn parse_args() -> ParsedArgs { } } flush(&mut entries, &mut pending); + // Rejected rather than given a precedence order: the two flags disagree + // about what the export may claim, not just about which model ranks, so + // silently preferring one would make the exported costs depend on an + // argument order the caller never stated. + if default_cost && planner_cost_json.is_some() { + panic!( + "--default-cost and --planner-cost-json are mutually exclusive: --default-cost ranks \ + with structural node counts and exports no cost, --planner-cost-json exports \ + deployment-owned costs" + ); + } 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| { @@ -898,6 +943,7 @@ fn parse_args() -> ParsedArgs { progress, table_schemas, planner_cost, + default_cost, topk_margin, } } @@ -1420,13 +1466,14 @@ async fn main() { progress, table_schemas, planner_cost, + default_cost, topk_margin, } = parse_args(); let sql_catalog = catalog(&table_schemas); let planner_started = Instant::now(); if entries.is_empty() { eprintln!( - "usage: dag_export --sql \"\" [--name