diff --git a/docs/specs/0022-composite-output-binding-render.md b/docs/specs/0022-composite-output-binding-render.md new file mode 100644 index 0000000..f26d311 --- /dev/null +++ b/docs/specs/0022-composite-output-binding-render.md @@ -0,0 +1,304 @@ +# 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 := `) +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 compilat; 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 `OutField`s 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) := `, 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_compilat`) 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): + +```text +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): + +```text +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`): + +```rust +fn render_definition(c: &Composite, color: Color) -> String { + let mut labels: Vec = 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): + +```rust +fn render_definition(c: &Composite, color: Color) -> String { + let mut labels: Vec = 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 { + 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` (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_compilat`) is name-free and byte-identical + (`compiled_view_golden`). + +## Error handling + +No new failure modes. `render_definition` reads structure only and never `eval`s. +`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: + +5. `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). + +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 := ` 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`. No engine/blueprint/bootstrap change. +- `cargo build/test/clippy --workspace` (`-D warnings`) green; `aura run --macd` + deterministic and unchanged in metrics. +- Closes #46.