Files
Aura/crates/aura-engine/tests/blueprint_serde_e2e.rs
T
claude b39fd63396 refactor: split the aura-std roster into C28 layer crates
Phase 4 of the Stratification milestone. aura-std held four C28 ladder
layers in one roster; this cuts them into layer-aligned, aura-core-only
node crates so the import direction is enforced by the crate graph:

- aura-std        — engine nodes only (arithmetic/logic/rolling + sinks)
- aura-market     — session, resample
- aura-strategy   — bias, stops, sizer, cost-model machinery
- aura-backtest   — sim_broker, position_management
- aura-vocabulary — the relocated closed std_vocabulary roster

Node modules move verbatim (byte-identical renames); consumers are
rewired by import path only. A new structural test
(aura-vocabulary/tests/c28_layering.rs) asserts each node crate's
[dependencies] stay within its C28-permitted inner set, catching the
acyclic-but-outward violation the compiler misses.

Behaviour byte-identical: full workspace suite green (1448 tests), no
golden edited, clippy -D warnings clean. C28 Status block updated.

closes #288
2026-07-19 20:28:20 +02:00

269 lines
13 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_vocabulary::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_identity_json, blueprint_to_json, BlueprintNode, Composite,
Edge, LoadError, OutField, Role, Target, VecSource, BLUEPRINT_FORMAT_VERSION,
};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use aura_vocabulary::std_vocabulary;
/// 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_vocabulary::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:?}",
);
}
/// Property (Tier-1 forward-compat at the **document envelope**, public seam — C24
/// / #156): an unknown optional key beside `format_version`/`blueprint` (the shape a
/// future writer emits for an additive doc-level section that does NOT bump the
/// version) is *tolerated* by the public loader — never refused as a version error,
/// never leaked into the built graph. The `BlueprintDoc` envelope carries no
/// `deny_unknown_fields`, so an older reader silently ignores the section and, by
/// C1, reconstructs the byte-identical blueprint that runs bit-identically. The
/// in-crate twin proves this through crate internals; this pins it across the crate
/// boundary the World/cdylib consumes (where adding `deny_unknown_fields` to the
/// envelope would turn every forward-compatible document into a hard load failure
/// while leaving the in-crate test green).
#[test]
fn unknown_envelope_field_tolerated_through_public_api() {
let canonical = blueprint_to_json(&signal()).expect("serializes");
// Inject an unknown optional key into the BlueprintDoc envelope (sibling of
// `format_version`/`blueprint`) — version-independent, no magic version literal.
let with_unknown =
canonical.replacen(r#","blueprint":"#, r#","metadata":{"author":"future"},"blueprint":"#, 1);
assert_ne!(with_unknown, canonical, "probe must actually inject an envelope key");
// tolerated, not refused
let loaded = blueprint_from_json(&with_unknown, &|t| std_vocabulary(t))
.expect("an unknown envelope field is tolerated, not refused");
// the unknown key did not leak: re-serialization equals the canonical form.
assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical, "unknown key leaked");
// and it runs bit-identically to the plain canonical twin (C1).
let plain = blueprint_from_json(&canonical, &|t| std_vocabulary(t)).expect("loads");
assert_eq!(
run_recording(loaded),
run_recording(plain),
"run diverged under an unknown envelope field",
);
}
/// Property (Tier-1 forward-compat **inside a primitive node**, public seam — C24 /
/// #156): an unknown optional key inside a serialized primitive object (the shape a
/// future writer emits for an additive *per-node* attribute) is tolerated by the
/// public loader and never leaks into the built graph. `PrimitiveData` carries no
/// `deny_unknown_fields` independently of the envelope, so this is a *distinct*
/// tolerance surface — a `deny_unknown_fields` added to the primitive deserializer
/// alone would refuse a forward-compatible node while the envelope test above stayed
/// green. Loads byte-identically and runs bit-identically to the plain twin.
#[test]
fn unknown_primitive_field_tolerated_through_public_api() {
let canonical = blueprint_to_json(&signal()).expect("serializes");
// The param-free `Sub` node serializes as the bare `{"type":"Sub"}`; inject an
// unknown optional key into that primitive object.
let with_unknown =
canonical.replacen(r#"{"type":"Sub"}"#, r#"{"type":"Sub","annotations":["future"]}"#, 1);
assert_ne!(with_unknown, canonical, "probe must actually inject a primitive key");
let loaded = blueprint_from_json(&with_unknown, &|t| std_vocabulary(t))
.expect("an unknown primitive field is tolerated, not refused");
assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical, "unknown key leaked");
let plain = blueprint_from_json(&canonical, &|t| std_vocabulary(t)).expect("loads");
assert_eq!(
run_recording(loaded),
run_recording(plain),
"run diverged under an unknown primitive field",
);
}
/// Property (additive-field safety, at the public boundary — #282 taps
/// substrate): the new, optional `taps` declaration on `CompositeData` (a
/// `Vec<Tap>`, `skip_serializing_if = "Vec::is_empty"`) must never perturb a
/// composite that carries none, for every consumer reachable through the
/// exported `aura_engine` surface. `Tap`/`TapWire` are not (yet) re-exported
/// from the crate root — unlike their sibling interface types `Role` and
/// `OutField` — so this file cannot itself construct a tap-bearing
/// `Composite`; the in-crate tests in `blueprint_serde.rs` cover the
/// tap-bearing round-trip and identity-blindness using crate-private access.
/// What IS reachable here, and is exactly the regression class an additive
/// struct field risks, is pinned: an un-tapped composite emits no `"taps"`
/// key in either its canonical or identity JSON, round-trips byte-for-byte
/// through the public loader, and runs bit-identically before and after.
#[test]
fn composites_without_taps_are_unaffected_by_the_additive_taps_field() {
let canonical = blueprint_to_json(&signal()).expect("serializes");
assert!(!canonical.contains("\"taps\""), "an un-tapped composite must not emit a taps key: {canonical}");
let identity = blueprint_identity_json(&signal()).expect("identity-serializes");
assert!(!identity.contains("\"taps\""), "identity form must not emit a taps key either: {identity}");
let loaded = blueprint_from_json(&canonical, &|t| std_vocabulary(t)).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
canonical,
"round-trip byte-stability broken by the additive taps field"
);
assert_eq!(
run_recording(loaded),
run_recording(signal()),
"run diverged after the additive taps field landed"
);
}