Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,197 changes: 1,182 additions & 15 deletions crates/devtools/src/bin/dag_export.rs

Large diffs are not rendered by default.

632 changes: 632 additions & 0 deletions crates/types/src/cost.rs

Large diffs are not rendered by default.

169 changes: 167 additions & 2 deletions crates/types/src/dag_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ use std::rc::Rc;

use serde::Serialize;

use crate::cost::CostAnnotation;
use crate::post_asap::{AccuracyError, ResultGuarantee, SummaryExpr, SummaryNode};
use crate::pre_asap::cse::{structural_hash, HashCache};
use crate::pre_asap::query_expr::{QueryExpr, Source};
Expand Down Expand Up @@ -152,13 +153,52 @@ 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<CostAnnotation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_cost: Option<CostAnnotation>,
/// `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<CostAnnotation>,
}

/// 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. Graph structure alone cannot determine transfer,
/// materialization, or read cost. A higher layer may attach this annotation
/// only when physical evidence attributes cost to this exact edge; this
/// module never derives one from structural node counts.
#[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.
#[derive(Debug, Clone, Serialize)]
pub struct DagGraph {
pub nodes: Vec<DagNode>,
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<EdgeCostAnnotation>,
}

/// A single named query within a multi-query export.
Expand Down Expand Up @@ -199,6 +239,24 @@ pub struct NamedGraph {
/// `NamedGraph` is unaffected.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_graph: Option<DagGraph>,
/// 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<crate::cost::WorkloadCostSummary>,
/// 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,
Expand All @@ -214,6 +272,14 @@ pub struct NamedGraph {
#[derive(Debug, Clone, Serialize)]
pub struct WorkloadGraph {
pub queries: Vec<NamedGraph>,
/// 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<crate::cost::WorkloadCostSummary>,
}

// ── Post-ASAP replacement export — a second, layering-seam-shaped feature ──
Expand Down Expand Up @@ -526,6 +592,19 @@ 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 is
/// `NaN`. Baseline and selected values require complete, scope-matched
/// physical evidence; neither is inferred from logical graph structure.
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_cost: Option<CostAnnotation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_cost: Option<CostAnnotation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub benefit: Option<CostAnnotation>,
}

/// What a [`TargetReplacement`] became — either a genuine post-ASAP binding
Expand Down Expand Up @@ -562,7 +641,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
Expand Down Expand Up @@ -641,7 +724,6 @@ fn deduplicate_pointer_shared_nodes(nodes: Vec<DagNode>, root: u32) -> DagGraph
let mut by_source_ptr = HashMap::<usize, u32>::new();
let mut old_to_new = vec![0_u32; nodes.len()];
let mut deduplicated = Vec::with_capacity(nodes.len());

for mut node in nodes {
node.children = node
.children
Expand All @@ -668,6 +750,7 @@ fn deduplicate_pointer_shared_nodes(nodes: Vec<DagNode>, root: u32) -> DagGraph
DagGraph {
nodes: deduplicated,
root: old_to_new[root as usize],
edge_annotations: Vec::new(),
}
}

Expand Down Expand Up @@ -1303,6 +1386,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"),
Expand All @@ -1312,6 +1396,86 @@ 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}"
);
}

#[test]
fn export_post_asap_does_not_invent_costs_for_shared_edges() {
// Two *distinct* parents (a Dedup and a Limit, each with their own
// single child slot) share the exact same `Rc` Scan —
// `export_post_asap` must merge them onto one node id. Sharing alone
// is not physical cost evidence, so no edge cost may be fabricated.
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"
);
assert!(graph.edge_annotations.is_empty());
}

/// 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
/// produce an edge-cost annotation without explicit physical evidence.
#[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]
Expand Down Expand Up @@ -1566,6 +1730,7 @@ mod tests {
graph: export(&leaf),
replacements: vec![],
post_graph: None,
workload_cost: None,
rejections: vec![TargetRejection {
target_pre_id: 0,
strategy: "SketchAlgorithmStrategy".into(),
Expand Down
1 change: 1 addition & 0 deletions crates/types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
70 changes: 66 additions & 4 deletions tools/dag-viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -52,12 +57,24 @@ 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: an immutable `evidence_version`, calibration, and
target records containing the exact target `QueryExpr` and comparison scope.
Each exact replacement candidate owns its complete logical-node
`PhysicalNodeEvidence`; summary candidates additionally own their bound
`PhysicalDag`. Candidate-local evidence prevents statistics for one physical
alternative from satisfying another. Candidate matching includes the complete
exported plan, including accuracy guarantees, and never uses a hash or strategy
name; derived floating constants allow only a one-ULP JSON round-trip tolerance.
Duplicate, conflicting, unused, 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
Expand Down Expand Up @@ -90,8 +107,11 @@ a selected replacement directly contains:
"strategy": "SketchAlgorithmStrategy",
"rationale": "count realizes as a Cms sketch",
"rank": 0,
"cost": 3.0,
"role": "replacement_root"
"cost": 1.14001088,
"role": "replacement_root",
"baseline_cost": { "value": 104.0032, "unit": "CostUnits", "source": "Modeled", "model_version": "analytical-cost-v1+example-calibration-v1", "evidence_version": "example-evidence-v1" },
"selected_cost": { "value": 1.14001088, "unit": "CostUnits", "source": "Modeled", "baseline": {"kind": "PreAsapRecomputation"}, "delta": 102.86318912, "benefit_ratio": 0.9890386941940248 },
"benefit": { "value": 102.86318912, "unit": "CostUnits", "source": "Modeled", "baseline": {"kind": "PreAsapRecomputation"}, "benefit_ratio": 0.9890386941940248 }
}
}
```
Expand All @@ -106,6 +126,48 @@ 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`/`evidence_version`/
`benchmark_id`/`inputs` for provenance. `model_version` identifies the
analytical formulas and calibration; `evidence_version` independently
identifies the immutable catalog/runtime generation. 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 checked-in viewer fixture makes its illustrative comparison reproducible.
It models 100 evaluations of 100 million 64-byte rows with 100,000 groups.
The raw path charges one scan plus three hash/key/accumulator operations per
row, so CPU is `100 × 100,000,000 × 4 = 40 billion` operations; it reads
`100 × 6.4 GB = 640 GB` and retains
`100,000 × (8-byte key + 8-byte accumulator + 16-byte hash metadata) = 3.2 MB`.
One incrementally built depth-5 CMS charges
`100,000,000 × 5 = 500 million` counter updates, reads the 6.4 GB source once,
and retains `272 × 5 × 8 = 10,880` bytes. With coefficients `1e-9` per CPU
operation, `1e-10` per scan byte, and `1e-9` per peak-memory byte, the displayed totals are
`104.0032` and `1.14001088` cost units. These are explicit fixture assumptions,
not statistics inferred by the viewer.

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
decisions counted once via `decision.id` dedup). `DagGraph.edge_annotations`
is reserved for a higher layer that has physical evidence for a particular
edge; graph sharing alone never creates an edge 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
Expand Down
Binary file added tools/dag-viewer/cost-benefit-annotations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading