From a421b5a8e1bbf782dc1ab9a1e00165ea97df971c Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 13 Jun 2026 18:38:47 +0200 Subject: [PATCH] spec: 0035 node-name-in-graph-model (boss-signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a node's explicit instance name in the `aura graph` viewer: a named leaf renders its box head as `fast: SMA[length]` (the instance name as a `:` declaration prefix), an unnamed leaf keeps the bare `SMA[length]`. The name crosses two surfaces — a conditional `"name"` field in the JSON graph model (engine half) and a `cellLabel` prefix in graph-viewer.js (viewer half). Explicit-`.named()`-only; `node_name()`'s lowercased-type default is not surfaced. `named()`'s non-empty rule is kept with its code unchanged, its doc re-grounded to the load-bearing invariant (knob-address segment + Some/None prefix switch). Signed via the /boss auto-sign gate: all objective gates green (precondition, self-review, grounding-check PASS) and a unanimous five-lens spec-skeptic panel (criterion, grounding, scope-fork, ambiguity, plan-readiness). Two earlier blocks were resolved before the unanimous round — a separator-ambiguity by an editorial fix, and a scope-fork (which nodes carry the prefix) by an explicit user decision recorded as a provenance-bearing reconciliation comment on the issue. refs #58 --- docs/specs/0035-node-name-in-graph-model.md | 358 ++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 docs/specs/0035-node-name-in-graph-model.md diff --git a/docs/specs/0035-node-name-in-graph-model.md b/docs/specs/0035-node-name-in-graph-model.md new file mode 100644 index 0000000..39d4110 --- /dev/null +++ b/docs/specs/0035-node-name-in-graph-model.md @@ -0,0 +1,358 @@ +# Node instance name in the graph model — Design Spec + +**Date:** 2026-06-13 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Surface a node's **explicit instance name** in the `aura graph` viewer, so two +structurally identical fan-in legs the author named (`fast`/`slow`) no longer +render as indistinguishable boxes. A named leaf primitive renders its box head as + +``` +fast: SMA[length] +``` + +— the instance name as an "is-a" declaration prefix joined by `:`, the type as +the head, the param signature in the (already-shipped, commit a051571) square +brackets. An **unnamed** leaf keeps the bare `SMA[length]` head: the prefix +appears only for an explicit `Sma::builder().named("fast")`, never for +`node_name()`'s lowercased-type default (which would render the redundant +`sub: Sub`). + +This closes the legibility gap left open by cycle 0031 (issue #58): the author +already addresses tuning knobs by instance identity in `param_space()` +(`sma_cross.fast.length` vs `sma_cross.slow.length`), but that identity did not +reach the JSON graph model, so the viewer could not speak it. This cycle carries +the name into the model surface and renders it. + +**Non-goals (out of scope, explicit):** + +- **Collapsed-composite visibility.** `fast`/`slow` live inside `sma_cross`, + which renders collapsed by default; the prefix is only visible after expanding + the composite. This cycle does **not** change default expansion — it is a + separate follow-up. +- **Composite boxes.** A composite box already shows its definition name as its + head (`sma_cross`); it is not a `name: TYPE[params]` binding and is untouched. +- **The INFO/tooltip panel** is unchanged (it keeps showing type · role · + params; surfacing the name there is a possible later nicety, not this cycle). +- **Values in the signature.** `aura graph` renders the value-empty blueprint, + so the brackets carry param *names* (`SMA[length]`), not values — unchanged. + +## Architecture + +The name crosses two surfaces that meet at the JSON graph model +(`model_to_json`, the canonical render model `aura graph` emits and embeds in +the standalone HTML): + +1. **Engine half (producer).** `prim_record` (the per-primitive JSON emitter in + `crates/aura-engine/src/graph_model.rs`) gains a `"name"` field carrying the + builder's **explicit** instance name. The field is **present only when the + node was explicitly `.named()`** — absent otherwise. This needs a new + read-only accessor on `PrimitiveBuilder` exposing the raw + `instance_name: Option` (today only the *resolved* `node_name()` is + public, which collapses the default in). + +2. **Viewer half (consumer).** `graph-viewer.js` passes the model's `name` + through `adaptNodes` onto the per-node object, and `cellLabel` prepends the + `name: ` declaration prefix to the box head when a name is present. + +**Invariant check.** The name is a render/debug symbol on the model surface +only; it is **not** carried into the compilat (lowering drops it, exactly as +today — C23). The model gains a deterministic field sourced from an +author-controlled identifier (C14: same input → same model bytes). No hot-path, +determinism, or causality surface is touched. This is the same render/model-only +change the issue's RESET comment confirmed was sound; only the discarded render +*rule* (`fast = SMA(length)`) is replaced by the `fast: SMA[length]` binding. + +## Concrete code shapes + +### User-facing program (the acceptance evidence) + +The author already writes this (no API change — `named()` exists since 0031): + +```rust +// crates/aura-cli/src/main.rs — the sma_cross composite (unchanged source) +fn sma_cross(name: &str) -> Composite { + Composite::new( + name, + vec![ + Sma::builder().named("fast").into(), // fast SMA leg + Sma::builder().named("slow").into(), // slow SMA leg + Sub::builder().into(), // unnamed + ], + /* edges, role, output … */ + ) +} +``` + +Then runs the viewer and expands the `sma_cross` composite: + +``` +$ aura graph > /tmp/aura-graph/index.html # serve + open in a browser +``` + +**Before** this cycle, the two legs render identically: + +``` +[ SMA[length] ] [ SMA[length] ] ← which leg is fast? indistinguishable +``` + +**After**, the explicit names disambiguate; the unnamed `Sub` stays bare: + +``` +[ fast: SMA[length] ] [ slow: SMA[length] ] [ Sub ] +``` + +### Change 1 — `instance_name()` accessor + re-grounded `named()` doc (engine) + +`crates/aura-core/src/node.rs`, alongside `node_name()`. The raw `Option` is +needed because `node_name()` already resolves `None` into the lowercased type, +which is exactly the default we must *not* surface. + +```rust +// before — only the resolved name is public +pub fn node_name(&self) -> String { + self.instance_name + .clone() + .unwrap_or_else(|| self.name.to_ascii_lowercase()) +} + +// after — add the raw accessor (node_name() unchanged) +/// The explicit instance name if one was set via `named()`, else `None`. +/// Unlike `node_name()`, this does not resolve the default — callers that must +/// distinguish "explicitly named" from "defaulted" (the graph model) read this. +pub fn instance_name(&self) -> Option<&str> { + self.instance_name.as_deref() +} +``` + +`named()`'s **code is unchanged**, but its doc is re-grounded. The non-empty +`debug_assert!` predates this cycle — it was written for the old notation where +the name *replaced* the type label, so an empty name meant an empty box. That +rationale is gone, but a stronger one replaces it: the explicit name is now +load-bearing on **two** surfaces — it forms a knob-address segment +(`..`, via `node_name()` in `collect_params`) **and** it +is the `Some`/`None` switch that gates this cycle's `"name"` field and viewer +prefix. The assert stays (the invariant belongs at its source, not spread across +N consumers); only the doc moves: + +```rust +// before +/// Set this node instance's name. Must be non-empty. +pub fn named(mut self, name: &str) -> Self { + debug_assert!(!name.is_empty(), "node name must be non-empty"); + self.instance_name = Some(name.to_string()); + self +} + +// after — same code, doc re-grounded to the load-bearing invariant +/// Set this node instance's explicit name. Must be non-empty: the name forms a +/// knob-address segment (`..`) and is the `Some`/`None` +/// switch for the graph-model `"name"` field and the viewer prefix. An empty +/// name would yield a broken address segment (`sma_cross..length`) and a +/// degenerate `Some("")` — serialised as `"name":""` yet rendering no prefix, +/// i.e. two encodings for "no visible name". The `debug_assert` guards that at +/// source. +pub fn named(mut self, name: &str) -> Self { + debug_assert!(!name.is_empty(), "node name must be non-empty"); + self.instance_name = Some(name.to_string()); + self +} +``` + +### Change 2 — `prim_record` emits a conditional `"name"` (engine) + +`crates/aura-engine/src/graph_model.rs`. The field is built as a possibly-empty +fragment and placed first inside the `prim` object; absent when unnamed. + +```rust +// before +fn prim_record(b: &aura_core::PrimitiveBuilder) -> String { + let s = b.schema(); + let role = if s.output.is_empty() { "sink" } else { "node" }; + let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind))); + let ins = join(s.inputs.iter().map(port_json)); + let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind))); + format!( + r#"{{"prim":{{"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#, + json_str(&b.label()), + json_str(role), + ) +} + +// after — explicit instance name only, emitted as a leading field +fn prim_record(b: &aura_core::PrimitiveBuilder) -> String { + let s = b.schema(); + let role = if s.output.is_empty() { "sink" } else { "node" }; + let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind))); + let ins = join(s.inputs.iter().map(port_json)); + let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind))); + // explicit instance name only — an unnamed node carries no "name" field + // (node_name()'s lowercased-type default would be a redundant type-duplicate) + let name = match b.instance_name() { + Some(n) => format!(r#""name":{},"#, json_str(n)), + None => String::new(), + }; + format!( + r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#, + json_str(&b.label()), + json_str(role), + ) +} +``` + +### Change 3 — both goldens gain `"name"` for the named legs + +The two SMA legs inside `sma_cross` are the only explicitly-named nodes in the +sample topology, so the diff is exactly two records in each golden; the unnamed +`Sub` and all root-level nodes are byte-identical. + +```json +// before (one SMA leg, in both goldens) +"0":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}} + +// after +"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}} +``` + +Touches: `crates/aura-engine/src/graph_model.rs` inline `model_golden` (its +header comment documents the re-capture procedure) and +`crates/aura-cli/tests/fixtures/sample-model.json`. Leg `"1"` gains +`"name":"slow"` the same way. + +### Change 4 — viewer passes `name` through (consumer) + +`crates/aura-cli/assets/graph-viewer.js`. `adaptNodes` adds `name`, and the +leaf-emit site in `genDot` forwards it into the `cellLabel` argument object +(`s` already is the adapted prim). `name` is `undefined` for unnamed nodes — +`cellLabel` guards on it. + +```js +// adaptNodes — before +out[key] = { prim: { type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } }; +// adaptNodes — after +out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } }; + +// genDot leaf-emit (build) — before +block += `${cid} [label=${cellLabel(cid, { type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`; +// after — forward name (composite branch at the `type: cname` site is NOT changed) +block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`; +``` + +### Change 5 — `cellLabel` prepends the `name: ` prefix (consumer) + +`crates/aura-cli/assets/graph-viewer.js`, the box-head assembly. The type stays +the bold head; the prefix is added only when `o.name` is present. + +```js +// before +const name = `${o.type}${sig}`; + +// after — declaration prefix when an explicit name is present +const prefix = o.name + ? `${o.name}: ` + : ""; +const name = `${prefix}${o.type}${sig}`; +``` + +(The exact prefix colour is cosmetic polish. The separator is exactly `: ` — +a colon then one trailing space, **no leading space** — so the head reads +`fast: SMA[length]` as the user requested, matching the already-shipped `, ` +param separator; it reuses the existing `#9399b2` punctuation grey.) + +## Components + +| Component | File | Change | +|-----------|------|--------| +| `PrimitiveBuilder::instance_name()` | `crates/aura-core/src/node.rs` | new read-only accessor exposing the raw `Option<&str>` | +| `PrimitiveBuilder::named()` doc | `crates/aura-core/src/node.rs` | doc re-grounded — the non-empty `debug_assert!` is now load-bearing for the prefix switch + knob-address segment; **code unchanged** | +| `prim_record` | `crates/aura-engine/src/graph_model.rs` | conditional leading `"name"` field | +| inline `model_golden` | `crates/aura-engine/src/graph_model.rs` (tests) | golden re-capture: `+name` on the two SMA legs | +| `sample-model.json` | `crates/aura-cli/tests/fixtures/` | golden: `+name` on the two SMA legs | +| `adaptNodes` | `crates/aura-cli/assets/graph-viewer.js` | pass `name` onto the per-node object | +| `genDot` leaf-emit | `crates/aura-cli/assets/graph-viewer.js` | forward `name` into `cellLabel` (leaf branch only) | +| `cellLabel` | `crates/aura-cli/assets/graph-viewer.js` | prepend `name: ` prefix when present | + +## Data flow + +``` +Sma::builder().named("fast") author sets instance_name = Some("fast") + │ PrimitiveBuilder.instance_name = Some("fast") + ▼ +prim_record ── b.instance_name() = Some("fast") ──▶ {"prim":{"name":"fast","type":"SMA",…}} + │ (None ⇒ no "name" field) + ▼ +model_to_json ─▶ canonical JSON model (the two goldens pin this surface) + │ embedded verbatim into the standalone HTML by render_html + ▼ +graph-viewer.js: normalizeModel → adaptNodes (name carried) → genDot leaf-emit + │ + ▼ +cellLabel ── o.name present ⇒ prefix "fast: " + head "SMA" + sig "[length]" + ⇒ fast: SMA[length] +``` + +The name is dropped at lowering (compilat wiring is by raw index, C23); it never +re-enters the engine — there is no runtime path here, only the render/model +surface. + +## Error handling + +No new failure modes. `named()` keeps its non-empty `debug_assert!` — the code +is unchanged; only its doc is re-grounded (Change 1). That invariant is now +load-bearing for *this* cycle, not only the old name-as-label render it was +written for: the `Some`/`None` distinction is the prefix/field switch, so a +`Some("")` would write `"name":""` into the model yet render no prefix (the JS +guard treats `""` as falsy) — two encodings for "no visible name", breaking the +canonical *field-present ⟺ prefix-rendered* correspondence. The assert forbids +that degenerate state at its source. `instance_name()` is total (`Option`). In +`prim_record` the `None` arm yields an empty fragment, so an unnamed node +serialises exactly as today — the change is additive and the absent-field case +is the existing behaviour. The viewer guard (`o.name ? … : ""`) makes a +missing/`undefined` name a no-prefix render, never a literal `undefined:`. + +## Testing strategy + +Properties protected (the planner sequences these; the engine half is golden + +unit, the viewer half drives the real module headlessly): + +1. **Model surface — explicit name present, default absent.** A focused + `prim_record`/`model_to_json` unit test: a `Sma::builder().named("fast")` + serialises with `"name":"fast"`; an unnamed `Sub::builder()` serialises with + **no** `"name"` substring. This is the load-bearing new assertion. +2. **Goldens re-captured.** Both `model_golden` (inline) and `sample-model.json` + updated to carry `"name":"fast"`/`"name":"slow"` on the two legs and remain + byte-exact elsewhere. These pin the whole-model shape (C14 determinism). +3. **Accessor.** `instance_name()` returns `Some("fast")` after `named("fast")` + and `None` otherwise — paired with the existing + `node_name_defaults_to_lowercased_type_label` / + `named …node_name() == "fast"` tests (`crates/aura-core/src/node.rs`), which + ratify the resolution behaviour this accessor deliberately bypasses. +4. **Viewer render.** A headless `.mjs` guard alongside + `crates/aura-cli/tests/viewer_dot_ids.mjs` (run via the `viewer_dot.rs` + wrapper): load the real `graph-viewer.js`, drive `genDot` over a model whose + leaf carries `"name":"fast"`, and assert the emitted DOT label contains the + `fast` prefix ahead of the `SMA` head; a leaf with no `name` emits no prefix. + +Existing green anchors for current behaviour the spec builds on: +`model_golden` (current model has no `name`), `viewer_dot_ids.mjs` (genDot is +exported and headlessly drivable), the `named()`/`node_name()` tests above. + +## Acceptance criteria + +- A leaf primitive built with `.named("X")` renders its viewer box head as + `X: TYPE[params]` (e.g. `fast: SMA[length]`); a leaf with no explicit name + renders the bare `TYPE[params]`. +- The JSON model carries `"name":"X"` on an explicitly-named primitive and **no** + `"name"` field on a defaulted one; both goldens reflect this and are otherwise + byte-identical to today. +- `cargo build --workspace`, `cargo test --workspace`, and + `cargo clippy --workspace --all-targets -- -D warnings` are green; the + `viewer_dot.rs`/`.mjs` guard passes. +- C23 (name dropped at lowering, not in the compilat) and C14 (deterministic + model bytes) hold — verified by the unchanged lowering path and the + re-captured goldens. +- Out-of-scope items (collapsed-composite visibility, composite-box prefix, INFO + panel) are untouched.