Files
Aura/docs/specs/0019-name-composite-boundary.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

406 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.