feat(aura-engine): composite output is a named multi-field record

Replace a composite's single output port (OutPort { node, field }) with
an ordered, named record (Vec<OutField { node, field, name }>). A
composite can now re-export K interior fields as one output; a consumer
selects one via the existing Edge::from_field — the same field-wise
selector that already works for multi-output leaf producers (OHLCV).

Why: multi-line indicators (MACD, Bollinger, Stochastic, Ichimoku) could
not be authored as a composition and re-exported as a unit — only one
line escaped the boundary. The MACD PoC was forced to expose only the
histogram. This lifts the three `field == 0` / `from_field == 0` caps at
the composite boundary (inline_composite nested arm, rewrite_edge
composite arm), range-checking the index instead.

Boundary completion, not an invariant change:
- C8/C7/C4 untouched — one port, one row per eval, K base columns (a
  3-line MACD is identical in kind to OHLCV).
- C23 honoured — re-export names live at the blueprint boundary only and
  are dropped in the compilat (raw index wiring). compiled_view_golden
  stayed byte-identical (the regression guard): no name leaked into the
  flat graph.

The MACD PoC now re-exports macd / signal / histogram as a record; the
strategy still trades the histogram, selected via from_field: 2. The
render shows K [out:<name>] markers per multi-output composite (input
roles stay [in:<index>] — naming them is the dependent #41).

Output naming ships with the capability (OutField is born name-ready) so
#41 — composite param aliasing + input-role naming — does not reopen the
type.

Verification: cargo build/test/clippy --workspace -- -D warnings all
green; aura-engine 62 tests (+3 capability, +1 through-run E2E), aura-cli
unchanged-count green; compiled_view_golden byte-identical.

Known debt (out of scope, pre-existing): the non-workspace construction-
layer fieldtests (fieldtests/milestone-construction-layer/mc_1..mc_4)
carry their OutField sweep but still do not compile standalone — they use
the Sma::new / BlueprintNode::from(Sma) API retired in the cycle-0016
value-empty migration, never propagated to these fixtures. Tracked as a
follow-up.

closes #40
This commit is contained in:
2026-06-08 01:00:04 +02:00
parent 2c33a8d74f
commit 784e6c917a
8 changed files with 260 additions and 77 deletions
+7 -5
View File
@@ -100,8 +100,8 @@ fn collect_distinct_composites(bp: &Blueprint) -> Vec<&Composite> {
/// Render one composite's interior as a flat graph: interior leaves as `[type]`,
/// nested composites as opaque `[name]`, plus an `[in:k]` entry marker per input
/// role (wired to its interior targets) and an `[out]` marker (wired from the
/// output port). Prefixed `"<name>:\n"`.
/// role (wired to its interior targets) and an `[out:<name>]` marker per
/// re-exported output field (wired from its producer). Prefixed `"<name>:\n"`.
fn render_definition(c: &Composite) -> String {
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
for inner in c.nodes() {
@@ -121,9 +121,11 @@ fn render_definition(c: &Composite) -> String {
edges.push((in_id, t.node));
}
}
let out_id = labels.len();
labels.push("out".to_string());
edges.push((c.output().node, out_id));
for of in c.output() {
let out_id = labels.len();
labels.push(format!("out:{}", of.name));
edges.push((of.node, out_id));
}
format!("{}:\n{}", c.name(), render_flat(&labels, &edges))
}
+19 -14
View File
@@ -10,7 +10,7 @@ mod graph;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort,
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutField,
RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
@@ -127,7 +127,7 @@ fn sma_cross(name: &str) -> Composite {
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 2, field: 0 },
vec![OutField { node: 2, field: 0, name: "cross".into() }],
)
}
@@ -176,12 +176,13 @@ fn sample_point() -> Vec<Scalar> {
// --- MACD proof-of-concept (a richer, nested indicator + strategy) -----------
/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line
/// (their spread) → a signal `Ema` of that line → the histogram (line signal),
/// which is the composite's single output — the line the strategy trades on. A
/// richer fixture than `sma_cross`: a nested EMA-of-EMA chain with interior
/// fan-out (the MACD line feeds *both* the signal EMA and the histogram). Three
/// `length` knobs (fast, slow, signal) are injected at compile in node order;
/// value-empty here.
/// (their spread) → a signal `Ema` of that line → the histogram (line signal).
/// The composite exposes all **three MACD lines** as a named output record
/// (`macd`, `signal`, `histogram`); the strategy trades the histogram by reading
/// `from_field: 2`. A richer fixture than `sma_cross`: a nested EMA-of-EMA chain
/// with interior fan-out (the MACD line feeds *both* the signal EMA and the
/// histogram). Three `length` knobs (fast, slow, signal) are injected at compile
/// in node order; value-empty here.
fn macd(name: &str) -> Composite {
Composite::new(
name,
@@ -203,7 +204,11 @@ fn macd(name: &str) -> Composite {
Target { node: 0, slot: 0 }, // price → fast EMA
Target { node: 1, slot: 0 }, // price → slow EMA
]],
OutPort { node: 4, field: 0 }, // the histogram
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
],
)
}
@@ -230,7 +235,7 @@ fn macd_strategy_blueprint(
],
}],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // histogram → Exposure
Edge { from: 0, to: 1, slot: 0, from_field: 2 }, // histogram → Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure → broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity → sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure → sink
@@ -374,7 +379,7 @@ mod tests {
let out = graph::render_blueprint(&sample_blueprint());
// the sma_cross body is defined exactly once, with its interior + ports
assert_eq!(out.matches("sma_cross:").count(), 1, "definition not rendered once:\n{out}");
for needle in ["[SMA]", "[Sub]", "[in:0]", "[out]"] {
for needle in ["[SMA]", "[Sub]", "[in:0]", "[out:cross]"] {
assert!(out.contains(needle), "missing {needle} in definition:\n{out}");
}
}
@@ -388,14 +393,14 @@ mod tests {
vec![Sma::factory().into()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let outer = Composite::new(
"outer",
vec![BlueprintNode::Composite(inner), Sub::factory().into()],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 1, field: 0 },
vec![OutField { node: 1, field: 0, name: "out".into() }],
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(outer)],
@@ -494,7 +499,7 @@ sma_cross:
[Sub]
[out]
[out:cross]
"#;