diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index 105bdf5..af91735 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -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!( diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index d190bb5..6dff6df 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 { + 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 { + 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] ┌────────└─┐──────┐ diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index bb6dc17..a4587be 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -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}; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 1d8c261..bf3ddf8 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -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, + // 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 Box>, +} + +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, + build: impl Fn(&[Scalar]) -> Box + '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 { + (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::::new()); + } + #[test] fn schema_carries_declared_params() { let s = NodeSchema { diff --git a/crates/aura-core/src/scalar.rs b/crates/aura-core/src/scalar.rs index 282bc71..c184dd0 100644 --- a/crates/aura-core/src/scalar.rs +++ b/crates/aura-core/src/scalar.rs @@ -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 { + 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 { + if let Scalar::F64(v) = self { Some(v) } else { None } + } } impl From 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); diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index bc5f68f..55c254b 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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), + Leaf(LeafFactory), 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(), - } +/// Ergonomic lift: a param-generic leaf recipe becomes a `Leaf` blueprint item. +impl From 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>, Vec, Vec), CompileError> { + pub fn compile_with_params( + self, + params: &[Scalar], + ) -> Result<(Vec>, Vec, Vec), CompileError> { + let expected = self.param_space().len(); + if params.len() != expected { + return Err(CompileError::ParamArity { expected, got: params.len() }); + } let mut flat_nodes: Vec> = Vec::new(); let mut flat_edges: Vec = 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 { - 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>, Vec, Vec), 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) -> Result { + let (nodes, sources, edges) = self.compile_with_params(¶ms)?; Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap) } + + /// No-param bootstrap (paramless blueprint). + pub fn bootstrap(self) -> Result { + 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) { 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, + params: &[Scalar], + cursor: &mut usize, 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) => { + BlueprintNode::Leaf(factory) => { + let n = factory.params().len(); + let slice = ¶ms[*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>, flat_edges: &mut Vec, ) -> Result { @@ -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]) -> Result 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::>(); @@ -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::>(); + + 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::>(); + + 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::>(), eq2.try_iter().collect::>()); + } + /// 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 = 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 = 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()); diff --git a/crates/aura-std/src/add.rs b/crates/aura-std/src/add.rs index 66d94d2..337e590 100644 --- a/crates/aura-std/src/add.rs +++ b/crates/aura-std/src/add.rs @@ -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(); diff --git a/crates/aura-std/src/exposure.rs b/crates/aura-std/src/exposure.rs index 6203046..d59ba9f 100644 --- a/crates/aura-std/src/exposure.rs +++ b/crates/aura-std/src/exposure.rs @@ -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); diff --git a/crates/aura-std/src/lincomb.rs b/crates/aura-std/src/lincomb.rs index 5b444c9..c66b26d 100644 --- a/crates/aura-std/src/lincomb.rs +++ b/crates/aura-std/src/lincomb.rs @@ -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() { diff --git a/crates/aura-std/src/recorder.rs b/crates/aura-std/src/recorder.rs index 7deb6f1..14ebec3 100644 --- a/crates/aura-std/src/recorder.rs +++ b/crates/aura-std/src/recorder.rs @@ -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)>) -> 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, + firing: Firing, + tx: Sender<(Timestamp, Vec)>, + ) -> 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(); diff --git a/crates/aura-std/src/sim_broker.rs b/crates/aura-std/src/sim_broker.rs index e59de8c..a9087b4 100644 --- a/crates/aura-std/src/sim_broker.rs +++ b/crates/aura-std/src/sim_broker.rs @@ -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); diff --git a/crates/aura-std/src/sma.rs b/crates/aura-std/src/sma.rs index f8032ef..58c1f2b 100644 --- a/crates/aura-std/src/sma.rs +++ b/crates/aura-std/src/sma.rs @@ -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}; diff --git a/crates/aura-std/src/sub.rs b/crates/aura-std/src/sub.rs index 9fd9f4b..55ab6bf 100644 --- a/crates/aura-std/src/sub.rs +++ b/crates/aura-std/src/sub.rs @@ -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();