Files
Aura/docs/specs/0022-composite-output-binding-render.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

15 KiB

Composite Output Re-exports as Producer Bindings — Design Spec

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

Goal

In the blueprint view's where: section, render a composite's output re-exports as bindings on their producing node's label (name := <producer-label>) instead of as standalone terminal nodes wired from the producer. This refines the output-port rendering settled in 0018/0019; everything else in the render lineage (0017 definitions, 0020 signature, 0021 fan-in identifiers) is untouched.

Today render_definition materializes each OutField as a fresh node labelled of.name, plus an edge of.node → that node. But an OutField re-exports the value of an existing interior node — it is never a new node. The current form has three defects:

  1. It draws false terminals. In MACD the macd line (a Sub) feeds both the signal EMA and the histogram Sub internally, yet renders as a dead-end leaf [macd]. macd and signal are interior junctions, not endpoints.
  2. It inflates the node count by the output arity. MACD's 5 compute nodes render as 8. Node count is the graph's first-order structural signal; showing 8 where the truth is 5 corrupts it, and the inflation compounds through composite nesting.
  3. It is redundant. The output names already appear in the signature line -> (macd, signal, histogram), so each leaf stub repeats information already shown.

All three MACD outputs (macd, signal, histogram) are duplicates of existing interior nodes — histogram included. There is no case where an OutField warrants a real extra node, so the fix is uniform: fold the name into the producer.

This is pure render-layer legibility, not a capability. OutField.name is a non-load-bearing render/debug symbol (C23) that never reaches the flat graph; the engine, blueprint data model, and bootstrap are untouched. The implementation commit closes #46.

Architecture

The settled decision: output names are bindings, inputs stay nodes

A composite boundary has two edge-kinds rendered in the where: interior: input roles and output re-exports. They are not symmetric, and this cycle makes the asymmetry explicit in the rendering:

  • An input role has no interior producer — the value enters from outside and fans into one or more interior targets. There is no node to annotate it onto, so it is correctly its own source node ([price]). Unchanged.
  • An output re-export is always the value of an existing interior node. It has a producer to annotate, so it is rendered as a binding on that producer ([macd := Sub(#Ef,#Es)]), emitting no standalone node and no producer→output edge.

The := prefix becomes the precise visual signal that a node's value escapes the composite boundary: a bound node is an output, a bare node is purely internal. The interior graph then shows exactly the computation DAG — every drawn node is a real compute step, every drawn edge a real dataflow.

Multiple outputs from one node: tuple binding

The data model permits two OutFields re-exporting the same producer node under different field indices (C8: one output port, K columns). When one node backs more than one output, the binding is a tuple (n1, n2) := <producer-label>, names ordered by field index (ties by author order). This does not occur in MACD — each output is a distinct node — but the renderer must implement the general rule so no test bakes in a MACD-only one-output-per-node assumption.

The name/param collision is intended

MACD node 3 is the signal-length EMA: its param is aliased signal (0019) and its output is also named signal, so it renders [signal := EMA(signal)]. This reads punningly but is truthful — the signal output is the signal-length EMA — and is specified as intended behaviour, not a defect to engineer around. The pinning test asserts it verbatim.

Invariant-neutrality

  • C23 honoured. OutField.name stays a non-load-bearing render symbol dropped at lowering; output identity is the (node, field) position, which survives. The := form changes only where the name is printed, never what it is. The compiled view (render_flat_graph) is name-free and byte-identical — compiled_view_golden is the regression guard, unchanged.
  • C8 / C7 untouched. No change to the runtime record shape, the four base scalars, or one-record-per-cycle. This is authoring-surface legibility only.
  • No engine / blueprint / bootstrap change. The only files touched are render_definition (+ its doc comment) in crates/aura-cli/src/graph.rs and the pinning test in crates/aura-cli/src/main.rs. The Composite data model, OutField, compile_with_params, and param_space() are all unchanged.

Concrete code shapes

Worked user-facing example (the acceptance evidence) — MACD where: body

The trader runs the blueprint view (aura graph over the MACD harness) and reads the where: definition of the macd composite. The interior node-label set is the empirical evidence (the ascii-dag pipe layout around these labels is renderer-determined and not asserted byte-for-byte).

Before (today — 8 interior nodes: 5 compute + price entry + 3 output stubs):

macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram):

  [price]               entry node (input role)
  [EMA(fast)]           node 0
  [EMA(slow)]           node 1
  [Sub(#Ef,#Es)]        node 2  (the macd line)        ── also feeds 3 and 4
  [EMA(signal)]         node 3  (the signal line)      ── also feeds 4
  [Sub(#S,#Es)]         node 4  (the histogram)
  [macd]                output stub  ← edge from node 2   (false terminal)
  [signal]              output stub  ← edge from node 3   (false terminal)
  [histogram]           output stub  ← edge from node 4

After (this cycle — 6 interior nodes: 5 compute + price entry; output stubs gone, names folded into their producers):

macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram):

  [price]                          entry node (input role) — unchanged
  [EMA(fast)]                      node 0 — bare (not an output)
  [EMA(slow)]                      node 1 — bare (not an output)
  [macd := Sub(#Ef,#Es)]           node 2 — bound; still feeds 3 and 4
  [signal := EMA(signal)]          node 3 — bound; still feeds 4
  [histogram := Sub(#S,#Es)]       node 4 — bound

The signature line -> (macd, signal, histogram) is unchanged (0020). The macd line (node 2) keeps its real interior fan-out to the signal EMA and the histogram Sub; it is no longer also drawn as a dead-end leaf. Node count drops from 8 to 6, and every drawn node is now a real compute step or the input entry.

The render change — render_definition (before → after)

crates/aura-cli/src/graph.rs. Only the output handling changes; the interior-node and input-role loops are as today.

Before (output loop appends a node + edge per OutField):

fn render_definition(c: &Composite, color: Color) -> String {
    let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
    for (i, inner) in c.nodes().iter().enumerate() {
        labels.push(match inner {
            BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
            BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
        });
    }
    let mut edges: Vec<(usize, usize)> = Vec::new();
    for e in c.edges() {
        edges.push((e.from, e.to));
    }
    for role in c.input_roles() {
        let in_id = labels.len();
        labels.push(role.name.clone());
        for t in &role.targets {
            edges.push((in_id, t.node));
        }
    }
    for of in c.output() {
        let out_id = labels.len();
        labels.push(of.name.clone());
        edges.push((of.node, out_id));
    }

    format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
}

After (each interior label is prefixed with its output binding, if any; the output loop is gone):

fn render_definition(c: &Composite, color: Color) -> String {
    let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
    for (i, inner) in c.nodes().iter().enumerate() {
        let base = match inner {
            BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
            BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
        };
        labels.push(match output_binding(c, i) {
            Some(prefix) => format!("{prefix}{base}"),
            None => base,
        });
    }
    let mut edges: Vec<(usize, usize)> = Vec::new();
    for e in c.edges() {
        edges.push((e.from, e.to));
    }
    for role in c.input_roles() {
        let in_id = labels.len();
        labels.push(role.name.clone());
        for t in &role.targets {
            edges.push((in_id, t.node));
        }
    }
    // outputs are no longer standalone nodes — see output_binding (C23: the name
    // is a render symbol folded onto its producer, never a wired terminal).

    format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
}

/// The `name := ` (single) or `(n1, n2) := ` (tuple) binding prefix for interior
/// node `i`, if any `OutField` re-exports it; `None` for a non-output node. Names
/// are ordered by re-exported `field` index (ties by author order). The producer
/// label follows the prefix; no standalone output node or producer→output edge is
/// emitted (the output is the producer, surfaced by name — C23).
fn output_binding(c: &Composite, i: usize) -> Option<String> {
    let mut outs: Vec<&OutField> = c.output().iter().filter(|of| of.node == i).collect();
    if outs.is_empty() {
        return None;
    }
    outs.sort_by_key(|of| of.field);
    let names: Vec<&str> = outs.iter().map(|of| of.name.as_str()).collect();
    Some(match names.as_slice() {
        [one] => format!("{one} := "),
        many => format!("({}) := ", many.join(", ")),
    })
}

OutField is already imported in graph.rs via the aura_engine use group; if not, add it there (engine re-exports it from lib.rs).

Components

Component File Change
render_definition crates/aura-cli/src/graph.rs drop the output-node loop; prefix each interior label via output_binding; update the doc comment
output_binding crates/aura-cli/src/graph.rs new private helper
macd_blueprint_renders_a_nested_composite_definition crates/aura-cli/src/main.rs re-pin to the := form (see Testing strategy)

The MACD author site (fn macd in main.rs) and every engine type are unchanged — this is render-only.

Data flow

  1. Author writes a Composite with Vec<OutField> (unchanged).
  2. param_space() / compile_with_params are untouched — outputs lower exactly as today; the name is dropped at inline (C23).
  3. Blueprint render (render_definition): for each interior node, the output names re-exporting it are folded onto its label as a := binding; no standalone output node is emitted. Input roles still render as entry nodes.
  4. Compiled render (render_flat_graph) is name-free and byte-identical (compiled_view_golden).

Error handling

No new failure modes. render_definition reads structure only and never evals. output_binding filters c.output() whose node/field indices were already validated at compile_with_params (the rendering of a malformed blueprint is not a correctness path — compile is the validator, #41). An OutField whose node is out of range simply matches no interior index in the label loop and contributes no binding — consistent with today's widening posture (no panic in the renderer).

Testing strategy

CLI render test (crates/aura-cli/src/main.rs), macd_blueprint_renders_a_nested_composite_definition — re-pinned to the := form:

  1. Bound producers. The render contains [macd := Sub(#Ef,#Es)], [signal := EMA(signal)] (the intended name/param collision), and [histogram := Sub(#S,#Es)].
  2. Bare non-outputs. [EMA(fast)] and [EMA(slow)] stay bare (nodes 0/1 are not re-exported).
  3. No standalone output stubs. The render does not contain the bracketed exact forms [signal] or [histogram]. ([macd] is not a valid negative assertion — it still appears in the main graph as the opaque composite node; discriminate on signal/histogram, which appear only inside their bindings and in the signature's (…) list, never as [signal]/[histogram].)
  4. Unchanged anchors (regression). [price] present; the signature line macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram) present; macd( occurs exactly once; no [param:, no [out:.

Build / determinism:

  1. cargo build/test/clippy --workspace (-D warnings) green; compiled_view_golden byte-identical (C23 — the compiled view never carried output names); the MACD run stays deterministic and unchanged in metrics (render-only change).

Post-ship note (audit 0022). This Testing strategy and the §Components table under-counted the test blast radius: besides the contains-style assertions, the full-render golden blueprint_view_golden (crates/aura-cli/src/main.rs) pins the entire sample (sma_cross) blueprint render and was forced to re-capture (the [cross] output stub + its edge collapse into [cross := Sub(#Sf,#Ss)]). The shipped change is correct and green; this note records the doc-vs-reality gap that the implement phase surfaced.

A dedicated multi-output-per-node tuple-binding unit test is not required for acceptance (no such composite exists in the fixtures), but output_binding's tuple branch is specified above so a future multi-output composite renders (a, b) := without a renderer change.

Acceptance criteria

  • The MACD where: definition renders 6 interior nodes (5 compute + price), not 8 — the worked before → after node-label set is the evidence. Every drawn node is a real compute step or the input entry; no output appears as a false terminal.
  • Output re-exports render as name := <producer-label> bindings; the producer's real interior fan-out is preserved (the macd line still visibly feeds the signal EMA and the histogram).
  • The renderer measurably removes redundancy (3 stub nodes + 3 edges per MACD render gone; the names live once, on their producers) — the aura feature-acceptance criterion this cycle leans on.
  • C23 preserved: compiled_view_golden byte-identical; no output name reaches a Box<dyn Node>. No engine/blueprint/bootstrap change.
  • cargo build/test/clippy --workspace (-D warnings) green; aura run --macd deterministic and unchanged in metrics.
  • Closes #46.