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:
@@ -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
@@ -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]
|
||||
|
||||
|
||||
"#;
|
||||
|
||||
@@ -15,11 +15,15 @@ use aura_core::{LeafFactory, Node, ParamSpec, Scalar, ScalarKind};
|
||||
|
||||
use crate::harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
|
||||
|
||||
/// Which interior `(node, output-field)` is a composite's single output port (C8).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OutPort {
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OutField {
|
||||
pub node: usize,
|
||||
pub field: usize,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A blueprint item: a leaf node or a nested composite. Both present a declared
|
||||
@@ -45,7 +49,7 @@ pub struct Composite {
|
||||
nodes: Vec<BlueprintNode>,
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Vec<Target>>,
|
||||
output: OutPort,
|
||||
output: Vec<OutField>,
|
||||
}
|
||||
|
||||
impl Composite {
|
||||
@@ -58,7 +62,7 @@ impl Composite {
|
||||
nodes: Vec<BlueprintNode>,
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Vec<Target>>,
|
||||
output: OutPort,
|
||||
output: Vec<OutField>,
|
||||
) -> Self {
|
||||
Self { name: name.into(), nodes, edges, input_roles, output }
|
||||
}
|
||||
@@ -79,9 +83,10 @@ impl Composite {
|
||||
pub fn input_roles(&self) -> &[Vec<Target>] {
|
||||
&self.input_roles
|
||||
}
|
||||
/// The single exposed output port.
|
||||
pub fn output(&self) -> OutPort {
|
||||
self.output
|
||||
/// The exposed output record: each entry re-exports one interior
|
||||
/// `(node, output-field)` under a boundary name (C8 — one port, K columns).
|
||||
pub fn output(&self) -> &[OutField] {
|
||||
&self.output
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,9 +248,10 @@ fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec
|
||||
enum ItemLowering {
|
||||
/// A leaf lowered to exactly one flat node at this index.
|
||||
Leaf { index: usize },
|
||||
/// A composite lowered to its interior: its single output port is this flat
|
||||
/// `(node, field)`, and input role `r` fans into `roles[r]` (flat targets).
|
||||
Composite { output: (usize, usize), roles: Vec<Vec<Target>> },
|
||||
/// A composite lowered to its interior: its output record is these flat
|
||||
/// `(node, field)` producers (one per re-exported field, declared order), and
|
||||
/// input role `r` fans into `roles[r]` (flat targets). Names dropped (C23).
|
||||
Composite { output: Vec<(usize, usize)>, roles: Vec<Vec<Target>> },
|
||||
}
|
||||
|
||||
/// Lower a list of blueprint items into the flat node array, appending interior
|
||||
@@ -301,12 +307,6 @@ fn inline_composite(
|
||||
let Composite { name: _, nodes, edges, input_roles, output } = c;
|
||||
let item_count = nodes.len();
|
||||
|
||||
// the output port must name an in-range interior item (field range checked
|
||||
// once the item's lowering is known)
|
||||
if output.node >= item_count {
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
|
||||
// recursively lower interior items, then rewrite interior edges through them
|
||||
let interior = lower_items(nodes, params, cursor, flat_nodes, flat_edges)?;
|
||||
for e in &edges {
|
||||
@@ -315,22 +315,25 @@ fn inline_composite(
|
||||
}
|
||||
}
|
||||
|
||||
// resolve the output port to a flat (node, field)
|
||||
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)
|
||||
// resolve each re-exported field to a flat (node, field), in declared order
|
||||
let mut out: Vec<(usize, usize)> = Vec::with_capacity(output.len());
|
||||
for of in &output {
|
||||
if of.node >= item_count {
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
ItemLowering::Composite { output: nested, .. } => {
|
||||
// a nested composite exposes exactly one output field
|
||||
if output.field != 0 {
|
||||
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)
|
||||
}
|
||||
*nested
|
||||
}
|
||||
};
|
||||
ItemLowering::Composite { output: nested, .. } => {
|
||||
*nested.get(of.field).ok_or(CompileError::OutputPortOutOfRange)?
|
||||
}
|
||||
};
|
||||
out.push(resolved);
|
||||
}
|
||||
|
||||
// resolve each input role to flat targets (a target into a nested composite
|
||||
// fans further) and kind-check every role
|
||||
@@ -373,11 +376,7 @@ fn rewrite_edge(
|
||||
(*index, e.from_field)
|
||||
}
|
||||
ItemLowering::Composite { output, .. } => {
|
||||
// a composite exposes one output field; reading any other is malformed
|
||||
if e.from_field != 0 {
|
||||
return Err(CompileError::BadInteriorIndex);
|
||||
}
|
||||
*output
|
||||
*output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?
|
||||
}
|
||||
};
|
||||
let targets = resolve_target(&Target { node: e.to, slot: e.slot }, lowerings)?;
|
||||
@@ -529,7 +528,7 @@ mod tests {
|
||||
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: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -546,7 +545,7 @@ mod tests {
|
||||
// 3 interior nodes (Pass1, Pass1, Join2) at flat 0..2, then SinkF64 at 3
|
||||
assert_eq!(nodes.len(), 4);
|
||||
// interior edges rewritten at offset 0, then the output edge resolves the
|
||||
// composite's OutPort (interior node 2, field 0) to the sink (flat node 3)
|
||||
// composite's OutField (interior node 2, field 0) to the sink (flat node 3)
|
||||
assert_eq!(
|
||||
edges,
|
||||
vec![
|
||||
@@ -563,6 +562,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_reexports_two_fields_to_distinct_consumers() {
|
||||
// composite: two independent Pass1 leaves; role 0 -> leaf 0, role 1 -> leaf 1;
|
||||
// output record re-exports leaf 0 as "a", leaf 1 as "b".
|
||||
let c = Composite::new(
|
||||
"two_out",
|
||||
vec![pass1(), pass1()],
|
||||
vec![],
|
||||
vec![
|
||||
vec![Target { node: 0, slot: 0 }],
|
||||
vec![Target { node: 1, slot: 0 }],
|
||||
],
|
||||
vec![
|
||||
OutField { node: 0, field: 0, name: "a".into() },
|
||||
OutField { node: 1, field: 0, name: "b".into() },
|
||||
],
|
||||
);
|
||||
// composite is item 0; two sinks (items 1, 2) read its two output fields by
|
||||
// from_field; one source fans into both roles.
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(c), sink_f64(), sink_f64()],
|
||||
vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
}],
|
||||
vec![
|
||||
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // field "a" -> sink 1
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // field "b" -> sink 2
|
||||
],
|
||||
);
|
||||
let (nodes, sources, edges) = bp.compile().expect("valid multi-output composite");
|
||||
// flat layout: Pass1(0), Pass1(1), SinkF64(2), SinkF64(3)
|
||||
assert_eq!(nodes.len(), 4);
|
||||
// from_field 0 resolves to leaf 0, from_field 1 to leaf 1 — distinct producers
|
||||
assert_eq!(
|
||||
edges,
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
|
||||
]
|
||||
);
|
||||
// the source fanned into both interior leaves
|
||||
assert_eq!(
|
||||
sources[0].targets,
|
||||
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_composite_inlines() {
|
||||
// outer composite wraps the inner fan_composite as its only interior item,
|
||||
@@ -575,7 +622,7 @@ mod tests {
|
||||
vec![BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(outer), sink_f64()],
|
||||
@@ -600,6 +647,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_reexports_two_fields_of_inner_composite() {
|
||||
// inner re-exports two leaves as "a","b"; outer re-exposes both inner roles
|
||||
// and re-exports inner field 0 and field 1 (the latter exercises the nested arm).
|
||||
let inner = Composite::new(
|
||||
"inner_two",
|
||||
vec![pass1(), pass1()],
|
||||
vec![],
|
||||
vec![
|
||||
vec![Target { node: 0, slot: 0 }],
|
||||
vec![Target { node: 1, slot: 0 }],
|
||||
],
|
||||
vec![
|
||||
OutField { node: 0, field: 0, name: "a".into() },
|
||||
OutField { node: 1, field: 0, name: "b".into() },
|
||||
],
|
||||
);
|
||||
let outer = Composite::new(
|
||||
"outer_two",
|
||||
vec![BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![
|
||||
vec![Target { node: 0, slot: 0 }], // outer role 0 -> inner role 0
|
||||
vec![Target { node: 0, slot: 1 }], // outer role 1 -> inner role 1
|
||||
],
|
||||
vec![
|
||||
OutField { node: 0, field: 0, name: "x".into() }, // inner field 0
|
||||
OutField { node: 0, field: 1, name: "y".into() }, // inner field 1 (nested arm)
|
||||
],
|
||||
);
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(outer), sink_f64(), sink_f64()],
|
||||
vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
}],
|
||||
vec![
|
||||
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // outer field x -> sink 1
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // outer field y -> sink 2
|
||||
],
|
||||
);
|
||||
let (nodes, _sources, edges) = bp.compile().expect("valid nested multi-output");
|
||||
assert_eq!(nodes.len(), 4); // Pass1, Pass1, SinkF64, SinkF64
|
||||
assert_eq!(
|
||||
edges,
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_interior_index_rejected() {
|
||||
// interior edge references interior node 9, which does not exist
|
||||
@@ -608,7 +707,7 @@ mod tests {
|
||||
vec![pass1()],
|
||||
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
|
||||
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
||||
@@ -623,7 +722,7 @@ mod tests {
|
||||
vec![pass1(), sink_i64()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
|
||||
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
||||
@@ -638,13 +737,32 @@ mod tests {
|
||||
vec![pass1()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
OutPort { node: 0, field: 5 },
|
||||
vec![OutField { node: 0, field: 5, name: "out".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
|
||||
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
||||
assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consume_of_missing_output_field_is_rejected() {
|
||||
// a single-field composite; a consumer reads from_field 1 (past the 1-field
|
||||
// record) -> the rewrite_edge range-check rejects it.
|
||||
let c = Composite::new(
|
||||
"c",
|
||||
vec![pass1()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
vec![OutField { node: 0, field: 0, name: "a".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(c), sink_f64()],
|
||||
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }], // only field 0 exists
|
||||
);
|
||||
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_error_is_wrapped() {
|
||||
// a top-level kind mismatch: a Pass1 f64 output wired into a SinkI64 i64
|
||||
@@ -733,7 +851,7 @@ mod tests {
|
||||
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: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -799,6 +917,64 @@ mod tests {
|
||||
assert!(!comp_ex_v.is_empty(), "exposure trace must be populated");
|
||||
}
|
||||
|
||||
/// E2E (cycle 0018): a composite's multi-field output record is selected
|
||||
/// field-wise downstream all the way through `bootstrap + run` — two consumers
|
||||
/// reading distinct `from_field`s off one multi-output composite record the two
|
||||
/// distinct interior producers' streams, deterministically. The Task-2 unit
|
||||
/// tests stop at `compile()` (edge resolution); this one runs the harness, so a
|
||||
/// regression that resolved both taps to the same producer (or dropped a field)
|
||||
/// would surface as identical recorded traces here, not just a bad edge table.
|
||||
#[test]
|
||||
fn multi_output_composite_taps_distinct_fields_through_a_run() {
|
||||
let prices = synthetic_prices();
|
||||
let (tx_a, rx_a) = mpsc::channel();
|
||||
let (tx_b, rx_b) = mpsc::channel();
|
||||
// composite: two SMAs of different lengths, each its own input role; the
|
||||
// output record re-exports SMA-fast as "a" (field 0) and SMA-slow as "b"
|
||||
// (field 1). One source fans into both roles; two recorders tap the two
|
||||
// fields by from_field.
|
||||
let c = Composite::new(
|
||||
"two_sma",
|
||||
vec![Sma::factory().into(), Sma::factory().into()],
|
||||
vec![],
|
||||
vec![
|
||||
vec![Target { node: 0, slot: 0 }],
|
||||
vec![Target { node: 1, slot: 0 }],
|
||||
],
|
||||
vec![
|
||||
OutField { node: 0, field: 0, name: "a".into() },
|
||||
OutField { node: 1, field: 0, name: "b".into() },
|
||||
],
|
||||
);
|
||||
let bp = Blueprint::new(
|
||||
vec![
|
||||
BlueprintNode::Composite(c),
|
||||
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_a).into(),
|
||||
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_b).into(),
|
||||
],
|
||||
vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
}],
|
||||
vec![
|
||||
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // field "a" (SMA-2) -> recorder a
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // field "b" (SMA-4) -> recorder b
|
||||
],
|
||||
);
|
||||
let mut h = bp
|
||||
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4)])
|
||||
.expect("multi-output composite bootstraps");
|
||||
h.run(vec![prices]);
|
||||
|
||||
let a = rx_a.try_iter().collect::<Vec<_>>();
|
||||
let b = rx_b.try_iter().collect::<Vec<_>>();
|
||||
// both fields recorded something (the equality below is meaningful only if
|
||||
// populated) and the two taps captured different streams — distinct fast vs
|
||||
// slow SMA, so the two from_field selections resolve to distinct producers.
|
||||
assert!(!a.is_empty() && !b.is_empty(), "both field taps must be populated");
|
||||
assert_ne!(a, b, "the two from_field taps must record distinct interior streams");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injecting_a_different_vector_changes_the_run() {
|
||||
let prices = synthetic_prices();
|
||||
@@ -913,7 +1089,7 @@ mod tests {
|
||||
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: "out".into() }],
|
||||
);
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
@@ -921,7 +1097,7 @@ mod tests {
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::factory(2).into()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(vec![BlueprintNode::Composite(strategy)], vec![], vec![]);
|
||||
|
||||
@@ -964,7 +1140,7 @@ mod tests {
|
||||
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: "out".into() }],
|
||||
);
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
@@ -972,7 +1148,7 @@ mod tests {
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::factory(2).into()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(vec![BlueprintNode::Composite(strategy)], vec![], vec![]);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ mod blueprint;
|
||||
mod harness;
|
||||
mod report;
|
||||
|
||||
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutPort};
|
||||
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutField};
|
||||
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
|
||||
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
|
||||
// #29: re-export the core scalar vocabulary a Blueprint builder needs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// and confirm the recorded trace is non-empty.
|
||||
//
|
||||
// Public interface only: Blueprint / Composite / BlueprintNode / Edge / Target /
|
||||
// OutPort / SourceSpec from aura-engine; Sma / Sub / Exposure / SimBroker /
|
||||
// OutField / SourceSpec from aura-engine; Sma / Sub / Exposure / SimBroker /
|
||||
// Recorder from aura-std; ScalarKind / Scalar / Firing / Timestamp from
|
||||
// aura-core. No crates/*/src was read.
|
||||
//
|
||||
@@ -17,7 +17,7 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutPort, SourceSpec, Target};
|
||||
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutField, SourceSpec, Target};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
|
||||
fn main() {
|
||||
@@ -44,7 +44,7 @@ fn main() {
|
||||
Target { node: 1, slot: 0 },
|
||||
]],
|
||||
// single exposed output: Sub's output field 0
|
||||
OutPort { node: 2, field: 0 },
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
);
|
||||
|
||||
// --- The harness blueprint that USES the composite. ----------------------
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// Public interface only: Composite / Blueprint accessors + Node::label() via
|
||||
// the boxed node; ledger C8-refinement (label is the disambiguating symbol).
|
||||
|
||||
use aura_engine::{BlueprintNode, Composite, Edge, OutPort, Target};
|
||||
use aura_engine::{BlueprintNode, Composite, Edge, OutField, Target};
|
||||
// NB: `node.label()` on a `&Box<dyn Node>` leaf dispatches via the trait object
|
||||
// without an explicit `use aura_core::Node` (the method is in scope through the
|
||||
// boxed trait object). A consumer does not need to import the Node trait to read
|
||||
@@ -44,7 +44,7 @@ fn cross(swap: bool) -> Composite {
|
||||
],
|
||||
vec![e_fast, e_slow],
|
||||
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
|
||||
OutPort { node: 2, field: 0 },
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutPort, SourceSpec, Target};
|
||||
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutField, SourceSpec, Target};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
|
||||
/// Inner composite: the SMA(2)/SMA(4) cross (one price role -> spread output).
|
||||
@@ -32,7 +32,7 @@ fn inner_cross() -> 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: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ fn strategy() -> Composite {
|
||||
],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // cross -> Exposure
|
||||
vec![vec![Target { node: 0, slot: 0 }]], // price -> inner role 0
|
||||
OutPort { node: 1, field: 0 },
|
||||
vec![OutField { node: 1, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Public interface only.
|
||||
|
||||
use aura_core::ScalarKind;
|
||||
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutPort, SourceSpec, Target};
|
||||
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutField, SourceSpec, Target};
|
||||
// NB (fieldtest finding): `aura_engine::ScalarKind` does NOT resolve — the
|
||||
// construction surface (aura-engine) does not re-export the core scalar
|
||||
// vocabulary (ScalarKind / Scalar / Firing) that a graph-builder needs. A
|
||||
@@ -31,7 +31,7 @@ fn sma_cross() -> 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: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user