diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs new file mode 100644 index 0000000..5b7175c --- /dev/null +++ b/crates/aura-engine/src/blueprint.rs @@ -0,0 +1,701 @@ +//! The construction layer (C9/C19/C23): a named, param-generic graph-as-data +//! (`Blueprint`) that **compiles** to the flat, type-erased instance the run loop +//! already runs (the *compilat*). The unit of reuse is the [`Composite`]: a +//! nestable sub-graph fragment exposing one output port (C8) and named input +//! roles, which `compile` **inlines** into the flat `(nodes, sources, edges)` the +//! unchanged [`crate::Harness::bootstrap`] consumes. +//! +//! The compilat is wired by raw index, **not by name** (C23): a composite's +//! boundary dissolves at compile time; field/role names, where kept, are +//! non-load-bearing debug symbols (as `FieldSpec.name` already is). This module +//! adds no optimisation pass (CSE/DCE, sweep-invariant hoisting are deferred, +//! C23) and no external dependency (C16). + +use aura_core::{Node, NodeSchema, 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 { + pub node: usize, + pub field: usize, +} + +/// A blueprint item: a leaf node or a nested composite. Both present a declared +/// interface (typed inputs + one output) to the enclosing graph. +pub enum BlueprintNode { + Leaf(Box), + Composite(Composite), +} + +/// Ergonomic lift: any concrete `Node` becomes a `Leaf` blueprint item. +impl From for BlueprintNode { + fn from(node: N) -> Self { + BlueprintNode::Leaf(Box::new(node)) + } +} + +impl BlueprintNode { + /// The declared interface this item presents to the enclosing graph: a leaf's + /// own `Node::schema`, or a composite's derived [`Composite::schema`]. + fn schema(&self) -> NodeSchema { + match self { + BlueprintNode::Leaf(node) => node.schema(), + BlueprintNode::Composite(c) => c.schema(), + } + } +} + +/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not** +/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices), +/// interior edges (local indices), input roles (role `r` fans into the interior +/// targets `input_roles[r]`), and the one exposed output port. +pub struct Composite { + nodes: Vec, + edges: Vec, + input_roles: Vec>, + output: OutPort, +} + +impl Composite { + /// Build a composite from its interior items, interior edges (local indices), + /// input roles, and output port. + pub fn new( + nodes: Vec, + edges: Vec, + input_roles: Vec>, + output: OutPort, + ) -> Self { + Self { nodes, edges, input_roles, output } + } + + /// The derived interface the enclosing graph wires against: input role `r`'s + /// spec is taken from its first interior target's slot; the output field is the + /// interior output port's field. This is a *derivation*, not a `Node` impl, and + /// it assumes well-formed indices — `compile` is the validator that rejects a + /// malformed composite with a typed [`CompileError`]. + pub fn schema(&self) -> NodeSchema { + let inputs = self + .input_roles + .iter() + .map(|role| { + let first = role[0]; + self.nodes[first.node].schema().inputs[first.slot] + }) + .collect(); + let out_field = self.nodes[self.output.node].schema().output[self.output.field]; + NodeSchema { inputs, output: vec![out_field] } + } +} + +/// A construction-phase fault, caught before the flat compilat reaches +/// `Harness::bootstrap`. +#[derive(Debug, PartialEq, Eq)] +pub enum CompileError { + /// An interior edge, role target, or output index is out of range. + BadInteriorIndex, + /// Input role `role` fans into interior slots of differing scalar kinds. + RoleKindMismatch { role: usize }, + /// The output port names a missing interior node or output field. + OutputPortOutOfRange, + /// The lowered flat compilat failed `Harness::bootstrap`'s checks (kind + /// mismatch, bad index, or directed cycle). + Bootstrap(BootstrapError), +} + +/// The root graph-as-data, before compilation: blueprint items + sources + edges, +/// all addressing blueprint-level indices. +pub struct Blueprint { + nodes: Vec, + sources: Vec, + edges: Vec, +} + +impl Blueprint { + /// Build a blueprint from its items, sources, and edges (blueprint-level + /// indices; a target/edge endpoint may name a composite). + pub fn new(nodes: Vec, sources: Vec, edges: Vec) -> Self { + Self { nodes, sources, edges } + } + + /// Lower to the flat compilat: inline every composite (recursive), offset + /// interior indices, rewrite edges, and fan input roles out. The run loop and + /// `bootstrap`'s data model are unchanged; the lowered compilat is wired by raw + /// index (C23). + // The flat triple is exactly `Harness::bootstrap`'s argument list; naming it + // would be a speculative type alias this cycle (same call as the CLI's sample). + #[allow(clippy::type_complexity)] + pub fn compile(self) -> Result<(Vec>, Vec, Vec), CompileError> { + let mut flat_nodes: Vec> = Vec::new(); + let mut flat_edges: Vec = Vec::new(); + + // lower every top-level item (recursively inlining composites) + let lowerings = lower_items(self.nodes, &mut flat_nodes, &mut flat_edges)?; + + // rewrite top-level edges through the lowerings (fan-out into composites) + for e in &self.edges { + for fe in rewrite_edge(e, &lowerings, &flat_nodes)? { + flat_edges.push(fe); + } + } + + // rewrite sources: each target into a composite fans into its role targets + let mut flat_sources: Vec = Vec::with_capacity(self.sources.len()); + for src in &self.sources { + let mut targets: Vec = Vec::new(); + for t in &src.targets { + targets.extend(resolve_target(t, &lowerings)?); + } + flat_sources.push(SourceSpec { kind: src.kind, targets }); + } + + Ok((flat_nodes, flat_sources, flat_edges)) + } + + /// Compile, then hand the flat compilat to the unchanged `Harness::bootstrap`. + pub fn bootstrap(self) -> Result { + let (nodes, sources, edges) = self.compile()?; + Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap) + } +} + +/// How one blueprint item resolved into the flat compilat. Edges and source +/// targets to/from an item are resolved through this. +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> }, +} + +/// Lower a list of blueprint items into the flat node array, appending interior +/// nodes and (for composites) their interior edges. Returns one `ItemLowering` per +/// input item, in order. +fn lower_items( + items: Vec, + flat_nodes: &mut Vec>, + flat_edges: &mut Vec, +) -> Result, CompileError> { + let mut lowerings = Vec::with_capacity(items.len()); + for item in items { + match item { + BlueprintNode::Leaf(node) => { + let index = flat_nodes.len(); + flat_nodes.push(node); + lowerings.push(ItemLowering::Leaf { index }); + } + BlueprintNode::Composite(c) => { + lowerings.push(inline_composite(c, flat_nodes, flat_edges)?); + } + } + } + Ok(lowerings) +} + +/// Inline one composite: recursively lower its interior items, rewrite its interior +/// edges, then resolve its output port and per-role flat targets. +fn inline_composite( + c: Composite, + flat_nodes: &mut Vec>, + flat_edges: &mut Vec, +) -> Result { + let Composite { 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, flat_nodes, flat_edges)?; + for e in &edges { + for fe in rewrite_edge(e, &interior, flat_nodes)? { + flat_edges.push(fe); + } + } + + // 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) + } + ItemLowering::Composite { output: nested, .. } => { + // a nested composite exposes exactly one output field + if output.field != 0 { + return Err(CompileError::OutputPortOutOfRange); + } + *nested + } + }; + + // resolve each input role to flat targets (a target into a nested composite + // fans further) and kind-check every role + let mut roles: Vec> = Vec::with_capacity(input_roles.len()); + for (r, role) in input_roles.iter().enumerate() { + let mut flat_targets: Vec = Vec::new(); + for t in role { + flat_targets.extend(resolve_target(t, &interior)?); + } + if let Some((first, rest)) = flat_targets.split_first() { + let k0 = slot_kind(*first, flat_nodes)?; + for ft in rest { + if slot_kind(*ft, flat_nodes)? != k0 { + return Err(CompileError::RoleKindMismatch { role: r }); + } + } + } + roles.push(flat_targets); + } + + Ok(ItemLowering::Composite { output: out, roles }) +} + +/// Rewrite one blueprint-level edge into flat edges. The `from` endpoint resolves +/// to a single flat producer `(node, field)`; the `to` endpoint may fan out (a +/// composite input role fans into several interior targets). +fn rewrite_edge( + e: &Edge, + lowerings: &[ItemLowering], + flat_nodes: &[Box], +) -> Result, CompileError> { + if e.from >= lowerings.len() { + return Err(CompileError::BadInteriorIndex); + } + let (from_node, from_field) = match &lowerings[e.from] { + ItemLowering::Leaf { index } => { + if e.from_field >= flat_nodes[*index].schema().output.len() { + return Err(CompileError::BadInteriorIndex); + } + (*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 + } + }; + let targets = resolve_target(&Target { node: e.to, slot: e.slot }, lowerings)?; + Ok(targets + .into_iter() + .map(|t| Edge { from: from_node, to: t.node, slot: t.slot, from_field }) + .collect()) +} + +/// Resolve a blueprint-level target `(node, slot)` into flat target(s). A target +/// into a leaf is itself (remapped index); a target into a composite fans into +/// that composite's input-role flat targets. +fn resolve_target(t: &Target, lowerings: &[ItemLowering]) -> Result, CompileError> { + if t.node >= lowerings.len() { + return Err(CompileError::BadInteriorIndex); + } + match &lowerings[t.node] { + ItemLowering::Leaf { index } => Ok(vec![Target { node: *index, slot: t.slot }]), + ItemLowering::Composite { roles, .. } => { + let role = roles.get(t.slot).ok_or(CompileError::BadInteriorIndex)?; + Ok(role.clone()) + } + } +} + +/// The declared scalar kind of a flat node's input slot (for role kind-checking). +fn slot_kind(t: Target, flat_nodes: &[Box]) -> Result { + flat_nodes[t.node] + .schema() + .inputs + .get(t.slot) + .map(|spec| spec.kind) + .ok_or(CompileError::BadInteriorIndex) +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Scalar, Timestamp}; + use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; + use std::sync::mpsc; + + /// A 2-input f64 node, one f64 output. Test-local fixture (C9: examples for the + /// engine's own tests, no speculative `aura-std` surface). + struct Join2 { + out: [Scalar; 1], + } + impl Node for Join2 { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![ + InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, + InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, + ], + output: vec![FieldSpec { name: "v", kind: ScalarKind::F64 }], + } + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + let a = ctx.f64_in(0); + let b = ctx.f64_in(1); + if a.is_empty() || b.is_empty() { + return None; + } + self.out[0] = Scalar::F64(a[0] + b[0]); + Some(&self.out) + } + } + + #[test] + fn composite_schema_derives_role_and_output_kinds() { + // one interior node (Join2: 2 f64 inputs, 1 f64 output); two roles, each + // feeding one interior slot; output port = the Join2 output field 0. + let c = Composite::new( + vec![BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))], + vec![], + vec![ + vec![Target { node: 0, slot: 0 }], + vec![Target { node: 0, slot: 1 }], + ], + OutPort { node: 0, field: 0 }, + ); + let schema = c.schema(); + assert_eq!(schema.inputs.len(), 2); + assert_eq!(schema.inputs[0].kind, ScalarKind::F64); + assert_eq!(schema.inputs[1].kind, ScalarKind::F64); + assert_eq!(schema.output, vec![FieldSpec { name: "v", kind: ScalarKind::F64 }]); + } + + /// A 1-input f64 node, one f64 output. Test-local fixture. + struct Pass1 { + out: [Scalar; 1], + } + impl Node for Pass1 { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], + output: vec![FieldSpec { name: "v", kind: ScalarKind::F64 }], + } + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; + } + self.out[0] = Scalar::F64(w[0]); + Some(&self.out) + } + } + + /// A pure consumer with one f64 input and no output (sink role, C8). + struct SinkF64; + impl Node for SinkF64 { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], + output: vec![], + } + } + fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { + None + } + } + + /// A pure consumer with one i64 input and no output. Used to provoke a role / + /// edge kind mismatch (its slot is i64 where an f64 is fanned in). + struct SinkI64; + impl Node for SinkI64 { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { kind: ScalarKind::I64, lookback: 1, firing: Firing::Any }], + output: vec![], + } + } + fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { + None + } + } + + fn pass1() -> BlueprintNode { + BlueprintNode::Leaf(Box::new(Pass1 { out: [Scalar::F64(0.0)] })) + } + fn join2() -> BlueprintNode { + BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] })) + } + + /// A composite: two Pass1 leaves feeding a Join2, role 0 fanning the source + /// into BOTH Pass1 slots, output = the Join2 field 0. The generic analogue of + /// the SMA-cross shape. + fn fan_composite() -> Composite { + Composite::new( + vec![pass1(), pass1(), join2()], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + 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 }, + ) + } + + #[test] + fn single_composite_inlines_with_offset_fan_and_output() { + // composite as item 0; a source into its role 0; an edge out of it to a sink. + let bp = Blueprint::new( + vec![BlueprintNode::Composite(fan_composite()), BlueprintNode::Leaf(Box::new(SinkF64))], + vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + ); + let (nodes, sources, edges) = bp.compile().expect("valid composite"); + + // 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) + assert_eq!( + edges, + 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 }, + ] + ); + // the source target into role 0 fanned into BOTH Pass1 slots + assert_eq!(sources.len(), 1); + 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, + // re-exposing the inner's role 0 (outer role 0 -> inner role 0) and the + // inner's output. A source into the outer role 0 must fan to BOTH inner + // Pass1 slots; the inner Join2 lands at flat index 2. + let inner = fan_composite(); + let outer = Composite::new( + vec![BlueprintNode::Composite(inner)], + vec![], + vec![vec![Target { node: 0, slot: 0 }]], + OutPort { node: 0, field: 0 }, + ); + let bp = Blueprint::new( + vec![BlueprintNode::Composite(outer), BlueprintNode::Leaf(Box::new(SinkF64))], + vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + ); + let (nodes, sources, edges) = bp.compile().expect("valid nested composite"); + + assert_eq!(nodes.len(), 4); // Pass1, Pass1, Join2, SinkF64 + assert_eq!( + sources[0].targets, + vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }] + ); + // inner interior edges + the output edge from the inner Join2 (flat 2) to sink + assert_eq!( + edges, + 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 }, + ] + ); + } + + #[test] + fn bad_interior_index_rejected() { + // interior edge references interior node 9, which does not exist + let c = Composite::new( + 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 }, + ); + let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex)); + } + + #[test] + fn role_kind_mismatch_rejected() { + // role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch + let c = Composite::new( + vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))], + vec![], + vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]], + OutPort { node: 0, field: 0 }, + ); + let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::RoleKindMismatch { role: 0 })); + } + + #[test] + fn output_port_out_of_range_rejected() { + // output names field 5 of a node whose output has one field + let c = Composite::new( + vec![pass1()], + vec![], + vec![vec![Target { node: 0, slot: 0 }]], + OutPort { node: 0, field: 5 }, + ); + let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange)); + } + + #[test] + fn bootstrap_error_is_wrapped() { + // a top-level kind mismatch: a Pass1 f64 output wired into a SinkI64 i64 + // input. compile() lowers it faithfully; bootstrap's kind-check rejects it. + let bp = Blueprint::new( + vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))], + vec![], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + ); + match bp.bootstrap().unwrap_err() { + CompileError::Bootstrap(BootstrapError::KindMismatch { producer, consumer }) => { + assert_eq!(producer, ScalarKind::F64); + assert_eq!(consumer, ScalarKind::I64); + } + other => panic!("expected Bootstrap(KindMismatch), got {other:?}"), + } + } + + /// The built-in synthetic price stream (a local copy of the CLI sample's + /// stream): rises through t=4 then reverses, so the trace is non-degenerate. + fn synthetic_prices() -> Vec<(Timestamp, Scalar)> { + [ + (1_i64, 1.0000_f64), + (2, 1.0010), + (3, 1.0030), + (4, 1.0060), + (5, 1.0040), + (6, 1.0010), + (7, 0.9990), + ] + .iter() + .map(|&(t, p)| (Timestamp(t), Scalar::F64(p))) + .collect() + } + + /// Today's flat, hand-wired SMA-cross signal-quality harness (the + /// `sample_harness` wiring from `aura-cli`), with two recording sinks. + #[allow(clippy::type_complexity)] + fn hand_wired_sma_cross_harness() -> ( + Harness, + mpsc::Receiver<(Timestamp, Vec)>, + mpsc::Receiver<(Timestamp, Vec)>, + ) { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let h = Harness::bootstrap( + vec![ + Box::new(Sma::new(2)), + Box::new(Sma::new(4)), + Box::new(Sub::new()), + Box::new(Exposure::new(0.5)), + Box::new(SimBroker::new(0.0001)), + Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), + Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 1, slot: 0 }, + Target { node: 4, slot: 1 }, + ], + }], + 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: 3, to: 4, slot: 0, from_field: 0 }, + Edge { from: 4, to: 5, slot: 0, from_field: 0 }, + Edge { from: 3, to: 6, slot: 0, from_field: 0 }, + ], + ) + .expect("valid hand-wired DAG"); + (h, rx_eq, rx_ex) + } + + /// The SMA-cross signal as a reusable composite: one input role (price), one + /// output (the fast-minus-slow spread). Interior wired with raw local indices. + fn sma_cross(fast: usize, slow: usize) -> Composite { + Composite::new( + vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + 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 }, + ) + } + + /// The same signal-quality harness authored as a composite blueprint. + #[allow(clippy::type_complexity)] + fn composite_sma_cross_harness() -> ( + Blueprint, + mpsc::Receiver<(Timestamp, Vec)>, + mpsc::Receiver<(Timestamp, Vec)>, + ) { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let bp = Blueprint::new( + vec![ + BlueprintNode::Composite(sma_cross(2, 4)), + Exposure::new(0.5).into(), + SimBroker::new(0.0001).into(), + Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(), + Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(), + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: 0, slot: 0 }, // price -> sma_cross role 0 + Target { node: 2, slot: 1 }, // price -> SimBroker price slot + ], + }], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> 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 + ], + ); + (bp, rx_eq, rx_ex) + } + + #[test] + fn composite_sma_cross_runs_bit_identical_to_hand_wired() { + let prices = synthetic_prices(); + + // (a) today's flat, hand-wired graph + let (mut flat, flat_eq, flat_ex) = hand_wired_sma_cross_harness(); + flat.run(vec![prices.clone()]); + + // (b) the same graph authored as a composite blueprint, compiled + let (bp, comp_eq, comp_ex) = composite_sma_cross_harness(); + let mut composed = bp.bootstrap().expect("composite blueprint compiles"); + composed.run(vec![prices]); + + let flat_eq_v = flat_eq.try_iter().collect::>(); + let flat_ex_v = flat_ex.try_iter().collect::>(); + let comp_eq_v = comp_eq.try_iter().collect::>(); + let comp_ex_v = comp_ex.try_iter().collect::>(); + + // both recording sinks captured the same equity + exposure traces, bit-for-bit + assert_eq!(flat_eq_v, comp_eq_v, "equity traces differ"); + assert_eq!(flat_ex_v, comp_ex_v, "exposure traces differ"); + // and the trace is populated (non-degenerate), so the equality is meaningful + assert!(!comp_eq_v.is_empty(), "equity trace must be populated"); + assert!(!comp_ex_v.is_empty(), "exposure trace must be populated"); + } +} diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 264730a..16d3b85 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -31,8 +31,10 @@ //! //! Visualization is never here: it is a downstream consumer node on the streams. +mod blueprint; mod harness; mod report; +pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutPort}; pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target}; pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};