feat(dag-viewer): export and visualize planner cost/benefit annotations (#286) - #296
Open
zzylol wants to merge 2 commits into
Open
feat(dag-viewer): export and visualize planner cost/benefit annotations (#286)#296zzylol wants to merge 2 commits into
zzylol wants to merge 2 commits into
Conversation
Adds a structured, optional CostAnnotation schema (crates/types/src/cost.rs) and wires it through dag_export's JSON output and the tools/dag-viewer sidebar/on-graph UI, per issue #286. Rust: - `asap_types::cost`: `CostAnnotation` (value/unit/source/baseline/delta/ benefit_ratio/model_version/benchmark_id/inputs), `CostUnit` (CostUnitsPerSecond / CostUnits / RelativeStructuralUnits), `CostSource` (Modeled/Measured/Unavailable), `BaselineRef`, `CostInput`, `total_cost(rate, horizon, one_shot)`, `sum_workload_costs` (dedups by an explicit key, rejects unit-mismatched aggregation), and `WorkloadCostSummary`/`workload_cost_summary`. - `DagGraph` gains `edge_annotations: Vec<EdgeCostAnnotation>`, populated by `deduplicate_pointer_shared_nodes` for every edge running into a genuine DAG merge point (never a guessed multi-hop path cost). - `DagDecision` and `TargetReplacement` gain `baseline_cost`/`selected_cost`/ `benefit` alongside their existing bare `cost: f64` (unchanged, for backward compat). `NamedGraph`/`WorkloadGraph` gain `workload_cost: Option<WorkloadCostSummary>`. - crates/devtools/src/bin/dag_export.rs populates all of the above from today's `asap_aware_mapping::cost_model` output (`estimate_cost`, `default_cse_recompute_cost`), deduplicating workload totals by `decision.id`. Every value dag_export produces today is honestly unit-tagged `RelativeStructuralUnits` (the same structural-size proxy the cost model already uses for ranking), not `CostUnitsPerSecond`: the cost model has no update_rate/evaluation_rate/query_interval recurrence inputs yet (#287's job). The annotation plumbing accepts a real rate unchanged once #287 lands those inputs. Nothing is ever fabricated: a value the cost model can't estimate is `CostSource::Unavailable` (`value: None`), never `0`. JS/viewer: - viewer.js renders concise on-graph `▼NN%`/`▲NN%` badges on a costed post-ASAP node's label, full baseline/selected/benefit/provenance blocks in the sidebar (node click, edge click, and a workload-scope cost summary for the current single- or multi-query selection), all sourced only from explicit JSON fields (decision.id dedup, `EdgeCostAnnotation`, `workload_cost`) — no client-side cost estimation. - index.html: cost UI CSS (light/dark aware, via existing --var tokens). - tools/dag-viewer/dag.example.json regenerated via generate-sample.sh (real lowering -> ASAP-aware mapping -> post-ASAP -> dag_export pipeline), not hand-patched. - README.md documents the new JSON contract fields. Tests: `cargo test --workspace` (all green) and `python3 -m unittest discover -s tools/dag-viewer -p test_render.py` (18/18) both pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes 5 confirmed bugs from PR review plus 3 lower-priority follow-ups.
Confirmed bugs:
1. viewer.js: `loadFiles()`/`loadWorkload()` dropped `workload_cost` from
the object pushed onto `queries` — the single-query "Workload cost"
panel (renderScopeSummary's `selected[0].workload_cost` read) was
silently `undefined` on every interactive load path (drag-and-drop,
file picker, and the planner/embedded path), even though the exported
JSON carried the data. Both loaders now forward `workload_cost`.
2. viewer.js: `computeSelectionWorkloadCost` deduped decisions across a
multi-query selection by bare `decision.id`, which is only unique
within one `dag_export` process invocation, not across independently
loaded files — a real collision (two files reusing the same small
integer id) would silently drop one file's cost from the aggregate.
Added a `sourceBatch` id assigned once per loaded document/file and
changed the dedup key to `${sourceBatch}:${decision.id}`.
3. dag_export.rs: `shared_node_edge_annotations` counted edge occurrences
(`Vec`) rather than distinct consuming nodes as `consumer_count` — a
`Join` whose left and right operands are the same `Rc` (post
pointer-dedup) inflated `consumer_count` to 2 for one real downstream
consumer, halving the reported per-edge cost and producing two
colliding `(from, to)` `EdgeCostAnnotation` entries (which
`edgeCostByPair` in viewer.js then silently collapsed via Map
overwrite). Switched to a `HashSet` per child so a single parent
referencing the same shared child twice counts as one consumer. Added
a regression test plus updated the existing edge-annotation test to use
two genuinely distinct parents.
4. cost.rs: `total_cost()` validated `horizon` but not
`recurring_cost_rate`/`one_shot_cost` themselves — a non-finite rate
(e.g. a stray NaN from a future #287 caller) silently produced
`Some(NaN)` instead of `None`, violating the module's own "never
fabricate, never a poisoned total" rule. Both inputs are now validated
finite before use; added regression tests.
5. dag_export.rs: `NamedGraph.workload_cost`'s doc claimed cross-query
dedup via `workload_node_id`, but the actual producer
(`decision_cost_entries`) only dedups within one query by
`decision.id` — a reader trusting the doc and summing
`NamedGraph.workload_cost` across queries would double-count a target
shared between them. Corrected the doc to state the per-query-only
scope and point cross-query readers at `WorkloadGraph.workload_cost`
instead (the implementation was already correct; only the doc was
wrong).
Lower-priority follow-ups also addressed:
- dag_export.rs: the legacy scalar `cost: f64` on `DagDecision`/
`TargetReplacement` is now derived from `selected_cost.value` at both
call sites instead of being set independently from `winner.cost` a
second time, closing the "kept in sync by convention only" gap the
review flagged.
- dag_export.rs: `default_cse_recompute_cost` is now memoized once per
winner (`per_consumer_recompute_costs`, built right after `winners`)
instead of being recomputed on every `winner_cost_annotations` call —
a winner's target can be reached from more than one node position
(internal sharing within a query, or the same CSE-shared target across
several queries), so this avoided redundant subtree walks.
- dag_export.rs (types crate): `shared_node_edge_annotations`'s
`parents_of` map is now built inline inside
`deduplicate_pointer_shared_nodes`'s existing per-node loop (which
already visits every remapped child edge once while assigning final
ids) instead of a second full pass over the deduplicated node list.
Not fixed (noted only): `computeSelectionWorkloadCost` in viewer.js still
hand-reimplements `cost.rs`'s `sum_workload_costs`/`workload_cost_summary`
dedup-and-sum algorithm in JS, with no shared source of truth — there's no
JS/Rust code-sharing mechanism in this tool today, so keeping the two
algorithms in sync remains a manual/review responsibility. Flagged as a
follow-up in the PR description.
Testing: `cargo build --workspace`, `cargo test --workspace` (all green,
no regressions), `cargo clippy --workspace --all-targets -- -D warnings`
(clean), `cargo fmt --all -- --check` (clean, after running `cargo fmt
--all` once for pre-existing drift), and
`python3 -m unittest discover -s tools/dag-viewer` (18/18). Regenerated
`dag.example.json` via generate-sample.sh — byte-identical, since the
sample workload doesn't happen to exercise the same-parent-twice edge
case fixed in item 3. `node --check` remains unavailable in this sandbox
(no Node.js installed); verified the viewer.js changes by careful manual
review plus the Python test suite, which inlines and structurally checks
viewer.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #286.
Update: review fixes (latest commit)
A code review found 5 confirmed bugs, all fixed on this branch:
viewer.js'sloadFiles()/loadWorkload()constructed each queryobject with a field allowlist that omitted the new
workload_costfield — the headline feature never rendered through drag-and-drop, the
file picker, or the planner UI, even though the exported JSON had the
data. Both loaders now forward
workload_cost.decision.idcollision could silently undercount amulti-query total.
decision.idis only unique within onedag_exportinvocation, not across independently loaded files.computeSelectionWorkloadCostnow dedups by${sourceBatch}: ${decision.id}, wheresourceBatchis a viewer-assigned id shared byevery query loaded from the same document.
shared_node_edge_annotationscountedVecoccurrences into a sharedchild as
consumer_count; aJoinwhose left and right operands arethe same
Rc(post pointer-dedup) inflated that to 2 for one realconsumer, halving the reported per-edge cost and producing two
colliding
EdgeCostAnnotationentries thatviewer.js'sedgeCostByPairMap then silently overwrote. Switched to aHashSetof distinct parent ids; added a regression test.
total_cost()validatedhorizonbut not the rate/one-shot inputsthemselves. A non-finite
recurring_cost_rate(e.g. a stray NaN)silently produced
Some(NaN)instead of following the module's own"never fabricate, never a poisoned total" rule. Both inputs are now
validated finite.
NamedGraph.workload_cost's dedupscope. The doc claimed cross-query dedup; the implementation only
ever dedups within one query (
WorkloadGraph.workload_costis the onethat dedups across all queries). The implementation was already
correct — only the doc was wrong, and is now fixed to say so, pointing
readers at
WorkloadGraph.workload_costfor cross-query totals.Also addressed two of the three lower-priority follow-ups:
cost: f64isnow derived from
selected_cost.valueat both call sites instead of beingset independently (closing the "kept in sync by convention" gap), and
default_cse_recompute_costis now memoized once per winner instead ofre-walking the same subtree on every matched node position (and
shared_node_edge_annotations'sparents_ofmap is now built inline indeduplicate_pointer_shared_nodes's existing loop instead of a secondfull pass). Not fixed:
computeSelectionWorkloadCostinviewer.jsstillhand-reimplements
cost.rs's dedup-and-sum algorithm in JS with no sharedsource of truth — there's no JS/Rust code-sharing mechanism in this tool,
so this remains a manual-sync follow-up.
Verified with
cargo build --workspace,cargo test --workspace(allgreen),
cargo clippy --workspace --all-targets -- -D warnings(clean),cargo fmt --all -- --check(clean), andpython3 -m unittest discover -s tools/dag-viewer(18/18).node --checkremains unavailable in this sandbox; the JS fixes were verified by careful
manual review instead (see "Testing" below for the same caveat on the
original diff).
Summary
Adds a structured, optional planner/exporter cost-and-benefit annotation
(
asap_types::cost::CostAnnotation) and wires it throughdag_export'sJSON output and
tools/dag-viewer's sidebar/on-graph UI.Rust:
crates/types/src/cost.rs(new)CostAnnotation { value, unit, source, baseline, delta, benefit_ratio, model_version, benchmark_id, inputs }— matches the issue's sketch, plusan explicit
benefit_ratiofield (kept separate fromdeltaso aconsumer never has to recompute it from
delta/baseline).CostUnit:CostUnitsPerSecond(the issue's rate formulas),CostUnits(atotal_cost(H)one-shot/finite-run total), andRelativeStructuralUnits(a dimensionless structural-size proxy — see"Deferred" below).
CostSource:Modeled/Measured/Unavailable. A missing value isalways
Unavailablewithvalue: None— never0or another syntheticnumber.
BaselineRef:PreAsapRecomputation,HighestRankedNonSelectedCandidate { rank },Named(String)— the issue's own two named examples, asfirst-class variants.
total_cost(recurring_rate, horizon, one_shot)—rate * H + one_shot,requires an explicit, finite, non-negative horizon for any recurring
term; rate and one-shot are separate arguments so they can never be
silently added by a caller before reaching this function.
sum_workload_costs/workload_cost_summary— sum a workload'sper-decision cost annotations, deduplicating by an explicit key (so a
node/decision shared across queries/regions is counted once), and
rejecting (not silently mixing) unit-mismatched aggregation.
(
sum_workload_costs_counts_a_shared_node_once,workload_cost_summary_computes_benefit_from_deduplicated_totals).Rust:
crates/types/src/dag_export.rsDagGraphgainsedge_annotations: Vec<EdgeCostAnnotation>({from, to, cost}), populated bydeduplicate_pointer_shared_nodesfor every edgerunning into a genuine DAG merge point — a node id referenced by more
than one parent after
export_post_asap's own dedup pass (realRcsharing, not necessarily a
SharedSubtreeStrategydecision). This isscoped exactly to the issue's "edge cost only when genuinely attributable
to the edge" bullet; arbitrary multi-hop path cost is explicitly not
attempted, matching the issue's own scope note.
DagDecisionandTargetReplacementgainbaseline_cost/selected_cost/benefit: Option<CostAnnotation>, additive alongsidetheir existing bare
cost: f64(unchanged — full backward compat, seebelow).
NamedGraph/WorkloadGraphgainworkload_cost: Option<WorkloadCostSummary>(whole selected-workload baseline/selected/benefit).
export()(plain, tree-only) never produces edgeannotations, and
export_post_asap()does for a real sharedRc— plusan updated existing test asserting
edge_annotationsis omitted (not[]) when empty.Rust:
crates/devtools/src/bin/dag_export.rswinner_cost_annotations(target, consumer_count, selected_cost)computesbaseline/selected/benefit for one winning candidate:
default_cse_recompute_cost(target) * consumer_count(
BaselineRef::PreAsapRecomputation) — the same structural-sizefunction
asap_aware_mapping::cost_model::DefaultCostModelalreadyuses, not a second formula. Always computable (never
Unavailable).RankedGroup::costs[0](already computed for ranking);
Unavailableexactly when that'sNaN(the cost model has no estimate for that candidate shape).baseline - selected, withbenefit_ratioguarded atbaseline <= 0.SharedSubtreeStrategy/CseSharedecision this isliterally "avoided recomputation for a shared sub-DAG" (issue
granularity item feat(core): interface types for L1 input, L3 intent algebra, L4 sketch algebra #3) — no separate mechanism needed, since the
baseline is exactly the cost of recomputing independently.
TargetReplacement, and every node'sDagDecisioninsidepost_graph), so they never disagree.NamedGraph.workload_cost/WorkloadGraph.workload_cost) are built by deduplicating every decisionin scope by
decision.id— already a collision-free key (one winnerindex), reused directly as
sum_workload_costs's dedup key rather thaninventing a second identity.
tools/dag-viewer/dag.example.jsonregenerated viagenerate-sample.sh→ real lowering → ASAP-aware mapping → post-ASAP →dag_export, not hand-patched. (One honest data point worth flagging:the regenerated
workload_cost.benefitfor that sample workload isnegative — today's structural-size proxy isn't always favorable to the
selected strategy. That's real output, not a bug; see "Deferred" below.)
JS:
tools/dag-viewer/viewer.js/index.html▼NN%/▲NN%badge appended to a post-ASAP node'slabel when its decision has a
benefitvalue (▼ = cheaper thanbaseline, ▲ = more expensive) — nothing appended when unavailable.
renderCostAnnotation/renderDecisionCostBlock): fullvalue/unit/Modeled-Measured-Unavailable badge/baseline/delta/ratio/
model-or-benchmark-version/inputs breakdown, on node click (replacement
decision cards) and edge click (
EdgeCostAnnotation).reads the exporter's own precomputed
NamedGraph.workload_costdirectly;multi-query selection aggregates the already-exported per-decision
annotations client-side, deduplicated by the explicit
decision.idfield (mirrors the Rust-side algorithm exactly) — this is aggregation of
explicit JSON fields, not client-side cost estimation, and it refuses to
aggregate mismatched units rather than mixing them.
renders exactly as before.
Deliberately deferred / stubbed (and why)
CostUnitsPerSecond): today's cost model(
asap_aware_mapping::cost_model) has noupdate_rate/evaluation_rate/query_intervalinputs at all — that's Recurrence-aware optimization: cost shared maintenance by query repetition #287's job.Every annotation this PR produces is honestly unit-tagged
RelativeStructuralUnitsinstead of mislabeling a structural-size proxyas a real rate. The
CostAnnotation/CostUnitplumbing already acceptsCostUnitsPerSecondunchanged, so wiring in Recurrence-aware optimization: cost shared maintenance by query repetition #287's inputs later is aproducer-side change only.
CostSource::Measured,benchmark_id): theschema supports this end-to-end (JS renders it with a distinct badge
color), but nothing populates it — Evaluate AHA-style sparse-subpopulation strategy vs. maintaining full hierarchical sub-population summaries #288's benchmark artifact/contract
doesn't exist yet, per the issue's own scope note.
decision(a winning replacement) get a
costannotation. Plain IR nodes (Scan,Filter, Join, ...) have no cost-model hook to estimate from today, so no
annotation is attached at all — no
Unavailableplaceholder is inventedwhere nothing was ever asked for.
edge_annotationsis only wired into the single-query lane view. Attribution across
collapsed cross-query union nodes is genuinely ambiguous (which query's
edge "owns" a cost after nodes from different queries have been merged
into one visual node) and didn't seem worth guessing at.
Design decisions where the issue was ambiguous
DagDecision.cost: f64/TargetReplacement.cost: f64unchangedand additive rather than replacing them with the new structured type —
the issue's own acceptance criteria requires "Existing exports without
annotations remain fully supported."
benefit_ratiois its own explicit field onCostAnnotationrather thanonly
delta(as the issue's literal sketch has it) — the issue's ownacceptance criteria requires the ratio to be renderable, and recomputing
it from
delta/baselineon every consumer seemed worse than computingit once, correctly (with the
baseline <= 0guard), at the source.default_cse_recompute_cost(target) * consumer_countas thedefault "pre-ASAP recomputation" baseline (rather than "highest-ranked
legal non-selected candidate") since it's always computable (a target
with only one candidate still gets a meaningful baseline) and it's the
more natural "why is this beneficial at all" comparison the issue's own
context section asks for.
Testing
cargo build --workspace— clean.cargo test --workspace— all green (146 inasap-types, 6 in thedag_exportbinary, no regressions elsewhere).python3 -m unittest discover -s tools/dag-viewer -p test_render.py—18/18 pass.
node --checkunavailable in this environment (no Node.js installed);substituted careful manual review of every JS diff plus end-to-end
smoke-testing (
dag_export→ JSON →render.py→ inlined HTML,inspected the resulting JSON for shape/values by hand) and the existing
Python test suite, which inlines and structurally checks
viewer.js. I'dflag re-running
node --check tools/dag-viewer/viewer.js(and eyeballingthe page in a real browser) as a good first thing for a human reviewer
with Node available to do.
Open questions / follow-ups for a reviewer
RelativeStructuralUnitsvalues should be hidden from theon-graph percentage badge until real rate/measured data exists (right
now a badge can read e.g.
▲25%off a structural proxy, which isdirectionally informative but not a real cost-per-second claim) — I left
it visible since the sidebar always makes the unit/provenance explicit
on click, but a reviewer more familiar with how this tool gets used
might prefer to gate the on-graph badge to
Modeled-with-a-real-rate-unit or
Measuredonly.maintained annotations) are the natural next steps to move
RelativeStructuralUnitsoutput toward realCostUnitsPerSecond/Measuredannotations; no plumbing changes should be needed on thisside when they land.
🤖 Generated with Claude Code