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

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

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

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

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

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

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

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

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

closes #43 #36
This commit is contained in:
2026-06-09 12:49:22 +02:00
parent c7fc2f6a37
commit 1b3909316e
17 changed files with 1398 additions and 775 deletions
File diff suppressed because it is too large Load Diff
+293 -88
View File
@@ -21,7 +21,7 @@
//! sources are four cycles, so RustAst's `cycle_id`-equality barrier could never
//! fire across sources — the timestamp generalizes it faithfully.
use aura_core::{AnyColumn, Ctx, Firing, Node, Scalar, ScalarKind, Timestamp};
use aura_core::{AnyColumn, Ctx, Firing, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
/// Forwards one field (`from_field`) of a producer's output record into a
/// consumer's input slot. Consuming a whole record is N such edges, one per field
@@ -51,6 +51,18 @@ pub struct SourceSpec {
pub targets: Vec<Target>,
}
/// The flat, type-erased, index-wired output of `Composite::compile` — the target
/// `Harness::bootstrap` consumes (C23's "compilat", now a named type). `signatures[i]`
/// is the static signature of `nodes[i]` (gathered from each primitive's builder at
/// lowering); `bootstrap` reads kinds/output from it and `nodes[i].lookbacks()` for
/// buffer depth. `sources` are the lowered bound roles, in role-declaration order.
pub struct FlatGraph {
pub nodes: Vec<Box<dyn Node>>,
pub signatures: Vec<NodeSchema>,
pub sources: Vec<SourceSpec>,
pub edges: Vec<Edge>,
}
/// A wiring fault caught once, at bootstrap — C7's "type check paid at wiring"
/// generalized to the whole topology.
#[derive(Debug, PartialEq, Eq)]
@@ -107,41 +119,41 @@ impl core::fmt::Debug for Harness {
}
impl Harness {
/// Bind nodes + wiring into a frozen, runnable graph. Sizes each node's input
/// columns from its `schema`, lifts each input's firing policy, initializes
/// per-slot freshness state, kind-checks every source target and edge, and
/// topologically orders the nodes (Kahn), rejecting any directed cycle.
pub fn bootstrap(
nodes: Vec<Box<dyn Node>>,
sources: Vec<SourceSpec>,
edges: Vec<Edge>,
) -> Result<Harness, BootstrapError> {
/// Bind a flat graph into a frozen, runnable graph. Sizes each node's input
/// columns — KIND/firing from its carried signature, DEPTH from the built node's
/// `lookbacks()` — lifts each input's firing policy, initializes per-slot
/// freshness state, kind-checks every source target and edge, and topologically
/// orders the nodes (Kahn), rejecting any directed cycle.
pub fn bootstrap(flat: FlatGraph) -> Result<Harness, BootstrapError> {
let FlatGraph { nodes, signatures, sources, edges } = flat;
let n = nodes.len();
let schemas: Vec<_> = nodes.iter().map(|nd| nd.schema()).collect();
// size each node's input columns from its schema; lift firing; init slots
// size each node's input columns: KIND/firing from the carried signature,
// DEPTH from the built node's lookbacks() (the one param-dependent quantity)
let mut boxes: Vec<NodeBox> = Vec::with_capacity(n);
for (nd, schema) in nodes.into_iter().zip(schemas.iter()) {
let inputs: Vec<AnyColumn> = schema
for (nd, sig) in nodes.into_iter().zip(signatures.iter()) {
let depths = nd.lookbacks();
debug_assert_eq!(depths.len(), sig.inputs.len(), "lookbacks() arity == signature inputs");
let inputs: Vec<AnyColumn> = sig
.inputs
.iter()
.map(|spec| AnyColumn::with_capacity(spec.kind, spec.lookback))
.zip(depths)
.map(|(spec, depth)| AnyColumn::with_capacity(spec.kind, depth))
.collect();
let firing: Vec<Firing> = schema.inputs.iter().map(|spec| spec.firing).collect();
let slots: Vec<SlotState> = schema
let firing: Vec<Firing> = sig.inputs.iter().map(|spec| spec.firing).collect();
let slots: Vec<SlotState> = sig
.inputs
.iter()
.map(|_| SlotState { fresh_at: 0, last_ts: Timestamp(i64::MIN) })
.collect();
let out_len = schema.output.len();
let out_len = sig.output.len();
boxes.push(NodeBox { node: nd, inputs, firing, slots, out_len });
}
// source targets: each source's value must match each of its target slots' kind
for src in &sources {
for t in &src.targets {
let s = schemas.get(t.node).ok_or(BootstrapError::BadIndex)?;
let s = signatures.get(t.node).ok_or(BootstrapError::BadIndex)?;
let slot = s.inputs.get(t.slot).ok_or(BootstrapError::BadIndex)?;
if slot.kind != src.kind {
return Err(BootstrapError::KindMismatch {
@@ -155,8 +167,8 @@ impl Harness {
// edges: indices in range, producer output field kind == consumer slot kind
let mut out_edges: Vec<Vec<Edge>> = vec![Vec::new(); n];
for &e in &edges {
let from = schemas.get(e.from).ok_or(BootstrapError::BadIndex)?;
let to = schemas.get(e.to).ok_or(BootstrapError::BadIndex)?;
let from = signatures.get(e.from).ok_or(BootstrapError::BadIndex)?;
let to = signatures.get(e.to).ok_or(BootstrapError::BadIndex)?;
let field = from.output.get(e.from_field).ok_or(BootstrapError::BadIndex)?;
let slot = to.inputs.get(e.slot).ok_or(BootstrapError::BadIndex)?;
if field.kind != slot.kind {
@@ -333,10 +345,9 @@ fn group_id(f: &Firing) -> Option<u8> {
#[cfg(test)]
mod tests {
use super::*;
// InputSpec / NodeSchema are not imported by harness.rs production code (it
// only reads `nd.schema()` fields, never naming the types), so they are not
// brought in by `use super::*` — the fixtures construct them, so import here.
use aura_core::{FieldSpec, InputSpec, NodeSchema};
// PortSpec / NodeSchema name the fixtures' declared signatures, brought in here
// (production code reads them via the carried signatures, never naming the types).
use aura_core::{FieldSpec, NodeSchema, PortSpec};
use aura_std::{Exposure, Recorder, Sma, SimBroker, Sub};
use std::sync::mpsc;
@@ -345,6 +356,33 @@ mod tests {
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
}
/// One f64 input port with the given firing policy (lookback 1 is the bootstrap
/// default for these test fixtures, supplied by their `lookbacks()`).
fn f64_port(firing: Firing) -> PortSpec {
PortSpec { kind: ScalarKind::F64, firing }
}
/// Bootstrap a hand-wired graph from boxed nodes + their declared signatures.
/// The signatures parallel `nodes` (one per node, in order); bootstrap reads
/// kinds/firing from them and depth from each node's `lookbacks()`.
fn boot(
nodes: Vec<Box<dyn Node>>,
signatures: Vec<NodeSchema>,
sources: Vec<SourceSpec>,
edges: Vec<Edge>,
) -> Result<Harness, BootstrapError> {
Harness::bootstrap(FlatGraph { nodes, signatures, sources, edges })
}
/// The declared signature of a `Recorder` over `kinds` with the given firing.
fn recorder_sig(kinds: &[ScalarKind], firing: Firing) -> NodeSchema {
NodeSchema {
inputs: kinds.iter().map(|&kind| PortSpec { kind, firing }).collect(),
output: vec![],
params: vec![],
}
}
// --- firing-policy fixtures (test-local; not library nodes — C9: examples
// for the engine's own tests, no speculative aura-std surface) ---
@@ -353,17 +391,19 @@ mod tests {
struct AsOfSum {
out: [Scalar; 1],
}
impl Node for AsOfSum {
fn schema(&self) -> NodeSchema {
impl AsOfSum {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
],
inputs: vec![f64_port(Firing::Any), f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![],
}
}
}
impl Node for AsOfSum {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let a = ctx.f64_in(0);
let b = ctx.f64_in(1);
@@ -380,17 +420,19 @@ mod tests {
struct BarrierSum {
out: [Scalar; 1],
}
impl Node for BarrierSum {
fn schema(&self) -> NodeSchema {
impl BarrierSum {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
],
inputs: vec![f64_port(Firing::Barrier(0)), f64_port(Firing::Barrier(0))],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![],
}
}
}
impl Node for BarrierSum {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
self.out[0] = Scalar::F64(ctx.f64_in(0)[0] + ctx.f64_in(1)[0]);
Some(&self.out)
@@ -403,18 +445,23 @@ mod tests {
struct MixedSum {
out: [Scalar; 1],
}
impl Node for MixedSum {
fn schema(&self) -> NodeSchema {
impl MixedSum {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
f64_port(Firing::Barrier(0)),
f64_port(Firing::Barrier(0)),
f64_port(Firing::Any),
],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![],
}
}
}
impl Node for MixedSum {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let a = ctx.f64_in(0);
let b = ctx.f64_in(1);
@@ -434,16 +481,10 @@ mod tests {
struct Ohlcv {
out: [Scalar; 5],
}
impl Node for Ohlcv {
fn schema(&self) -> NodeSchema {
impl Ohlcv {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) },
],
inputs: vec![f64_port(Firing::Barrier(0)); 5],
output: vec![
FieldSpec { name: "open", kind: ScalarKind::F64 },
FieldSpec { name: "high", kind: ScalarKind::F64 },
@@ -454,6 +495,11 @@ mod tests {
params: vec![],
}
}
}
impl Node for Ohlcv {
fn lookbacks(&self) -> Vec<usize> {
vec![1; 5]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
for i in 0..5 {
let w = ctx.f64_in(i);
@@ -473,10 +519,10 @@ mod tests {
struct TwoField {
out: [Scalar; 2],
}
impl Node for TwoField {
fn schema(&self) -> NodeSchema {
impl TwoField {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
inputs: vec![f64_port(Firing::Any)],
output: vec![
FieldSpec { name: "f", kind: ScalarKind::F64 },
FieldSpec { name: "i", kind: ScalarKind::I64 },
@@ -484,6 +530,11 @@ mod tests {
params: vec![],
}
}
}
impl Node for TwoField {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
self.out[0] = Scalar::F64(0.0);
self.out[1] = Scalar::I64(0);
@@ -498,14 +549,19 @@ mod tests {
out: [Scalar; 1],
tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
}
impl Node for TapForward {
fn schema(&self) -> NodeSchema {
impl TapForward {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
inputs: vec![f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![],
}
}
}
impl Node for TapForward {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
@@ -522,11 +578,15 @@ mod tests {
fn chain_source_sma_runs() {
// node 0 = SMA(3); source -> SMA(3).in0; node 1 = Recorder taps node 0.
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(Sma::new(3)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
Sma::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
)
@@ -550,13 +610,19 @@ mod tests {
// 0 = SMA(2), 1 = SMA(4), 2 = Sub; source fans into both SMAs; SMAs join
// into Sub; node 3 = Recorder taps Sub — the 0003 baseline on the new API.
let build = |tx| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
@@ -598,11 +664,15 @@ mod tests {
// AsOfSum @0; node 1 = Recorder taps it. source 0 ticks t=1..4; source 1
// ticks t=2,4 (slower); both AsOfSum inputs Any.
let build = |tx| {
Harness::bootstrap(
boot(
vec![
Box::new(AsOfSum { out: [Scalar::F64(0.0)] }),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
AsOfSum::sig(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
@@ -640,11 +710,15 @@ mod tests {
fn mode_b_barrier_fires_only_on_timestamp_coincidence() {
// identical wiring to mode A, but both BarrierSum inputs are Barrier(0).
let build = |tx| {
Harness::bootstrap(
boot(
vec![
Box::new(BarrierSum { out: [Scalar::F64(0.0)] }),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
BarrierSum::sig(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
@@ -683,13 +757,19 @@ mod tests {
// that cycle's timestamp, so once both SMAs warm and emit in the same
// cycle, both barrier inputs share the timestamp and the barrier fires.
let build = |tx| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(BarrierSum { out: [Scalar::F64(0.0)] }),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
BarrierSum::sig(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
@@ -730,11 +810,15 @@ mod tests {
fn mixed_a_and_b_or_combine_on_one_node() {
// MixedSum @0 (in0,in1 barrier group 0; in2 as-of); node 1 = Recorder taps it.
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(MixedSum { out: [Scalar::F64(0.0)] }),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
MixedSum::sig(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
@@ -763,8 +847,12 @@ mod tests {
#[test]
fn bootstrap_rejects_a_cycle() {
// two SMA(1) nodes wired a -> b -> a
let err = Harness::bootstrap(
let err = boot(
vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
],
vec![],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }, Edge { from: 1, to: 0, slot: 0, from_field: 0 }],
)
@@ -775,8 +863,11 @@ mod tests {
#[test]
fn bootstrap_rejects_a_kind_mismatch() {
// SMA(1) declares an f64 input; an i64 source mismatches
let err = Harness::bootstrap(
let err = boot(
vec![Box::new(Sma::new(1))],
vec![
Sma::builder().schema().clone(),
],
vec![SourceSpec { kind: ScalarKind::I64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
)
@@ -792,8 +883,12 @@ mod tests {
// an edge target node (9) that does not exist -> BadIndex. (The old trigger
// — an out-of-range observe index — is gone with `observe`; BadIndex itself
// is unchanged, only the path that reaches it.)
let err = Harness::bootstrap(
let err = boot(
vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }],
)
@@ -827,7 +922,7 @@ mod tests {
// taps all five fields via five edges. The barrier fires once all five share
// the timestamp, so each bar is recorded once, on the fifth cycle of its ts.
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
Box::new(Recorder::new(
@@ -836,6 +931,10 @@ mod tests {
tx,
)),
],
vec![
Ohlcv::sig(),
recorder_sig(&[ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], Firing::Any),
],
ohlcv_sources(),
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // open
@@ -876,12 +975,17 @@ mod tests {
// Proves from_field routes the right columns (not field 0) and the two
// bound fields are co-fresh (Sub's Any inputs both fire in the bar's cycle).
let build = |tx| {
Harness::bootstrap(
boot(
vec![
Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
Box::new(Sub::new()),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
Ohlcv::sig(),
Sub::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
ohlcv_sources(),
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 1 }, // high
@@ -917,12 +1021,17 @@ mod tests {
// (field 0) -> close - open; the Recorder taps Sub. Proves two edges on one
// record read two different fields (3 and 0, not the high/low pair above).
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
Box::new(Sub::new()),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
Ohlcv::sig(),
Sub::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
ohlcv_sources(),
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 3 }, // close
@@ -947,8 +1056,12 @@ mod tests {
fn bootstrap_rejects_from_field_out_of_range() {
// Sma(0) has a 1-field output (index 0 only); an edge reading field 9 is
// out of range -> BadIndex (caught before any kind check).
let err = Harness::bootstrap(
let err = boot(
vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 9 }],
)
@@ -961,8 +1074,12 @@ mod tests {
// TwoField(0) output: field 0 f64, field 1 i64. Binding field 1 (i64) into
// Sma(1)'s f64 input slot is a per-field kind mismatch -> KindMismatch. (The
// mismatch is field-specific: from_field 0 would have matched.)
let err = Harness::bootstrap(
let err = boot(
vec![Box::new(TwoField { out: [Scalar::F64(0.0), Scalar::I64(0)] }), Box::new(Sma::new(1))],
vec![
TwoField::sig(),
Sma::builder().schema().clone(),
],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }],
)
@@ -979,13 +1096,19 @@ mod tests {
// streams (the #2 headline). Each drained stream is individually correct.
let (tx_fast, rx_fast) = mpsc::channel();
let (tx_slow, rx_slow) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_fast)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_slow)),
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
@@ -1023,7 +1146,7 @@ mod tests {
// A 5-input Recorder taps all five OHLCV fields via five field-wise edges
// (0005: N edges, no whole-record bind); its recorded row is the whole bar.
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }),
Box::new(Recorder::new(
@@ -1032,6 +1155,10 @@ mod tests {
tx,
)),
],
vec![
Ohlcv::sig(),
recorder_sig(&[ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], Firing::Any),
],
ohlcv_sources(),
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
@@ -1071,12 +1198,15 @@ mod tests {
// at t=1,2,3,4; only on cycle 4 are all slots warm, so it records once,
// holding the earlier-ticked values.
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![Box::new(Recorder::new(
&[ScalarKind::I64, ScalarKind::F64, ScalarKind::Bool, ScalarKind::Timestamp],
Firing::Any,
tx,
))],
vec![
recorder_sig(&[ScalarKind::I64, ScalarKind::F64, ScalarKind::Bool, ScalarKind::Timestamp], Firing::Any),
],
vec![
SourceSpec { kind: ScalarKind::I64, targets: vec![Target { node: 0, slot: 0 }] },
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
@@ -1111,11 +1241,15 @@ mod tests {
// one node is producer and sink at once (C8 "both").
let (tx_tap, rx_tap) = mpsc::channel();
let (tx_down, rx_down) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(TapForward { out: [Scalar::F64(0.0)], tx: tx_tap }),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_down)),
],
vec![
TapForward::sig(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
)
@@ -1137,11 +1271,15 @@ mod tests {
// Two fresh harnesses, two channels, identical input -> bit-identical
// recorded streams (C1).
let build = |tx| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(3)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
Sma::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
)
@@ -1168,12 +1306,15 @@ mod tests {
// A 2-input Barrier(0) recorder records only on cycles where both inputs
// share the timestamp — the recorder's OWN firing policy gates recording.
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![Box::new(Recorder::new(
&[ScalarKind::F64, ScalarKind::F64],
Firing::Barrier(0),
tx,
))],
vec![
recorder_sig(&[ScalarKind::F64, ScalarKind::F64], Firing::Barrier(0)),
],
vec![
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
@@ -1200,12 +1341,15 @@ mod tests {
// A 2-input Any recorder records on any-fresh once both are warm (as-of),
// holding the stale input.
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![Box::new(Recorder::new(
&[ScalarKind::F64, ScalarKind::F64],
Firing::Any,
tx,
))],
vec![
recorder_sig(&[ScalarKind::F64, ScalarKind::F64], Firing::Any),
],
vec![
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] },
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] },
@@ -1236,11 +1380,15 @@ mod tests {
// Recorder's f64 input slot is a per-field kind mismatch -> KindMismatch
// (0005's check already covers recorder edges; recording adds no new hole).
let (tx, _rx) = mpsc::channel();
let err = Harness::bootstrap(
let err = boot(
vec![
Box::new(TwoField { out: [Scalar::F64(0.0), Scalar::I64(0)] }),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
TwoField::sig(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }],
)
@@ -1265,13 +1413,19 @@ mod tests {
// correct stream — node fan-out (not source fan-out: a single SMA(3)
// output is forwarded down three edges to three recorders).
let build = |t1, t2, t3| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(3)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t1)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t2)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t3)),
],
vec![
Sma::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }],
@@ -1322,7 +1476,7 @@ mod tests {
// other input is a second producer (SMA(4)) — yields both the raw tap
// and the downstream-combined stream, each independently correct.
let build = |t_raw, t_sub| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(2)), // 0: shared producer
Box::new(Sma::new(4)), // 1: second producer
@@ -1330,6 +1484,13 @@ mod tests {
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t_raw)), // 3: raw tap of 0
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t_sub)), // 4: tap of Sub
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
@@ -1384,13 +1545,19 @@ mod tests {
// tap fires on every SMA push; the BarrierSum fires only on the cycles
// where the held SMA output and a second source coincide on a timestamp.
let build = |t_any, t_bar| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(2)), // 0: shared producer (src A)
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t_any)), // 1: as-of tap of 0
Box::new(BarrierSum { out: [Scalar::F64(0.0)] }), // 2: SMA(2) + src B (barrier)
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t_bar)), // 3: tap of barrier
],
vec![
Sma::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
BarrierSum::sig(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, // A
SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 2, slot: 1 }] }, // B
@@ -1441,13 +1608,19 @@ mod tests {
// -> SMA(2) -> recorder) propagates values correctly through depth, with
// each stage's warm-up delaying the tail — closes "deep chains untested".
let build = |tx| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(2)), // 1
Box::new(Sma::new(2)), // 2
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), // 3 tail
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }],
@@ -1486,7 +1659,7 @@ mod tests {
// run, yields each parallel stream correctly — closes "wide layers
// untested" and exercises multi-sink at width.
let build = |t2, t3, t4| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(3)), // 1
@@ -1495,6 +1668,14 @@ mod tests {
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t3)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t4)), // 5
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
@@ -1559,7 +1740,7 @@ mod tests {
// deterministic — the "pure compute substrate carries arbitrary
// M-producer x N-consumer x K-sink DAGs" gate.
let build = |t_raw, t_sub, t_i64| {
Harness::bootstrap(
boot(
vec![
Box::new(Sma::new(2)), // 0: shared f64 producer
Box::new(Sma::new(4)), // 1: second f64 producer
@@ -1568,6 +1749,14 @@ mod tests {
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, t_sub)), // 4
Box::new(Recorder::new(&[ScalarKind::I64], Firing::Any, t_i64)), // 5: i64 sink
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::I64], Firing::Any),
],
vec![
SourceSpec {
kind: ScalarKind::F64,
@@ -1634,7 +1823,7 @@ mod tests {
#[test]
fn signal_quality_loop_records_pip_equity() {
let (tx_eq, rx_eq) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(Sma::new(2)), // 0 fast
Box::new(Sma::new(4)), // 1 slow
@@ -1643,6 +1832,14 @@ mod tests {
Box::new(SimBroker::new(0.0001)), // 4 pip equity
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 sink
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
@@ -1692,7 +1889,7 @@ mod tests {
fn signal_quality_loop_is_deterministic() {
let build = || {
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
let mut h = boot(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
@@ -1701,6 +1898,14 @@ mod tests {
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
+3 -4
View File
@@ -36,15 +36,14 @@ mod harness;
mod report;
pub use blueprint::{
aliases_on, signature_of, Blueprint, BlueprintNode, CompileError, Composite, OutField,
ParamAlias, Role,
aliases_on, signature_of, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role,
};
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
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, ParamSpec, Scalar, ScalarKind, Timestamp};
pub use aura_core::{Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp};
#[cfg(test)]
mod reexport_tests {
+26 -7
View File
@@ -198,11 +198,21 @@ pub fn f64_field(rows: &[(Timestamp, Vec<Scalar>)], field: usize) -> Vec<(Timest
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edge, Harness, SourceSpec, Target};
use aura_core::{Firing, ScalarKind};
use crate::{Edge, FlatGraph, Harness, SourceSpec, Target};
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// The declared signature of a `Recorder` over one f64 column (the sink shape
/// the two-sink harness uses).
fn f64_recorder_sig() -> NodeSchema {
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
output: vec![],
params: vec![],
}
}
/// Build an f64 source stream from (timestamp, value) points (mirrors the
/// harness.rs test helper; the e2e test needs its own copy — the harness
/// test module's is private to that module).
@@ -221,8 +231,8 @@ mod tests {
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let h = Harness::bootstrap(
vec![
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
@@ -231,7 +241,16 @@ mod tests {
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
vec![SourceSpec {
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
@@ -239,7 +258,7 @@ mod tests {
Target { node: 4, slot: 1 }, // price into the broker
],
}],
vec![
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
@@ -247,7 +266,7 @@ mod tests {
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
)
})
.expect("valid signal-quality DAG");
(h, rx_eq, rx_ex)
}