Files
Aura/docs/plans/0020-composite-signature-render.md
T
Brummel 71f50330db plan: 0020 composite signature render
Two-task render-only plan executing spec 0020:

- T1 (graph.rs + node.rs): add `signature` (typed title line, with a new
  ScalarKind->lowercase `kind_str` match — none exists today, {:?} gives
  PascalCase) and `leaf_label` (fold aliased param names + ordered
  input-slot stubs); edit render_definition (title->signature,
  leaf->leaf_label, drop [param:*] loop, de-prefix outputs); fix two stale
  doc comments (the render_definition [param:] description + the
  node.rs:93-101 cluster rationale). Gate: cargo build -p aura-cli (the
  goldens go red until T2 re-captures; cargo test is NOT a T1 gate).
- T2 (main.rs): repin the FIVE title asserts name:->name( (the blast
  radius: sma_cross/outer/inner/dup/macd, four beyond the spec's named
  one); update the definition needle ([Sub]->[Sub(#A,#B)], [out:cross]->
  [cross]); rewrite the MACD render assertions (drop [param:*], add the
  signature + [EMA(fast)] + [Sub(#A,#B)] + [macd] + [param:/[out: absence);
  re-capture blueprint_view_golden wholesale; confirm compiled_view_golden
  byte-identical (C23); full --workspace triple -D warnings.

Render-only: no engine/ParamAlias/param_space change; compiled_view_golden
byte-identical; aura run --macd unchanged. Output kinds deferred to #43.

refs #41 #43
2026-06-08 10:27:38 +02:00

14 KiB

Composite Signature Render — Implementation Plan

Parent spec: docs/specs/0020-composite-signature-render.md

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

Goal: Render a composite's blueprint definition as a typed signature title line (macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)) with param names folded into leaf labels ([EMA(fast)]), ordered input slots as stubs ([Sub(#A,#B)]), de-prefixed outputs ([macd]), and the [param:*] marker nodes removed.

Architecture: Render-only. Two new helpers (signature, leaf_label) in graph.rs + edits to render_definition; a ScalarKind→lowercase match (none exists today); test/golden updates in main.rs; two stale doc-comment fixes. No engine / ParamAlias / param_space() change. Task 1 changes only render code and gates on cargo build -p aura-cli (the goldens go red and are re-captured in Task 2 — cargo test is NOT a Task-1 gate). Task 2 fixes the tests and runs the full workspace triple.

Tech Stack: crates/aura-cli/src/graph.rs, crates/aura-cli/src/main.rs, crates/aura-core/src/node.rs (doc comment).


Files this plan creates or modifies:

  • Modify: crates/aura-cli/src/graph.rs:16-18 (imports), :101-137 (render_definition + doc), plus two new helpers
  • Modify: crates/aura-core/src/node.rs:93-101 (doc comment only)
  • Modify: crates/aura-cli/src/main.rs:393,394,428,429,452,581 (pins/needles), :580-587 (MACD render test), :483-521 (blueprint_view_golden re-capture)

Task 1: graph.rs render change + doc fixes

Files:

  • Modify: crates/aura-cli/src/graph.rs:16-18,101-137

  • Modify: crates/aura-core/src/node.rs:93-101

  • Step 1: Add ScalarKind and LeafFactory to graph.rs imports

graph.rs:17. Replace:

use aura_core::Node;

with:

use aura_core::{LeafFactory, Node, ScalarKind};
  • Step 2: Add the signature + kind_str helpers

Insert immediately before render_definition (currently graph.rs:106, after the doc comment is rewritten in Step 5 — insert these fns just above that doc comment at :101):

/// `ScalarKind` as a lowercase type string for a signature (`i64`/`f64`/`bool`/
/// `timestamp`). The derived `Debug` gives PascalCase (`I64`), so this is explicit.
fn kind_str(kind: ScalarKind) -> &'static str {
    match kind {
        ScalarKind::I64 => "i64",
        ScalarKind::F64 => "f64",
        ScalarKind::Bool => "bool",
        ScalarKind::Timestamp => "timestamp",
    }
}

/// The composite's typed signature for the definition title:
/// `name(p1:kind, …) -> (o1, …)`. Param kinds come from the aliased interior leaf's
/// declared params; output **names only** (kinds need a pre-build factory interface,
/// #43). An empty alias list renders `name()`. Total: a malformed alias falls back
/// to `?` rather than panicking (compile is the validator, #41).
fn signature(c: &Composite) -> String {
    let params: Vec<String> = c
        .params()
        .iter()
        .map(|a| {
            let kind = c
                .nodes()
                .get(a.node)
                .and_then(|n| match n {
                    BlueprintNode::Leaf(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)),
                    BlueprintNode::Composite(_) => None,
                })
                .unwrap_or("?");
            format!("{}:{}", a.name, kind)
        })
        .collect();
    let outs: Vec<String> = c.output().iter().map(|of| of.name.clone()).collect();
    format!("{}({}) -> ({})", c.name(), params.join(", "), outs.join(", "))
}
  • Step 3: Add the leaf_label helper

Insert directly after signature (above the render_definition doc comment):

/// A leaf item's render label: `factory.label()` plus an optional `(...)` listing
/// its aliased param names, then its input-slot stubs (`#A`, `#B`, … one per wired
/// input slot, slot index → letter) when the leaf has more than one wired input
/// slot. Params and stubs are `; `-separated when both present; either alone has no
/// separator; neither yields the bare label. A node's wired slots are the distinct
/// `.slot` values targeting it across interior edges (`Edge.to == index`) and input
/// roles (`Role.targets` with `node == index`).
fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String {
    let params: Vec<&str> = c
        .params()
        .iter()
        .filter(|a| a.node == index)
        .map(|a| a.name.as_str())
        .collect();

    let mut slots: Vec<usize> = Vec::new();
    for e in c.edges() {
        if e.to == index && !slots.contains(&e.slot) {
            slots.push(e.slot);
        }
    }
    for role in c.input_roles() {
        for t in &role.targets {
            if t.node == index && !slots.contains(&t.slot) {
                slots.push(t.slot);
            }
        }
    }
    slots.sort_unstable();
    let stubs: Vec<String> = if slots.len() > 1 {
        slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect()
    } else {
        Vec::new()
    };

    let parts: Vec<String> = match (params.is_empty(), stubs.is_empty()) {
        (true, true) => return factory.label(),
        (false, true) => vec![params.join(", ")],
        (true, false) => vec![stubs.join(", ")],
        (false, false) => vec![params.join(", "), stubs.join(", ")],
    };
    format!("{}({})", factory.label(), parts.join("; "))
}
  • Step 4: Edit render_definition — title, leaf labels, drop param markers, de-prefix outputs

graph.rs:107-136. Replace the leaf-label loop, the [param:*] loop, the output loop, and the title.

Replace :108-113 (the leaf loop):

    for inner in c.nodes() {
        labels.push(match inner {
            BlueprintNode::Leaf(factory) => factory.label(),
            BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
        });
    }

with (enumerate, fold via leaf_label):

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

Delete the [param:*] marker loop :125-129 entirely:

    for a in c.params() {
        let p_id = labels.len();
        labels.push(format!("param:{}", a.name));
        edges.push((p_id, a.node));
    }

Replace the output marker label :132:

        labels.push(format!("out:{}", of.name));

with (drop the out: prefix):

        labels.push(of.name.clone());

Replace the title :136:

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

with:

    format!("{}:\n{}", signature(c), render_flat(&labels, &edges))
  • Step 5: Fix the stale render_definition doc comment

graph.rs:101-105. Replace:

/// Render one composite's interior as a flat graph: interior leaves as `[type]`,
/// nested composites as opaque `[name]`, plus an `[in:<name>]` entry marker per
/// input role (wired to its interior targets), a `[param:<name>]` marker per param
/// alias (wired to the leaf it relabels), and an `[out:<name>]` marker per
/// re-exported output field (wired from its producer). Prefixed `"<name>:\n"`.

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]`, an `[in:<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.
  • Step 6: Fix the stale LeafFactory::label doc comment

crates/aura-core/src/node.rs:93-101. Replace:

    /// The param-generic render label for the blueprint view (C22 "structure
    /// before"): just the node type, e.g. `SMA`. A value-empty recipe has no
    /// values to show; the tunable knobs are surfaced by `Blueprint::param_space`,
    /// not in the graph. The label stays the bare type because the `ascii-dag`
    /// renderer writes a label verbatim on one line (no wrapping) and overlaps two
    /// wide sibling boxes inside a cluster subgraph — appending the knob names
    /// (`SMA(length)`) would garble the blueprint view, and domain labels grow
    /// unboundedly wide. The compiled view labels the built node valued (`SMA(2)`)
    /// via `Node::label`, unaffected.

with:

    /// The param-generic render label for the blueprint view (C22 "structure
    /// before"): just the node type, e.g. `SMA`. A value-empty recipe has no values
    /// to show; the tunable knobs are surfaced by `Blueprint::param_space`. The
    /// factory label stays the bare type because alias / handle names are a
    /// *composite-level* concept — the renderer folds them into the leaf label at
    /// the composite boundary (`render_definition`'s `leaf_label`), where the
    /// `(node, slot)` → name mapping lives, not on the standalone factory. (Wide
    /// labels are safe: both `aura graph` views are flat since cycle 0017 — no
    /// cluster subgraph, so no sibling-overlap garble.) The compiled view labels the
    /// built node valued (`SMA(2)`) via `Node::label`, unaffected.
  • Step 7: Build the CLI crate (compile gate; tests deferred to Task 2)

Run: cargo build -p aura-cli Expected: PASS — the render code compiles with the two new helpers, the imports, and the edited render_definition. (cargo test is NOT run here: the goldens/needle assertions are now stale and go red until Task 2 re-captures them. cargo build does not compile the #[cfg(test)] module, so this gate is a clean compile check of the render path.)


Task 2: main.rs tests + goldens + workspace gate

Files:

  • Modify: crates/aura-cli/src/main.rs:393,394,428,429,452 (pins/needles), :580-587 (MACD render test), :483-521 (blueprint_view_golden)

  • Step 1: Update the four non-MACD title pins (name:name()

The title format changed from name: to name(…) -> (…):, so every .matches("<name>:") count assert must repin to "<name>(" (the new title is the only place name( occurs; opaque main nodes are [name], no paren).

main.rs:393 — replace:

        assert_eq!(out.matches("sma_cross:").count(), 1, "definition not rendered once:\n{out}");

with:

        assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}");

main.rs:428 — replace out.matches("outer:") with out.matches("outer(") (keep the rest of the line identical). main.rs:429 — replace out.matches("inner:") with out.matches("inner("). main.rs:452 — replace out.matches("dup:") with out.matches("dup(").

  • Step 2: Update the definition needle array

main.rs:394. Replace:

        for needle in ["[SMA]", "[Sub]", "[in:price]", "[out:cross]"] {

with (the Sub now shows its two ordered inputs; the output marker is de-prefixed):

        for needle in ["[SMA]", "[Sub(#A,#B)]", "[in:price]", "[cross]"] {
  • Step 3: Rewrite the MACD definition render assertions

main.rs:580-587. Replace:

        assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
        assert_eq!(out.matches("macd:").count(), 1, "macd defined once:\n{out}");
        assert!(out.contains("[EMA]"), "macd interior must show EMA leaves:\n{out}");
        assert!(out.contains("[in:price]"), "named MACD input role: {out}");
        assert!(out.contains("[param:fast]"), "aliased fast length: {out}");
        assert!(out.contains("[param:slow]"), "aliased slow length: {out}");
        assert!(out.contains("[param:signal]"), "aliased signal length: {out}");

with:

        assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
        assert_eq!(out.matches("macd(").count(), 1, "macd defined once:\n{out}");
        assert!(
            out.contains("macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)"),
            "typed signature line: {out}"
        );
        assert!(out.contains("[EMA(fast)]"), "fast EMA folds its param: {out}");
        assert!(out.contains("[EMA(slow)]"), "slow EMA folds its param: {out}");
        assert!(out.contains("[EMA(signal)]"), "signal EMA folds its param: {out}");
        assert!(out.contains("[Sub(#A,#B)]"), "Sub shows its two ordered inputs: {out}");
        assert!(out.contains("[in:price]"), "named MACD input role: {out}");
        assert!(!out.contains("[param:"), "param marker nodes removed: {out}");
        assert!(!out.contains("[out:"), "output prefix dropped: {out}");
  • Step 4: Re-capture blueprint_view_golden wholesale

Run: cargo test -p aura-cli blueprint_view_golden Expected: FAIL — the where: definition section drifted (sma_cross:sma_cross() -> (cross):, [Sub][Sub(#A,#B)], [out:cross][cross]). Read the assertion's "left" (actual) rendered string and replace the entire expected raw-string literal (main.rs:488-519) with the actual bytes verbatim — do NOT hand-edit field-by-field. Re-run: PASS.

  • Step 5: Confirm compiled_view_golden byte-identical (C23 guard)

Run: cargo test -p aura-cli compiled_view_golden Expected: PASS with NO edit to the golden (main.rs:529-547). The compiled view does not render a composite definition, so it must be untouched. If it drifts, STOP — that is a bug, not a re-capture.

  • Step 6: Full workspace triple (-D warnings)

Run: cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings Expected: all PASS — every test green (the five repins, the needle update, the MACD assertions, both goldens), no clippy warnings. Spot-check the acceptance evidence:

Run: cargo run -q -p aura-cli -- graph --macd Expected: the definition title reads macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram):, the EMAs read [EMA(fast)]/[EMA(slow)]/[EMA(signal)], the Subs read [Sub(#A,#B)], outputs read [macd]/[signal]/[histogram], and no [param:*] marker appears.

Run: cargo run -q -p aura-cli -- run --macd Expected: deterministic JSON, total_pips and exposure_sign_flips unchanged from before (render-only change does not touch the run).