feat(aura-engine): blueprint construction layer with composite inlining
Add 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 unit of reuse is the `Composite` — a nestable sub-graph fragment exposing one output port (C8) and named input roles — which `Blueprint::compile()` **inlines** by raw-index lowering into the flat `(nodes, sources, edges)` the unchanged `Harness::bootstrap` consumes. Design (settled in spec 0012, ledger C9/C19/C23): a composite is an authoring-level node, NOT a runtime `Box<dyn Node>` sub-engine. The chosen inlining approach keeps the sacrosanct deterministic run loop untouched and leaves the composite interior fully inspectable for the deferred cross-graph optimiser; the rejected "runtime sub-engine" reading would have put throwaway complexity into the run loop and kept the interior opaque. The compilat is wired by raw index, not by name; names survive only as non-load-bearing debug symbols (as `FieldSpec.name` already is). Lowering is recursive index rewriting: interior items append at an offset, interior edges rewrite by that offset, an edge into a composite fans through its input roles (one blueprint edge -> several flat edges), an edge out resolves to the interior output port, and nesting recurses inside-out. Construction-phase faults are caught as a typed `CompileError` (BadInteriorIndex / RoleKindMismatch / OutputPortOutOfRange); the lowered flat compilat is validated by bootstrap's existing kind- and Kahn-cycle-check (wrapped as `CompileError::Bootstrap`), with no re-implementation. Non-goals (deferred per C23/C16): no optimisation pass (CSE/DCE, sweep-invariant hoisting), no external optimisation crate, no named-handle ergonomic wiring, no `aura graph` render (#13). Verification: 48 engine tests green (8 blueprint: derived-schema, single + nested composite inlining, the three CompileError paths, bootstrap-error wrap, and the headline `composite_sma_cross_runs_bit_identical_to_hand_wired` C1 demonstrator — the SMA-cross composite lowers to a flat graph byte-identical to the hand-wired sample harness, producing bit-for-bit identical equity + exposure traces); `cargo clippy --workspace --all-targets -D warnings` clean. `Node`, `Harness::bootstrap`, the run loop, and `Edge`/`Target`/`SourceSpec` are unchanged. closes #12
This commit is contained in:
@@ -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<dyn Node>),
|
||||||
|
Composite(Composite),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergonomic lift: any concrete `Node` becomes a `Leaf` blueprint item.
|
||||||
|
impl<N: Node + 'static> From<N> 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<BlueprintNode>,
|
||||||
|
edges: Vec<Edge>,
|
||||||
|
input_roles: Vec<Vec<Target>>,
|
||||||
|
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<BlueprintNode>,
|
||||||
|
edges: Vec<Edge>,
|
||||||
|
input_roles: Vec<Vec<Target>>,
|
||||||
|
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<BlueprintNode>,
|
||||||
|
sources: Vec<SourceSpec>,
|
||||||
|
edges: Vec<Edge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BlueprintNode>, sources: Vec<SourceSpec>, edges: Vec<Edge>) -> 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<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
|
||||||
|
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
|
||||||
|
let mut flat_edges: Vec<Edge> = 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<SourceSpec> = Vec::with_capacity(self.sources.len());
|
||||||
|
for src in &self.sources {
|
||||||
|
let mut targets: Vec<Target> = 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<Harness, CompileError> {
|
||||||
|
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<Vec<Target>> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<BlueprintNode>,
|
||||||
|
flat_nodes: &mut Vec<Box<dyn Node>>,
|
||||||
|
flat_edges: &mut Vec<Edge>,
|
||||||
|
) -> Result<Vec<ItemLowering>, 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<Box<dyn Node>>,
|
||||||
|
flat_edges: &mut Vec<Edge>,
|
||||||
|
) -> Result<ItemLowering, CompileError> {
|
||||||
|
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<Target>> = Vec::with_capacity(input_roles.len());
|
||||||
|
for (r, role) in input_roles.iter().enumerate() {
|
||||||
|
let mut flat_targets: Vec<Target> = 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<dyn Node>],
|
||||||
|
) -> Result<Vec<Edge>, 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<Vec<Target>, 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<dyn Node>]) -> Result<ScalarKind, CompileError> {
|
||||||
|
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<dyn Node> (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<dyn Node> (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<dyn Node> (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<Scalar>)>,
|
||||||
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
||||||
|
) {
|
||||||
|
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<Scalar>)>,
|
||||||
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
||||||
|
) {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
let flat_ex_v = flat_ex.try_iter().collect::<Vec<_>>();
|
||||||
|
let comp_eq_v = comp_eq.try_iter().collect::<Vec<_>>();
|
||||||
|
let comp_ex_v = comp_ex.try_iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,10 @@
|
|||||||
//!
|
//!
|
||||||
//! Visualization is never here: it is a downstream consumer node on the streams.
|
//! Visualization is never here: it is a downstream consumer node on the streams.
|
||||||
|
|
||||||
|
mod blueprint;
|
||||||
mod harness;
|
mod harness;
|
||||||
mod report;
|
mod report;
|
||||||
|
|
||||||
|
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutPort};
|
||||||
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
|
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
|
||||||
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
|
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
|
||||||
|
|||||||
Reference in New Issue
Block a user