From 931109df58aee816107d19810992f0a38fb96c6a Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 7 Jun 2026 15:29:58 +0200 Subject: [PATCH] feat(aura-core,aura-std,aura-engine): declare node tunable params (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle A of milestone 'The World — parameter-space & sweep'. A node now declares its tunable parameters in its C8 schema, and a blueprint aggregates them into one flat, inspectable param-space — the root that unlocks the C12 orchestration axes (#31 bind, #32 sweep), filling the gap C8/C23 name 'deliberately not in the schema yet'. - aura-core: ParamSpec { name: String, kind: ScalarKind } as a third schema- declaration type; NodeSchema gains a third field 'params'. name is String (not &'static like FieldSpec) because a vector knob carries a runtime index and aggregation prefixes the composite path. - aura-std: Sma declares [length:I64], Exposure [scale:F64], LinComb expands to N flat [weights[i]:F64] (N = its input arity, topology-fixed per C19); Sub/Add/ SimBroker/Recorder declare none (pip_size is metadata, C10/C15; Recorder is wiring). - aura-engine: Blueprint::param_space() walks the graph-as-data depth-first in lower_items order, path-qualifying via the already-public Composite::name() — a read-only projection (C9). compile/inline_composite/lower_items are untouched, so the compilat stays bit-identical (composite_sma_cross_runs_bit_identical_to_hand_wired and the golden render tests stay green). Design: param identity is positional (slot after the deterministic inline order, C23 'by index not name'); the path-qualified name is a non-load-bearing debug symbol — same-type siblings in one composite share a name, uniqueness is at the slot. Flat over a structured arity-bearing ParamSpec because flattening is unavoidable (a sweep enumerates a flat point-space) and structure-in-the-runtime is the nested-composite reading C23 rejects (see spec 0015 for the full rationale). Scope is declaration + aggregation + inspection only; binding (#31), sweep enumeration (#32), search-range, validity-constraint, and default-range/slider are deferred. An E2E test pins the load-bearing C23/#31 invariant: param_space() slot order mirrors compile()'s flat-node param order, kind-by-slot, on the SMA-cross harness. fieldtests/ (excluded crates) left as frozen snapshots. Verified: cargo build/test --workspace green (64 tests), clippy --all-targets -D warnings clean; compile/inline diff empty. closes #30 --- crates/aura-core/src/lib.rs | 2 +- crates/aura-core/src/node.rs | 35 +++++- crates/aura-engine/src/blueprint.rs | 160 +++++++++++++++++++++++++++- crates/aura-engine/src/harness.rs | 6 ++ crates/aura-engine/src/lib.rs | 2 +- crates/aura-std/src/add.rs | 1 + crates/aura-std/src/exposure.rs | 3 +- crates/aura-std/src/lincomb.rs | 12 ++- crates/aura-std/src/recorder.rs | 1 + crates/aura-std/src/sim_broker.rs | 1 + crates/aura-std/src/sma.rs | 30 +++++- crates/aura-std/src/sub.rs | 1 + 12 files changed, 240 insertions(+), 14 deletions(-) diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index dd79add..bb6dc17 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}; +pub use node::{FieldSpec, Firing, InputSpec, 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 acbf023..1d8c261 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -3,8 +3,10 @@ //! and its output record (0..K base columns, C7; an empty output declares a //! pure consumer / sink role, C8) via `schema`, and computes one cycle's row //! via `eval`. -//! Tunable params (C12/C19) are deliberately not part of the schema yet — see -//! spec 0002's "Out of scope". +//! Tunable params (C12/C19) are declared via `params` (cycle 0015): each node's +//! typed knobs, which `Blueprint::param_space` aggregates into the sweep's flat, +//! path-qualified param-space (C8/C23). Identity is positional (slot); the name is +//! a non-load-bearing debug symbol. use crate::{Ctx, Scalar, ScalarKind}; @@ -41,6 +43,20 @@ pub struct FieldSpec { pub kind: ScalarKind, } +/// One declared tunable parameter of a node (C8/C12): its render name and scalar +/// kind. The name is a **non-load-bearing** debug symbol (path-qualified at +/// aggregation, as `FieldSpec.name` already is); a param's identity is its +/// positional slot in the blueprint's aggregated param-space (C23 — by index, not +/// by name). Unlike `FieldSpec.name` (`&'static str`), the name is a `String`: a +/// vector knob carries a runtime index (`weights[0]`) and aggregation prefixes the +/// composite path (`strategy.weights[0]`). Permitted kinds: `I64`/`F64`/`Bool`; +/// `Timestamp` is a structural axis (C20), never a numeric knob. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParamSpec { + pub name: String, + pub kind: ScalarKind, +} + /// 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** @@ -50,6 +66,7 @@ pub struct FieldSpec { pub struct NodeSchema { pub inputs: Vec, pub output: Vec, + pub params: Vec, } /// The universal composable dataflow unit (C8): a **producer, consumer, or both**. @@ -97,7 +114,7 @@ mod tests { struct Bare; impl Node for Bare { fn schema(&self) -> NodeSchema { - NodeSchema { inputs: vec![], output: vec![] } + NodeSchema { inputs: vec![], output: vec![], params: vec![] } } fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { None @@ -105,4 +122,16 @@ mod tests { } assert_eq!(Bare.label(), "node"); } + + #[test] + fn schema_carries_declared_params() { + let s = NodeSchema { + inputs: vec![], + output: vec![], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + }; + assert_eq!(s.params.len(), 1); + assert_eq!(s.params[0].name, "length"); + assert_eq!(s.params[0].kind, ScalarKind::I64); + } } diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 3f238dd..cfe400d 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, ScalarKind}; +use aura_core::{Node, NodeSchema, ParamSpec, ScalarKind}; use crate::harness::{BootstrapError, Edge, Harness, SourceSpec, Target}; @@ -110,7 +110,7 @@ impl Composite { }) .collect(); let out_field = self.nodes[self.output.node].schema().output[self.output.field]; - NodeSchema { inputs, output: vec![out_field] } + NodeSchema { inputs, output: vec![out_field], params: vec![] } } } @@ -157,6 +157,18 @@ impl Blueprint { &self.edges } + /// The aggregated, flat, path-qualified param-space (C12): every node's declared + /// params, concatenated in the deterministic depth-first item order `lower_items` + /// uses, so a param's slot here matches the later flat-node order (#31 binds + /// slot-by-slot). Read-only graph-as-data (C9); does not compile. Names are + /// non-load-bearing: a composite's `name()` is prefixed at each level, but + /// same-type siblings in one composite share a name — uniqueness is at the slot. + pub fn param_space(&self) -> Vec { + let mut out = Vec::new(); + collect_params(&self.nodes, "", &mut out); + 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 @@ -198,6 +210,35 @@ impl Blueprint { } } +/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its +/// declared params under the running path prefix; a composite pushes its `name()` +/// onto the path and recurses. Order mirrors `lower_items` (items in declared order, +/// composites depth-first) so a param's slot matches the later flat-node order. +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 { + let name = if prefix.is_empty() { + p.name + } else { + format!("{prefix}.{}", p.name) + }; + out.push(ParamSpec { name, kind: p.kind }); + } + } + BlueprintNode::Composite(c) => { + let child = if prefix.is_empty() { + c.name().to_string() + } else { + format!("{prefix}.{}", c.name()) + }; + collect_params(c.nodes(), &child, out); + } + } + } +} + /// How one blueprint item resolved into the flat compilat. Edges and source /// targets to/from an item are resolved through this. enum ItemLowering { @@ -376,6 +417,7 @@ mod tests { InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, ], output: vec![FieldSpec { name: "v", kind: ScalarKind::F64 }], + params: vec![], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -419,6 +461,7 @@ mod tests { NodeSchema { inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], output: vec![FieldSpec { name: "v", kind: ScalarKind::F64 }], + params: vec![], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -438,6 +481,7 @@ mod tests { NodeSchema { inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], output: vec![], + params: vec![], } } fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -453,6 +497,7 @@ mod tests { NodeSchema { inputs: vec![InputSpec { kind: ScalarKind::I64, lookback: 1, firing: Firing::Any }], output: vec![], + params: vec![], } } fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -745,4 +790,115 @@ mod tests { assert!(!comp_eq_v.is_empty(), "equity trace must be populated"); assert!(!comp_ex_v.is_empty(), "exposure trace must be populated"); } + + /// 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 + /// params of the compiled flat nodes (the premise #31's slot-by-slot binding + /// rests on). Driven on the realistic SMA-cross harness, not a synthetic graph, + /// and on the blueprint *as compiled* — so a future inliner reorder that + /// silently desynced the two projections would fail here, not just the isolated + /// `param_space` order tests. + #[test] + fn param_space_mirrors_compiled_flat_node_param_order() { + let (bp, _rx_eq, _rx_ex) = composite_sma_cross_harness(); + + // the aggregated, path-qualified projection + let space = bp.param_space(); + + // 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 from_compilat: Vec = + flat_nodes.iter().flat_map(|n| n.schema().params).collect(); + + // same count, same per-slot kind, same order — the projection mirrors the + // compilation (names differ: param_space path-qualifies, the raw node does + // not, so compare on the load-bearing axis, kind-by-slot) + assert_eq!(space.len(), from_compilat.len(), "param count must match the compilat"); + assert_eq!( + space.iter().map(|p| p.kind).collect::>(), + from_compilat.iter().map(|p| p.kind).collect::>(), + "per-slot param kinds must line up with the compiled flat-node order", + ); + // the realistic harness's concrete space: two SMA lengths (I64) + Exposure + // scale (F64); Sub/SimBroker/Recorder declare none + assert_eq!( + space.iter().map(|p| p.name.as_str()).collect::>(), + ["sma_cross.length", "sma_cross.length", "scale"], + ); + assert_eq!( + space.iter().map(|p| p.kind).collect::>(), + [ScalarKind::I64, ScalarKind::I64, ScalarKind::F64], + ); + } + + #[test] + fn param_space_is_flat_path_qualified_and_slot_disambiguated() { + use aura_std::{LinComb, Sma, Sub}; + // 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![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]], + OutPort { node: 2, field: 0 }, + ); + // 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![], + vec![vec![Target { node: 0, slot: 0 }]], + OutPort { node: 0, field: 0 }, + ); + let bp = Blueprint::new(vec![BlueprintNode::Composite(strategy)], vec![], vec![]); + + let space = bp.param_space(); + let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); + assert_eq!( + names, + [ + "strategy.fast_slow.length", // slot 0 — Sma(2) + "strategy.fast_slow.length", // slot 1 — Sma(4): same name, distinct slot + "strategy.weights[0]", // slot 2 — LinComb weight 0 + "strategy.weights[1]", // slot 3 — LinComb weight 1 + ] + ); + assert_eq!(space[0].kind, ScalarKind::I64); + assert_eq!(space[2].kind, ScalarKind::F64); + } + + #[test] + fn top_level_leaf_params_are_unqualified() { + use aura_std::Sma; + let bp = Blueprint::new(vec![Sma::new(3).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 + } + + #[test] + 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![], + vec![], + ); + assert_eq!(bp.param_space(), bp.param_space()); // pure structural function (C1) + } + + #[test] + 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![]); + 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-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index 2787875..51ace74 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -361,6 +361,7 @@ mod tests { InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, ], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -387,6 +388,7 @@ mod tests { InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, ], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -410,6 +412,7 @@ mod tests { InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, ], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -448,6 +451,7 @@ mod tests { FieldSpec { name: "close", kind: ScalarKind::F64 }, FieldSpec { name: "volume", kind: ScalarKind::F64 }, ], + params: vec![], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -477,6 +481,7 @@ mod tests { FieldSpec { name: "f", kind: ScalarKind::F64 }, FieldSpec { name: "i", kind: ScalarKind::I64 }, ], + params: vec![], } } fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { @@ -498,6 +503,7 @@ mod tests { NodeSchema { inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 45b9232..8702225 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -41,7 +41,7 @@ pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport}; // #29: re-export the core scalar vocabulary a Blueprint builder needs // (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar / // Firing / Timestamp) so a graph builder has one import surface, not two. -pub use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +pub use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; #[cfg(test)] mod reexport_tests { diff --git a/crates/aura-std/src/add.rs b/crates/aura-std/src/add.rs index 209529f..66d94d2 100644 --- a/crates/aura-std/src/add.rs +++ b/crates/aura-std/src/add.rs @@ -41,6 +41,7 @@ impl Node for Add { InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, ], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![], } } diff --git a/crates/aura-std/src/exposure.rs b/crates/aura-std/src/exposure.rs index 90a703e..6203046 100644 --- a/crates/aura-std/src/exposure.rs +++ b/crates/aura-std/src/exposure.rs @@ -3,7 +3,7 @@ //! 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, Scalar, ScalarKind}; +use aura_core::{Ctx, FieldSpec, Firing, InputSpec, 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). @@ -25,6 +25,7 @@ impl Node for Exposure { 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 }], } } diff --git a/crates/aura-std/src/lincomb.rs b/crates/aura-std/src/lincomb.rs index 4bcef65..5b444c9 100644 --- a/crates/aura-std/src/lincomb.rs +++ b/crates/aura-std/src/lincomb.rs @@ -2,12 +2,11 @@ //! general combinator for the north-star "combine signals with weights" move //! (C10). `LinComb([1.0, 1.0])` is `Add`; `LinComb([1.0, -1.0])` is `Sub`. The //! weights are construction parameters that configure the node and fix its -//! arity (`weights.len()` inputs) — the natural home for the combination tuning -//! params, though the schema-level tunable-param surface (C12/C19) is not yet -//! implemented: the node contract deliberately keeps params out of the schema -//! for now (see aura_core's node contract and spec 0002's "Out of scope"). +//! arity (`weights.len()` inputs); they are also the combination's tunable +//! 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, Scalar, ScalarKind}; +use aura_core::{Ctx, FieldSpec, Firing, InputSpec, 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 @@ -48,6 +47,9 @@ impl Node for LinComb { .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(), } } diff --git a/crates/aura-std/src/recorder.rs b/crates/aura-std/src/recorder.rs index db03632..7deb6f1 100644 --- a/crates/aura-std/src/recorder.rs +++ b/crates/aura-std/src/recorder.rs @@ -37,6 +37,7 @@ impl Node for Recorder { .map(|&kind| InputSpec { kind, lookback: 1, firing: self.firing }) .collect(), output: vec![], + params: vec![], } } diff --git a/crates/aura-std/src/sim_broker.rs b/crates/aura-std/src/sim_broker.rs index 7090b36..e59de8c 100644 --- a/crates/aura-std/src/sim_broker.rs +++ b/crates/aura-std/src/sim_broker.rs @@ -64,6 +64,7 @@ impl Node for SimBroker { InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, // 1 price ], output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }], + params: vec![], } } diff --git a/crates/aura-std/src/sma.rs b/crates/aura-std/src/sma.rs index 9377b87..f8032ef 100644 --- a/crates/aura-std/src/sma.rs +++ b/crates/aura-std/src/sma.rs @@ -3,7 +3,7 @@ //! `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, Scalar, ScalarKind}; +use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec, Scalar, ScalarKind}; /// Simple moving average over the last `length` values of one f64 input. pub struct Sma { @@ -28,6 +28,7 @@ impl Node for Sma { firing: Firing::Any, }], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], } } @@ -109,4 +110,31 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); assert_eq!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).label(), "Recorder"); } + + #[test] + fn nodes_declare_expected_params() { + use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub}; + use aura_core::{Firing, ParamSpec, ScalarKind}; + // single scalar knobs + assert_eq!( + Sma::new(3).schema().params, + vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + ); + assert_eq!( + Exposure::new(0.5).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; + 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()); + let (tx, _rx) = std::sync::mpsc::channel(); + assert!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).schema().params.is_empty()); + } } diff --git a/crates/aura-std/src/sub.rs b/crates/aura-std/src/sub.rs index c130859..9fd9f4b 100644 --- a/crates/aura-std/src/sub.rs +++ b/crates/aura-std/src/sub.rs @@ -32,6 +32,7 @@ impl Node for Sub { InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, ], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![], } }