feat(aura-core,aura-std,aura-engine): declare node tunable params (#30)

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
This commit is contained in:
2026-06-07 15:29:58 +02:00
parent 6911fa52ed
commit 931109df58
12 changed files with 240 additions and 14 deletions
+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};
pub use node::{FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec};
pub use scalar::{Scalar, ScalarKind, Timestamp};
+32 -3
View File
@@ -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<InputSpec>,
pub output: Vec<FieldSpec>,
pub params: Vec<ParamSpec>,
}
/// 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);
}
}
+158 -2
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, 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<ParamSpec> {
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<ParamSpec>) {
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<ParamSpec> =
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::<Vec<_>>(),
from_compilat.iter().map(|p| p.kind).collect::<Vec<_>>(),
"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::<Vec<_>>(),
["sma_cross.length", "sma_cross.length", "scale"],
);
assert_eq!(
space.iter().map(|p| p.kind).collect::<Vec<_>>(),
[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());
}
}
+6
View File
@@ -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]> {
+1 -1
View File
@@ -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 {
+1
View File
@@ -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![],
}
}
+2 -1
View File
@@ -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 }],
}
}
+7 -5
View File
@@ -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(),
}
}
+1
View File
@@ -37,6 +37,7 @@ impl Node for Recorder {
.map(|&kind| InputSpec { kind, lookback: 1, firing: self.firing })
.collect(),
output: vec![],
params: vec![],
}
}
+1
View File
@@ -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![],
}
}
+29 -1
View File
@@ -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());
}
}
+1
View File
@@ -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![],
}
}