Files
Aura/docs/specs/0037-bound-param-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

21 KiB

Bound params in the graph model + viewer signature — Design Spec

Date: 2026-06-13 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

A node built with .bind(slot, value) fixes one declared param to a structural constant: the slot leaves the tunable surface (params() / the aggregated param_space / the sweep axes) — correct, and the point of bind. But it also vanishes from the rendered node signature, because the graph-model serializer (prim_record) emits only schema.params, which no longer holds the bound slot.

The observable defect (cycle 0036 showcase): the signals composite's blend node is a LinComb(3) with three input ports (term[0..2]) whose third weight is bind-bound to 0.5. The viewer renders

blend: LinComb[weights[0], weights[1]]

— a 2-arity signature over a box that shows 3 input ports, with the fixed 0.5 invisible. The signature misrepresents the node.

This cycle separates the two concerns bind conflates:

  • Tunable surface (param_space, sweep axes, C12/C19): bind removes the slot. Unchanged — correct.
  • Node signature (the render): bind should annotate, not erase. Show all slots; mark the bound one with its fixed value.

Target render (the settled notation — see Settled design decisions):

blend: LinComb[weights[0], weights[1], weights[2]=0.5]

all three slots, the bound weights[2]=0.5 visually dimmed. This is consistent with the value-empty blueprint convention: a free param renders as a name (value deferred to sweep/bootstrap, e.g. SMA[length]); a bound param has its value fixed at blueprint time, so it renders name=value. Showing the bound value is showing a structural fact, not a tuning value.

The change is structurally identical to cycle 0035 (commit 0ae8320, the node instance-name surface): a conditional field threaded engine → model → viewer plus goldens and a headless render guard. Like the instance name, the bound annotation is a render/model-only debug symbol, dropped at lowering (C23), and the model stays deterministic (C14).

Settled design decisions

The issue (#63) flagged one open design decision — the render notation — "settle BEFORE implementing (with the user)". It was settled with the user in this /boss session (reconciliation comment posted on #63, 2026-06-13):

  • Notation = Option A. Keep the square-bracket TYPE[...] convention (as SMA[length]); render the bound slot as name=value (weights[2]=0.5), visually dimmed (reuse the 0035 : separator's muted grey #9399b2). Rejected alternatives: positional value-only (...,0.5, drops the slot name); parens (LinComb(...), a global notation change beyond this issue's scope).

Two further decisions are realizations of the settled notation, not open forks (they do not change the cycle's observable acceptance — the showcase bind is of the trailing slot, where every option renders identically):

  • Slot-order faithfulness — record the original slot position. The issue's stated goal is "merge the free params and the bound params back into slot order". For a trailing bind (the showcase) "append bound after free" and "true slot order" coincide; for a non-trailing bind they diverge. Honouring the issue's own "slot order" goal for any bind pattern requires the bound record to carry the slot's original position. This is the value-empty-blueprint convention applied faithfully (a slot's position is a structural fact, like its name). The issue's illustrative triple ["weights[2]","f64","0.5"] (an e.g.) therefore gains a leading position: [2,"weights[2]","f64","0.5"]. Chosen on substance (what the design permits — correct rendering of mid-slot binds — and compositional clarity), not effort.

  • Deterministic value rendering (C14). The bound Scalar value formats to a canonical string via a new scalar_str helper, mirroring the existing kind_str: i64/bool/timestamp by their integer/true/false/epoch-ns form, f64 by Rust's shortest round-trippable debug form ({:?}), which keeps a decimal point for whole values (1.0, not 1) so a bound f64 never reads as an i64 in the head. 0.5 renders 0.5 under either Display or Debug; the Debug choice only disambiguates whole-number floats.

Architecture

Three layers, mirroring the 0035 thread, plus goldens and a guard:

  1. aura-core (node.rs, PrimitiveBuilder): bind additionally records a bound: Vec<BoundParam> entry — the bound slot's original position, name, kind, and value — alongside the existing closure capture (which is unchanged). A bound_params() accessor exposes it, the twin of instance_name().

  2. aura-engine (graph_model.rs, prim_record): emit a conditional "bound" field — present only when non-empty (mirrors the conditional "name" field) — each entry serialized [pos,"name","kind","value"] via a new scalar_str value helper. Deterministic (C14).

  3. graph-viewer.js (adaptNodes, genDot leaf-emit, cellLabel): carry bound; cellLabel merges the free params and the bound params back into slot order by position and renders a bound slot as weights[2]=0.5, dimmed.

  4. Goldens + tests: re-capture the sample-model.json fixture (the blend node gains a "bound" field); the engine's own inline model_golden (sample_root, which uses no bind) stays byte-unchanged. Add a prim_record unit test (bound field present only when bound) and a headless viewer render guard (bound slot renders name=value, dimmed; free slots unchanged; slot order correct for a non-trailing bind).

Concrete code shapes

User-facing program (the acceptance evidence)

The authoring is already shipped (cycle 0036, aura-cli/src/main.rs signals()); this cycle fixes only what it renders:

// crates/aura-cli/src/main.rs — signals(), UNCHANGED authoring
LinComb::builder(3)
    .named("blend")
    .bind("weights[2]", Scalar::F64(0.5)) // a fixed signal-line weight
    .into()

aura graph viewer box head, before this cycle (the defect):

blend: LinComb[weights[0], weights[1]]      # 2 slots, but the box shows 3 ports

after this cycle (the fix — weights[2]=0.5 dimmed):

blend: LinComb[weights[0], weights[1], weights[2]=0.5]

A trader/researcher who fixes a weight with .bind() and opens the graph now reads the true signature: all three slots, the constant 0.5 shown as a fixed value rather than silently dropped. This is the criterion's evidence — the feature measurably removes a misrepresentation (signature arity now matches the port count) and reintroduces no failure class (render-only; C23/C14 intact).

aura-core — node.rs (PrimitiveBuilder)

A small record type and a field on the builder:

/// One param bound to a structural constant by `bind` (render/debug only, C23).
/// `pos` is the slot's position in the node's ORIGINAL (pre-bind) param list, so
/// the renderer can place it back into slot order regardless of bind sequence.
#[derive(Clone, Debug, PartialEq)]
pub struct BoundParam {
    pub pos: usize,
    pub name: String,
    pub kind: ScalarKind,
    pub value: Scalar,
}

before — the builder fields:

pub struct PrimitiveBuilder {
    name: &'static str,
    instance_name: Option<String>,
    schema: NodeSchema,
    #[allow(clippy::type_complexity)]
    build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}

after — add bound (and initialize it Vec::new() in new):

pub struct PrimitiveBuilder {
    name: &'static str,
    instance_name: Option<String>,
    schema: NodeSchema,
    bound: Vec<BoundParam>,
    #[allow(clippy::type_complexity)]
    build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}

before — bind (the closure mechanics, unchanged) records nothing visible:

pub fn bind(mut self, slot: &str, value: Scalar) -> Self {
    // ... matches/pos resolution + kind assert (unchanged) ...
    self.schema.params.remove(pos);            // [param_space side]
    let inner = self.build;                     // [value side]
    self.build = Box::new(move |open: &[Scalar]| {
        let mut full = open.to_vec();
        full.insert(pos, value);
        inner(&full)
    });
    self
}

after — record a BoundParam with the slot's ORIGINAL position before removal. pos is the index in the already-shrunk array; map it to the original index by adding back every earlier-bound slot at an original position <= it:

pub fn bind(mut self, slot: &str, value: Scalar) -> Self {
    // ... matches/pos resolution + kind assert (unchanged) ...
    let removed = &self.schema.params[pos];     // capture name+kind before removal
    // map the compressed index `pos` to the original full-arity position:
    let mut orig = pos;
    let mut prior: Vec<usize> = self.bound.iter().map(|b| b.pos).collect();
    prior.sort_unstable();
    for p in prior {
        if p <= orig {
            orig += 1;
        }
    }
    self.bound.push(BoundParam {
        pos: orig,
        name: removed.name.clone(),
        kind: removed.kind,
        value,
    });
    self.schema.params.remove(pos);            // [param_space side] — unchanged
    let inner = self.build;                      // [value side] — unchanged
    self.build = Box::new(move |open: &[Scalar]| {
        let mut full = open.to_vec();
        full.insert(pos, value);
        inner(&full)
    });
    self
}

new accessor — the twin of instance_name():

/// The params bound to structural constants by `bind`, in bind-call order, each
/// carrying its ORIGINAL slot position. The render-surface twin of the value
/// captured in the build closure (unreachable to the model serializer, which
/// never builds). Empty for a node with no binds.
pub fn bound_params(&self) -> &[BoundParam] {
    &self.bound
}

aura-engine — graph_model.rs (prim_record + a value helper)

new value helper, mirroring kind_str:

/// A scalar VALUE as a canonical, deterministic string for the model (C14).
/// `f64` uses the shortest round-trippable debug form (keeps a decimal point on
/// whole values, so a bound f64 is not mistaken for an i64 in the render).
fn scalar_str(s: aura_core::Scalar) -> String {
    use aura_core::Scalar;
    match s {
        Scalar::I64(n) => n.to_string(),
        Scalar::F64(f) => format!("{f:?}"),
        Scalar::Bool(b) => b.to_string(),
        Scalar::Ts(t) => t.0.to_string(),
    }
}

before — prim_record emits {name?}type/role/params/ins/outs:

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)));
    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),
    )
}

after — a conditional "bound" field, placed right after "params" (the tunable surface and its bound annotation grouped). Present only when non-empty, exactly like "name", so an unbound node's bytes are unchanged:

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)));
    let name = match b.instance_name() {
        Some(n) => format!(r#""name":{},"#, json_str(n)),
        None => String::new(),
    };
    // bound params: present only when the node has binds (mirrors "name").
    // each entry: [pos,"name","kind","value"].
    let bound = if b.bound_params().is_empty() {
        String::new()
    } else {
        let items = join(b.bound_params().iter().map(|bp| {
            format!(
                "[{},{},{},{}]",
                bp.pos,
                json_str(&bp.name),
                json_str(kind_str(bp.kind)),
                json_str(&scalar_str(bp.value)),
            )
        }));
        format!(r#","bound":[{items}]"#)
    };
    format!(
        r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#,
        json_str(&b.label()),
        json_str(role),
    )
}

The blend node in the re-captured sample-model.json then reads (abridged):

{"prim":{"name":"blend","type":"LinComb","role":"node",
  "params":[["weights[0]","f64"],["weights[1]","f64"]],
  "bound":[[2,"weights[2]","f64","0.5"]],
  "ins":[["f64","any","term[0]"],["f64","any","term[1]"],["f64","any","term[2]"]],
  "outs":[["value","f64"]]}}

graph-viewer.js (adaptNodes, genDot leaf-emit, cellLabel)

before — adaptNodes carries name but not bound:

out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };

after — carry bound (default [] when absent):

out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, bound: p.bound || [], ins: adaptIns(p.ins), outs: p.outs } };

before — genDot leaf-emit forwards the prim fields to cellLabel:

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`;

after — forward bound:

block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, bound: s.bound, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;

before — cellLabel builds the signature from free params only:

const sp = o.params.map(([n, k]) => `<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
const sig = o.params.length ? `<font color="#9399b2">[</font>${sp.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";

after — merge free params and bound params into slot order by position; render a bound slot as name=value in the dimmed separator grey (#9399b2); the bracket test keys on the total slot count so an all-bound node still shows [...]:

const bound = o.bound || [];
const total = o.params.length + bound.length;
const byPos = new Map(bound.map(([pos, n, k, v]) => [pos, [n, k, v]]));
const slots = [];
let fi = 0;
for (let pos = 0; pos < total; pos++) {
  if (byPos.has(pos)) {
    const [n, , v] = byPos.get(pos);                       // bound: dimmed name=value
    slots.push(`<font color="#9399b2">${n}=${v}</font>`);
  } else {
    const [n, k] = o.params[fi++];                          // free: type-coloured name
    slots.push(`<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
  }
}
const sig = total ? `<font color="#9399b2">[</font>${slots.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";

addInfo (the tooltip) is left unchanged: it shows the tunable params (the knob view); the bound constant is a signature fact shown in the head, not a knob.

Components

Component File Change
BoundParam + PrimitiveBuilder.bound + bind record + bound_params() crates/aura-core/src/node.rs new struct, field, ~6 lines in bind, accessor
scalar_str + conditional "bound" in prim_record crates/aura-engine/src/graph_model.rs new helper, ~8 lines in prim_record
bound carried + merged-slot render crates/aura-cli/assets/graph-viewer.js adaptNodes, genDot, cellLabel
sample fixture re-capture crates/aura-cli/tests/fixtures/sample-model.json blend gains "bound"
prim_record bound unit test crates/aura-engine/src/graph_model.rs (tests) new test
bind records position unit test crates/aura-core/src/node.rs (tests) new test
headless render guard (.mjs + .rs) crates/aura-cli/tests/viewer_bound_param.{mjs,rs} new guard pair

Data flow

author: LinComb::builder(3).bind("weights[2]", F64(0.5))
  → PrimitiveBuilder { schema.params: [w0,w1], bound: [{pos:2,"weights[2]",F64,0.5}] }
  → prim_record: "params":[[w0],[w1]], "bound":[[2,"weights[2]","f64","0.5"]]
  → render_html: window.AURA_MODEL = { ... blend ... }
  → adaptNodes: { ..., bound: [[2,"weights[2]","f64","0.5"]] }
  → cellLabel merge by pos: [ w0(free), w1(free), weights[2]=0.5(bound, dimmed) ]
  → "blend: LinComb[weights[0], weights[1], weights[2]=0.5]"

At lowering (bootstrap/compile) nothing changes: bind still resolves the name to a fixed position and re-splices the value in the build closure; bound is a render/debug surface only, never read by bootstrap or the run loop (C23).

Error handling

  • bind's existing preconditions are unchanged: panic on zero / ambiguous slot matches and on a value-kind mismatch (its should-panic tests stay green).
  • The original-position computation is total and panic-free (a sort + a counted increment over already-recorded positions); no new failure mode.
  • scalar_str is total over all four Scalar variants — no unreachable!.
  • The model JSON stays well-formed for any bind count (zero → no "bound" key; N → an N-element array). json_str escaping is reused for name/kind/value.

Testing strategy

  • bind records the original position (aura-core unit). Bind a non-trailing slot and a chained reverse-order pair (mirroring chained_bind_…); assert bound_params() carries the correct ORIGINAL positions (e.g. binding the middle of three → pos == 1; reverse-binding weights[1] then weights[0] on LinComb(2) → positions {0,1}). This is the test that justifies carrying the position rather than appending.
  • prim_record emits "bound" only when bound (aura-engine unit). A bound builder's record contains "bound":[[…]] with the canonical value string; an unbound builder's record contains no "bound" substring (twin of prim_record_emits_name_for_explicit_only).
  • Model determinism + goldens. model_is_deterministic and the inline model_golden (sample_root, no binds) stay byte-unchanged — proves the conditional field does not perturb unbound nodes. The sample-model.json fixture is re-captured; the two fixture consumers (viewer_dot_ids.mjs, viewer_nested_depth.mjs) keep passing (they assert DOT-id grammar / nesting depth, not params), confirming the new field flows through adaptNodes/genDot without breaking id generation.
  • Headless render guard (viewer_bound_param.mjs + .rs). Mirror viewer_name_prefix. Drive the real genDot over two minimal models: (a) a LinComb-like leaf with free weights[0]/weights[1] and bound:[[2,"weights[2]","f64","0.5"]] → assert the head contains the dimmed <font color="#9399b2">weights[2]=0.5</font> and the two free slots in their type colour, in order weights[0], weights[1], weights[2]=0.5; (b) a 3-param leaf binding the MIDDLE slot (pos 1) → assert the rendered order is a, b=<v>, c (the bound slot between the free ones), which fails under an append-only design and so pins slot-order faithfulness. node is REQUIRED (a skipped guard is not a guard).
  • Sweep / param_space unaffected. The cli_run E2E params pin (eight free params, weights[2] correctly absent) and the in-crate sweep pins stay green — bind still removes the slot from the tunable surface.

Acceptance criteria

  1. A node built with .bind(slot, value) renders all its declared slots in the aura graph viewer; the bound slot shows name=value, visually dimmed, in true slot order. The cycle-0036 blend renders blend: LinComb[weights[0], weights[1], weights[2]=0.5].
  2. The graph model carries a conditional "bound" field ([pos,"name","kind","value"]), present only for nodes with binds; deterministic (C14). Unbound nodes' model bytes are unchanged.
  3. The bound slot remains absent from the tunable surface (params() / param_space / sweep axes) — bind's tuning-side behaviour is unchanged (C12/C19), and the flat graph is unchanged (C23: render/debug symbol only).
  4. New unit tests pin the recorded original position and the conditional model field; a headless guard pins the dimmed name=value render and slot-order faithfulness (including a non-trailing bind). All existing goldens, viewer guards, and sweep pins stay green; cargo build/test/clippy clean.