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:
@@ -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.
|
||||
Reference in New Issue
Block a user