spec: 0019 name composite boundary

Sibling of #40 (composite output as a named multi-field record). Brings
the other two composite-boundary edge-kinds to the same named-projection
shape: input roles (Vec<Role { name, targets }>, rendered [in:<name>])
and a param-alias overlay (Vec<ParamAlias { name, node, slot }>) that
relabels an interior leaf param slot's surface name in param_space().

Load-bearing decision recorded in the spec: param aliasing is a pure
naming overlay, NOT curation — every interior slot stays in param_space()
and sweepable; the alias only relabels in place, never reorders/hides
(forced by the issue's "legibility not capability" framing + C23). The
injected point vector is unchanged; MACD surfaces macd.fast/slow/signal
instead of three indistinguishable macd.length.

Gates green: feature-acceptance (worked MACD before->after is the
evidence), placeholder scan clean, parse-every-block no-op (profile
declares no spec_validation), grounding-check PASS (7 load-bearing
assumptions each ratified by a named currently-green test; compiled_view
+ param_space slot-order goldens stand as the C23 regression anchors).

refs #41
This commit is contained in:
2026-06-08 01:58:44 +02:00
parent 241bb626e6
commit 03f2dc4c51
+405
View File
@@ -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<OutField { node, field, name }>`). This cycle does the
same for the other two:
- **Input role naming.** `input_roles: Vec<Vec<Target>>` (bare, positional,
rendered `[in:0]`, `[in:1]`) becomes `Vec<Role { name, targets }>`, 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<ParamAlias { name, node, slot }>` 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<dyn Node>`.
- **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:<name>]` (was `[in:<index>]`); outputs already render `[out:<name>]` (#40);
aliased params render `[param:<name>]` 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<Target>,
}
/// 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<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: Vec<OutField>,
}
// after
pub struct Composite {
name: String,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Role>,
params: Vec<ParamAlias>,
output: Vec<OutField>,
}
```
`Composite::new` takes `input_roles: Vec<Role>` and a new `params: Vec<ParamAlias>`
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<ParamSpec>)
// 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<ParamSpec>,
) {
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:<name>]` 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<Role>`; new `params: Vec<ParamAlias>`; `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:<name>]`; `[param:<name>]` 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<Role>`, `Vec<ParamAlias>`,
`Vec<OutField>`.
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<dyn Node>`.
- `cargo build/test/clippy --workspace` (-D warnings) green; `aura run --macd`
deterministic and unchanged in metrics.
- Closes #41.