diff --git a/docs/specs/0020-composite-signature-render.md b/docs/specs/0020-composite-signature-render.md new file mode 100644 index 0000000..df0a9da --- /dev/null +++ b/docs/specs/0020-composite-signature-render.md @@ -0,0 +1,307 @@ +# Composite Signature Render — Design Spec + +**Date:** 2026-06-08 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Render a composite's blueprint definition as a **typed signature line** plus a +de-cluttered body, replacing the `[param:]` marker nodes #41 introduced +(which bloat the ASCII graph). The interface moves into the title; the params and +ordered inputs fold into the node labels that already exist. + +This is **render-only** — no engine logic changes. The #41 `ParamAlias` data and +`param_space()` are untouched. + +For the MACD composite, the definition title becomes: + +``` +macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram) +``` + +and the three `[param:*]` marker nodes vanish. Output **names** only (not kinds): +a value-empty `LeafFactory` exposes no output schema pre-build (the `mc_4`/#42 +finding), so the typed output arrow (`-> (macd:f64, …)`) is **deferred to #43**. +This cycle references #41 (the surface it refines) and #43 (the deferred typing). + +## Architecture + +The blueprint-definition view (`render_definition` in `crates/aura-cli/src/graph.rs`) +is the only renderer that changes. Five edits, all in `render_definition` except +one doc-comment fix: + +1. **Title = signature, not bare name.** `c.name()` → `name(params) -> (outputs)`. +2. **Leaf labels fold their aliased param names.** `[EMA]` → `[EMA(fast)]`. +3. **Unnamed interior input slots render as positional stubs.** `[Sub]` → + `[Sub(#A,#B)]`, derived from the interior wiring. +4. **Output markers drop the `out:` prefix.** `[out:macd]` → `[macd]`. +5. **The `[param:]` marker nodes are removed.** + +Plus a stale-prose fix in `crates/aura-core/src/node.rs`. + +**Why this is renderable now (the ascii-dag history).** `LeafFactory::label` +historically stayed the bare type (`SMA`, not `SMA(length)`) because `ascii-dag` +0.9.1 garbles wide sibling labels inside a **cluster** subgraph. Cycle 0017 +removed clusters — both `aura graph` views build **flat** graphs +(`crates/aura-cli/src/graph.rs:13-14`: *"the subgraph layout mis-centres wide +sibling labels, the flat layout does not"*). Wide labels are now safe, which is +what makes `[EMA(fast)]` / `[Sub(#A,#B)]` renderable. The doc-comment at +`node.rs:93-101` still cites the cluster constraint as the reason for bare-type +labels — stale prose this cycle corrects. + +### Invariant-neutrality +- **Render-only.** No engine logic change; `ParamAlias`, `param_space()`, the + compile path, and the run are untouched. Files: `graph.rs` (`render_definition`), + `main.rs` (render tests/goldens), `node.rs` (doc comment only). +- **C23.** `compiled_view_golden` is byte-identical — only the blueprint-definition + render changes; the compiled/flat view and the compilat are unaffected. Boundary + names still never reach a `Box`. +- **C8/C7/C4 untouched** — authoring-surface legibility only. `aura run --macd` + is deterministic and unchanged (render-only). + +## Concrete code shapes + +### Worked example — `aura graph --macd`, before → after + +The MACD definition block today (real output — note the three `[param:*]` markers +and the loose `[in:price]` cluttering the top, and the bare `[EMA]`/`[Sub]`): + +``` +macd: + [param:fast] [in:price] [param:slow] [param:signal] + │ └┐───────┌────┘ ┌─────────────┘ + └──────────────┐ │ │ + ↓ ↓ │ + [EMA] [EMA] │ + └───┌───┘ │ + ↓ ┌───┘ + [Sub] │ + ┌───┘──────│────┐ + ↓ └────↓ + [out:macd] │ [EMA] + ┌───────────┌────┘ + ↓───────────↓ + [Sub] [out:signal] + └───────┐ + ↓ + [out:histogram] +``` + +After this cycle — title carries the signature, `[param:*]` markers gone, params +folded into the EMAs, ordered Sub inputs shown as stubs, outputs de-prefixed +(exact box-drawing layout is whatever the flat renderer produces; the load-bearing +changes are the labels): + +``` +macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram): + [in:price] + └┐──────┌┘ + ↓ ↓ + [EMA(fast)] [EMA(slow)] + └──┌────┘ + ↓ + [Sub(#A,#B)] + ┌────┘────┐ + ↓ ↓ + [macd] [EMA(signal)] + ┌──────┘ + ↓ + [Sub(#A,#B)] + ┌────┘────┐ + ↓ ↓ + [signal] [histogram] +``` + +The `sma_cross` sample (named role, **no** aliases) confirms the empty-param case +renders cleanly — no params in the signature, leaves stay bare: + +``` +sma_cross() -> (cross): + [in:price] + └┐──────┌┘ + ↓ ↓ + [SMA] [SMA] + └──┌───┘ + ↓ + [Sub(#A,#B)] + │ + ↓ + [cross] +``` + +### The label-annotation rule (pinned) + +A leaf item's label in `render_definition` is `factory.label()` plus an optional +parenthesised annotation built in this order: + +1. **Aliased param names** for this leaf — every `a.name` in `c.params()` with + `a.node == this_index`, in `c.params()` order. +2. **Input-slot stubs** — only if the leaf has **>1** distinct wired input slot: + one positional stub per slot in slot order (`#A`, `#B`, `#C`, …). A single-input + leaf gets no stub (no ordering ambiguity). + +If both are present, the annotation is `factory.label()` + `(` + params + `; ` + +stubs + `)` (params and stubs separated by `; `). If only one is present, no +separator. If neither, the bare label (today's behaviour). In the worked MACD/SMA +graphs every leaf hits exactly one branch — EMAs have one aliased param and one +input (→ `[EMA(fast)]`); Subs have no params and two inputs (→ `[Sub(#A,#B)]`) — +the combined `; ` form is defined for completeness, not exercised here. + +A node's wired input slots are the distinct `.slot` values targeting it, collected +from **both** `c.edges()` (entries with `to == index`) **and** `c.input_roles()` +(each role's `targets` with `node == index`). Nested-composite interior items keep +rendering as their opaque `name()` (unchanged; their own definition renders +separately). + +### Before → after — `render_definition` (`crates/aura-cli/src/graph.rs`) + +Today (the relevant parts): leaves labelled bare; an `[in:k]`, `[param:name]`, and +`[out:name]` marker per role/alias/output; title is `c.name()`. + +```rust +fn render_definition(c: &Composite) -> String { + let mut labels: Vec = Vec::with_capacity(c.nodes().len()); + for inner in c.nodes() { + labels.push(match inner { + BlueprintNode::Leaf(factory) => factory.label(), + 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(format!("in:{}", role.name)); + for t in &role.targets { + edges.push((in_id, t.node)); + } + } + for a in c.params() { + let p_id = labels.len(); + labels.push(format!("param:{}", a.name)); + edges.push((p_id, a.node)); + } + for of in c.output() { + let out_id = labels.len(); + labels.push(format!("out:{}", of.name)); + edges.push((of.node, out_id)); + } + format!("{}:\n{}", c.name(), render_flat(&labels, &edges)) +} +``` + +After: leaf labels annotated (params folded + input stubs), the `[param:*]` loop +removed, outputs de-prefixed, the title built from a new `signature(c)` helper. + +```rust +fn render_definition(c: &Composite) -> 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(format!("in:{}", role.name)); + for t in &role.targets { + edges.push((in_id, t.node)); + } + } + // params are no longer marker nodes — they fold into leaf labels (leaf_label) + // and the signature title (signature). The [param:*] loop is removed. + for of in c.output() { + let out_id = labels.len(); + labels.push(of.name.clone()); // [out:macd] -> [macd] + edges.push((of.node, out_id)); + } + format!("{}:\n{}", signature(c), render_flat(&labels, &edges)) +} +``` + +with two new render-side helpers (shapes; exact bodies are the planner's): + +```rust +// "macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)" +// param kinds from the interior leaf's factory().params()[slot].kind; +// output names only (kinds deferred to #43). +fn signature(c: &Composite) -> String { /* ... */ } + +// "EMA(fast)" / "Sub(#A,#B)" / bare "SMA" per the label-annotation rule above. +fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String { /* ... */ } +``` + +### node.rs doc-comment fix (`crates/aura-core/src/node.rs:93-101`) + +The `LeafFactory::label` doc currently reads (paraphrased) "the label stays the bare +type because ascii-dag overlaps two wide sibling boxes inside a cluster subgraph". +Replace that rationale: the renderer is flat (no clusters) since cycle 0017, so wide +labels are safe; the factory label stays the bare param-generic type because +**alias / handle names are composite-level concepts resolved at render** +(`render_definition` folds them via `c.params()`), not because of any ascii-dag +constraint. Behaviour-neutral (a comment). + +## Components + +| Component | File | Change | +|-----------|------|--------| +| `render_definition` | `crates/aura-cli/src/graph.rs` | title = `signature(c)`; leaf labels via `leaf_label`; drop `[param:*]` loop; outputs de-prefixed | +| `signature` (new) | `crates/aura-cli/src/graph.rs` | builds the `name(params) -> (outputs)` line; param kinds from `factory().params()[slot].kind`; output names only | +| `leaf_label` (new) | `crates/aura-cli/src/graph.rs` | folds aliased param names + input-slot stubs per the pinned rule | +| `LeafFactory::label` doc | `crates/aura-core/src/node.rs:93-101` | replace stale cluster rationale (comment only) | +| render tests / goldens | `crates/aura-cli/src/main.rs` | `blueprint_view_golden` re-capture; new assertions; `compiled_view_golden` untouched | + +## Data flow + +Render is a pure read over the blueprint (C9 graph-as-data): `render_definition` +reads `c.nodes()`, `c.edges()`, `c.input_roles()`, `c.params()`, `c.output()` and +the interior leaves' `factory().label()` / `factory().params()`. No build, no +param vector, no engine call. `signature` resolves param kinds by indexing the +aliased leaf's `factory().params()`; output kinds are intentionally not resolved +(deferred to #43). + +## Error handling + +Render is total over a well-formed blueprint (the same blueprints the existing +`render_definition` already renders). An alias is validated at compile +(`BadInteriorIndex`, #41); the renderer is reached only for renderable blueprints, +so `signature`/`leaf_label` index `c.params()`/`factory().params()` within the +bounds an already-constructed composite guarantees. No new error paths. + +## Testing strategy + +- **`blueprint_view_golden`** (`crates/aura-cli/src/main.rs`) re-captured — the + definition render changed. Run the test, paste the actual rendered output + wholesale into the golden literal. +- **New render assertions** on the MACD definition: contains the signature + substring `macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)`, + contains `[EMA(fast)]`, `[Sub(#A,#B)]`, `[macd]`, `[in:price]`; and asserts + `[param:` is **absent** (markers removed) and `[out:` is **absent** (prefix + dropped). +- **`compiled_view_golden`** byte-identical (C23 guard — must stay green, + untouched; a drift here is a bug, not a re-capture). +- **`run_macd…deterministic`** green, metrics unchanged (render-only). +- **No-alias case**: a `sma_cross` render assertion confirms `sma_cross() -> (cross)` + (empty param list) and bare `[SMA]` leaves render cleanly. + +## Acceptance criteria + +- A trader reading `aura graph --macd` sees the composite's interface as a typed + signature line (`macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, + histogram)`) and an uncluttered body — the `[param:*]` marker bloat is gone, + params read on the EMAs (`[EMA(fast)]`), the ordered Sub inputs are visible + (`[Sub(#A,#B)]`). The worked before→after is the evidence. +- Render-only: `compiled_view_golden` byte-identical; `aura run --macd` + deterministic and unchanged; no engine/`ParamAlias`/`param_space()` change. +- Output kinds are honestly omitted (names only), with the typed arrow deferred to + #43; the spec and commit reference #41 (surface) and #43 (deferred typing), and + this cycle `closes` no issue. +- `cargo build/test/clippy --workspace` (-D warnings) green.