Files
Aura/docs/plans/0022-composite-output-binding-render.md
T
Brummel b2f27f8590 plan: 0022 composite output binding render
RED-first: re-pin all five render assertions whose producer carries an
OutField (macd, sma_cross cross, two fan-in `o` outputs — across main.rs
+ cli_run.rs) to the `:= ` binding form, then fold output_binding onto
the producer label in render_definition. Blast radius wider than spec
§Components named (flagged for audit).

refs #46
2026-06-08 17:10:54 +02:00

16 KiB

Composite Output Re-exports as Producer Bindings — Implementation Plan

Parent spec: docs/specs/0022-composite-output-binding-render.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

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 standalone terminal nodes.

Architecture: A single render-layer change in crates/aura-cli/src/graph.rs: render_definition drops its per-OutField node+edge loop and instead prefixes each interior node's label with an output_binding(c, i) (single name := or tuple (a, b) := ). RED-first: re-pin every render test that asserts a producer label which carries an OutField (the blast radius is wider than the spec's Components table named — see Scope note), then implement.

Tech Stack: Rust; aura-cli (graph.rs renderer + in-crate tests + tests/cli_run.rs integration test); aura-engine::OutField (read-only).


Scope note — test blast radius (wider than spec §Components)

The spec's Components table named only macd_blueprint_renders_a_nested_composite_definition. Recon + a workspace grep found four more render assertions that mechanically break, because their asserted producer node carries an OutField and so gains a := prefix (the leading [ in contains("[Sub(…)]") stops matching once the label becomes [name := Sub(…)]). All are forced by the design — zero design judgement. Full list (the only render assertions that change):

Site Test Producer / OutField Old assertion New assertion
main.rs:405 blueprint_view_defines_each_composite_once sma_cross node 2 = OutField "cross" [Sub(#Sf,#Ss)] + standalone [cross] [cross := Sub(#Sf,#Ss)] (drop standalone [cross])
main.rs:600-602 macd_blueprint_renders_a_nested_composite_definition macd nodes 2/3/4 = macd/signal/histogram [EMA(signal)], [Sub(#Ef,#Es)], [Sub(#S,#Es)] [signal := EMA(signal)], [macd := Sub(#Ef,#Es)], [histogram := Sub(#S,#Es)] + negatives on [signal]/[histogram]
main.rs:622 fan_in_identifiers_are_source_derived_and_scoped_per_node_call roles node 1 = OutField "o" [Sub(#price,#Es)] [o := Sub(#price,#Es)]
main.rs:668 fan_in_identifiers_descend_into_bare_combinators nest node 6 = OutField "o" [Sub(#SEf,#SEu)] [o := Sub(#SEf,#SEu)]
cli_run.rs:177 graph_renders_source_derived_fan_in_identifiers sma_cross node 2 = OutField "cross" [Sub(#Sf,#Ss)] [cross := Sub(#Sf,#Ss)]

Verified to survive unchanged (asserted producer carries NO OutField, or the assertion targets a main-graph opaque node): main.rs:598/599 ([EMA(fast)], [EMA(slow)]), main.rs:405 ([SMA(fast)], [SMA(slow)], [price]), main.rs:592 ([macd] opaque main node), main.rs:669 ([Sub(#Ef,#Es)]nest node 4 carries no OutField), main.rs:681 ([macd] opaque), main.rs:437-440 (nested_composite_renders_without_panic asserts only [outer]/[inner] opaque nodes + (-counts; the re-exported producers there are not asserted), cli_run.rs:182 (negative !contains("[Sub(#A,#B)]")). The compiled-view golden and the MACD determinism test are render-independent regression guards.


Files this plan creates or modifies:

  • Modify: crates/aura-cli/src/main.rs — 4 render-assertion sites re-pinned (Task 1)
  • Modify: crates/aura-cli/tests/cli_run.rs:177 — 1 render-assertion site re-pinned (Task 1)
  • Modify: crates/aura-cli/src/graph.rs:19 — add OutField to the aura_engine use group (Task 2)
  • Modify: crates/aura-cli/src/graph.rs:282-315 — rewrite render_definition doc comment + output handling; add output_binding helper (Task 2)

No new files; no engine/blueprint/bootstrap change; the fn macd / fn sma_cross fixtures are unchanged.


Task 1: RED — re-pin render assertions to the := binding form

Files:

  • Modify: crates/aura-cli/src/main.rs (lines 405, 600-602, 622, 668)

  • Modify: crates/aura-cli/tests/cli_run.rs (line 177)

  • Step 1: Re-pin blueprint_view_defines_each_composite_once (main.rs:405)

Replace this exact line:

        for needle in ["[SMA(fast)]", "[SMA(slow)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] {

with:

        for needle in ["[SMA(fast)]", "[SMA(slow)]", "[cross := Sub(#Sf,#Ss)]", "[price]"] {

(The cross output is now folded onto its producer Sub, so [Sub(#Sf,#Ss)] becomes [cross := Sub(#Sf,#Ss)] and the standalone [cross] stub is gone.)

  • Step 2: Re-pin the MACD producer assertions + add negatives (main.rs:600-602)

Replace these exact three lines:

        assert!(out.contains("[EMA(signal)]"), "signal EMA folds its param: {out}");
        assert!(out.contains("[Sub(#Ef,#Es)]"), "MACD line Sub is fast-EMA minus slow-EMA: {out}");
        assert!(out.contains("[Sub(#S,#Es)]"), "histogram Sub is the line minus signal-EMA: {out}");

with:

        assert!(out.contains("[macd := Sub(#Ef,#Es)]"), "macd line Sub bound as output `macd`: {out}");
        assert!(out.contains("[signal := EMA(signal)]"), "signal EMA bound as output `signal` (name/param pun is intended): {out}");
        assert!(out.contains("[histogram := Sub(#S,#Es)]"), "histogram Sub bound as output `histogram`: {out}");
        // output re-exports are folded onto their producers — no standalone stubs.
        // `[macd]` is NOT a valid negative here: it still appears as the opaque
        // composite node in the MAIN graph. Discriminate on signal/histogram.
        assert!(!out.contains("[signal]"), "no standalone signal output stub: {out}");
        assert!(!out.contains("[histogram]"), "no standalone histogram output stub: {out}");

([EMA(fast)]/[EMA(slow)] on the lines just above stay unchanged — nodes 0/1 carry no OutField.)

  • Step 3: Re-pin fan_in_identifiers_are_source_derived_and_scoped_per_node_call (main.rs:622)

Replace this exact line:

        assert!(out.contains("[Sub(#price,#Es)]"), "role name verbatim + source-derived: {out}");

with:

        assert!(out.contains("[o := Sub(#price,#Es)]"), "role name verbatim + source-derived, bound as output `o`: {out}");
  • Step 4: Re-pin fan_in_identifiers_descend_into_bare_combinators (main.rs:668)

Replace this exact line:

        assert!(out.contains("[Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs: {out}");

with:

        assert!(out.contains("[o := Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs, bound as output `o`: {out}");

(The next line, assert!(out.contains("[Sub(#Ef,#Es)]"), …), stays unchanged — nest node 4 carries no OutField.)

  • Step 5: Re-pin the CLI integration test (cli_run.rs:177)

Replace this exact block:

    assert!(
        stdout.contains("[Sub(#Sf,#Ss)]"),
        "fan-in inputs must be source-derived (#Sf/#Ss), not positional: {stdout}"
    );

with:

    assert!(
        stdout.contains("[cross := Sub(#Sf,#Ss)]"),
        "fan-in inputs source-derived (#Sf/#Ss), bound as output `cross`: {stdout}"
    );

(The negative assertion below, !stdout.contains("[Sub(#A,#B)]"), stays unchanged.)

  • Step 6: Run the re-pinned tests to verify they FAIL (RED)

Run: cargo test -p aura-cli 2>&1 | tail -40 Expected: FAIL. The current renderer still emits standalone output nodes and bare producer labels, so the new contains("[… := …]") assertions fail (and the MACD negatives !contains("[signal]") fail because the standalone [signal]/[histogram] stubs still exist). Failing tests include blueprint_view_defines_each_composite_once, macd_blueprint_renders_a_nested_composite_definition, fan_in_identifiers_are_source_derived_and_scoped_per_node_call, fan_in_identifiers_descend_into_bare_combinators, and (integration) graph_renders_source_derived_fan_in_identifiers.

Task 2: GREEN — fold output re-exports onto their producers in render_definition

Files:

  • Modify: crates/aura-cli/src/graph.rs:19 (use group)

  • Modify: crates/aura-cli/src/graph.rs:282-315 (render_definition + new output_binding)

  • Step 1: Import OutField (graph.rs:19)

Replace this exact line:

use aura_engine::{aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, SourceSpec};

with:

use aura_engine::{
    aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, OutField, SourceSpec,
};
  • Step 2: Rewrite render_definition's doc comment + body and add output_binding

Replace the exact current doc comment + function (graph.rs:282-315):

/// Render one composite's interior as a flat graph: interior leaves as
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
/// in via `leaf_label`), nested composites as opaque `[name]`, an `[<name>]`
/// entry marker per input role (wired to its interior targets), and an `[<name>]`
/// node per re-exported output field (wired from its producer). The title line is
/// the composite's typed `signature` (`name(p:kind, …) -> (out, …)`); params live
/// in the signature, not as marker nodes.
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))
}

with:

/// Render one composite's interior as a flat graph: interior leaves as
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
/// in via `leaf_label`), nested composites as opaque `[name]`, and an `[<name>]`
/// entry marker per input role (wired to its interior targets). A re-exported
/// output is **not** a standalone node: its name is folded onto its producing
/// node's label as a binding (`[macd := Sub(…)]`, tuple `(a, b) := …` for several
/// outputs on one node) via [`output_binding`] — so the drawn graph is exactly the
/// computation DAG, every node a real step (C23: the name is a render symbol on the
/// producer, never a wired terminal). The title line is the composite's typed
/// `signature` (`name(p:kind, …) -> (out, …)`); params live in the signature, not
/// as marker nodes.
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 folded onto their producers by output_binding above — no
    // standalone output node and no producer→output edge (C23).

    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 keep 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(", ")),
    })
}
  • Step 3: Run the named render tests to verify they PASS (GREEN)

Run: cargo test -p aura-cli 2>&1 | tail -40 Expected: PASS. All five re-pinned assertions now hold; the MACD negatives pass (no standalone [signal]/[histogram]); the survivors ([EMA(fast)], [price], [macd] opaque, [Sub(#Ef,#Es)] on nest node 4, compiled_view_golden, run_macd_compiles_from_nested_composite_and_is_deterministic) stay green.

  • Step 4: Full workspace gate

Run: cargo test --workspace 2>&1 | tail -20 Expected: PASS (0 failed). In particular compiled_view_golden is byte-identical (C23 — the compiled view never carried output names) and the MACD run is deterministic and unchanged in metrics.

  • Step 5: Lint + build gate

Run: cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -20 Expected: PASS, no warnings (the output_binding match names.as_slice() arms and the OutField import are all used).


Self-review (planner, inline)

  1. Spec coverage: spec's render change → Task 2; spec's "re-pin the pinning test" → Task 1 Step 2; the four incidental breaks the spec under-counted → Task 1 Steps 1/3/4/5 (Scope note documents why each is forced by the design). Regression guards (golden, determinism) → Task 2 Steps 3-4. Covered.
  2. Placeholder scan: no TBD/TODO/"similar to"/"implement later". Clean.
  3. Type consistency: output_binding, OutField, render_definition, leaf_label, render_flat spelled identically across tasks and matching the tree. OutField fields (node, field, name) match blueprint.rs:24.
  4. Step granularity: each step is one edit or one command (2-5 min).
  5. No commit steps: none — orchestrator commits at iter close.
  6. Pin/replacement substring contiguity: every asserted substring ([cross := Sub(#Sf,#Ss)], [macd := Sub(#Ef,#Es)], [signal := EMA(signal)], [histogram := Sub(#S,#Es)], [o := Sub(#price,#Es)], [o := Sub(#SEf,#SEu)]) appears contiguously as a single string literal in its assertion, and is produced contiguously by the renderer as format!("{prefix}{base}") (e.g. prefix "macd := " + base "Sub(#Ef,#Es)"; ascii-dag adds the […]). No soft-wrap split.
  7. Compile-gate vs deferred-caller: no signature change. render_definition's signature is unchanged; output_binding is new and self-contained. Task 1 leaves the tree compiling (test-body edits only); Task 2's build gate is satisfiable.
  8. Verification-command filters resolve: Run steps use cargo test -p aura-cli (whole crate suite — all five named tests exist in it today; no filter substring that could match zero) and cargo test --workspace with an explicit "0 failed" expectation, so "nothing ran" cannot masquerade as success.
  9. Parse-the-bytes: all inlined bodies are Rust source-language (test + implementation), not surface-language snippets; the profile declares no spec_validation parser → parse gate is a no-op. The implement Rust compile gate (Task 2 Steps 4-5) is the real check.