# 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
flat graph) 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 flat graph 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 graph. (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.
