spec: 0018 composite multi-output record
Composite::output: OutPort -> Vec<OutField { node, field, name }> so
multi-line indicators (MACD/Bollinger/Stochastic/Ichimoku) can be
authored as a composition and re-exported as a named record. Output
half only; param/input-role naming is the dependent sibling.
refs #40
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
# Composite multi-output record — Design Spec
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** orchestrator + Claude
|
||||
**Closes:** #40
|
||||
|
||||
## Goal
|
||||
|
||||
A composite (`aura_engine::Composite`) currently exposes exactly **one** output
|
||||
field (`output: OutPort`, a single interior `(node, field)`). Multi-line
|
||||
indicators — MACD (`macd` / `signal` / `histogram`), Bollinger
|
||||
(`upper` / `mid` / `lower`), Stochastic (`%K` / `%D`), Ichimoku (5 lines) —
|
||||
therefore cannot be authored as a composition and re-exported as a unit: only
|
||||
one line escapes the boundary. The MACD proof-of-concept (commit `d8b2a28`,
|
||||
`crates/aura-cli/src/main.rs:206`) is forced to expose only the histogram —
|
||||
`OutPort { node: 4, field: 0 }` — discarding the MACD line and signal line.
|
||||
|
||||
This cycle makes a composite's output a **named, ordered, multi-field record**:
|
||||
`output: Vec<OutField { node, field, name }>`. Each entry re-exports one interior
|
||||
`(node, output-field)` under a boundary name. A consumer selects which
|
||||
re-exported field it reads via the existing `Edge::from_field` — exactly the
|
||||
mechanism that already works for multi-output **leaf** producers.
|
||||
|
||||
This is a **boundary completion**, not an invariant change (see Architecture).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Why this does not touch C8 / C7 / C4
|
||||
|
||||
The substrate is already multi-field everywhere *except* the composite boundary:
|
||||
|
||||
- `NodeSchema.output` is `Vec<FieldSpec>` — a K-column record through **one**
|
||||
port (OHLCV = 5 columns, one port, supported today; C8 realization, cycle
|
||||
0005). "One output **port**" (C8) and "≤1 record per `eval`" are about the
|
||||
**port** and the **row**, never the column count.
|
||||
- `Edge::from_field` already selects *which* output column a consumer reads.
|
||||
- The inliner already routes `from_field` for **leaf** producers
|
||||
(`rewrite_edge`, the `ItemLowering::Leaf` arm at `blueprint.rs:369`): a
|
||||
multi-output leaf works today.
|
||||
|
||||
A 3-line MACD is therefore **one** record of 3 fields, **one** port, **one** row
|
||||
per `eval` — identical in kind to OHLCV. The only cap is the *composite* boundary,
|
||||
which asserts `from_field == 0` in three spots. Lifting that cap completes the
|
||||
boundary; it does not widen the node contract.
|
||||
|
||||
- **C8** (one output port; record of 1..K base columns) — unchanged. A composite
|
||||
re-exports a record, exactly as a leaf already may.
|
||||
- **C7** (four scalar base types, SoA) — unchanged. Each re-exported field is one
|
||||
base column; the record is a bundle of base columns, as today.
|
||||
- **C4** (one record per cycle) — unchanged. One `eval`, one row, K columns,
|
||||
co-fresh by construction.
|
||||
- **C23** (compilat wired by raw index; names are non-load-bearing debug
|
||||
symbols) — **honoured**: the re-export *names* live at the blueprint boundary
|
||||
only. The compilat lowering drops them and carries raw `(node, field)` indices,
|
||||
exactly as `FieldSpec.name` and `Composite.name` are dropped at inline today.
|
||||
|
||||
A strategy composite still outputs **one** exposure (C10/C7): it is simply a
|
||||
composite whose output record happens to have one field. Indicator composites
|
||||
have K fields, strategy composites have 1 — same mechanism, different arity.
|
||||
|
||||
### Scope boundary (output half only)
|
||||
|
||||
This cycle is the **output** half of giving a composite a typed, named boundary
|
||||
signature. The output type is born **name-ready** (`name` is a field of
|
||||
`OutField` from the start) so it is not reopened later merely to add names. The
|
||||
**legibility** half — composite-level param aliasing and input-role naming on the
|
||||
already-functioning param / input edges — is issue **#41**, which depends on this
|
||||
cycle and reuses the same named-projection vocabulary. Input roles keep rendering
|
||||
as `in:<index>` here; naming them is #41's job.
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### Worked author example (the acceptance evidence)
|
||||
|
||||
The MACD composite, re-authored to re-export all three lines as a named record.
|
||||
Today (`crates/aura-cli/src/main.rs:185`) it ends with
|
||||
`OutPort { node: 4, field: 0 }` — only the histogram. After:
|
||||
|
||||
```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 }, // fast → line[0]
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow → line[1]
|
||||
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // line → signal EMA
|
||||
Edge { from: 2, to: 4, slot: 0, from_field: 0 }, // line → histogram[0]
|
||||
Edge { from: 3, to: 4, slot: 1, from_field: 0 }, // signal → histogram[1]
|
||||
],
|
||||
vec![vec![
|
||||
Target { node: 0, slot: 0 }, // price → fast EMA
|
||||
Target { node: 1, slot: 0 }, // price → slow EMA
|
||||
]],
|
||||
// BEFORE: OutPort { node: 4, field: 0 } — only the histogram escaped
|
||||
// AFTER: a 3-field record, all three lines re-exported under names
|
||||
vec![
|
||||
OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line
|
||||
OutField { node: 3, field: 0, name: "signal".into() }, // the signal line
|
||||
OutField { node: 4, field: 0, name: "histogram".into() }, // the histogram
|
||||
],
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
A downstream consumer now selects a re-exported field by `from_field` — the same
|
||||
selector a leaf record already uses. E.g. a strategy that trades the histogram
|
||||
reads `from_field: 2`, while a chart sink records the signal line via
|
||||
`from_field: 1`:
|
||||
|
||||
```rust
|
||||
// macd composite is interior item 0 of the enclosing blueprint
|
||||
Edge { from: 0, to: /*Exposure*/ 1, slot: 0, from_field: 2 }, // histogram → strategy
|
||||
Edge { from: 0, to: /*signal sink*/ 5, slot: 0, from_field: 1 }, // signal line → sink
|
||||
```
|
||||
|
||||
This is the feature-acceptance evidence: the author reaches for the composition
|
||||
exactly as for any record producer, and a behaviour the engine previously made
|
||||
impossible (re-exporting >1 interior line) is now expressible without touching
|
||||
the node contract.
|
||||
|
||||
### Change 1 — the output element type (`blueprint.rs:18`)
|
||||
|
||||
`OutPort` is replaced by `OutField`, carrying the boundary name. It is the only
|
||||
use of `OutPort` (a composite's output port), so this is a rename + one added
|
||||
field, not a parallel type.
|
||||
|
||||
```rust
|
||||
// BEFORE
|
||||
/// Which interior `(node, output-field)` is a composite's single output port (C8).
|
||||
pub struct OutPort {
|
||||
pub node: usize,
|
||||
pub field: usize,
|
||||
}
|
||||
|
||||
// AFTER
|
||||
/// One re-exported field of a composite's output record: an interior
|
||||
/// `(node, output-field)` surfaced at the boundary under `name`. `name` is a
|
||||
/// non-load-bearing render/debug symbol (C23) — like `FieldSpec.name` and
|
||||
/// `Composite.name`, it does not reach the compilat.
|
||||
pub struct OutField {
|
||||
pub node: usize,
|
||||
pub field: usize,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
|
||||
`name` is `String` (not `&'static str`), consistent with the sibling
|
||||
`Composite.name: String` and with author-supplied, possibly dynamic names. It is
|
||||
non-load-bearing either way.
|
||||
|
||||
### Change 2 — `Composite.output` and `Composite::new` (`blueprint.rs:43`, `:56`)
|
||||
|
||||
```rust
|
||||
// BEFORE
|
||||
pub struct Composite {
|
||||
name: String,
|
||||
nodes: Vec<BlueprintNode>,
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Vec<Target>>,
|
||||
output: OutPort,
|
||||
}
|
||||
|
||||
// AFTER — the output record = ordered re-exported interior fields
|
||||
pub struct Composite {
|
||||
name: String,
|
||||
nodes: Vec<BlueprintNode>,
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Vec<Target>>,
|
||||
output: Vec<OutField>,
|
||||
}
|
||||
```
|
||||
|
||||
`Composite::new(… output: Vec<OutField>)` and the accessor
|
||||
`output(&self) -> &[OutField]` (was `-> OutPort`) change in step.
|
||||
|
||||
### Change 3 — the lowering (`blueprint.rs:243`)
|
||||
|
||||
```rust
|
||||
// BEFORE
|
||||
enum ItemLowering {
|
||||
Leaf { index: usize },
|
||||
Composite { output: (usize, usize), roles: Vec<Vec<Target>> },
|
||||
}
|
||||
|
||||
// AFTER — the composite lowers to K flat (node, field) producers, one per
|
||||
// re-exported field, in declared order. Names are dropped here (C23).
|
||||
enum ItemLowering {
|
||||
Leaf { index: usize },
|
||||
Composite { output: Vec<(usize, usize)>, roles: Vec<Vec<Target>> },
|
||||
}
|
||||
```
|
||||
|
||||
### Change 4 — `inline_composite` resolves each re-exported field (`blueprint.rs:292`)
|
||||
|
||||
The single `output` resolution (`blueprint.rs:304`–`333`) becomes a loop over the
|
||||
record. Each `OutField.node` must be in range; each resolves to a flat
|
||||
`(node, field)` and is range-checked against that producer's actual output arity
|
||||
— the leaf arm checks `field` against `schema().output.len()`, the nested arm
|
||||
indexes the nested composite's output `Vec` (instead of asserting `field == 0`).
|
||||
|
||||
```rust
|
||||
// BEFORE (single port; nested arm asserts field == 0)
|
||||
if output.node >= item_count {
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
// ... after lowering interior ...
|
||||
let out = match &interior[output.node] {
|
||||
ItemLowering::Leaf { index } => {
|
||||
if output.field >= flat_nodes[*index].schema().output.len() {
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
(*index, output.field)
|
||||
}
|
||||
ItemLowering::Composite { output: nested, .. } => {
|
||||
if output.field != 0 { // <-- the cap
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
*nested
|
||||
}
|
||||
};
|
||||
Ok(ItemLowering::Composite { output: out, roles })
|
||||
|
||||
// AFTER (K fields; nested arm indexes by field)
|
||||
let mut out: Vec<(usize, usize)> = Vec::with_capacity(output.len());
|
||||
for of in &output {
|
||||
if of.node >= item_count {
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
let resolved = match &interior[of.node] {
|
||||
ItemLowering::Leaf { index } => {
|
||||
if of.field >= flat_nodes[*index].schema().output.len() {
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
(*index, of.field)
|
||||
}
|
||||
ItemLowering::Composite { output: nested, .. } => {
|
||||
*nested.get(of.field).ok_or(CompileError::OutputPortOutOfRange)?
|
||||
}
|
||||
};
|
||||
out.push(resolved);
|
||||
}
|
||||
Ok(ItemLowering::Composite { output: out, roles })
|
||||
```
|
||||
|
||||
Note the in-range check on `output.node` moves *inside* the loop (it was a single
|
||||
pre-check before lowering); the rest of `inline_composite` — interior lowering,
|
||||
edge rewrite, role resolution — is unchanged.
|
||||
|
||||
### Change 5 — `rewrite_edge` indexes the composite output by `from_field` (`blueprint.rs:360`)
|
||||
|
||||
The composite arm (`blueprint.rs:375`–`381`) drops the `from_field == 0`
|
||||
assertion and indexes the output `Vec` with a range-check — identical in shape to
|
||||
the leaf arm directly above it.
|
||||
|
||||
```rust
|
||||
// BEFORE
|
||||
ItemLowering::Composite { output, .. } => {
|
||||
if e.from_field != 0 { // <-- the cap
|
||||
return Err(CompileError::BadInteriorIndex);
|
||||
}
|
||||
*output
|
||||
}
|
||||
|
||||
// AFTER
|
||||
ItemLowering::Composite { output, .. } => {
|
||||
*output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?
|
||||
}
|
||||
```
|
||||
|
||||
### Change 6 — render K named output markers (`crates/aura-cli/src/graph.rs:124`)
|
||||
|
||||
`render_definition` emits one `[out:<name>]` marker per re-exported field, each
|
||||
wired from its interior source — analogous to the per-role `[in:k]` markers. The
|
||||
output **name** is shown (this cycle's deliverable); input roles stay
|
||||
index-labelled (`in:<index>`) until #41.
|
||||
|
||||
```rust
|
||||
// BEFORE — one unnamed [out] marker
|
||||
let out_id = labels.len();
|
||||
labels.push("out".to_string());
|
||||
edges.push((c.output().node, out_id));
|
||||
|
||||
// AFTER — one [out:<name>] marker per re-exported field
|
||||
for of in c.output() {
|
||||
let out_id = labels.len();
|
||||
labels.push(format!("out:{}", of.name));
|
||||
edges.push((of.node, out_id));
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| Component | File | Change |
|
||||
|---|---|---|
|
||||
| `OutField` type (was `OutPort`) | `crates/aura-engine/src/blueprint.rs:18` | rename + `name: String` field |
|
||||
| `Composite` struct + `new` + `output()` | `blueprint.rs:43`, `:56`, `:83` | `OutPort` → `Vec<OutField>` |
|
||||
| `ItemLowering::Composite.output` | `blueprint.rs:243` | `(usize,usize)` → `Vec<(usize,usize)>` |
|
||||
| `inline_composite` | `blueprint.rs:292` | loop over output record; nested arm indexes by field |
|
||||
| `rewrite_edge` (composite arm) | `blueprint.rs:360` | index `output[from_field]` + range-check |
|
||||
| `lib.rs` re-export | `crates/aura-engine/src/lib.rs:38` | `OutPort` → `OutField` |
|
||||
| `render_definition` | `crates/aura-cli/src/graph.rs:105` | K `[out:<name>]` markers |
|
||||
| MACD PoC author site | `crates/aura-cli/src/main.rs:185` | re-export all 3 lines as a record |
|
||||
| All `OutPort { … }` literals | `blueprint.rs` tests, `main.rs:130,206,391,398` | → `OutField { …, name }` |
|
||||
|
||||
## Data flow
|
||||
|
||||
Authoring → compile → run is unchanged in shape; only the boundary arity widens.
|
||||
|
||||
1. **Author:** a composite declares `output: vec![OutField { node, field, name }, …]`
|
||||
— an ordered record of re-exported interior fields.
|
||||
2. **Compile (`inline_composite`):** each `OutField` resolves to a flat
|
||||
`(node, field)`; the composite lowers to `ItemLowering::Composite { output:
|
||||
Vec<(node,field)>, … }`. Names are dropped (C23).
|
||||
3. **Edge rewrite (`rewrite_edge`):** a consumer edge reading the composite with
|
||||
`from_field = k` resolves to `output[k]` — the k-th re-exported flat
|
||||
`(node, field)`. Out-of-range `k` → `BadInteriorIndex`.
|
||||
4. **Bootstrap:** the flat compilat is unchanged in kind — ordinary
|
||||
`(from_node, from_field) → (to_node, slot)` edges. Existing per-field
|
||||
kind-checks at bootstrap (`slot_kind`) apply to each re-exported field as they
|
||||
already do for any producer field. No new kind rule.
|
||||
|
||||
## Error handling
|
||||
|
||||
- `CompileError::OutputPortOutOfRange` — generalized from "the output port names
|
||||
a missing interior node or output field" to "an output **record entry** names a
|
||||
missing interior node or output field". Raised per `OutField` when its `node` is
|
||||
out of interior range, its `field` exceeds a leaf producer's output arity, or
|
||||
its `field` exceeds a nested composite's re-exported arity. (No new variant; the
|
||||
existing one widens.)
|
||||
- `CompileError::BadInteriorIndex` — a consumer edge whose `from_field` indexes
|
||||
past the composite's output record (was: any non-zero `from_field` on a
|
||||
composite). Same variant, range-checked instead of `== 0`.
|
||||
- An **empty** output record (`output: vec![]`) is structurally a composite no
|
||||
edge can read from (every `from_field` is out of range → `BadInteriorIndex`),
|
||||
mirroring a leaf sink's `output: vec![]`. Not specially rejected; it is simply
|
||||
unreadable, which is the correct semantics.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Unit tests in `blueprint.rs` (alongside the existing compile tests):
|
||||
|
||||
1. **Multi-output happy path:** a composite re-exporting 2 interior fields; two
|
||||
downstream consumers read `from_field: 0` and `from_field: 1`; assert the
|
||||
compilat wires each consumer to the correct distinct flat `(node, field)`.
|
||||
2. **Nested multi-output:** an outer composite re-exporting two fields of an
|
||||
**inner** multi-output composite (exercises the nested arm's
|
||||
`nested.get(field)`); assert correct flat resolution.
|
||||
3. **Out-of-range re-export (`OutputPortOutOfRange`):** an `OutField` whose
|
||||
`field` exceeds the interior producer's arity → `OutputPortOutOfRange`.
|
||||
4. **Out-of-range consume (`BadInteriorIndex`):** a consumer edge with
|
||||
`from_field` past the output record length → `BadInteriorIndex`.
|
||||
5. **Single-field record is the old behaviour:** a composite with a one-field
|
||||
output record (e.g. `sma_cross`) compiles identically to today — the
|
||||
regression guard that strategy composites are unaffected.
|
||||
|
||||
Render test in `crates/aura-cli` (alongside the existing `[in:0]`/`[out]`
|
||||
assertion at `main.rs:377`): assert a multi-output composite's definition renders
|
||||
`[out:<name>]` markers for each re-exported field.
|
||||
|
||||
The existing `cargo test --workspace` suite is the regression boundary; all
|
||||
current composite tests update their `OutPort { … }` literals to
|
||||
`OutField { …, name }` and must stay green (single-field records are behaviour-
|
||||
preserving).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. `Composite::output` is `Vec<OutField>`; a composite re-exports K named interior
|
||||
fields as one output record.
|
||||
2. A downstream consumer selects a re-exported field via `Edge::from_field`,
|
||||
range-checked at compile; out-of-range consume → `BadInteriorIndex`,
|
||||
out-of-range re-export → `OutputPortOutOfRange`.
|
||||
3. Nested multi-output composites resolve correctly (outer re-exports inner
|
||||
fields by index).
|
||||
4. The MACD PoC re-exports all three lines (`macd` / `signal` / `histogram`) as a
|
||||
record; the histogram remains reachable as one selected field.
|
||||
5. `aura graph` renders K `[out:<name>]` markers per multi-output composite.
|
||||
6. Single-field output records (every strategy composite, `sma_cross`) compile
|
||||
and run identically to today — C8 / C7 / C4 untouched; the compilat for an
|
||||
unchanged blueprint is byte-identical.
|
||||
7. `cargo build --workspace`, `cargo test --workspace`, and
|
||||
`cargo clippy --workspace --all-targets -- -D warnings` are green.
|
||||
Reference in New Issue
Block a user