feat(aura-core,aura-std,aura-engine,aura-cli): inject a param-set vector at bootstrap (#31)
Cycle B of milestone "The World — parameter-space & sweep". A blueprint is now value-empty: a leaf holds a param-generic recipe, not a built node, and a positional Scalar vector is bound slot-by-slot at bootstrap — so one blueprint bootstraps into many distinct instances under different vectors, with no cdylib rebuild (C12/C19). This is the binding primitive a sweep (#32) drives. Ratified design (brainstorm): value-empty reconstruct-through-new() over mutate-in-place and over a default-bearing variant. The value lives only in the injected vector (no baked default), keeping the blueprint a pure param-generic recipe (C19); every injected value flows through the node's own constructor (the single sizing/validation gate). - aura-core: LeafFactory { name, params, build } — the recipe (params -> sized node through `new`); Scalar::as_i64/as_f64 value accessors. Node trait unchanged. - aura-std: each of the 7 nodes exposes factory() (SMA length:I64, Exposure scale:F64, LinComb arity x weights[i]:F64; Sub/Add/SimBroker/Recorder paramless, capturing their non-param construction args — pip_size, the Recorder channel). - aura-engine: BlueprintNode::Leaf(LeafFactory), From<LeafFactory>; param_space() reads factory.params() pre-build; compile_with_params/bootstrap_with_params build each leaf from its kind-checked slice while lowering (build-then-wire), arity checked up front via param_space().len(); CompileError::{ParamKindMismatch, ParamArity}. compile/inline/edge-rewrite are structurally unchanged, so the compilat stays bit-identical for a given point (the bit-identity and both param_space mirror tests stay green). The vestigial pre-build Composite::schema / BlueprintNode::schema (no live caller — interface resolution is structural on the built flat nodes) are removed. - aura-cli: the blueprint view labels leaves by bare type via LeafFactory::label() (`[SMA]`) — the ascii-dag renderer cannot render wide cluster-sibling labels (see spec); the compiled view still labels valued (`SMA(2)`). The sample supplies its point as a vector; the mis-wiring swap moved to the compiled view. The #34 dual-traversal drift hazard is subsumed: compile_with_params consumes the vector in the same recipe walk param_space() reports, so the two share one traversal. Verified: cargo build/test --workspace green (127 tests, incl. bit-identity, both mirror tests, and 4 new injection tests — different-vector-different-run, kind mismatch, arity, determinism); clippy --workspace --all-targets -D warnings clean; `aura graph` blueprint view renders cleanly. Scope: one vector -> one instance. Deferred: sweep enumeration (#32), domain validation (#32/C20), single-run authoring convenience (#35). closes #31
This commit is contained in:
@@ -57,18 +57,18 @@ pub fn render_blueprint(bp: &Blueprint) -> String {
|
||||
// interior edges (the interior ids are in hand here).
|
||||
for item in bp.nodes() {
|
||||
match item {
|
||||
BlueprintNode::Leaf(node) => {
|
||||
BlueprintNode::Leaf(factory) => {
|
||||
let id = labels.len();
|
||||
labels.push(node.label());
|
||||
labels.push(factory.label());
|
||||
item_display.push(ItemDisplay::Leaf(id));
|
||||
}
|
||||
BlueprintNode::Composite(c) => {
|
||||
let mut interior_ids = Vec::with_capacity(c.nodes().len());
|
||||
for inner in c.nodes() {
|
||||
match inner {
|
||||
BlueprintNode::Leaf(node) => {
|
||||
BlueprintNode::Leaf(factory) => {
|
||||
let id = labels.len();
|
||||
labels.push(node.label());
|
||||
labels.push(factory.label());
|
||||
interior_ids.push(id);
|
||||
}
|
||||
BlueprintNode::Composite(_) => unimplemented!(
|
||||
|
||||
+70
-50
@@ -116,11 +116,12 @@ fn run_sample() -> RunReport {
|
||||
|
||||
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
|
||||
/// CLI-local sample builder; the engine ships no sample (the duplication with
|
||||
/// `blueprint.rs`'s test helper is the dedup tracked in #14).
|
||||
fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite {
|
||||
/// `blueprint.rs`'s test helper is the dedup tracked in #14). Value-empty: the SMA
|
||||
/// lengths are injected at compile, not baked here.
|
||||
fn sma_cross(name: &str) -> Composite {
|
||||
Composite::new(
|
||||
name,
|
||||
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
|
||||
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
@@ -130,19 +131,20 @@ fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite {
|
||||
)
|
||||
}
|
||||
|
||||
/// The sample signal-quality blueprint, parameterized by the SMA windows so a
|
||||
/// test can author a deliberately swapped variant. Recorders need a channel to
|
||||
/// construct; the receivers are dropped because the render never runs the graph.
|
||||
fn build_sample(fast: usize, slow: usize) -> Blueprint {
|
||||
/// The sample signal-quality blueprint (value-empty): a recipe whose SMA lengths +
|
||||
/// exposure scale are injected at compile via the point vector (see
|
||||
/// `sample_point`). Recorders need a channel to construct; the receivers are
|
||||
/// dropped because the render never runs the graph.
|
||||
fn build_sample() -> Blueprint {
|
||||
let (tx_eq, _rx_eq) = mpsc::channel();
|
||||
let (tx_ex, _rx_ex) = mpsc::channel();
|
||||
Blueprint::new(
|
||||
vec![
|
||||
BlueprintNode::Composite(sma_cross("sma_cross", fast, slow)),
|
||||
Exposure::new(0.5).into(),
|
||||
SimBroker::new(0.0001).into(),
|
||||
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(),
|
||||
BlueprintNode::Composite(sma_cross("sma_cross")),
|
||||
Exposure::factory().into(),
|
||||
SimBroker::factory(0.0001).into(),
|
||||
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
||||
],
|
||||
vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
@@ -162,7 +164,13 @@ fn build_sample(fast: usize, slow: usize) -> Blueprint {
|
||||
|
||||
/// The built-in sample rendered by `aura graph`.
|
||||
fn sample_blueprint() -> Blueprint {
|
||||
build_sample(2, 4)
|
||||
build_sample()
|
||||
}
|
||||
|
||||
/// The point vector injected into the sample blueprint, in `param_space()` slot
|
||||
/// order: `[fast SMA length, slow SMA length, exposure scale]`.
|
||||
fn sample_point() -> Vec<Scalar> {
|
||||
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -177,7 +185,8 @@ fn main() {
|
||||
let compiled = args.next().as_deref() == Some("--compiled");
|
||||
let bp = sample_blueprint();
|
||||
let out = if compiled {
|
||||
let (nodes, sources, edges) = bp.compile().expect("valid sample blueprint");
|
||||
let (nodes, sources, edges) =
|
||||
bp.compile_with_params(&sample_point()).expect("valid sample blueprint");
|
||||
graph::render_compilat(&nodes, &sources, &edges)
|
||||
} else {
|
||||
graph::render_blueprint(&bp)
|
||||
@@ -196,21 +205,23 @@ fn main() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sample authored with fast/slow SMA windows swapped — the mis-wiring
|
||||
/// the render must surface. Test-only: nothing outside tests builds it.
|
||||
fn sample_blueprint_swapped() -> Blueprint {
|
||||
build_sample(4, 2)
|
||||
/// The sample's point vector with the fast/slow SMA lengths swapped — the
|
||||
/// mis-wiring the compiled render must surface. The blueprint is param-generic
|
||||
/// and identical for both orderings; only the injected vector differs.
|
||||
fn swapped_point() -> Vec<Scalar> {
|
||||
vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_view_shows_cluster_and_param_labels() {
|
||||
fn blueprint_view_shows_cluster_and_param_generic_labels() {
|
||||
let out = graph::render_blueprint(&sample_blueprint());
|
||||
// the composite renders as a named cluster box
|
||||
assert!(out.contains("sma_cross"), "missing composite name:\n{out}");
|
||||
// param-carrying labels disambiguate the two SMAs
|
||||
assert!(out.contains("SMA(2)"), "missing SMA(2):\n{out}");
|
||||
assert!(out.contains("SMA(4)"), "missing SMA(4):\n{out}");
|
||||
for needle in ["Sub", "Exposure(0.5)", "SimBroker(0.0001)", "Recorder"] {
|
||||
// the value-empty blueprint view labels leaves param-generically by bare
|
||||
// type (no values, and no knob suffix — the ascii-dag layout cannot render
|
||||
// wide cluster-sibling labels); both SMAs render identically as [SMA]
|
||||
assert!(out.contains("[SMA]"), "missing [SMA]:\n{out}");
|
||||
for needle in ["Sub", "Exposure", "SimBroker", "Recorder"] {
|
||||
assert!(out.contains(needle), "missing {needle}:\n{out}");
|
||||
}
|
||||
}
|
||||
@@ -218,7 +229,8 @@ mod tests {
|
||||
#[test]
|
||||
fn compiled_view_dissolves_the_composite_boundary() {
|
||||
let bp = sample_blueprint();
|
||||
let (nodes, sources, edges) = bp.compile().expect("valid sample");
|
||||
let (nodes, sources, edges) =
|
||||
bp.compile_with_params(&sample_point()).expect("valid sample");
|
||||
let out = graph::render_compilat(&nodes, &sources, &edges);
|
||||
// node labels survive inlining...
|
||||
assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
|
||||
@@ -227,11 +239,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapped_sma_inputs_render_differently() {
|
||||
fn swapped_param_vector_changes_the_compiled_render() {
|
||||
// the property the cycle exists to buy: a mis-wiring is no longer invisible.
|
||||
let correct = graph::render_blueprint(&sample_blueprint());
|
||||
let swapped = graph::render_blueprint(&sample_blueprint_swapped());
|
||||
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the render");
|
||||
// The blueprint is now param-generic (identical for both orderings), so the
|
||||
// swap is observable only after the vector is injected — in the COMPILED
|
||||
// view, where the flat nodes carry their valued labels (SMA(2)/SMA(4)).
|
||||
let (cn, cs, ce) =
|
||||
sample_blueprint().compile_with_params(&sample_point()).expect("valid sample");
|
||||
let correct = graph::render_compilat(&cn, &cs, &ce);
|
||||
let (sn, ss, se) =
|
||||
sample_blueprint().compile_with_params(&swapped_point()).expect("valid sample");
|
||||
let swapped = graph::render_compilat(&sn, &ss, &se);
|
||||
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the compiled render");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -239,27 +258,27 @@ mod tests {
|
||||
let out = graph::render_blueprint(&sample_blueprint());
|
||||
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
|
||||
// exact bytes `aura graph` emits (render() ends in three newlines).
|
||||
let expected = r#" [source:F64]
|
||||
┌─────────└┐────────┐
|
||||
│ │ │
|
||||
╔═════╪══════════╪════╗ │
|
||||
║ sma_cross │ ║ │
|
||||
║ ↓ ↓ ║ │
|
||||
║ [SMA(2)] [SMA(4)] ║ │
|
||||
║ └──┌───────┘ ║ │
|
||||
║ ↓ ┌─╫───┘
|
||||
║ [Sub] │ ║
|
||||
║ │ │ ║
|
||||
╚════════╪══════════╪═╝
|
||||
│ │
|
||||
↓ │
|
||||
[Exposure(0.5)] │
|
||||
┌───────┘────────┐ │
|
||||
↓ ↓─┘
|
||||
[Recorder] [SimBroker(0.0001)]
|
||||
│
|
||||
↓
|
||||
[Recorder]
|
||||
let expected = r#" [source:F64]
|
||||
┌───────└───────┐
|
||||
│ │ │
|
||||
╔═══╪═══════╪═══╗ │
|
||||
║ sma_cross │ ║ │
|
||||
║ ↓ ↓ ║ │
|
||||
║ [SMA] [SMA] ║ │
|
||||
║ └────┌──┘ ║ │
|
||||
║ ↓ ║┌──┘
|
||||
║ [Sub] ║│
|
||||
║ │ ║│
|
||||
╚════════╪══════╝│
|
||||
│ │
|
||||
↓ │
|
||||
[Exposure] │
|
||||
┌───┘───────┼┐
|
||||
↓ └↓
|
||||
[Recorder] [SimBroker]
|
||||
│
|
||||
↓
|
||||
[Recorder]
|
||||
|
||||
|
||||
"#;
|
||||
@@ -269,7 +288,8 @@ mod tests {
|
||||
#[test]
|
||||
fn compiled_view_golden() {
|
||||
let bp = sample_blueprint();
|
||||
let (nodes, sources, edges) = bp.compile().expect("valid sample");
|
||||
let (nodes, sources, edges) =
|
||||
bp.compile_with_params(&sample_point()).expect("valid sample");
|
||||
let out = graph::render_compilat(&nodes, &sources, &edges);
|
||||
let expected = r#" [source:F64]
|
||||
┌────────└─┐──────┐
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -57,6 +57,53 @@ pub struct ParamSpec {
|
||||
pub kind: ScalarKind,
|
||||
}
|
||||
|
||||
/// A param-generic blueprint leaf (C19): a node's declared tunable params plus a
|
||||
/// closure that builds a sized instance through the node's own constructor (the
|
||||
/// single sizing/validation gate). A blueprint holds these recipes, never built
|
||||
/// instances, so it stays value-empty until a param-set is injected (C19/C23).
|
||||
pub struct LeafFactory {
|
||||
name: &'static str,
|
||||
params: Vec<ParamSpec>,
|
||||
// The build closure's type is exactly the recipe contract (a param slice in, a
|
||||
// boxed node out); a type alias would not clarify it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
|
||||
}
|
||||
|
||||
impl LeafFactory {
|
||||
/// `name` is the param-generic render label (the node type, e.g. `"SMA"`);
|
||||
/// `params` the declared knobs; `build` constructs a sized node from a
|
||||
/// kind-checked param slice.
|
||||
pub fn new(
|
||||
name: &'static str,
|
||||
params: Vec<ParamSpec>,
|
||||
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
|
||||
) -> Self {
|
||||
Self { name, params, build: Box::new(build) }
|
||||
}
|
||||
/// The declared tunable params (read by `Blueprint::param_space`, pre-build).
|
||||
pub fn params(&self) -> &[ParamSpec] {
|
||||
&self.params
|
||||
}
|
||||
/// Build a sized node from its param slice (the slice is kind-checked by the
|
||||
/// caller before this runs).
|
||||
pub fn build(&self, params: &[Scalar]) -> Box<dyn Node> {
|
||||
(self.build)(params)
|
||||
}
|
||||
/// The param-generic render label for the blueprint view (C22 "structure
|
||||
/// before"): just the node type, e.g. `SMA`. A value-empty recipe has no
|
||||
/// values to show; the tunable knobs are surfaced by `Blueprint::param_space`,
|
||||
/// not in the graph. The label stays the bare type because the `ascii-dag`
|
||||
/// renderer writes a label verbatim on one line (no wrapping) and overlaps two
|
||||
/// wide sibling boxes inside a cluster subgraph — appending the knob names
|
||||
/// (`SMA(length)`) would garble the blueprint view, and domain labels grow
|
||||
/// unboundedly wide. The compiled view labels the built node valued (`SMA(2)`)
|
||||
/// via `Node::label`, unaffected.
|
||||
pub fn label(&self) -> String {
|
||||
self.name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||
/// ordered list of named base columns; length 1 is a scalar (the degenerate
|
||||
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
|
||||
@@ -99,6 +146,17 @@ pub trait Node {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal node with an empty schema, reused across the label / factory tests.
|
||||
struct Bare;
|
||||
impl Node for Bare {
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] }
|
||||
}
|
||||
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_spec_carries_firing() {
|
||||
let a = InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any };
|
||||
@@ -111,18 +169,29 @@ mod tests {
|
||||
#[test]
|
||||
fn default_label_is_placeholder() {
|
||||
// A node that does not override label() falls back to the placeholder.
|
||||
struct Bare;
|
||||
impl Node for Bare {
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] }
|
||||
}
|
||||
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
assert_eq!(Bare.label(), "node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_factory_label_is_the_bare_type() {
|
||||
// param-generic: the label is just the node type, no knob suffix (the
|
||||
// ascii-dag blueprint view cannot render wide cluster-sibling labels).
|
||||
let with = LeafFactory::new(
|
||||
"SMA",
|
||||
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(with.label(), "SMA");
|
||||
let none = LeafFactory::new("Sub", vec![], |_| Box::new(Bare));
|
||||
assert_eq!(none.label(), "Sub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_factory_build_runs_the_closure() {
|
||||
let f = LeafFactory::new("Bare", vec![], |_| Box::new(Bare));
|
||||
assert_eq!(f.build(&[]).schema().params, Vec::<ParamSpec>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_carries_declared_params() {
|
||||
let s = NodeSchema {
|
||||
|
||||
@@ -35,6 +35,14 @@ impl Scalar {
|
||||
Scalar::Ts(_) => ScalarKind::Timestamp,
|
||||
}
|
||||
}
|
||||
/// The `i64` payload, or `None` if this scalar is not an `I64`.
|
||||
pub fn as_i64(self) -> Option<i64> {
|
||||
if let Scalar::I64(v) = self { Some(v) } else { None }
|
||||
}
|
||||
/// The `f64` payload, or `None` if this scalar is not an `F64`.
|
||||
pub fn as_f64(self) -> Option<f64> {
|
||||
if let Scalar::F64(v) = self { Some(v) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for Scalar {
|
||||
@@ -84,6 +92,14 @@ mod tests {
|
||||
assert_eq!(Timestamp::default(), Timestamp(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_value_accessors_are_kind_exact() {
|
||||
assert_eq!(Scalar::I64(3).as_i64(), Some(3));
|
||||
assert_eq!(Scalar::I64(3).as_f64(), None);
|
||||
assert_eq!(Scalar::F64(0.5).as_f64(), Some(0.5));
|
||||
assert_eq!(Scalar::F64(0.5).as_i64(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_is_copy() {
|
||||
let s = Scalar::F64(1.0);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! adds no optimisation pass (CSE/DCE, sweep-invariant hoisting are deferred,
|
||||
//! C23) and no external dependency (C16).
|
||||
|
||||
use aura_core::{Node, NodeSchema, ParamSpec, ScalarKind};
|
||||
use aura_core::{LeafFactory, Node, ParamSpec, Scalar, ScalarKind};
|
||||
|
||||
use crate::harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
|
||||
|
||||
@@ -25,25 +25,14 @@ pub struct OutPort {
|
||||
/// A blueprint item: a leaf node or a nested composite. Both present a declared
|
||||
/// interface (typed inputs + one output) to the enclosing graph.
|
||||
pub enum BlueprintNode {
|
||||
Leaf(Box<dyn Node>),
|
||||
Leaf(LeafFactory),
|
||||
Composite(Composite),
|
||||
}
|
||||
|
||||
/// Ergonomic lift: any concrete `Node` becomes a `Leaf` blueprint item.
|
||||
impl<N: Node + 'static> From<N> for BlueprintNode {
|
||||
fn from(node: N) -> Self {
|
||||
BlueprintNode::Leaf(Box::new(node))
|
||||
}
|
||||
}
|
||||
|
||||
impl BlueprintNode {
|
||||
/// The declared interface this item presents to the enclosing graph: a leaf's
|
||||
/// own `Node::schema`, or a composite's derived [`Composite::schema`].
|
||||
fn schema(&self) -> NodeSchema {
|
||||
match self {
|
||||
BlueprintNode::Leaf(node) => node.schema(),
|
||||
BlueprintNode::Composite(c) => c.schema(),
|
||||
}
|
||||
/// Ergonomic lift: a param-generic leaf recipe becomes a `Leaf` blueprint item.
|
||||
impl From<LeafFactory> for BlueprintNode {
|
||||
fn from(factory: LeafFactory) -> Self {
|
||||
BlueprintNode::Leaf(factory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,24 +83,6 @@ impl Composite {
|
||||
pub fn output(&self) -> OutPort {
|
||||
self.output
|
||||
}
|
||||
|
||||
/// The derived interface the enclosing graph wires against: input role `r`'s
|
||||
/// spec is taken from its first interior target's slot; the output field is the
|
||||
/// interior output port's field. This is a *derivation*, not a `Node` impl, and
|
||||
/// it assumes well-formed indices — `compile` is the validator that rejects a
|
||||
/// malformed composite with a typed [`CompileError`].
|
||||
pub fn schema(&self) -> NodeSchema {
|
||||
let inputs = self
|
||||
.input_roles
|
||||
.iter()
|
||||
.map(|role| {
|
||||
let first = role[0];
|
||||
self.nodes[first.node].schema().inputs[first.slot]
|
||||
})
|
||||
.collect();
|
||||
let out_field = self.nodes[self.output.node].schema().output[self.output.field];
|
||||
NodeSchema { inputs, output: vec![out_field], params: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
/// A construction-phase fault, caught before the flat compilat reaches
|
||||
@@ -127,6 +98,11 @@ pub enum CompileError {
|
||||
/// The lowered flat compilat failed `Harness::bootstrap`'s checks (kind
|
||||
/// mismatch, bad index, or directed cycle).
|
||||
Bootstrap(BootstrapError),
|
||||
/// An injected param value's scalar kind does not match the slot's declared
|
||||
/// kind. `slot` is the flat param-space index.
|
||||
ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
|
||||
/// The injected vector's length does not equal the sum of declared params.
|
||||
ParamArity { expected: usize, got: usize },
|
||||
}
|
||||
|
||||
/// The root graph-as-data, before compilation: blueprint items + sources + edges,
|
||||
@@ -169,19 +145,29 @@ impl Blueprint {
|
||||
out
|
||||
}
|
||||
|
||||
/// Lower to the flat compilat: inline every composite (recursive), offset
|
||||
/// interior indices, rewrite edges, and fan input roles out. The run loop and
|
||||
/// `bootstrap`'s data model are unchanged; the lowered compilat is wired by raw
|
||||
/// index (C23).
|
||||
/// Compile the value-empty recipe under an injected param vector: build each
|
||||
/// leaf from its kind-checked slice while lowering, then rewrite edges/sources
|
||||
/// exactly as before (structure is param-invariant, C19/C23). The vector is
|
||||
/// total and positional — one value per `param_space()` slot, in slot order.
|
||||
// The flat triple is exactly `Harness::bootstrap`'s argument list; naming it
|
||||
// would be a speculative type alias this cycle (same call as the CLI's sample).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn compile(self) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
|
||||
pub fn compile_with_params(
|
||||
self,
|
||||
params: &[Scalar],
|
||||
) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
|
||||
let expected = self.param_space().len();
|
||||
if params.len() != expected {
|
||||
return Err(CompileError::ParamArity { expected, got: params.len() });
|
||||
}
|
||||
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
|
||||
let mut flat_edges: Vec<Edge> = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
|
||||
// lower every top-level item (recursively inlining composites)
|
||||
let lowerings = lower_items(self.nodes, &mut flat_nodes, &mut flat_edges)?;
|
||||
// lower every top-level item (recursively inlining composites), building
|
||||
// each leaf from its kind-checked param slice as it lowers
|
||||
let lowerings =
|
||||
lower_items(self.nodes, params, &mut cursor, &mut flat_nodes, &mut flat_edges)?;
|
||||
|
||||
// rewrite top-level edges through the lowerings (fan-out into composites)
|
||||
for e in &self.edges {
|
||||
@@ -203,11 +189,24 @@ impl Blueprint {
|
||||
Ok((flat_nodes, flat_sources, flat_edges))
|
||||
}
|
||||
|
||||
/// Compile, then hand the flat compilat to the unchanged `Harness::bootstrap`.
|
||||
pub fn bootstrap(self) -> Result<Harness, CompileError> {
|
||||
let (nodes, sources, edges) = self.compile()?;
|
||||
/// No-param compile (a blueprint that declares no params); errors `ParamArity`
|
||||
/// if any param is declared.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn compile(self) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
|
||||
self.compile_with_params(&[])
|
||||
}
|
||||
|
||||
/// Compile under an injected vector, then hand the flat compilat to the
|
||||
/// unchanged `Harness::bootstrap`.
|
||||
pub fn bootstrap_with_params(self, params: Vec<Scalar>) -> Result<Harness, CompileError> {
|
||||
let (nodes, sources, edges) = self.compile_with_params(¶ms)?;
|
||||
Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap)
|
||||
}
|
||||
|
||||
/// No-param bootstrap (paramless blueprint).
|
||||
pub fn bootstrap(self) -> Result<Harness, CompileError> {
|
||||
self.bootstrap_with_params(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
|
||||
@@ -217,10 +216,10 @@ impl Blueprint {
|
||||
fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
|
||||
for item in items {
|
||||
match item {
|
||||
BlueprintNode::Leaf(node) => {
|
||||
for p in node.schema().params {
|
||||
BlueprintNode::Leaf(factory) => {
|
||||
for p in factory.params() {
|
||||
let name = if prefix.is_empty() {
|
||||
p.name
|
||||
p.name.clone()
|
||||
} else {
|
||||
format!("{prefix}.{}", p.name)
|
||||
};
|
||||
@@ -254,19 +253,34 @@ enum ItemLowering {
|
||||
/// input item, in order.
|
||||
fn lower_items(
|
||||
items: Vec<BlueprintNode>,
|
||||
params: &[Scalar],
|
||||
cursor: &mut usize,
|
||||
flat_nodes: &mut Vec<Box<dyn Node>>,
|
||||
flat_edges: &mut Vec<Edge>,
|
||||
) -> Result<Vec<ItemLowering>, CompileError> {
|
||||
let mut lowerings = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
BlueprintNode::Leaf(node) => {
|
||||
BlueprintNode::Leaf(factory) => {
|
||||
let n = factory.params().len();
|
||||
let slice = ¶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<Box<dyn Node>>,
|
||||
flat_edges: &mut Vec<Edge>,
|
||||
) -> Result<ItemLowering, CompileError> {
|
||||
@@ -292,7 +308,7 @@ fn inline_composite(
|
||||
}
|
||||
|
||||
// recursively lower interior items, then rewrite interior edges through them
|
||||
let interior = lower_items(nodes, flat_nodes, flat_edges)?;
|
||||
let interior = lower_items(nodes, params, cursor, flat_nodes, flat_edges)?;
|
||||
for e in &edges {
|
||||
for fe in rewrite_edge(e, &interior, flat_nodes)? {
|
||||
flat_edges.push(fe);
|
||||
@@ -400,7 +416,7 @@ fn slot_kind(t: Target, flat_nodes: &[Box<dyn Node>]) -> Result<ScalarKind, Comp
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Scalar, Timestamp};
|
||||
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, NodeSchema, Timestamp};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
|
||||
@@ -431,27 +447,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_schema_derives_role_and_output_kinds() {
|
||||
// one interior node (Join2: 2 f64 inputs, 1 f64 output); two roles, each
|
||||
// feeding one interior slot; output port = the Join2 output field 0.
|
||||
let c = Composite::new(
|
||||
"c",
|
||||
vec![BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))],
|
||||
vec![],
|
||||
vec![
|
||||
vec![Target { node: 0, slot: 0 }],
|
||||
vec![Target { node: 0, slot: 1 }],
|
||||
],
|
||||
OutPort { node: 0, field: 0 },
|
||||
);
|
||||
let schema = c.schema();
|
||||
assert_eq!(schema.inputs.len(), 2);
|
||||
assert_eq!(schema.inputs[0].kind, ScalarKind::F64);
|
||||
assert_eq!(schema.inputs[1].kind, ScalarKind::F64);
|
||||
assert_eq!(schema.output, vec![FieldSpec { name: "v", kind: ScalarKind::F64 }]);
|
||||
}
|
||||
|
||||
/// A 1-input f64 node, one f64 output. Test-local fixture.
|
||||
struct Pass1 {
|
||||
out: [Scalar; 1],
|
||||
@@ -506,10 +501,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn pass1() -> BlueprintNode {
|
||||
BlueprintNode::Leaf(Box::new(Pass1 { out: [Scalar::F64(0.0)] }))
|
||||
BlueprintNode::Leaf(LeafFactory::new("Pass1", vec![], |_| {
|
||||
Box::new(Pass1 { out: [Scalar::F64(0.0)] })
|
||||
}))
|
||||
}
|
||||
fn join2() -> BlueprintNode {
|
||||
BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))
|
||||
BlueprintNode::Leaf(LeafFactory::new("Join2", vec![], |_| {
|
||||
Box::new(Join2 { out: [Scalar::F64(0.0)] })
|
||||
}))
|
||||
}
|
||||
fn sink_f64() -> BlueprintNode {
|
||||
BlueprintNode::Leaf(LeafFactory::new("SinkF64", vec![], |_| Box::new(SinkF64)))
|
||||
}
|
||||
fn sink_i64() -> BlueprintNode {
|
||||
BlueprintNode::Leaf(LeafFactory::new("SinkI64", vec![], |_| Box::new(SinkI64)))
|
||||
}
|
||||
|
||||
/// A composite: two Pass1 leaves feeding a Join2, role 0 fanning the source
|
||||
@@ -532,7 +537,7 @@ mod tests {
|
||||
fn single_composite_inlines_with_offset_fan_and_output() {
|
||||
// composite as item 0; a source into its role 0; an edge out of it to a sink.
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(fan_composite()), BlueprintNode::Leaf(Box::new(SinkF64))],
|
||||
vec![BlueprintNode::Composite(fan_composite()), sink_f64()],
|
||||
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
||||
);
|
||||
@@ -573,7 +578,7 @@ mod tests {
|
||||
OutPort { node: 0, field: 0 },
|
||||
);
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(outer), BlueprintNode::Leaf(Box::new(SinkF64))],
|
||||
vec![BlueprintNode::Composite(outer), sink_f64()],
|
||||
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
||||
);
|
||||
@@ -615,7 +620,7 @@ mod tests {
|
||||
// role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch
|
||||
let c = Composite::new(
|
||||
"c",
|
||||
vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))],
|
||||
vec![pass1(), sink_i64()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
@@ -645,7 +650,7 @@ mod tests {
|
||||
// a top-level kind mismatch: a Pass1 f64 output wired into a SinkI64 i64
|
||||
// input. compile() lowers it faithfully; bootstrap's kind-check rejects it.
|
||||
let bp = Blueprint::new(
|
||||
vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))],
|
||||
vec![pass1(), sink_i64()],
|
||||
vec![],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
||||
);
|
||||
@@ -718,10 +723,11 @@ mod tests {
|
||||
|
||||
/// The SMA-cross signal as a reusable composite: one input role (price), one
|
||||
/// output (the fast-minus-slow spread). Interior wired with raw local indices.
|
||||
fn sma_cross(fast: usize, slow: usize) -> Composite {
|
||||
/// Value-empty: the two SMA lengths are injected at compile, not baked here.
|
||||
fn sma_cross() -> Composite {
|
||||
Composite::new(
|
||||
"sma_cross",
|
||||
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
|
||||
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
@@ -742,11 +748,11 @@ mod tests {
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let bp = Blueprint::new(
|
||||
vec![
|
||||
BlueprintNode::Composite(sma_cross(2, 4)),
|
||||
Exposure::new(0.5).into(),
|
||||
SimBroker::new(0.0001).into(),
|
||||
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(),
|
||||
BlueprintNode::Composite(sma_cross()),
|
||||
Exposure::factory().into(),
|
||||
SimBroker::factory(0.0001).into(),
|
||||
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
||||
],
|
||||
vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
@@ -775,7 +781,9 @@ mod tests {
|
||||
|
||||
// (b) the same graph authored as a composite blueprint, compiled
|
||||
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
|
||||
let mut composed = bp.bootstrap().expect("composite blueprint compiles");
|
||||
let mut composed = bp
|
||||
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
|
||||
.expect("composite blueprint compiles");
|
||||
composed.run(vec![prices]);
|
||||
|
||||
let flat_eq_v = flat_eq.try_iter().collect::<Vec<_>>();
|
||||
@@ -791,6 +799,64 @@ mod tests {
|
||||
assert!(!comp_ex_v.is_empty(), "exposure trace must be populated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injecting_a_different_vector_changes_the_run() {
|
||||
let prices = synthetic_prices();
|
||||
let (bp, eq, _ex) = composite_sma_cross_harness();
|
||||
let mut a = bp.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
|
||||
.expect("compiles");
|
||||
a.run(vec![prices.clone()]);
|
||||
let a_eq = eq.try_iter().collect::<Vec<_>>();
|
||||
|
||||
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
|
||||
let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(5), Scalar::I64(20), Scalar::F64(1.0)])
|
||||
.expect("compiles");
|
||||
b.run(vec![prices]);
|
||||
let b_eq = eq2.try_iter().collect::<Vec<_>>();
|
||||
|
||||
assert!(!a_eq.is_empty() && !b_eq.is_empty(), "both traces populated");
|
||||
assert_ne!(a_eq, b_eq, "a different vector must yield a different run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_kind_is_a_param_kind_mismatch() {
|
||||
let (bp, _eq, _ex) = composite_sma_cross_harness();
|
||||
// slot 0 is I64 (an SMA length); inject F64 there
|
||||
let err = bp.bootstrap_with_params(vec![Scalar::F64(2.0), Scalar::I64(4), Scalar::F64(0.5)])
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CompileError::ParamKindMismatch { slot: 0, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_arity_is_a_param_arity_error() {
|
||||
let (short, _e1, _x1) = composite_sma_cross_harness();
|
||||
assert!(matches!(
|
||||
short.bootstrap_with_params(vec![Scalar::I64(2)]).unwrap_err(),
|
||||
CompileError::ParamArity { expected: 3, got: 1 }
|
||||
));
|
||||
let (long, _e2, _x2) = composite_sma_cross_harness();
|
||||
assert!(matches!(
|
||||
long.bootstrap_with_params(
|
||||
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5), Scalar::F64(0.0)]
|
||||
).unwrap_err(),
|
||||
CompileError::ParamArity { expected: 3, got: 4 }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_vector_bootstraps_identically() {
|
||||
let prices = synthetic_prices();
|
||||
let (bp, eq, _ex) = composite_sma_cross_harness();
|
||||
let mut a = bp.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
|
||||
.expect("compiles");
|
||||
a.run(vec![prices.clone()]);
|
||||
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
|
||||
let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
|
||||
.expect("compiles");
|
||||
b.run(vec![prices]);
|
||||
assert_eq!(eq.try_iter().collect::<Vec<_>>(), eq2.try_iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// E2E (cycle 0015): the C23/#31 cross-cutting invariant — `param_space()` is a
|
||||
/// parallel projection of the *same* traversal `compile` inlines, so a param's
|
||||
/// slot in the aggregated space lines up, in order and kind, with the declared
|
||||
@@ -808,7 +874,9 @@ mod tests {
|
||||
|
||||
// the same blueprint, actually compiled to its flat node array; each flat
|
||||
// node's own declared params, concatenated in flat-node order
|
||||
let (flat_nodes, _sources, _edges) = bp.compile().expect("harness compiles");
|
||||
let (flat_nodes, _sources, _edges) = bp
|
||||
.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
|
||||
.expect("harness compiles");
|
||||
let from_compilat: Vec<ParamSpec> =
|
||||
flat_nodes.iter().flat_map(|n| n.schema().params).collect();
|
||||
|
||||
@@ -839,7 +907,7 @@ mod tests {
|
||||
// inner composite "fast_slow": two SMAs (same type → same param name) + a Sub
|
||||
let fast_slow = Composite::new(
|
||||
"fast_slow",
|
||||
vec![Sma::new(2).into(), Sma::new(4).into(), Sub::new().into()],
|
||||
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
@@ -850,7 +918,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::new(vec![1.0, -1.0]).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::factory(2).into()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
@@ -890,7 +958,7 @@ mod tests {
|
||||
// isolated path-qualification test above)
|
||||
let fast_slow = Composite::new(
|
||||
"fast_slow",
|
||||
vec![Sma::new(2).into(), Sma::new(4).into(), Sub::new().into()],
|
||||
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
@@ -901,7 +969,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::new(vec![1.0, -1.0]).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::factory(2).into()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
OutPort { node: 0, field: 0 },
|
||||
@@ -914,7 +982,9 @@ mod tests {
|
||||
|
||||
// the same blueprint, compiled to its flat node array; each flat node's
|
||||
// own declared params, concatenated in flat-node order
|
||||
let (flat_nodes, _sources, _edges) = bp.compile().expect("nested composite compiles");
|
||||
let (flat_nodes, _sources, _edges) = bp
|
||||
.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)])
|
||||
.expect("nested composite compiles");
|
||||
let from_compilat: Vec<ParamSpec> =
|
||||
flat_nodes.iter().flat_map(|n| n.schema().params).collect();
|
||||
|
||||
@@ -938,7 +1008,7 @@ mod tests {
|
||||
#[test]
|
||||
fn top_level_leaf_params_are_unqualified() {
|
||||
use aura_std::Sma;
|
||||
let bp = Blueprint::new(vec![Sma::new(3).into()], vec![], vec![]);
|
||||
let bp = Blueprint::new(vec![Sma::factory().into()], vec![], vec![]);
|
||||
let space = bp.param_space();
|
||||
assert_eq!(space.len(), 1);
|
||||
assert_eq!(space[0].name, "length"); // no path prefix at the top level
|
||||
@@ -948,7 +1018,7 @@ mod tests {
|
||||
fn param_space_is_deterministic() {
|
||||
use aura_std::{LinComb, Sma};
|
||||
let bp = Blueprint::new(
|
||||
vec![Sma::new(2).into(), LinComb::new(vec![1.0, -1.0]).into()],
|
||||
vec![Sma::factory().into(), LinComb::factory(2).into()],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
@@ -959,7 +1029,7 @@ mod tests {
|
||||
fn param_space_empty_for_paramless_and_empty_blueprints() {
|
||||
use aura_std::{Add, Sub};
|
||||
let only_paramless =
|
||||
Blueprint::new(vec![Sub::new().into(), Add::new().into()], vec![], vec![]);
|
||||
Blueprint::new(vec![Sub::factory().into(), Add::factory().into()], vec![], vec![]);
|
||||
assert!(only_paramless.param_space().is_empty());
|
||||
let empty = Blueprint::new(vec![], vec![], vec![]);
|
||||
assert!(empty.param_space().is_empty());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! four base scalar kinds so any column can be persisted; returns `None` (filters)
|
||||
//! until every input column is warm.
|
||||
|
||||
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
|
||||
use aura_core::{Ctx, Firing, InputSpec, LeafFactory, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
/// A recording sink over `kinds.len()` input columns. Each fired cycle it reads
|
||||
@@ -26,6 +26,19 @@ impl Recorder {
|
||||
pub fn new(kinds: &[ScalarKind], firing: Firing, tx: Sender<(Timestamp, Vec<Scalar>)>) -> Self {
|
||||
Self { kinds: kinds.to_vec(), firing, tx }
|
||||
}
|
||||
|
||||
/// The param-generic recipe for a blueprint leaf. The channel + kinds + firing
|
||||
/// are non-param construction args (captured), not tunable params; the node
|
||||
/// declares none. `tx` is cloned per build (`mpsc::Sender: Clone`).
|
||||
pub fn factory(
|
||||
kinds: Vec<ScalarKind>,
|
||||
firing: Firing,
|
||||
tx: Sender<(Timestamp, Vec<Scalar>)>,
|
||||
) -> LeafFactory {
|
||||
LeafFactory::new("Recorder", vec![], move |_| {
|
||||
Box::new(Recorder::new(&kinds, firing, tx.clone()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Recorder {
|
||||
@@ -68,6 +81,14 @@ mod tests {
|
||||
use aura_core::{AnyColumn, Timestamp};
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn factory_params_match_built_node_schema() {
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let f = Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx);
|
||||
let built = f.build(&[]);
|
||||
assert_eq!(f.params(), built.schema().params.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorder_captures_f64_stream_after_warmup() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user