diff --git a/docs/specs/0019-name-composite-boundary.md b/docs/specs/0019-name-composite-boundary.md new file mode 100644 index 0000000..d32ec84 --- /dev/null +++ b/docs/specs/0019-name-composite-boundary.md @@ -0,0 +1,405 @@ +# Name the Composite Boundary — Design Spec + +**Date:** 2026-06-08 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Make all three composite-boundary edge-kinds — **input roles**, **params**, and +**outputs** — a uniform *named projection* of interior handles. Cycle 0018 (#40) +did this for outputs (`Vec`). This cycle does the +same for the other two: + +- **Input role naming.** `input_roles: Vec>` (bare, positional, + rendered `[in:0]`, `[in:1]`) becomes `Vec`, rendered + `[in:price]`. +- **Param aliasing.** Today `param_space()` aggregates interior leaf params under + their *factory* names, so the three EMA `length` knobs of a MACD composite all + surface as `macd.length` — distinguishable only by slot. A composite gains a + `params: Vec` projection that relabels a named + interior param slot's surface name (`fast`, `slow`, `signal`). + +This is **pure legibility, not a capability** (the load-bearing decision — see +Architecture). Inputs and params already *function* (wireable by role index, +sweepable by slot); this names slots that already work. The payoff is the full +boundary signature visible in one render — conceptually, for MACD, named inputs +(`price`), named params (`fast`, `slow`, `signal`), and named outputs (`macd`, +`signal`, `histogram`). The authoritative output format is the marker DAG of +`render_definition` (see "Render — the full named signature" below), not a +one-line signature string. + +The implementation commit closes #41. + +## Architecture + +### The one load-bearing decision: naming overlay, not curation + +When a composite aliases a param slot, does it **change the sweep surface** +(`param_space()`) — selectively exposing/hiding interior slots — or is it a +**pure naming overlay**? + +**Resolved → pure naming overlay.** Every interior param slot remains in +`param_space()` and stays sweepable exactly as today; an alias only relabels one +slot's *surface name* from the factory name to the alias. Nothing is hidden, the +surface arity is unchanged, the alias is cosmetic. This is forced by two sources: + +- The issue's own framing: *"Pure legibility, NOT a capability: params and inputs + already work; this renames slots that already function."* A selective/curation + reading (the composite curates which knobs are public) is a *capability* the + issue explicitly disclaims. +- **C23** (the design ledger): a boundary name is a non-load-bearing debug symbol; + identity is positional and survives lowering, the name does not. An alias that + changed *what is sweepable* would make the name load-bearing — a C23 violation. + +Consequence, made explicit so the implementation cannot drift: a param's identity +in `param_space()` stays its **slot** (position), never its name. Aliases relabel +in place; they never reorder, add, or remove a slot. The existing invariant test +`param_space_mirrors_compiled_flat_node_param_order` (slot order mirrors the flat +compilat) therefore stays green unchanged — it is the C23 anchor for this cycle. + +### Invariant-neutrality + +- **C23 honoured.** Names are non-load-bearing debug symbols dropped at lowering; + identity is positional everywhere (param slot, role index, output field index). + The compilat is byte-identical — `compiled_view_golden` is the regression guard, + exactly as #40 honoured it. The role/param/output *names* never reach a + `Box`. +- **C8 / C7 / C4 untouched.** This is authoring-surface legibility only: no change + to the runtime record shape, the four base scalars, or one-record-per-cycle. +- **`param_space()` surface unchanged** in size, order, kind, and sweepability — + only the `name` string of an aliased slot changes. + +### Orthogonal to #36 + +Issue #36 ("param declared twice: `factory().params()` and `schema().params` must +stay in lockstep") touches the *leaf* param-declaration surface. #41 is purely +*additive* at the *composite* level (an alias overlay consulted during +aggregation). They are orthogonal: this cycle neither resolves nor depends on #36, +and the spec deliberately does not entangle them. + +## Concrete code shapes + +### Worked author example (the acceptance evidence) — MACD + +The MACD composite (`crates/aura-cli/src/main.rs`) is the worked example: three +interior EMAs each carry a `length` param, so today its boundary reads by position +only. Below is the author site **before → after**. + +**Before** (today — one bare role, params anonymous, outputs already named in #40): + +```rust +fn macd(name: &str) -> Composite { + Composite::new( + name, + vec![ + Ema::factory().into(), // 0 fast EMA + Ema::factory().into(), // 1 slow EMA + Sub::factory().into(), // 2 MACD line = fast − slow + Ema::factory().into(), // 3 signal EMA of the MACD line + Sub::factory().into(), // 4 histogram = MACD line − signal + ], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + Edge { from: 2, to: 3, slot: 0, from_field: 0 }, + Edge { from: 2, to: 4, slot: 0, from_field: 0 }, + Edge { from: 3, to: 4, slot: 1, from_field: 0 }, + ], + vec![vec![ + Target { node: 0, slot: 0 }, // price → fast EMA + Target { node: 1, slot: 0 }, // price → slow EMA + ]], + vec![ + OutField { node: 2, field: 0, name: "macd".into() }, + OutField { node: 3, field: 0, name: "signal".into() }, + OutField { node: 4, field: 0, name: "histogram".into() }, + ], + ) +} +``` + +`macd_blueprint().param_space()` today surfaces (the three EMA `length` knobs, +indistinguishable by name): + +``` +["macd.length", "macd.length", "macd.length"] +``` + +**After** (this cycle — named role, aliased params; outputs unchanged): + +```rust +fn macd(name: &str) -> Composite { + Composite::new( + name, + vec![ + Ema::factory().into(), // 0 fast EMA + Ema::factory().into(), // 1 slow EMA + Sub::factory().into(), // 2 MACD line = fast − slow + Ema::factory().into(), // 3 signal EMA of the MACD line + Sub::factory().into(), // 4 histogram = MACD line − signal + ], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + Edge { from: 2, to: 3, slot: 0, from_field: 0 }, + Edge { from: 2, to: 4, slot: 0, from_field: 0 }, + Edge { from: 3, to: 4, slot: 1, from_field: 0 }, + ], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, // price → fast EMA + Target { node: 1, slot: 0 }, // price → slow EMA + ], + }], + vec![ + ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast EMA length + ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow EMA length + ParamAlias { name: "signal".into(), node: 3, slot: 0 }, // signal EMA length + ], + vec![ + OutField { node: 2, field: 0, name: "macd".into() }, + OutField { node: 3, field: 0, name: "signal".into() }, + OutField { node: 4, field: 0, name: "histogram".into() }, + ], + ) +} +``` + +`macd_blueprint().param_space()` now surfaces (same slots, same order, same kinds — +only the names change): + +``` +["macd.fast", "macd.slow", "macd.signal"] +``` + +The injected point vector (`sample_point` / the MACD point) is **unchanged** — slot +order is identical; only the labels improved. This is the empirical proof of the +naming-overlay decision: the sweep surface is byte-for-byte the same shape, the +author just reads it by name now. + +### Render — the full named signature + +`aura graph --macd` renders the composite definition. Input roles render +`[in:]` (was `[in:]`); outputs already render `[out:]` (#40); +aliased params render `[param:]` markers wired into the leaf they configure +(direction: marker → node, like `[in:]`). Marker labels stay short (one word) to +avoid the ascii-dag wide-sibling-label garble. The MACD definition gains: + +``` +[in:price] (was [in:0]) +[param:fast] → EMA(0) +[param:slow] → EMA(1) +[param:signal] → EMA(3) +[out:macd] [out:signal] [out:histogram] (unchanged, #40) +``` + +The **compiled** view (`aura graph --macd --compiled`) stays name-free — the +boundary dissolves at inline, markers gone (C23). `compiled_view_golden` is +byte-identical. + +### Secondary — engine struct / signature shapes (before → after) + +**`Role`** (new) and **`ParamAlias`** (new) in `crates/aura-engine/src/blueprint.rs`, +each `#[derive(Clone, Debug, PartialEq, Eq)]` (mirroring `Target` / `OutField`): + +```rust +/// One named input role: role `r` (by position) fans the source value into +/// `targets`. The name is a non-load-bearing render symbol (C23); identity is +/// the role index, which survives lowering. +pub struct Role { + pub name: String, + pub targets: Vec, +} + +/// A composite-level alias relabelling one interior leaf param slot's surface +/// name in `param_space()`. `node` is the interior item index, `slot` the param +/// slot within that leaf. Pure legibility: the alias relabels in place and never +/// reorders, adds, or removes a slot (C23 — identity stays the slot). +pub struct ParamAlias { + pub name: String, + pub node: usize, + pub slot: usize, +} +``` + +**`Composite`** gains a `params` field; `input_roles` changes element type: + +```rust +// before +pub struct Composite { + name: String, + nodes: Vec, + edges: Vec, + input_roles: Vec>, + output: Vec, +} + +// after +pub struct Composite { + name: String, + nodes: Vec, + edges: Vec, + input_roles: Vec, + params: Vec, + output: Vec, +} +``` + +`Composite::new` takes `input_roles: Vec` and a new `params: Vec` +argument (positioned between `input_roles` and `output`, matching the +inputs → params → outputs signature order). New accessors: + +```rust +pub fn input_roles(&self) -> &[Role] { &self.input_roles } +pub fn params(&self) -> &[ParamAlias] { &self.params } // new +// output() unchanged +``` + +**`collect_params`** (the `param_space()` walk) threads the directly-containing +composite's alias slice and consults it when naming a leaf param: + +```rust +// before: fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec) +// after: aliases is the alias list of the composite directly containing `items` +// (empty &[] at the top level). +fn collect_params( + items: &[BlueprintNode], + prefix: &str, + aliases: &[ParamAlias], + out: &mut Vec, +) { + for (i, item) in items.iter().enumerate() { + match item { + BlueprintNode::Leaf(factory) => { + for (s, p) in factory.params().iter().enumerate() { + // an alias for this exact (node, slot) relabels in place; + // otherwise the factory param name, as today. + let local = aliases + .iter() + .find(|a| a.node == i && a.slot == s) + .map(|a| a.name.as_str()) + .unwrap_or(p.name.as_str()); + let name = if prefix.is_empty() { + local.to_string() + } else { + format!("{prefix}.{local}") + }; + out.push(ParamSpec { name, kind: p.kind }); + } + } + BlueprintNode::Composite(c) => { + let child = if prefix.is_empty() { + c.name().to_string() + } else { + format!("{prefix}.{}", c.name()) + }; + collect_params(c.nodes(), &child, c.params(), out); + } + } + } +} +``` + +`Blueprint::param_space` seeds the top level with no aliases: +`collect_params(&self.nodes, "", &[], &mut out)`. + +**`inline_composite`** (the compile-path role walk, ~blueprint.rs:342) iterates +`Role`s instead of bare target vecs — `for (r, role) in input_roles.iter().enumerate()` +becomes a walk over `role.targets`; the role's `name` is inert at compile (dropped, +C23). The destructure `let Composite { name: _, nodes, edges, input_roles, output } = c;` +gains `params: _` (params do not lower — they are a read-side projection only). + +**`render_definition`** (`crates/aura-cli/src/graph.rs`): the input-role loop reads +`role.name` for the marker label and `role.targets` for the edges; a new loop emits +`[param:]` markers for `c.params()`, each wired marker → `alias.node`. + +## Components + +| Component | File | Change | +|-----------|------|--------| +| `Role` struct | `crates/aura-engine/src/blueprint.rs` | new; re-exported from `lib.rs` | +| `ParamAlias` struct | `crates/aura-engine/src/blueprint.rs` | new; re-exported from `lib.rs` | +| `Composite` | `crates/aura-engine/src/blueprint.rs` | `input_roles: Vec`; new `params: Vec`; `new` arg; `params()` accessor | +| `collect_params` | `crates/aura-engine/src/blueprint.rs` | thread `aliases: &[ParamAlias]`; relabel matched slot | +| `inline_composite` | `crates/aura-engine/src/blueprint.rs` | walk `role.targets`; destructure `params: _` | +| `render_definition` | `crates/aura-cli/src/graph.rs` | `[in:]`; `[param:]` markers | +| MACD + sample author sites | `crates/aura-cli/src/main.rs` | `Role { … }`, `ParamAlias { … }` | +| Construction-layer fixtures | `fieldtests/milestone-construction-layer/mc_*.rs` | `Role { … }` sweep (single unnamed role → `Role { name, targets }`) | + +## Data flow + +1. **Author** writes a `Composite` with `Vec`, `Vec`, + `Vec`. +2. **`param_space()`** walks the tree; for each leaf param slot it picks the alias + name (if the directly-containing composite aliases that `(node, slot)`) or the + factory name, path-qualified by composite `name()`s as today. Slot order is + unchanged — aliases relabel, never reorder. +3. **`compile_with_params`** lowers exactly as today: roles fan in by `targets`, + params bind slot-by-slot to the injected vector, names dropped (C23). An + out-of-range alias `(node, slot)` is rejected here (see Error handling). +4. **Render** surfaces `name`s as markers in the blueprint view; the compiled view + is name-free. + +## Error handling + +- **Alias references a missing/non-leaf interior node or a slot beyond the leaf's + param count.** (A composite interior item has no params, so any slot on it is + out of range.) Rejected at `compile_with_params` (the fallible, tree-walking path that + already range-checks edges and outputs) as `CompileError::BadInteriorIndex` — + reusing the existing variant, no new variant (the same widening posture #40 took + for outputs). `param_space()` stays infallible: an alias that matches no emitted + `(node, slot)` is simply inert there (no slot is relabelled), and the structural + defect surfaces at compile. This keeps `param_space()` deterministic and + slot-stable regardless of alias validity. +- **Role with a bad target** — unchanged: already `BadInteriorIndex` / + `RoleKindMismatch { role }` at compile. +- **Duplicate aliases for the same `(node, slot)`.** First match wins (`find`); + benign and not worth a dedicated error — the surface is still a valid relabel. + +## Testing strategy + +Engine unit tests (`crates/aura-engine/src/blueprint.rs`): + +1. **Alias relabels in place.** A composite with two same-type leaves + two + aliases → `param_space()` names are the aliases (e.g. `["c.shortLen", + "c.longLen"]`), not duplicate factory names. +2. **Unaliased params unchanged (regression).** A composite with `params: vec![]` + → `param_space()` identical to today's path-qualified factory names. +3. **Slot order invariant (C23 anchor).** `param_space_mirrors_compiled_flat_node_param_order` + stays green unchanged — aliases relabel, never reorder; the slot still mirrors + the flat compilat. (No new test; the existing one is the guard.) +4. **Out-of-range alias → `BadInteriorIndex`** at `compile_with_params` + (alias `node`/`slot` past the interior leaf's params). +5. **Partial aliasing.** A composite aliasing one of two slots → the aliased slot + takes the alias name, the other keeps its factory name; order intact. + +CLI render test (`crates/aura-cli`): + +6. **Named role + param markers render.** `render_definition` of the MACD (or a + fixture) composite contains `[in:price]`, `[param:fast]`, `[param:slow]`, + `[param:signal]` and the existing `[out:*]`; asserts no `[in:0]`. +7. **`compiled_view_golden` byte-identical** (C23 regression — names dropped at + lowering). If it drifts, that is a bug, not a golden to re-capture. + +Author-site / build: + +8. `cargo build/test/clippy --workspace` (-D warnings) all green; the MACD run + (`aura run --macd`) stays deterministic (same metrics as 0018 — params only + got names, the injected vector is unchanged). + +## Acceptance criteria + +- A trader authoring a multi-line indicator reads `macd.fast` / `macd.slow` / + `macd.signal` and `[in:price]` instead of three indistinguishable `macd.length` + and `[in:0]` — the worked MACD before → after is the evidence the project's + intended audience naturally reaches for this. +- `param_space()` surface is unchanged in size, order, kind, and sweepability; only + names change (the naming-overlay decision, empirically shown by the unchanged + point vector). +- C23 preserved: `compiled_view_golden` byte-identical; no role/param/output name + reaches a `Box`. +- `cargo build/test/clippy --workspace` (-D warnings) green; `aura run --macd` + deterministic and unchanged in metrics. +- Closes #41.