spec: 0020 composite signature render
Render-only refinement of #41's composite blueprint-definition view. The [param:<name>] marker nodes #41 added bloat the ASCII graph; this moves the interface into a typed signature title line and folds names into the node labels that already exist: - title: macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram) (param kinds from factory().params()[slot].kind; OUTPUT NAMES ONLY) - leaf labels fold aliased param names: [EMA] -> [EMA(fast)] - unnamed interior input slots as positional stubs: [Sub] -> [Sub(#A,#B)] (count/order derived render-side from c.edges() + c.input_roles()) - outputs de-prefixed: [out:macd] -> [macd]; [param:*] marker nodes removed - fix stale node.rs:93-101 cluster rationale (renderer went flat in 0017) Output KINDS are deferred: a value-empty LeafFactory exposes no output schema pre-build (the mc_4/#42 finding), so the typed arrow (-> macd:f64) waits on #43. Render-only: no engine/ParamAlias/param_space change; compiled_view_golden byte-identical (C23); aura run --macd unchanged. Gates green: feature-acceptance (the worked MACD before->after is the evidence), placeholder scan clean, parse-every-block no-op (no spec_validation parser), grounding-check PASS (10 assumptions ratified; both load-bearing premises — no pre-build factory output schema, flat renderer — confirmed). refs #41 #43
This commit is contained in:
@@ -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:<name>]` 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:<name>]` 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<dyn Node>`.
|
||||
- **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<String> = 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<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(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.
|
||||
Reference in New Issue
Block a user