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

16 KiB

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):

// 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.

// 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:

// 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.

// 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.

// 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.

// 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.

// 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.