d5602ec5ad
C24 first cut (#155): a blueprint — the param-generic, named graph-as-data a Rust builder produces — is now a first-class serializable data value with a canonical, versioned format, and the engine has the missing load path (data -> blueprint -> FlatGraph), not only the Rust-builder construction path. What ships: - `blueprint_to_json` / `blueprint_from_json` in a new `aura-engine` blueprint_serde module: a faithful serde DTO projection (the build closures dropped; re-derived on load), top-level `format_version` envelope, canonical compact JSON (struct field order, defaults omitted via skip_serializing_if). - A resolver seam: the loader is generic over an injected `Fn(&str) -> Option<PrimitiveBuilder>`; the concrete closed `match` over the aura-std vocabulary lives in aura-std (`std_vocabulary`), never in the engine (the engine stays domain-free: lib deps aura-core + aura-analysis only, never the node-vocabulary crate). This is C24's compiled-in closed-set referenced by type identity, NOT a dynamic/marketplace node registry (domain invariant 9). - serde derives on the serialized plain types (Edge, Target, OutField, Role, BoundParam, ScalarKind); BoundParam additionally re-exported from aura-core's root (an additive fix the plan's file list missed). Load-bearing scope decisions (derived, logged on #155): - Sinks are excluded from the serialized blueprint — a recording sink captures an mpsc::Sender (runtime identity, not param/topology, C19 value-empty); sink addressing is the C18-registry / #101 concern. The round-trip test attaches identical sinks post-load to both twins. - Construction-arg builders (LinComb(arity), CostSum(n), SimBroker(pip), Session(..)) are out of the round-trippable set: their structural args are a C20 structural-axis concern the param-generic format does not yet encode; an absent type_id fails the load cleanly (UnknownNodeType), never a silent wrong graph. #155's vocabulary is the 22 zero-arg aura-std builders. Additively extensible later (#156). Orchestrator hardening on review: the serializer now emits bound params in ascending original-slot order (mirroring the loader), so the canonical form is bind-order-independent — a precondition for content-addressing (#158). Identity today (every #155 node has <=1 param); holds by construction for future multi-param nodes. Acceptance (all green): a serialized blueprint runs bit-identical (C1) to its Rust-built twin; serializer/loader are inverse (canonical idempotence, incl. a recursive nested composite); unknown-type and unsupported-version fail named, never panicking. cargo test --workspace 748 passed; clippy --all-targets -D warnings clean. closes #155
165 lines
7.8 KiB
Rust
165 lines
7.8 KiB
Rust
//! End-to-end coverage for blueprint serialization (C24, cycle 0087): a
|
|
//! param-generic `Composite` is projected to canonical, versioned JSON
|
|
//! (`blueprint_to_json`) and reconstructed from it (`blueprint_from_json`) through
|
|
//! an **injected** node-vocabulary resolver (`aura_std::std_vocabulary`).
|
|
//!
|
|
//! The in-module tests in `blueprint_serde.rs` reach into crate internals
|
|
//! (`crate::test_fixtures::sink_free_sma_cross_signal`, the crate-private
|
|
//! `VecSource` re-export). These tests use only the **exported** surface — the
|
|
//! exact seam the World / a project `cdylib` consumes when it owns a blueprint as
|
|
//! serializable data (C24). A refactor that made `Composite`, an edge/role type,
|
|
//! `blueprint_from_json`, or a `LoadError` variant unreachable across the crate
|
|
//! boundary would break a real consumer while leaving every in-crate test green;
|
|
//! that is the regression class this file pins.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
blueprint_from_json, blueprint_to_json, BlueprintNode, Composite, Edge, LoadError, OutField,
|
|
Role, Target, VecSource, BLUEPRINT_FORMAT_VERSION,
|
|
};
|
|
use aura_std::{std_vocabulary, Bias, Recorder, Sma, Sub};
|
|
|
|
/// Seven synthetic F64 ticks (mirrors the crate-private `synthetic_prices`):
|
|
/// short enough that the fast (2) and slow (4) SMA windows warm up within the
|
|
/// window, so the `bias` output fires and the recorded trace is non-degenerate.
|
|
/// Deterministic input fixture — no time, no randomness.
|
|
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
|
[
|
|
(1_i64, 1.0000_f64),
|
|
(2, 1.0010),
|
|
(3, 1.0030),
|
|
(4, 1.0060),
|
|
(5, 1.0040),
|
|
(6, 1.0010),
|
|
(7, 0.9990),
|
|
]
|
|
.iter()
|
|
.map(|&(t, p)| (Timestamp(t), Scalar::f64(p)))
|
|
.collect()
|
|
}
|
|
|
|
/// The SMA-cross **signal** as a sink-free, param-generic composite built through
|
|
/// the public `Composite` builder + `aura-std` nodes: fast SMA (length bound to 2)
|
|
/// and slow SMA over one `price` role, their spread, a `Bias`; output is the
|
|
/// `bias` field. Mirrors the crate-private `sink_free_sma_cross_signal` fixture so
|
|
/// the open param point `[slow.length, bias.scale]` is identical. A fresh value per
|
|
/// call — running consumes the blueprint.
|
|
fn signal() -> Composite {
|
|
Composite::new(
|
|
"sma_cross",
|
|
vec![
|
|
Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(),
|
|
Sma::builder().named("slow").into(),
|
|
Sub::builder().into(),
|
|
Bias::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast -> Sub slot 0
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow -> Sub slot 1
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // spread -> Bias
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: None,
|
|
}],
|
|
vec![OutField { node: 3, field: 0, name: "bias".into() }],
|
|
)
|
|
}
|
|
|
|
// The open param point the signal bootstraps with: [slow.length, bias.scale]
|
|
// (fast.length is bound out). Same point for the Rust-built and the round-tripped
|
|
// blueprint, so the only thing under test is the serialization loop.
|
|
fn signal_point() -> Vec<Scalar> {
|
|
vec![Scalar::i64(4), Scalar::f64(0.5)]
|
|
}
|
|
|
|
/// Nest `signal` under a root that records its single `bias` output, feed the
|
|
/// synthetic price fixture through the public `VecSource`, and collect the recorded
|
|
/// `(ts, bias)` trace — the only observable behaviour this E2E asserts on.
|
|
fn run_recording(signal: Composite) -> Vec<(Timestamp, Vec<Scalar>)> {
|
|
let (tx, rx) = mpsc::channel();
|
|
let root = Composite::new(
|
|
"h",
|
|
vec![
|
|
BlueprintNode::Composite(signal),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
|
|
],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // bias -> recorder
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }], // price -> nested signal's price port
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![],
|
|
);
|
|
let mut h = root.bootstrap_with_params(signal_point()).expect("bootstraps");
|
|
h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
|
|
rx.try_iter().collect()
|
|
}
|
|
|
|
/// Property (C24 acceptance, through the public seam): a blueprint authored via the
|
|
/// exported `Composite` builder, serialized to JSON, then loaded back through
|
|
/// `blueprint_from_json` + the injected `aura_std::std_vocabulary` and run, records
|
|
/// a `bias` trace **byte-identical** to the Rust-built twin's run — proven entirely
|
|
/// through `aura_engine`/`aura_std` public exports (the World/cdylib seam C24
|
|
/// serves). The serialization round-trip is behaviour-preserving (C1): same input,
|
|
/// same recorded stream. The trace is also non-empty, so equality is not the
|
|
/// vacuous match of two empty runs.
|
|
#[test]
|
|
fn serialized_blueprint_runs_bit_identical_through_public_api() {
|
|
let rust_built = signal();
|
|
let json = blueprint_to_json(&rust_built).expect("serializes");
|
|
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("loads");
|
|
|
|
let trace_rust = run_recording(rust_built);
|
|
let trace_loaded = run_recording(loaded);
|
|
|
|
assert!(!trace_rust.is_empty(), "the price fixture must warm up the SMAs and emit a bias");
|
|
assert_eq!(trace_rust, trace_loaded, "serialized-then-loaded run diverged from the Rust twin");
|
|
}
|
|
|
|
/// Property (refuse-don't-guess, at the public boundary): a structurally-valid
|
|
/// envelope naming a real node that the injected vocabulary deliberately does NOT
|
|
/// resolve — a recording **sink** (`Recorder`, absent from the sink-free #155
|
|
/// `std_vocabulary`) — fails the public loader with the named
|
|
/// `LoadError::UnknownNodeType("Recorder")`, never a panic and never a silently
|
|
/// sink-less graph. This is the realistic round-trip case a project hits when it
|
|
/// serializes a full harness and loads it against the std vocabulary; the typed,
|
|
/// matchable error reaching across the crate boundary is the protected property.
|
|
#[test]
|
|
fn unresolvable_sink_node_fails_with_named_load_error() {
|
|
let json = r#"{"format_version":1,"blueprint":{"name":"h","nodes":[{"primitive":{"type":"Recorder"}}]}}"#;
|
|
// `.err().unwrap()` (not `unwrap_err`): the Ok type `Composite` holds a build
|
|
// closure and is not `Debug`, which `unwrap_err`'s bound would require.
|
|
let err = blueprint_from_json(json, &|t| std_vocabulary(t)).err().unwrap();
|
|
assert!(
|
|
matches!(err, LoadError::UnknownNodeType(ref t) if t == "Recorder"),
|
|
"an out-of-vocabulary node must fail loud and named, got {err:?}",
|
|
);
|
|
}
|
|
|
|
/// Property (version envelope, at the public boundary): a document whose
|
|
/// `format_version` the loader does not understand is refused with
|
|
/// `LoadError::UnsupportedVersion { found, supported }` — the envelope is checked
|
|
/// *before* the payload is reconstructed, so a future or stale format can never be
|
|
/// silently mis-read into a wrong graph. The reported `supported` is the public
|
|
/// `BLUEPRINT_FORMAT_VERSION` constant (not a magic literal), pinning that the
|
|
/// loader advertises its own understood version across the crate boundary.
|
|
#[test]
|
|
fn unsupported_format_version_fails_named() {
|
|
let future = BLUEPRINT_FORMAT_VERSION + 1;
|
|
let json = format!(r#"{{"format_version":{future},"blueprint":{{"name":"h","nodes":[]}}}}"#);
|
|
let err = blueprint_from_json(&json, &|t| std_vocabulary(t)).err().unwrap();
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
LoadError::UnsupportedVersion { found, supported }
|
|
if found == future && supported == BLUEPRINT_FORMAT_VERSION
|
|
),
|
|
"a version the loader does not understand must be refused by name, got {err:?}",
|
|
);
|
|
}
|