fix(dag-viewer): recategorize QueryExpr node kinds against real IR (#187) - #292
Open
zzylol wants to merge 2 commits into
Open
fix(dag-viewer): recategorize QueryExpr node kinds against real IR (#187)#292zzylol wants to merge 2 commits into
zzylol wants to merge 2 commits into
Conversation
) node-style.js's KIND_CATEGORY table was keyed on names from the old l2-intent-algebra design doc (InfoJoin, LetBinding/Ref, Window/WindowFunc, Distinct, Merge, plain Sample), not the actual kind strings crates/types/src/dag_export.rs emits at runtime. Most of those old names don't exist as DagNode.kind values at all, and several real kinds (PromqlRelabel, PromqlInfoEnrich, PromqlSeriesSample, Concat, Dedup, SQLWindowFunc, CurrentTimestamp, ...) had no entry and silently fell back to 'derive'. Rebuilt the table against dag_export.rs's build_no_recheck/summary_shape match arms (the real ground truth for what DagNode.kind can be) and re-decided every category assignment: - PromqlInfoEnrich (was 'InfoJoin' in 'join'): has exactly one QueryExpr child, unlike Join's two -- the info metric it enriches from is never a DagNode, it's resolved at runtime. Moved to 'derive' (it grafts extra columns onto passthrough rows, like PromqlRelabel). - PromqlSeriesSample (was 'Sample' in 'filter'): keeps a deterministic subset of series by quota, not a boolean predicate. Given its own new 'sample' category instead of overloading 'filter'. - Concat (was 'Merge', lumped into 'set' with SetOp): exact n-ary UNION ALL, no dedup -- its own doc explicitly contrasts it with SetOp. Split into a new 'combine' category; 'set' keeps only genuine set-semantic ops (Dedup, SetOp). - LetBinding/'bind' category: removed outright. LetBinding has no equivalent in the current QueryExpr enum at all (no let/ref binding concept survives past the front end); nothing else maps into 'bind' either. - TimeShift (was 'derive'): its own doc says it changes when child is evaluated and leaves its schema unchanged -- no value transform at all, so it belongs with the other time-scoping kinds in 'window'. Locks the corrected mapping in with two new cargo tests in crates/types/src/dag_export.rs that parse node-style.js's own source and check its KIND_CATEGORY keys exactly match the literal kind strings build_no_recheck/summary_shape can produce (no missing, no orphaned/stale entries), and that every category value it uses is actually declared. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses three follow-up items from review of the #187 PR: 1. .github/workflows/rust.yml only triggered on crates/**/Cargo.*, so the new dag_export.rs sync tests never ran on a JS-only node-style.js edit -- exactly the kind of change most likely to reintroduce the drift #187 was about. Added tools/dag-viewer/** to both the push and pull_request path filters. 2. crates/types/src/dag_export.rs's DAG_NODE_KINDS was itself a third hand-copied kind list: nothing tied its string literals back to what push_node/summary_shape actually emit, so a renamed kind literal at a push_node call site could drift from DAG_NODE_KINDS (and from node-style.js) with every test still green. Fixed by making the kind string single-sourced instead of independently duplicated: - New kind_tag(&QueryExpr) -> &'static str and summary_kind_tag(&SummaryExpr) -> &'static str, each an exhaustive match over the real enum. - push_node now computes DagNode.kind via kind_tag(expr) instead of taking a separate kind: &'static str argument -- removed that parameter and the 23 literal arguments at its call sites in build_no_recheck. - summary_shape now derives its returned kind via summary_kind_tag(expr) instead of typing each variant's name out a second time in its own match arms. - The test module no longer hardcodes a canonical kind list at all: canonical_dag_node_kinds() builds one representative sample QueryExpr/SummaryExpr per operator variant and reads back kind_tag/ summary_kind_tag's real output for it -- the same functions production code calls -- so the string values have exactly one source of truth. This does not close the gap all the way: nothing forces the sample list to grow when a brand-new variant is added (that variant's own match arm in kind_tag/summary_kind_tag and build_no_recheck/summary_shape is compiler-enforced, but a new sample here is not). Fully closing that would need a proc-macro/derive that enumerates QueryExpr's variants (e.g. adding a strum dependency) or a third hand-maintained match whose only job is enumeration -- left as follow-up, noted in the PR description and in this file's own test-module comment. 3. node-style.js's categoryOf fell back to 'derive' silently for any unmapped kind -- the exact failure mode that let #187 go unnoticed for the table's whole life. Added a dedicated 'unknown' category (hatched red, distinct from every real category and from 'summary's neutral gray), a legend row for it, a console.warn from categoryOf when it fires, and a matching dashed/thicker-border cytoscape style in viewer.js so it reads as "needs attention" rather than blending in. Verified: cargo test -p asap-types (110 passed, including both node-style sync tests), cargo test -p asap-devtools --bin dag_export (6 passed), cargo check --workspace, cargo clippy -p asap-types --tests (clean), cargo fmt --check -p asap-types (clean), python3 -m unittest tools/dag- viewer/test_render.py (18 passed), and tools/dag-viewer/generate-sample.sh regenerates dag.example.json byte-identical to what's committed. Refs #187. 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.
Summary
Fixes #187.
tools/dag-viewer/node-style.js'sKIND_CATEGORYtable wasbuilt against names from the old
old_docs/docs/l2-intent-algebra.mddesign doc (
InfoJoin,LetBinding/Ref,Window/WindowFunc,Distinct,Merge, plainSample) rather than the literal&'static strkind tags
crates/types/src/dag_export.rs'sbuild_no_recheckandsummary_shapeactually push onto aDagNode/SummaryDagNodetoday. Thatturned out to be a bigger problem than the four examples in the issue
suggested: most of those old names are dead entries that never match a
real
DagNode.kind, and several real, currently-emitted kinds(
PromqlRelabel,PromqlInfoEnrich,PromqlSeriesSample,Concat,Dedup,SQLWindowFunc,PromqlScalarBridge,EvalTimestamp,CurrentTimestamp,PromqlVectorFromScalar,PromqlScalarFromVector,PromqlSubquery) had no entry at all and were silently falling back toderiveviacategoryOf's|| 'derive'.This PR rebuilds the table against the real 23 pre-ASAP + 7 post-ASAP kind
strings and re-decides every category assignment on the actual IR shape
(arity, schema effect, semantics), not surface name similarity to the old
doc.
Categorization rationale
The four kinds the issue called out by name:
InfoJoin→ nowPromqlInfoEnrich, movedjoin→derive. It hasexactly one
QueryExprchild (child), unlikeJoin's two. The"other side" it enriches labels from (an info metric matched by
selector) is never aQueryExpr/DagNodein this graph at all — it'sresolved at runtime by the post-ASAP binder. So there's no second DAG
input for it to combine the way
Join/SetOpgenuinely do. What it does— graft extra label columns onto rows that otherwise pass through
unchanged — is the same shape of operation as
PromqlRelabel's columnrewrite, so it belongs in
derive.Sample→ nowPromqlSeriesSample, movedfilter→ newsamplecategory.
Filternarrows rows by a boolean predicate;PromqlSeriesSample(
limitk/limit_ratio) keeps a deterministic subset of whole seriesper group by quota — its own doc is explicit that it's "not a ranking
(TopK) and not a reduction." Neither predicate-based nor a
cardinality-reducing aggregate, so it gets its own category instead of
overloading
filter's meaning.Merge→ nowConcat, andSetOp: split the oldsetbucket.Concatis an exact, n-aryUNION ALL— rows are concatenated, neverdeduplicated — and its own doc explicitly contrasts it with
SetOp("SQL's
UNION/INTERSECT/EXCEPTareQueryExpr::SetOp, not this").SetOpis a binary, genuinely set-theoretic combinator(union/intersect/except, dedup-by-default). Lumping them together implied
Concatcarries set semantics it explicitly doesn't. Added a newcombinecategory forConcat;setnow holds onlyDedup(SQLDISTINCT— also makes a relation behave like a set) andSetOp.LetBinding/bindcategory: removed. There is noLetBinding(orRef) variant in the currentQueryExprenum at all — that conceptexisted only in the old pre-refactor design doc. Nothing in
dag_export.rscan ever produce aDagNodethis category would applyto, and nothing else warrants folding into it, so both the kind entry and
the category were dead weight.
Other corrections found while reviewing every kind (per the "review
every other node kind too" instruction):
TimeShift: movedderive→window. Its own doc says it "moveswhen
childis evaluated... but leaves its schema unchanged" — nocolumn is transformed at all, so
derive("transforms values") waswrong. It's the same time-scoping concept
TimeRange/PromqlSubqueryrepresent.
CurrentTimestampandPromqlScalarBridge(thenew name for the old
Scalar) todata, alongsideScan/EvalTimestamp.(
Relabel→PromqlRelabel,VectorFromScalar→PromqlVectorFromScalar,ScalarFromVector→PromqlScalarFromVector,Subquery→PromqlSubquery,WindowFunc→SQLWindowFunc,Distinct→Dedup) without changing theircategory, since those reassignments already looked right on review.
Final category set (pre-ASAP;
summaryis unchanged and untouched):data,filter,sample(new),derive,aggregate,window,join,set,combine(new),sort—bindremoved.Other files checked
viewer.js/index.htmlbuild cytoscape styles, CSS vars, and the legendgenerically from
Object.keys(CATEGORIES)/Object.entries(CATEGORIES)—no other file hardcodes a category name (
viewer.jsonly special-cases thedatacategory and theKeepPreAsapkind, both untouched), so no CSS orlegend changes were needed beyond
node-style.jsitself.render.py's one"kind"string default is an unrelatedAggIntentmeasure-kind fallback,not a
DagNode.kind.Tests
cargo tests incrates/types/src/dag_export.rs(
node_style_js_categorizes_every_dag_node_kind_exactly_once,node_style_js_every_kind_category_value_is_a_declared_category) thatparse
tools/dag-viewer/node-style.js's own source and check itsKIND_CATEGORYkeys exactly match every kind string production code canemit — no missing, no orphaned/stale entries — and that every category
value used is actually declared in
CATEGORIES. See "Review follow-up"below for how the canonical kind list these compare against is itself
derived (not a third hand-copied list). This is a real regression
guardrail: it would have caught the exact staleness this issue reports,
and will catch it again if a future
QueryExpr/SummaryExprvariant isadded/renamed without updating the viewer — and CI now actually runs it
on a
tools/dag-viewer/**-only change (see below).cargo test -p asap-types— 110 passed (includes the 2 new tests).cargo test -p asap-devtools --bin dag_export— 6 passed.cargo check --workspace— clean.python3 -m unittest discover -s tools/dag-viewer -p test_render.py— 18passed.
cargo fmt --check -p asap-types/cargo clippy -p asap-types --tests— clean.
tools/dag-viewer/generate-sample.shregeneratesdag.example.jsonbyte-identical to what's committed, and every
kindit contains(
Aggregate,BinaryOp,Join,KeepPreAsap,Limit,Project,Scan,Sort,SummaryAgg,SummaryEstimate,TimeRange) has aKIND_CATEGORYentry.node) is available in this sandbox, sonode --checkstill couldn't be run directly (see open questions); I hand-verified
brace/paren/bracket balance and the new
cargo tests exercise the file'sactual object-literal contents end-to-end (parsing the real source, not a
mock), which gives strong confidence the syntax is valid.
Review follow-up (addressed)
A first review of this PR found no blocking bugs but flagged three real
gaps, all fixed on this branch:
.github/workflows/rust.ymltriggered only oncrates/**/Cargo.*paths, so a JS-only
node-style.jsedit wouldn't runcargo testatall. Added
tools/dag-viewer/**to both thepushandpull_requestpath filters.
DAG_NODE_KINDSwas itself a third hand-copied kind list — nothingtied its string literals back to what
push_node/summary_shapeactually emit, so a renamed kind literal at a
push_nodecall sitecould drift from both
DAG_NODE_KINDSandnode-style.jswith everytest still green (the exact bug class dag-viewer: verify QueryExpr node-category mapping #187 was about). Fixed by
factoring the kind string out to single-sourced functions instead of
duplicating it:
kind_tag(&QueryExpr) -> &'static strandsummary_kind_tag(&SummaryExpr) -> &'static str, each an exhaustivematch over the real enum.
push_nodenow computesDagNode.kindviakind_tag(expr)instead of taking a separatekindargument (removedfrom all 23 call sites in
build_no_recheck);summary_shapederivesits returned kind via
summary_kind_tag(expr)the same way. The testmodule no longer hardcodes a kind list at all —
canonical_dag_node_kinds()builds one representative sampleQueryExpr/SummaryExprper operator variant and reads backkind_tag/summary_kind_tag's real output for it, so the stringvalues have exactly one source of truth. This doesn't close the gap
entirely — see open question below.
categoryOf's silent|| 'derive'fallback was left in place —exactly what let dag-viewer: verify QueryExpr node-category mapping #187 hide for the table's whole life. Added a
dedicated
unknowncategory (hatched red, distinct from every realcategory and from
summary's neutral gray) with a legend row, aconsole.warnfromcategoryOfwhen it fires, and a matchingdashed/thicker-border cytoscape style in
viewer.js.Open questions / follow-ups for a human reviewer
windowstill spans two different "window" concepts on purpose:PromQL temporal range-scoping (
TimeRange,PromqlSubquery,TimeShift) and SQL'sOVER (...)analytic window functions(
SQLWindowFunc). I judged these share enough of a "reads/positions ascoped window of rows or time around each row" idea to stay one
category rather than splitting further (the file's own comment already
flags that a 10th/11th saturated hue is pushing visual distinguishability),
but a domain expert might prefer splitting this the same way
setwassplit from
combine.node --checkwas not run (no Node.js in this environment) — wortha human/CI running it once before merge, though the new Rust-side
parsing test already exercises the real file content.
PromqlInfoEnrich's move out ofjoinis the most debatable call inhere — it is conceptually a join against an info metric, just one
where the other side never becomes a graph node. If the viewer's
audience cares more about "this combines data from two logical sources"
than "this is literally a two-child DAG combinator,"
joinmight stillbe defensible. I went with arity/graph-shape as the tiebreaker since
that's what the rest of the categories track.
canonical_dag_node_kinds()test helper still doesn'tauto-discover a brand-new variant (item 2 above): adding a new
QueryExpr/SummaryExproperator variant forces a new match arm inkind_tag/summary_kind_tag/build_no_recheck/summary_shape(compiler-enforced, exhaustive matches), but doesn't force a new sample
in
sample_query_exprs/sample_summary_exprs— so it's possible to adda variant, give it a real kind tag, and still have the "every kind has a
node-style.js entry" test silently not cover it. Fully closing that
would need either a proc-macro/derive enumerating
QueryExpr's variants(e.g. a new
strumdependency) or a third hand-maintained match whoseonly job is enumeration — judged out of scope for this pass.
(
parse_kind_category/parse_category_names) is format-brittle(assumes 2-space indent, single quotes,
\n};-style closes). It failsloud rather than silent if
node-style.js's formatting changes, solower priority — flagged as-is, not hardened in this pass.
dag_export.rs's new tests hard-depend on$CARGO_MANIFEST_DIR/../../tools/dag-viewer/node-style.jsexisting at afixed relative path — fine today, but worth a
publish = falsenote (orsimilar) on the crate if it's ever published standalone. Not touched
here to avoid unrelated crate-config changes.
viewer.js'snode[kind = "KeepPreAsap"]selector remains a kind-stringliteral outside
KIND_CATEGORY(by design —KeepPreAsapgets adeliberate one-off override, not a category). Noted for awareness, not a
defect.
🤖 Generated with Claude Code