Files
Aura/docs/specs/0035-node-name-in-graph-model.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

359 lines
16 KiB
Markdown

# 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<String>` (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 flat graph (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
(`<composite>.<name>.<param>`, 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 (`<composite>.<name>.<param>`) 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 = `<font color="#f5f5f5"><b>${o.type}</b></font>${sig}`;
// after — declaration prefix when an explicit name is present
const prefix = o.name
? `<font color="#cdd6f4">${o.name}</font><font color="#9399b2">: </font>`
: "";
const name = `${prefix}<font color="#f5f5f5"><b>${o.type}</b></font>${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 (flat graph 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 flat graph) 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.