"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.
17 KiB
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.outputisVec<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 pereval" are about the port and the row, never the column count.Edge::from_fieldalready selects which output column a consumer reads.- The inliner already routes
from_fieldfor leaf producers (rewrite_edge, theItemLowering::Leafarm atblueprint.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 (flat graph wired by raw index; names are non-load-bearing debug
symbols) — honoured: the re-export names live at the blueprint boundary
only. The flat graph lowering drops them and carries raw
(node, field)indices, exactly asFieldSpec.nameandComposite.nameare 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:
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:
// 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.
// 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 flat graph.
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)
// 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)
// 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).
// 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.
// 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.
// 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.
- Author: a composite declares
output: vec![OutField { node, field, name }, …]— an ordered record of re-exported interior fields. - Compile (
inline_composite): eachOutFieldresolves to a flat(node, field); the composite lowers toItemLowering::Composite { output: Vec<(node,field)>, … }. Names are dropped (C23). - Edge rewrite (
rewrite_edge): a consumer edge reading the composite withfrom_field = kresolves tooutput[k]— the k-th re-exported flat(node, field). Out-of-rangek→BadInteriorIndex. - Bootstrap: the flat graph 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 perOutFieldwhen itsnodeis out of interior range, itsfieldexceeds a leaf producer's output arity, or itsfieldexceeds a nested composite's re-exported arity. (No new variant; the existing one widens.)CompileError::BadInteriorIndex— a consumer edge whosefrom_fieldindexes past the composite's output record (was: any non-zerofrom_fieldon a composite). Same variant, range-checked instead of== 0.- An empty output record (
output: vec![]) is structurally a composite no edge can read from (everyfrom_fieldis out of range →BadInteriorIndex), mirroring a leaf sink'soutput: 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):
- Multi-output happy path: a composite re-exporting 2 interior fields; two
downstream consumers read
from_field: 0andfrom_field: 1; assert the flat graph wires each consumer to the correct distinct flat(node, field). - 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. - Out-of-range re-export (
OutputPortOutOfRange): anOutFieldwhosefieldexceeds the interior producer's arity →OutputPortOutOfRange. - Out-of-range consume (
BadInteriorIndex): a consumer edge withfrom_fieldpast the output record length →BadInteriorIndex. - 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
Composite::outputisVec<OutField>; a composite re-exports K named interior fields as one output record.- 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. - Nested multi-output composites resolve correctly (outer re-exports inner fields by index).
- The MACD PoC re-exports all three lines (
macd/signal/histogram) as a record; the histogram remains reachable as one selected field. aura graphrenders K[out:<name>]markers per multi-output composite.- Single-field output records (every strategy composite,
sma_cross) compile and run identically to today — C8 / C7 / C4 untouched; the flat graph for an unchanged blueprint is byte-identical. cargo build --workspace,cargo test --workspace, andcargo clippy --workspace --all-targets -- -D warningsare green.