feat(aura-core,aura-std,aura-engine,aura-cli): inject a param-set vector at bootstrap (#31)

Cycle B of milestone "The World — parameter-space & sweep". A blueprint is now
value-empty: a leaf holds a param-generic recipe, not a built node, and a positional
Scalar vector is bound slot-by-slot at bootstrap — so one blueprint bootstraps into
many distinct instances under different vectors, with no cdylib rebuild (C12/C19).
This is the binding primitive a sweep (#32) drives.

Ratified design (brainstorm): value-empty reconstruct-through-new() over
mutate-in-place and over a default-bearing variant. The value lives only in the
injected vector (no baked default), keeping the blueprint a pure param-generic
recipe (C19); every injected value flows through the node's own constructor (the
single sizing/validation gate).

- aura-core: LeafFactory { name, params, build } — the recipe (params -> sized node
  through `new`); Scalar::as_i64/as_f64 value accessors. Node trait unchanged.
- aura-std: each of the 7 nodes exposes factory() (SMA length:I64, Exposure
  scale:F64, LinComb arity x weights[i]:F64; Sub/Add/SimBroker/Recorder paramless,
  capturing their non-param construction args — pip_size, the Recorder channel).
- aura-engine: BlueprintNode::Leaf(LeafFactory), From<LeafFactory>; param_space()
  reads factory.params() pre-build; compile_with_params/bootstrap_with_params build
  each leaf from its kind-checked slice while lowering (build-then-wire), arity
  checked up front via param_space().len(); CompileError::{ParamKindMismatch,
  ParamArity}. compile/inline/edge-rewrite are structurally unchanged, so the
  compilat stays bit-identical for a given point (the bit-identity and both
  param_space mirror tests stay green). The vestigial pre-build Composite::schema /
  BlueprintNode::schema (no live caller — interface resolution is structural on the
  built flat nodes) are removed.
- aura-cli: the blueprint view labels leaves by bare type via LeafFactory::label()
  (`[SMA]`) — the ascii-dag renderer cannot render wide cluster-sibling labels (see
  spec); the compiled view still labels valued (`SMA(2)`). The sample supplies its
  point as a vector; the mis-wiring swap moved to the compiled view.

The #34 dual-traversal drift hazard is subsumed: compile_with_params consumes the
vector in the same recipe walk param_space() reports, so the two share one
traversal.

Verified: cargo build/test --workspace green (127 tests, incl. bit-identity, both
mirror tests, and 4 new injection tests — different-vector-different-run, kind
mismatch, arity, determinism); clippy --workspace --all-targets -D warnings clean;
`aura graph` blueprint view renders cleanly.

Scope: one vector -> one instance. Deferred: sweep enumeration (#32), domain
validation (#32/C20), single-run authoring convenience (#35).

closes #31
This commit is contained in:
2026-06-07 21:04:52 +02:00
parent b02f31cdd4
commit 4b64409036
13 changed files with 469 additions and 168 deletions
+4 -4
View File
@@ -57,18 +57,18 @@ pub fn render_blueprint(bp: &Blueprint) -> String {
// interior edges (the interior ids are in hand here).
for item in bp.nodes() {
match item {
BlueprintNode::Leaf(node) => {
BlueprintNode::Leaf(factory) => {
let id = labels.len();
labels.push(node.label());
labels.push(factory.label());
item_display.push(ItemDisplay::Leaf(id));
}
BlueprintNode::Composite(c) => {
let mut interior_ids = Vec::with_capacity(c.nodes().len());
for inner in c.nodes() {
match inner {
BlueprintNode::Leaf(node) => {
BlueprintNode::Leaf(factory) => {
let id = labels.len();
labels.push(node.label());
labels.push(factory.label());
interior_ids.push(id);
}
BlueprintNode::Composite(_) => unimplemented!(
+70 -50
View File
@@ -116,11 +116,12 @@ fn run_sample() -> RunReport {
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
/// CLI-local sample builder; the engine ships no sample (the duplication with
/// `blueprint.rs`'s test helper is the dedup tracked in #14).
fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite {
/// `blueprint.rs`'s test helper is the dedup tracked in #14). Value-empty: the SMA
/// lengths are injected at compile, not baked here.
fn sma_cross(name: &str) -> Composite {
Composite::new(
name,
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -130,19 +131,20 @@ fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite {
)
}
/// The sample signal-quality blueprint, parameterized by the SMA windows so a
/// test can author a deliberately swapped variant. Recorders need a channel to
/// construct; the receivers are dropped because the render never runs the graph.
fn build_sample(fast: usize, slow: usize) -> Blueprint {
/// The sample signal-quality blueprint (value-empty): a recipe whose SMA lengths +
/// exposure scale are injected at compile via the point vector (see
/// `sample_point`). Recorders need a channel to construct; the receivers are
/// dropped because the render never runs the graph.
fn build_sample() -> Blueprint {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
Blueprint::new(
vec![
BlueprintNode::Composite(sma_cross("sma_cross", fast, slow)),
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(),
BlueprintNode::Composite(sma_cross("sma_cross")),
Exposure::factory().into(),
SimBroker::factory(0.0001).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
@@ -162,7 +164,13 @@ fn build_sample(fast: usize, slow: usize) -> Blueprint {
/// The built-in sample rendered by `aura graph`.
fn sample_blueprint() -> Blueprint {
build_sample(2, 4)
build_sample()
}
/// The point vector injected into the sample blueprint, in `param_space()` slot
/// order: `[fast SMA length, slow SMA length, exposure scale]`.
fn sample_point() -> Vec<Scalar> {
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]
}
fn main() {
@@ -177,7 +185,8 @@ fn main() {
let compiled = args.next().as_deref() == Some("--compiled");
let bp = sample_blueprint();
let out = if compiled {
let (nodes, sources, edges) = bp.compile().expect("valid sample blueprint");
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample blueprint");
graph::render_compilat(&nodes, &sources, &edges)
} else {
graph::render_blueprint(&bp)
@@ -196,21 +205,23 @@ fn main() {
mod tests {
use super::*;
/// The sample authored with fast/slow SMA windows swapped — the mis-wiring
/// the render must surface. Test-only: nothing outside tests builds it.
fn sample_blueprint_swapped() -> Blueprint {
build_sample(4, 2)
/// The sample's point vector with the fast/slow SMA lengths swapped — the
/// mis-wiring the compiled render must surface. The blueprint is param-generic
/// and identical for both orderings; only the injected vector differs.
fn swapped_point() -> Vec<Scalar> {
vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)]
}
#[test]
fn blueprint_view_shows_cluster_and_param_labels() {
fn blueprint_view_shows_cluster_and_param_generic_labels() {
let out = graph::render_blueprint(&sample_blueprint());
// the composite renders as a named cluster box
assert!(out.contains("sma_cross"), "missing composite name:\n{out}");
// param-carrying labels disambiguate the two SMAs
assert!(out.contains("SMA(2)"), "missing SMA(2):\n{out}");
assert!(out.contains("SMA(4)"), "missing SMA(4):\n{out}");
for needle in ["Sub", "Exposure(0.5)", "SimBroker(0.0001)", "Recorder"] {
// the value-empty blueprint view labels leaves param-generically by bare
// type (no values, and no knob suffix — the ascii-dag layout cannot render
// wide cluster-sibling labels); both SMAs render identically as [SMA]
assert!(out.contains("[SMA]"), "missing [SMA]:\n{out}");
for needle in ["Sub", "Exposure", "SimBroker", "Recorder"] {
assert!(out.contains(needle), "missing {needle}:\n{out}");
}
}
@@ -218,7 +229,8 @@ mod tests {
#[test]
fn compiled_view_dissolves_the_composite_boundary() {
let bp = sample_blueprint();
let (nodes, sources, edges) = bp.compile().expect("valid sample");
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample");
let out = graph::render_compilat(&nodes, &sources, &edges);
// node labels survive inlining...
assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
@@ -227,11 +239,18 @@ mod tests {
}
#[test]
fn swapped_sma_inputs_render_differently() {
fn swapped_param_vector_changes_the_compiled_render() {
// the property the cycle exists to buy: a mis-wiring is no longer invisible.
let correct = graph::render_blueprint(&sample_blueprint());
let swapped = graph::render_blueprint(&sample_blueprint_swapped());
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the render");
// The blueprint is now param-generic (identical for both orderings), so the
// swap is observable only after the vector is injected — in the COMPILED
// view, where the flat nodes carry their valued labels (SMA(2)/SMA(4)).
let (cn, cs, ce) =
sample_blueprint().compile_with_params(&sample_point()).expect("valid sample");
let correct = graph::render_compilat(&cn, &cs, &ce);
let (sn, ss, se) =
sample_blueprint().compile_with_params(&swapped_point()).expect("valid sample");
let swapped = graph::render_compilat(&sn, &ss, &se);
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the compiled render");
}
#[test]
@@ -239,27 +258,27 @@ mod tests {
let out = graph::render_blueprint(&sample_blueprint());
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
// exact bytes `aura graph` emits (render() ends in three newlines).
let expected = r#" [source:F64]
┌─────────└┐────────┐
╔═════╪══════════╪═══╗ │
║ sma_cross ║ │
║ ↓ ║ │
║ [SMA(2)] [SMA(4)] ║ │
└──┌──────┘ ║ │
║ ↓ ┌─╫───┘
║ [Sub]
║ │
╚════════╪══════════╪═
[Exposure(0.5)] │
┌───────┘────────┐ │
↓─┘
[Recorder] [SimBroker(0.0001)]
[Recorder]
let expected = r#" [source:F64]
───────└───────┐
│ │
╔═══╪═══════╪═══╗ │
║ sma_cross ║ │
║ ↓ ↓ ║ │
║ [SMA] [SMA] ║ │
└──────┘ ║ │
║ ↓ ║┌──┘
║ [Sub] ║
║ │ ║
╚════════╪══════╝
[Exposure] │
┌───┘───────┼┐
└↓
[Recorder] [SimBroker]
[Recorder]
"#;
@@ -269,7 +288,8 @@ mod tests {
#[test]
fn compiled_view_golden() {
let bp = sample_blueprint();
let (nodes, sources, edges) = bp.compile().expect("valid sample");
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample");
let out = graph::render_compilat(&nodes, &sources, &edges);
let expected = r#" [source:F64]
┌────────└─┐──────┐
+1 -1
View File
@@ -39,5 +39,5 @@ pub use any::AnyColumn;
pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use node::{FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec};
pub use node::{FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec};
pub use scalar::{Scalar, ScalarKind, Timestamp};
+78 -9
View File
@@ -57,6 +57,53 @@ pub struct ParamSpec {
pub kind: ScalarKind,
}
/// A param-generic blueprint leaf (C19): a node's declared tunable params plus a
/// closure that builds a sized instance through the node's own constructor (the
/// single sizing/validation gate). A blueprint holds these recipes, never built
/// instances, so it stays value-empty until a param-set is injected (C19/C23).
pub struct LeafFactory {
name: &'static str,
params: Vec<ParamSpec>,
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
impl LeafFactory {
/// `name` is the param-generic render label (the node type, e.g. `"SMA"`);
/// `params` the declared knobs; `build` constructs a sized node from a
/// kind-checked param slice.
pub fn new(
name: &'static str,
params: Vec<ParamSpec>,
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, params, build: Box::new(build) }
}
/// The declared tunable params (read by `Blueprint::param_space`, pre-build).
pub fn params(&self) -> &[ParamSpec] {
&self.params
}
/// Build a sized node from its param slice (the slice is kind-checked by the
/// caller before this runs).
pub fn build(&self, params: &[Scalar]) -> Box<dyn Node> {
(self.build)(params)
}
/// The param-generic render label for the blueprint view (C22 "structure
/// before"): just the node type, e.g. `SMA`. A value-empty recipe has no
/// values to show; the tunable knobs are surfaced by `Blueprint::param_space`,
/// not in the graph. The label stays the bare type because the `ascii-dag`
/// renderer writes a label verbatim on one line (no wrapping) and overlaps two
/// wide sibling boxes inside a cluster subgraph — appending the knob names
/// (`SMA(length)`) would garble the blueprint view, and domain labels grow
/// unboundedly wide. The compiled view labels the built node valued (`SMA(2)`)
/// via `Node::label`, unaffected.
pub fn label(&self) -> String {
self.name.to_string()
}
}
/// A node's declared interface: its inputs (in order) and its output record — an
/// ordered list of named base columns; length 1 is a scalar (the degenerate
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
@@ -99,6 +146,17 @@ pub trait Node {
mod tests {
use super::*;
/// A minimal node with an empty schema, reused across the label / factory tests.
struct Bare;
impl Node for Bare {
fn schema(&self) -> NodeSchema {
NodeSchema { inputs: vec![], output: vec![], params: vec![] }
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
None
}
}
#[test]
fn input_spec_carries_firing() {
let a = InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any };
@@ -111,18 +169,29 @@ mod tests {
#[test]
fn default_label_is_placeholder() {
// A node that does not override label() falls back to the placeholder.
struct Bare;
impl Node for Bare {
fn schema(&self) -> NodeSchema {
NodeSchema { inputs: vec![], output: vec![], params: vec![] }
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
None
}
}
assert_eq!(Bare.label(), "node");
}
#[test]
fn leaf_factory_label_is_the_bare_type() {
// param-generic: the label is just the node type, no knob suffix (the
// ascii-dag blueprint view cannot render wide cluster-sibling labels).
let with = LeafFactory::new(
"SMA",
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|_| Box::new(Bare),
);
assert_eq!(with.label(), "SMA");
let none = LeafFactory::new("Sub", vec![], |_| Box::new(Bare));
assert_eq!(none.label(), "Sub");
}
#[test]
fn leaf_factory_build_runs_the_closure() {
let f = LeafFactory::new("Bare", vec![], |_| Box::new(Bare));
assert_eq!(f.build(&[]).schema().params, Vec::<ParamSpec>::new());
}
#[test]
fn schema_carries_declared_params() {
let s = NodeSchema {
+16
View File
@@ -35,6 +35,14 @@ impl Scalar {
Scalar::Ts(_) => ScalarKind::Timestamp,
}
}
/// The `i64` payload, or `None` if this scalar is not an `I64`.
pub fn as_i64(self) -> Option<i64> {
if let Scalar::I64(v) = self { Some(v) } else { None }
}
/// The `f64` payload, or `None` if this scalar is not an `F64`.
pub fn as_f64(self) -> Option<f64> {
if let Scalar::F64(v) = self { Some(v) } else { None }
}
}
impl From<i64> for Scalar {
@@ -84,6 +92,14 @@ mod tests {
assert_eq!(Timestamp::default(), Timestamp(0));
}
#[test]
fn scalar_value_accessors_are_kind_exact() {
assert_eq!(Scalar::I64(3).as_i64(), Some(3));
assert_eq!(Scalar::I64(3).as_f64(), None);
assert_eq!(Scalar::F64(0.5).as_f64(), Some(0.5));
assert_eq!(Scalar::F64(0.5).as_i64(), None);
}
#[test]
fn scalar_is_copy() {
let s = Scalar::F64(1.0);
+167 -97
View File
@@ -11,7 +11,7 @@
//! adds no optimisation pass (CSE/DCE, sweep-invariant hoisting are deferred,
//! C23) and no external dependency (C16).
use aura_core::{Node, NodeSchema, ParamSpec, ScalarKind};
use aura_core::{LeafFactory, Node, ParamSpec, Scalar, ScalarKind};
use crate::harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
@@ -25,25 +25,14 @@ pub struct OutPort {
/// 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>),
Leaf(LeafFactory),
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(),
}
/// Ergonomic lift: a param-generic leaf recipe becomes a `Leaf` blueprint item.
impl From<LeafFactory> for BlueprintNode {
fn from(factory: LeafFactory) -> Self {
BlueprintNode::Leaf(factory)
}
}
@@ -94,24 +83,6 @@ impl Composite {
pub fn output(&self) -> OutPort {
self.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], params: vec![] }
}
}
/// A construction-phase fault, caught before the flat compilat reaches
@@ -127,6 +98,11 @@ pub enum CompileError {
/// The lowered flat compilat failed `Harness::bootstrap`'s checks (kind
/// mismatch, bad index, or directed cycle).
Bootstrap(BootstrapError),
/// An injected param value's scalar kind does not match the slot's declared
/// kind. `slot` is the flat param-space index.
ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
/// The injected vector's length does not equal the sum of declared params.
ParamArity { expected: usize, got: usize },
}
/// The root graph-as-data, before compilation: blueprint items + sources + edges,
@@ -169,19 +145,29 @@ impl Blueprint {
out
}
/// 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).
/// Compile the value-empty recipe under an injected param vector: build each
/// leaf from its kind-checked slice while lowering, then rewrite edges/sources
/// exactly as before (structure is param-invariant, C19/C23). The vector is
/// total and positional — one value per `param_space()` slot, in slot order.
// 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> {
pub fn compile_with_params(
self,
params: &[Scalar],
) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
let expected = self.param_space().len();
if params.len() != expected {
return Err(CompileError::ParamArity { expected, got: params.len() });
}
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
let mut flat_edges: Vec<Edge> = Vec::new();
let mut cursor = 0usize;
// lower every top-level item (recursively inlining composites)
let lowerings = lower_items(self.nodes, &mut flat_nodes, &mut flat_edges)?;
// lower every top-level item (recursively inlining composites), building
// each leaf from its kind-checked param slice as it lowers
let lowerings =
lower_items(self.nodes, params, &mut cursor, &mut flat_nodes, &mut flat_edges)?;
// rewrite top-level edges through the lowerings (fan-out into composites)
for e in &self.edges {
@@ -203,11 +189,24 @@ impl Blueprint {
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()?;
/// No-param compile (a blueprint that declares no params); errors `ParamArity`
/// if any param is declared.
#[allow(clippy::type_complexity)]
pub fn compile(self) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
self.compile_with_params(&[])
}
/// Compile under an injected vector, then hand the flat compilat to the
/// unchanged `Harness::bootstrap`.
pub fn bootstrap_with_params(self, params: Vec<Scalar>) -> Result<Harness, CompileError> {
let (nodes, sources, edges) = self.compile_with_params(&params)?;
Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap)
}
/// No-param bootstrap (paramless blueprint).
pub fn bootstrap(self) -> Result<Harness, CompileError> {
self.bootstrap_with_params(vec![])
}
}
/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
@@ -217,10 +216,10 @@ impl Blueprint {
fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
for item in items {
match item {
BlueprintNode::Leaf(node) => {
for p in node.schema().params {
BlueprintNode::Leaf(factory) => {
for p in factory.params() {
let name = if prefix.is_empty() {
p.name
p.name.clone()
} else {
format!("{prefix}.{}", p.name)
};
@@ -254,19 +253,34 @@ enum ItemLowering {
/// input item, in order.
fn lower_items(
items: Vec<BlueprintNode>,
params: &[Scalar],
cursor: &mut usize,
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) => {
BlueprintNode::Leaf(factory) => {
let n = factory.params().len();
let slice = &params[*cursor..*cursor + n]; // in range: arity checked up front
for (i, spec) in factory.params().iter().enumerate() {
let got = slice[i].kind();
if got != spec.kind {
return Err(CompileError::ParamKindMismatch {
slot: *cursor + i,
expected: spec.kind,
got,
});
}
}
let index = flat_nodes.len();
flat_nodes.push(node);
flat_nodes.push(factory.build(slice));
*cursor += n;
lowerings.push(ItemLowering::Leaf { index });
}
BlueprintNode::Composite(c) => {
lowerings.push(inline_composite(c, flat_nodes, flat_edges)?);
lowerings.push(inline_composite(c, params, cursor, flat_nodes, flat_edges)?);
}
}
}
@@ -277,6 +291,8 @@ fn lower_items(
/// edges, then resolve its output port and per-role flat targets.
fn inline_composite(
c: Composite,
params: &[Scalar],
cursor: &mut usize,
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<ItemLowering, CompileError> {
@@ -292,7 +308,7 @@ fn inline_composite(
}
// recursively lower interior items, then rewrite interior edges through them
let interior = lower_items(nodes, flat_nodes, flat_edges)?;
let interior = lower_items(nodes, params, cursor, flat_nodes, flat_edges)?;
for e in &edges {
for fe in rewrite_edge(e, &interior, flat_nodes)? {
flat_edges.push(fe);
@@ -400,7 +416,7 @@ fn slot_kind(t: Target, flat_nodes: &[Box<dyn Node>]) -> Result<ScalarKind, Comp
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Scalar, Timestamp};
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, NodeSchema, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
@@ -431,27 +447,6 @@ mod tests {
}
}
#[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(
"c",
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],
@@ -506,10 +501,20 @@ mod tests {
}
fn pass1() -> BlueprintNode {
BlueprintNode::Leaf(Box::new(Pass1 { out: [Scalar::F64(0.0)] }))
BlueprintNode::Leaf(LeafFactory::new("Pass1", vec![], |_| {
Box::new(Pass1 { out: [Scalar::F64(0.0)] })
}))
}
fn join2() -> BlueprintNode {
BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))
BlueprintNode::Leaf(LeafFactory::new("Join2", vec![], |_| {
Box::new(Join2 { out: [Scalar::F64(0.0)] })
}))
}
fn sink_f64() -> BlueprintNode {
BlueprintNode::Leaf(LeafFactory::new("SinkF64", vec![], |_| Box::new(SinkF64)))
}
fn sink_i64() -> BlueprintNode {
BlueprintNode::Leaf(LeafFactory::new("SinkI64", vec![], |_| Box::new(SinkI64)))
}
/// A composite: two Pass1 leaves feeding a Join2, role 0 fanning the source
@@ -532,7 +537,7 @@ mod tests {
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![BlueprintNode::Composite(fan_composite()), sink_f64()],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
);
@@ -573,7 +578,7 @@ mod tests {
OutPort { node: 0, field: 0 },
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(outer), BlueprintNode::Leaf(Box::new(SinkF64))],
vec![BlueprintNode::Composite(outer), sink_f64()],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
);
@@ -615,7 +620,7 @@ mod tests {
// role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch
let c = Composite::new(
"c",
vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))],
vec![pass1(), sink_i64()],
vec![],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 0, field: 0 },
@@ -645,7 +650,7 @@ mod tests {
// 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![pass1(), sink_i64()],
vec![],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
);
@@ -718,10 +723,11 @@ mod tests {
/// 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 {
/// Value-empty: the two SMA lengths are injected at compile, not baked here.
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -742,11 +748,11 @@ mod tests {
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(),
BlueprintNode::Composite(sma_cross()),
Exposure::factory().into(),
SimBroker::factory(0.0001).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
@@ -775,7 +781,9 @@ mod tests {
// (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");
let mut composed = bp
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("composite blueprint compiles");
composed.run(vec![prices]);
let flat_eq_v = flat_eq.try_iter().collect::<Vec<_>>();
@@ -791,6 +799,64 @@ mod tests {
assert!(!comp_ex_v.is_empty(), "exposure trace must be populated");
}
#[test]
fn injecting_a_different_vector_changes_the_run() {
let prices = synthetic_prices();
let (bp, eq, _ex) = composite_sma_cross_harness();
let mut a = bp.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("compiles");
a.run(vec![prices.clone()]);
let a_eq = eq.try_iter().collect::<Vec<_>>();
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(5), Scalar::I64(20), Scalar::F64(1.0)])
.expect("compiles");
b.run(vec![prices]);
let b_eq = eq2.try_iter().collect::<Vec<_>>();
assert!(!a_eq.is_empty() && !b_eq.is_empty(), "both traces populated");
assert_ne!(a_eq, b_eq, "a different vector must yield a different run");
}
#[test]
fn wrong_kind_is_a_param_kind_mismatch() {
let (bp, _eq, _ex) = composite_sma_cross_harness();
// slot 0 is I64 (an SMA length); inject F64 there
let err = bp.bootstrap_with_params(vec![Scalar::F64(2.0), Scalar::I64(4), Scalar::F64(0.5)])
.unwrap_err();
assert!(matches!(err, CompileError::ParamKindMismatch { slot: 0, .. }));
}
#[test]
fn wrong_arity_is_a_param_arity_error() {
let (short, _e1, _x1) = composite_sma_cross_harness();
assert!(matches!(
short.bootstrap_with_params(vec![Scalar::I64(2)]).unwrap_err(),
CompileError::ParamArity { expected: 3, got: 1 }
));
let (long, _e2, _x2) = composite_sma_cross_harness();
assert!(matches!(
long.bootstrap_with_params(
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5), Scalar::F64(0.0)]
).unwrap_err(),
CompileError::ParamArity { expected: 3, got: 4 }
));
}
#[test]
fn same_vector_bootstraps_identically() {
let prices = synthetic_prices();
let (bp, eq, _ex) = composite_sma_cross_harness();
let mut a = bp.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
.expect("compiles");
a.run(vec![prices.clone()]);
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
.expect("compiles");
b.run(vec![prices]);
assert_eq!(eq.try_iter().collect::<Vec<_>>(), eq2.try_iter().collect::<Vec<_>>());
}
/// E2E (cycle 0015): the C23/#31 cross-cutting invariant — `param_space()` is a
/// parallel projection of the *same* traversal `compile` inlines, so a param's
/// slot in the aggregated space lines up, in order and kind, with the declared
@@ -808,7 +874,9 @@ mod tests {
// the same blueprint, actually compiled to its flat node array; each flat
// node's own declared params, concatenated in flat-node order
let (flat_nodes, _sources, _edges) = bp.compile().expect("harness compiles");
let (flat_nodes, _sources, _edges) = bp
.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("harness compiles");
let from_compilat: Vec<ParamSpec> =
flat_nodes.iter().flat_map(|n| n.schema().params).collect();
@@ -839,7 +907,7 @@ mod tests {
// inner composite "fast_slow": two SMAs (same type → same param name) + a Sub
let fast_slow = Composite::new(
"fast_slow",
vec![Sma::new(2).into(), Sma::new(4).into(), Sub::new().into()],
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -850,7 +918,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::new(vec![1.0, -1.0]).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::factory(2).into()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
@@ -890,7 +958,7 @@ mod tests {
// isolated path-qualification test above)
let fast_slow = Composite::new(
"fast_slow",
vec![Sma::new(2).into(), Sma::new(4).into(), Sub::new().into()],
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -901,7 +969,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::new(vec![1.0, -1.0]).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::factory(2).into()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
@@ -914,7 +982,9 @@ mod tests {
// the same blueprint, compiled to its flat node array; each flat node's
// own declared params, concatenated in flat-node order
let (flat_nodes, _sources, _edges) = bp.compile().expect("nested composite compiles");
let (flat_nodes, _sources, _edges) = bp
.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)])
.expect("nested composite compiles");
let from_compilat: Vec<ParamSpec> =
flat_nodes.iter().flat_map(|n| n.schema().params).collect();
@@ -938,7 +1008,7 @@ mod tests {
#[test]
fn top_level_leaf_params_are_unqualified() {
use aura_std::Sma;
let bp = Blueprint::new(vec![Sma::new(3).into()], vec![], vec![]);
let bp = Blueprint::new(vec![Sma::factory().into()], vec![], vec![]);
let space = bp.param_space();
assert_eq!(space.len(), 1);
assert_eq!(space[0].name, "length"); // no path prefix at the top level
@@ -948,7 +1018,7 @@ mod tests {
fn param_space_is_deterministic() {
use aura_std::{LinComb, Sma};
let bp = Blueprint::new(
vec![Sma::new(2).into(), LinComb::new(vec![1.0, -1.0]).into()],
vec![Sma::factory().into(), LinComb::factory(2).into()],
vec![],
vec![],
);
@@ -959,7 +1029,7 @@ mod tests {
fn param_space_empty_for_paramless_and_empty_blueprints() {
use aura_std::{Add, Sub};
let only_paramless =
Blueprint::new(vec![Sub::new().into(), Add::new().into()], vec![], vec![]);
Blueprint::new(vec![Sub::factory().into(), Add::factory().into()], vec![], vec![]);
assert!(only_paramless.param_space().is_empty());
let empty = Blueprint::new(vec![], vec![], vec![]);
assert!(empty.param_space().is_empty());
+14 -1
View File
@@ -2,7 +2,7 @@
//! Combines two signal streams into one — the most basic combinator for the
//! north-star "combine one signal with another" research move (C10).
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, Scalar, ScalarKind};
/// Two-input f64 sum: input 0 plus input 1. Emits `None` until both inputs
/// have a value.
@@ -25,6 +25,12 @@ impl Add {
pub fn new() -> Self {
Self { out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: paramless, builds through
/// `Add::new`.
pub fn factory() -> LeafFactory {
LeafFactory::new("Add", vec![], |_| Box::new(Add::new()))
}
}
impl Default for Add {
@@ -65,6 +71,13 @@ mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
#[test]
fn factory_params_match_built_node_schema() {
let f = Add::factory();
let built = f.build(&[]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
#[test]
fn add_is_sum_once_both_inputs_present() {
let mut add = Add::new();
+21 -1
View File
@@ -3,7 +3,9 @@
//! exposure stream`: one f64 input, one f64 output `clamp(signal / scale, -1, +1)`.
//! `scale` sets which signal magnitude maps to full exposure (sizing lives here).
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec, Scalar, ScalarKind};
use aura_core::{
Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind,
};
/// Bounded exposure from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
/// Emits `None` until its input is present (warm-up filter, C8).
@@ -18,6 +20,17 @@ impl Exposure {
assert!(scale > 0.0, "Exposure scale must be > 0");
Self { scale, out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: declares `scale` and builds
/// through `Exposure::new` (the single sizing/validation gate; the slice is
/// kind-checked before `build` runs, so the typed read is total).
pub fn factory() -> LeafFactory {
LeafFactory::new(
"Exposure",
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|p| Box::new(Exposure::new(p[0].as_f64().expect("scale slot is F64"))),
)
}
}
impl Node for Exposure {
@@ -69,6 +82,13 @@ mod tests {
}
}
#[test]
fn factory_params_match_built_node_schema() {
let f = Exposure::factory();
let built = f.build(&[Scalar::F64(0.5)]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
#[test]
fn exposure_is_none_until_input_present() {
let mut e = Exposure::new(0.5);
+26 -1
View File
@@ -6,7 +6,9 @@
//! params, declared in the schema (cycle 0015) as `weights[0..N]` — N flat
//! indexed `F64` knobs that `Blueprint::param_space` aggregates (C8/C12/C19).
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec, Scalar, ScalarKind};
use aura_core::{
Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind,
};
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are
/// construction parameters that configure the node and fix its arity
@@ -36,6 +38,22 @@ impl LinComb {
assert!(!weights.is_empty(), "LinComb needs at least one weight");
Self { weights, out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf. The `arity` is topology
/// (fixed per blueprint, C19), taken as a factory arg; only the weight *values*
/// are injected, slot by slot, through `LinComb::new` (the single sizing gate).
pub fn factory(arity: usize) -> LeafFactory {
let params = (0..arity)
.map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
.collect();
LeafFactory::new(
"LinComb",
params,
|p| Box::new(LinComb::new(
p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(),
)),
)
}
}
impl Node for LinComb {
@@ -124,6 +142,13 @@ mod tests {
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(6.0)].as_slice()));
}
#[test]
fn factory_params_match_built_node_schema() {
let f = LinComb::factory(2);
let built = f.build(&[Scalar::F64(1.0), Scalar::F64(-1.0)]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
#[test]
#[should_panic(expected = "LinComb needs at least one weight")]
fn lincomb_empty_weights_panics() {
+22 -1
View File
@@ -7,7 +7,7 @@
//! four base scalar kinds so any column can be persisted; returns `None` (filters)
//! until every input column is warm.
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_core::{Ctx, Firing, InputSpec, LeafFactory, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use std::sync::mpsc::Sender;
/// A recording sink over `kinds.len()` input columns. Each fired cycle it reads
@@ -26,6 +26,19 @@ impl Recorder {
pub fn new(kinds: &[ScalarKind], firing: Firing, tx: Sender<(Timestamp, Vec<Scalar>)>) -> Self {
Self { kinds: kinds.to_vec(), firing, tx }
}
/// The param-generic recipe for a blueprint leaf. The channel + kinds + firing
/// are non-param construction args (captured), not tunable params; the node
/// declares none. `tx` is cloned per build (`mpsc::Sender: Clone`).
pub fn factory(
kinds: Vec<ScalarKind>,
firing: Firing,
tx: Sender<(Timestamp, Vec<Scalar>)>,
) -> LeafFactory {
LeafFactory::new("Recorder", vec![], move |_| {
Box::new(Recorder::new(&kinds, firing, tx.clone()))
})
}
}
impl Node for Recorder {
@@ -68,6 +81,14 @@ mod tests {
use aura_core::{AnyColumn, Timestamp};
use std::sync::mpsc;
#[test]
fn factory_params_match_built_node_schema() {
let (tx, _rx) = mpsc::channel();
let f = Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx);
let built = f.build(&[]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
#[test]
fn recorder_captures_f64_stream_after_warmup() {
let (tx, rx) = mpsc::channel();
+15 -1
View File
@@ -4,7 +4,7 @@
//! each cycle (decided at t-1) into a cumulative synthetic pip-equity output.
//! Measures signal quality, not execution-modelled P&L.
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, Scalar, ScalarKind};
/// Integrates `exposure * price-return` into cumulative pips. `pip_size` is
/// per-instrument reference metadata (beside the hot path, C7/C15), held here,
@@ -54,6 +54,13 @@ impl SimBroker {
out: [Scalar::F64(0.0)],
}
}
/// The param-generic recipe for a blueprint leaf. `pip_size` is per-instrument
/// metadata (C10/C15), not a tunable param — it is captured by the closure, not
/// injected; the node declares no params.
pub fn factory(pip_size: f64) -> LeafFactory {
LeafFactory::new("SimBroker", vec![], move |_| Box::new(SimBroker::new(pip_size)))
}
}
impl Node for SimBroker {
@@ -115,6 +122,13 @@ mod tests {
}
}
#[test]
fn factory_params_match_built_node_schema() {
let f = SimBroker::factory(0.0001);
let built = f.build(&[]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
#[test]
fn sim_broker_integrates_lagged_exposure_times_return() {
let mut b = SimBroker::new(1.0);
+21 -1
View File
@@ -3,7 +3,9 @@
//! `Node` contract is authorable from a downstream crate and evaluable with no
//! engine present (the test drives it by hand, as the sim loop later will).
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec, Scalar, ScalarKind};
use aura_core::{
Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind,
};
/// Simple moving average over the last `length` values of one f64 input.
pub struct Sma {
@@ -17,6 +19,17 @@ impl Sma {
assert!(length >= 1, "SMA length must be >= 1");
Self { length, out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: declares `length` and builds
/// through `Sma::new` (the single sizing/validation gate; the slice is
/// kind-checked before `build` runs, so the typed read is total).
pub fn factory() -> LeafFactory {
LeafFactory::new(
"SMA",
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)),
)
}
}
impl Node for Sma {
@@ -111,6 +124,13 @@ mod tests {
assert_eq!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).label(), "Recorder");
}
#[test]
fn factory_params_match_built_node_schema() {
let f = Sma::factory();
let built = f.build(&[Scalar::I64(3)]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
#[test]
fn nodes_declare_expected_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
+14 -1
View File
@@ -3,7 +3,7 @@
//! real fan-out + join to run (two SMAs joining into one node), exercising
//! multi-input `Ctx` access inside a running graph.
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, Scalar, ScalarKind};
/// Two-input f64 difference: input 0 minus input 1. Emits `None` until both
/// inputs have a value.
@@ -16,6 +16,12 @@ impl Sub {
pub fn new() -> Self {
Self { out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: paramless, builds through
/// `Sub::new`.
pub fn factory() -> LeafFactory {
LeafFactory::new("Sub", vec![], |_| Box::new(Sub::new()))
}
}
impl Default for Sub {
@@ -56,6 +62,13 @@ mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
#[test]
fn factory_params_match_built_node_schema() {
let f = Sub::factory();
let built = f.build(&[]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
#[test]
fn sub_is_difference_once_both_inputs_present() {
let mut sub = Sub::new();