feat(aura): node signature lives in the blueprint; collapse Blueprint into Composite

Consolidate the node data structure so every node's signature (NodeSchema:
inputs/output/params) is declared once and exists in the blueprint pre-build,
and dissolve the special "root graph" type. Behaviour-preserving (C1).

Signature vs sizing
- NodeSchema is now the static signature only: InputSpec -> PortSpec{kind,firing},
  with lookback removed. The signature is fully static per blueprint (input
  kinds/firing, output fields, params); LinComb's variable arity is a builder arg,
  not an injected param.
- The one param-dependent quantity, an input's buffer lookback (e.g. Sma's window =
  its injected length), moves out of the signature to Node::lookbacks() -> Vec<usize>,
  read only by bootstrap for sizing. Node::schema() is removed.
- LeafFactory -> PrimitiveBuilder, which carries the full NodeSchema. The built node
  no longer re-declares it: closes the params-declared-twice drift (#36, the 8
  per-node factory_params_match_built_node_schema lockstep tests are deleted — their
  subject is now structurally impossible) and a value-empty recipe exposes its full
  I/O interface pre-build (#43).

Root is just a bound composite
- struct Blueprint is deleted; its compile/bootstrap/param_space methods move onto
  Composite. Role gains source: Option<ScalarKind> (None = open interior port,
  Some = bound ingestion feed). A composite is runnable iff every root role is bound;
  the "main graph" is no longer a category, only the fully-source-bound composite.
  New error CompileError::UnboundRootRole for an open root role.
- BlueprintNode::signature() answers uniformly for both arms: Primitive returns the
  builder's declared schema, Composite derives it from the interior (role kinds in,
  OutField kinds out, aggregated params), pre-build, no build.

compile -> FlatGraph -> bootstrap
- compile validates structure pre-build via signature() (validate_wiring: range +
  kind, returning the same variants as before, so an edge kind fault is now caught
  before any build closure fires) and emits FlatGraph{nodes,signatures,sources,edges}.
- bootstrap consumes the FlatGraph: kinds/firing/output from the carried signatures,
  buffer depth from node.lookbacks(). SourceSpec survives as the flat descriptor.

Renames: BlueprintNode::Leaf -> Primitive, LeafFactory -> PrimitiveBuilder.

Render (aura-cli/src/graph.rs) is migrated compile-only: it takes &Composite, maps
bound roles to the same source-entry shape, so both render goldens reproduce
byte-identical output (no re-capture needed). Render-fidelity tuning is the next cycle.

Verification (orchestrator-run, not agent-reported): cargo build --workspace green;
cargo test --workspace 150 passed / 0 failed; cargo clippy --workspace --all-targets
-D warnings clean. All pinned determinism/run-output tests pass with values unchanged;
no behavioural assertion was altered to go green. 5 new tests assert the signature is
pre-build and uniform, that compile rejects a kind mismatch without building (via a
panicking builder), UnboundRootRole, and lookbacks()/signature arity agreement.

Deferred to cycle-close audit (per plan): docs/design/INDEX.md and some aura-std
module docs still name the old Node::schema()/LeafFactory/BlueprintNode::Leaf/
Blueprint::param_space contracts; prose reconciliation is the architect's at audit.

closes #43 #36
This commit is contained in:
2026-06-09 12:49:22 +02:00
parent c7fc2f6a37
commit 1b3909316e
17 changed files with 1398 additions and 775 deletions
+17 -20
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, LeafFactory, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind};
/// Two-input f64 sum: input 0 plus input 1. Emits `None` until both inputs
/// have a value.
@@ -26,10 +26,21 @@ impl Add {
Self { out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: paramless, builds through
/// The param-generic recipe for a blueprint primitive: paramless, builds through
/// `Add::new`.
pub fn factory() -> LeafFactory {
LeafFactory::new("Add", vec![], |_| Box::new(Add::new()))
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Add",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Add::new()),
)
}
}
@@ -40,15 +51,8 @@ impl Default for Add {
}
impl Node for Add {
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: "value", kind: ScalarKind::F64 }],
params: vec![],
}
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -71,13 +75,6 @@ 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();
+20 -24
View File
@@ -18,7 +18,8 @@
//! nothing on the hot path (the output buffer is reused).
use aura_core::{
Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind,
Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
/// Exponential moving average of one f64 input, smoothing `alpha = 2/(length+1)`,
@@ -52,31 +53,27 @@ impl Ema {
}
}
/// The param-generic recipe for a blueprint leaf: declares `length` and builds
/// The param-generic recipe for a blueprint primitive: declares `length` and builds
/// through `Ema::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(
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"EMA",
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
},
|p| Box::new(Ema::new(p[0].as_i64().expect("length slot is I64") as usize)),
)
}
}
impl Node for Ema {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec {
kind: ScalarKind::F64,
// recursive: the running average lives in internal state, so only
// the newest sample is read — `length` sizes alpha, not the window.
lookback: 1,
firing: Firing::Any,
}],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
}
// recursive: the running average lives in internal state, so only the newest
// sample is read — `length` sizes alpha, not the window.
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -157,12 +154,11 @@ mod tests {
}
#[test]
fn factory_params_match_built_node_schema() {
let f = Ema::factory();
let built = f.build(&[Scalar::I64(9)]);
assert_eq!(f.params(), built.schema().params.as_slice());
// the built node carries the declared I64 `length` knob
assert_eq!(built.schema().params[0].name, "length");
assert_eq!(built.schema().params[0].kind, ScalarKind::I64);
fn builder_declares_the_length_knob() {
// the param-generic recipe carries the declared I64 `length` knob
let b = Ema::builder();
assert_eq!(b.params(), b.schema().params.as_slice());
assert_eq!(b.schema().params[0].name, "length");
assert_eq!(b.schema().params[0].kind, ScalarKind::I64);
}
}
+12 -18
View File
@@ -4,7 +4,8 @@
//! `scale` sets which signal magnitude maps to full exposure (sizing lives here).
use aura_core::{
Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind,
Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
/// Bounded exposure from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
@@ -21,25 +22,25 @@ impl Exposure {
Self { scale, out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: declares `scale` and builds
/// The param-generic recipe for a blueprint primitive: 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(
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Exposure",
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }],
params: 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 {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
}
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -82,13 +83,6 @@ 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);
+12 -25
View File
@@ -7,7 +7,8 @@
//! indexed `F64` knobs that `Blueprint::param_space` aggregates (C8/C12/C19).
use aura_core::{
Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind,
Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are
@@ -39,16 +40,19 @@ impl LinComb {
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*
/// The param-generic recipe for a blueprint primitive. The `arity` is topology
/// (fixed per blueprint, C19), taken as a builder arg; only the weight *values*
/// are injected, slot by slot, through `LinComb::new` (the single sizing gate).
pub fn factory(arity: usize) -> LeafFactory {
pub fn builder(arity: usize) -> PrimitiveBuilder {
let inputs = (0..arity)
.map(|_| PortSpec { kind: ScalarKind::F64, firing: Firing::Any })
.collect();
let params = (0..arity)
.map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
.collect();
LeafFactory::new(
PrimitiveBuilder::new(
"LinComb",
params,
NodeSchema { inputs, output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params },
|p| Box::new(LinComb::new(
p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(),
)),
@@ -57,18 +61,8 @@ impl LinComb {
}
impl Node for LinComb {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: self
.weights
.iter()
.map(|_| InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any })
.collect(),
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: (0..self.weights.len())
.map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
.collect(),
}
fn lookbacks(&self) -> Vec<usize> {
vec![1; self.weights.len()]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -142,13 +136,6 @@ 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() {
+32 -37
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, LeafFactory, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_core::{Ctx, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
use std::sync::mpsc::Sender;
/// A recording sink over `kinds.len()` input columns. Each fired cycle it reads
@@ -16,42 +16,41 @@ use std::sync::mpsc::Sender;
/// value.
pub struct Recorder {
kinds: Vec<ScalarKind>,
firing: Firing,
tx: Sender<(Timestamp, Vec<Scalar>)>,
}
impl Recorder {
/// A recorder over one input column per entry in `kinds`, each with the given
/// `firing` policy, sending recorded `(timestamp, row)` pairs to `tx`.
pub fn new(kinds: &[ScalarKind], firing: Firing, tx: Sender<(Timestamp, Vec<Scalar>)>) -> Self {
Self { kinds: kinds.to_vec(), firing, tx }
/// `firing` policy, sending recorded `(timestamp, row)` pairs to `tx`. The
/// `firing` policy is a property of the declared signature (carried by the
/// `PrimitiveBuilder`'s `PortSpec`s), not of the built sink, so it is part of
/// the construction contract but not stored on the instance.
pub fn new(kinds: &[ScalarKind], _firing: Firing, tx: Sender<(Timestamp, Vec<Scalar>)>) -> Self {
Self { kinds: kinds.to_vec(), tx }
}
/// The param-generic recipe for a blueprint leaf. The channel + kinds + firing
/// The param-generic recipe for a blueprint primitive. 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(
/// declares none. The input ports (one per kind) are threaded into the schema
/// statically; `tx`/`kinds` are cloned for the build closure.
pub fn builder(
kinds: Vec<ScalarKind>,
firing: Firing,
tx: Sender<(Timestamp, Vec<Scalar>)>,
) -> LeafFactory {
LeafFactory::new("Recorder", vec![], move |_| {
Box::new(Recorder::new(&kinds, firing, tx.clone()))
})
) -> PrimitiveBuilder {
let inputs = kinds.iter().map(|&kind| PortSpec { kind, firing }).collect();
let build_kinds = kinds.clone();
PrimitiveBuilder::new(
"Recorder",
NodeSchema { inputs, output: vec![], params: vec![] }, // sink: empty output (C8)
move |_| Box::new(Recorder::new(&build_kinds, firing, tx.clone())),
)
}
}
impl Node for Recorder {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: self
.kinds
.iter()
.map(|&kind| InputSpec { kind, lookback: 1, firing: self.firing })
.collect(),
output: vec![],
params: vec![],
}
fn lookbacks(&self) -> Vec<usize> {
vec![1; self.kinds.len()]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -81,26 +80,22 @@ 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();
let mut rec = Recorder::new(&[ScalarKind::F64], Firing::Any, tx);
// size the one f64 input column from the schema, as the engine would.
let schema = rec.schema();
assert!(schema.output.is_empty(), "a sink declares no output (C8)");
let mut inputs = vec![AnyColumn::with_capacity(
schema.inputs[0].kind,
schema.inputs[0].lookback,
)];
// a sink declares no output (C8) — asserted on the param-generic builder
let (tx_b, _rx_b) = mpsc::channel();
assert!(
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_b)
.schema()
.output
.is_empty(),
"a sink declares no output (C8)"
);
// size the one f64 input column from the node's lookback, as bootstrap would.
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, rec.lookbacks()[0])];
// cold: returns None and records nothing.
assert_eq!(rec.eval(Ctx::new(&inputs, Timestamp(1))), None);
+17 -20
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, LeafFactory, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind};
/// Integrates `exposure * price-return` into cumulative pips. `pip_size` is
/// per-instrument reference metadata (beside the hot path, C7/C15), held here,
@@ -55,24 +55,28 @@ impl SimBroker {
}
}
/// The param-generic recipe for a blueprint leaf. `pip_size` is per-instrument
/// The param-generic recipe for a blueprint primitive. `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)))
pub fn builder(pip_size: f64) -> PrimitiveBuilder {
PrimitiveBuilder::new(
"SimBroker",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 0 exposure
PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 1 price
],
output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }],
params: vec![],
},
move |_| Box::new(SimBroker::new(pip_size)),
)
}
}
impl Node for SimBroker {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, // 0 exposure
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, // 1 price
],
output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }],
params: vec![],
}
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -122,13 +126,6 @@ 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);
+30 -37
View File
@@ -4,7 +4,8 @@
//! engine present (the test drives it by hand, as the sim loop later will).
use aura_core::{
Ctx, FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec, Scalar, ScalarKind,
Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
/// Simple moving average over the last `length` values of one f64 input.
@@ -20,29 +21,25 @@ impl Sma {
Self { length, out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: declares `length` and builds
/// The param-generic recipe for a blueprint primitive: 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(
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"SMA",
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: 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 {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec {
kind: ScalarKind::F64,
lookback: self.length,
firing: Firing::Any,
}],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
}
fn lookbacks(&self) -> Vec<usize> {
vec![self.length]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -70,14 +67,12 @@ mod tests {
#[test]
fn sma_warms_up_then_tracks_the_window_mean() {
let mut sma = Sma::new(3);
let schema = sma.schema();
let sma_for_depth = Sma::new(3);
// size the input column from the schema, as the engine will at wiring
let mut inputs = vec![AnyColumn::with_capacity(
schema.inputs[0].kind,
schema.inputs[0].lookback,
)];
// size the input column from the node's lookback, as bootstrap will at wiring
let mut inputs =
vec![AnyColumn::with_capacity(ScalarKind::F64, sma_for_depth.lookbacks()[0])];
let mut sma = sma_for_depth;
let feed = [1.0_f64, 2.0, 3.0, 4.0, 5.0];
// means of [1,2,3], [2,3,4], [3,4,5] once warmed up
@@ -124,37 +119,35 @@ 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};
use aura_core::{Firing, ParamSpec, ScalarKind};
// single scalar knobs
// single scalar knobs (declared on the param-generic builder, pre-build)
assert_eq!(
Sma::new(3).schema().params,
Sma::builder().schema().params,
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
);
assert_eq!(
Exposure::new(0.5).schema().params,
Exposure::builder().schema().params,
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
);
// vector knob expands flat to N indexed F64 entries
let lc = LinComb::new(vec![1.0, -1.0]).schema().params;
let lc = LinComb::builder(2).schema().params.clone();
assert_eq!(lc.len(), 2);
assert_eq!(lc[0].name, "weights[0]");
assert_eq!(lc[1].name, "weights[1]");
assert!(lc.iter().all(|p| p.kind == ScalarKind::F64));
// param-less nodes declare empty
assert!(Sub::new().schema().params.is_empty());
assert!(Add::new().schema().params.is_empty());
assert!(SimBroker::new(0.0001).schema().params.is_empty());
assert!(Sub::builder().schema().params.is_empty());
assert!(Add::builder().schema().params.is_empty());
assert!(SimBroker::builder(0.0001).schema().params.is_empty());
let (tx, _rx) = std::sync::mpsc::channel();
assert!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).schema().params.is_empty());
assert!(
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx)
.schema()
.params
.is_empty()
);
}
}
+17 -20
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, LeafFactory, Node, NodeSchema, Scalar, ScalarKind};
use aura_core::{Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind};
/// Two-input f64 difference: input 0 minus input 1. Emits `None` until both
/// inputs have a value.
@@ -17,10 +17,21 @@ impl Sub {
Self { out: [Scalar::F64(0.0)] }
}
/// The param-generic recipe for a blueprint leaf: paramless, builds through
/// The param-generic recipe for a blueprint primitive: paramless, builds through
/// `Sub::new`.
pub fn factory() -> LeafFactory {
LeafFactory::new("Sub", vec![], |_| Box::new(Sub::new()))
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Sub",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Sub::new()),
)
}
}
@@ -31,15 +42,8 @@ impl Default for Sub {
}
impl Node for Sub {
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: "value", kind: ScalarKind::F64 }],
params: vec![],
}
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
@@ -62,13 +66,6 @@ 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();