# 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.