diff --git a/docs/plans/0087-blueprint-serialization-loader.md b/docs/plans/0087-blueprint-serialization-loader.md new file mode 100644 index 0000000..aef1d44 --- /dev/null +++ b/docs/plans/0087-blueprint-serialization-loader.md @@ -0,0 +1,667 @@ +# Blueprint serialization & loader (cycle 0087) — Implementation Plan + +> **Parent spec:** `docs/specs/0087-blueprint-serialization-loader.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Close the C24 graph-as-data loop (#155): a canonical, versioned +serialization of a param-generic `Composite` blueprint plus a `data → blueprint` +loader, so a serialized blueprint runs bit-identical (C1) to its Rust-built twin. + +**Architecture:** A serde DTO layer in a new `aura-engine` module projects a +`&Composite` to canonical JSON (the build closures dropped) and reconstructs it +through an **injected** node resolver; the concrete closed `match` over the +`aura-std` vocabulary lives in `aura-std` (the engine never depends on the node +vocabulary crate — its only library deps are `aura-core` + `aura-analysis` + +serde; `aura-std` is a test-only dep). Sinks and structural-construction-arg +nodes are out of the round-trippable set (see spec; recorded on #155). + +**Tech Stack:** `serde` + `serde_json` (both already deps of `aura-engine`; +`serde` derive already in `aura-core`), the existing `Composite` / +`PrimitiveBuilder` / `compile_with_params` machinery. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-core/src/scalar.rs:14` — add serde derive to `ScalarKind` +- Modify: `crates/aura-core/src/node.rs:91` — add serde derive to `BoundParam` +- Modify: `crates/aura-engine/src/harness.rs:29,38` — add serde derive to `Edge`, `Target` +- Modify: `crates/aura-engine/src/blueprint.rs:29,126` — add serde derive to `OutField`, `Role` (`source` skip-when-None) +- Create: `crates/aura-std/src/vocabulary.rs` — `std_vocabulary(type_id)` closed match (22 zero-arg builders) +- Modify: `crates/aura-std/src/lib.rs` — `mod vocabulary;` + `pub use vocabulary::std_vocabulary;` +- Create: `crates/aura-engine/src/blueprint_serde.rs` — DTOs, `BLUEPRINT_FORMAT_VERSION`, `blueprint_to_json`, `blueprint_from_json`, `SerializeError`, `LoadError` +- Modify: `crates/aura-engine/src/lib.rs:45-79` — `mod blueprint_serde;` + re-exports +- Modify: `crates/aura-engine/src/test_fixtures.rs` — add `sink_free_sma_cross_signal()` fixture +- Test: `crates/aura-core/src/scalar.rs` (tests) — `ScalarKind` serde = variant-name +- Test: `crates/aura-std/src/vocabulary.rs` (tests) — resolver hits/misses +- Test: `crates/aura-engine/src/blueprint_serde.rs` (tests) — canonical golden, named failures, bit-identical run, idempotence, recursion + +Note on the spec's type-list: `Firing` (spec Components table) is **not** in the +serialized closure — the DTO carries only `type_id`/`name`/`bound`; `Firing` +lives in `NodeSchema`/`PortSpec`, which are never serialized (schemas are +re-derived from the resolver). It gets no serde derive. The spec also mis-locates +`ScalarKind` in `node.rs`; it is in `scalar.rs:15`. + +--- + +### Task 1: serde derives on the plain serialized types + +**Files:** +- Modify: `crates/aura-core/src/scalar.rs:14` (`ScalarKind`), `crates/aura-core/src/node.rs:91` (`BoundParam`) +- Modify: `crates/aura-engine/src/harness.rs:29` (`Edge`), `:38` (`Target`) +- Modify: `crates/aura-engine/src/blueprint.rs:29` (`OutField`), `:126` (`Role`) +- Test: `crates/aura-core/src/scalar.rs` (tests module) + +- [ ] **Step 1: Add the serde encoding test (RED)** + +In `crates/aura-core/src/scalar.rs`, inside the existing `#[cfg(test)] mod tests` +block (the one holding `scalar_serde_round_trips`), add: + +```rust + #[test] + fn scalar_kind_serde_is_variant_name() { + // canonical kind encoding: a bare variant-name string, both directions. + assert_eq!(serde_json::to_string(&ScalarKind::I64).unwrap(), "\"I64\""); + assert_eq!(serde_json::to_string(&ScalarKind::F64).unwrap(), "\"F64\""); + let back: ScalarKind = serde_json::from_str("\"I64\"").unwrap(); + assert_eq!(back, ScalarKind::I64); + } +``` + +- [ ] **Step 2: Run the test to verify it fails to compile** + +Run: `cargo test -p aura-core scalar_kind_serde_is_variant_name` +Expected: FAIL — compile error, `ScalarKind` does not implement `Serialize`. + +- [ ] **Step 3: Add the derives** + +`crates/aura-core/src/scalar.rs:14` — change +`#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]` (on `ScalarKind`) to: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +``` + +`crates/aura-core/src/node.rs:91` — change `#[derive(Clone, Debug, PartialEq)]` +(on `BoundParam`) to: + +```rust +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +``` + +`crates/aura-engine/src/harness.rs:29` (`Edge`) and `:38` (`Target`) — change each +`#[derive(Clone, Copy, Debug, PartialEq, Eq)]` to: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +``` + +`crates/aura-engine/src/blueprint.rs:29` (`OutField`) — change +`#[derive(Clone, Debug, PartialEq, Eq)]` to: + +```rust +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +``` + +`crates/aura-engine/src/blueprint.rs:126` (`Role`) — change +`#[derive(Clone, Debug, PartialEq, Eq)]` to the line below, and add the field +attribute on `source`: + +```rust +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Role { + pub name: String, + pub targets: Vec, + /// `None` = an open interior port (wired by the enclosing graph's edges); + /// `Some(kind)` = a bound ingestion feed of `kind` (only meaningful at the root, + /// where it lowers to a `FlatGraph` source). C3: sources bind at ingestion only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} +``` + +(Deriving serde directly on `Role` — rather than the spec's separate `RoleData` +mirror — yields the identical wire bytes with one fewer type; the format is +unchanged. `Edge`/`Target`/`OutField`/`BoundParam` likewise serialize 1:1, so the +DTO embeds the real types.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p aura-core scalar_kind_serde_is_variant_name` +Expected: PASS. + +- [ ] **Step 5: Build the engine to confirm the engine-side derives compile** + +Run: `cargo build -p aura-engine` +Expected: clean build (0 errors). + +--- + +### Task 2: the closed aura-std node-vocabulary resolver + +**Files:** +- Create: `crates/aura-std/src/vocabulary.rs` +- Modify: `crates/aura-std/src/lib.rs` (`mod` list + `pub use` block) + +- [ ] **Step 1: Create `crates/aura-std/src/vocabulary.rs`** + +```rust +//! The closed, compiled-in dispatch from a serialized node **type identity** to +//! its `aura-std` builder factory — the load-side counterpart to the type label +//! a blueprint serializes (`PrimitiveBuilder::label`). This is **not** a dynamic +//! registry (domain invariant 9): it is a `match` the crate that *owns* the +//! nodes writes, over its own closed, compiled-in vocabulary (C24 — nodes are +//! referenced "by compiled-in type identity", never a by-name marketplace). The +//! engine cannot hold this table (it does not depend on `aura-std`); a loader +//! takes it as an injected resolver, and a project `cdylib` later supplies its +//! own (C16) through the same seam. +//! +//! Scope (#155): only the **zero-argument** `Type::builder()` factories are +//! resolvable. Builders that take structural construction arguments +//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`) +//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which +//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id` +//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`) +//! rather than guessing. Serialising structural-axis construction args is a +//! later, additive extension (#156/C20). + +use crate::{ + Add, And, Bias, CarryCost, ConstantCost, Delay, Ema, EqConst, FixedStop, Gt, Latch, LongOnly, + Mul, PositionManagement, Resample, RollingMax, RollingMin, Sizer, Sma, Sqrt, Sub, + VolSlippageCost, +}; +use aura_core::PrimitiveBuilder; + +/// Resolve a serialized node type identity (the `PrimitiveBuilder::label`, e.g. +/// `"SMA"`) to a fresh, unbound builder from the node's own factory. Returns +/// `None` for any identity outside the zero-arg `aura-std` vocabulary. +pub fn std_vocabulary(type_id: &str) -> Option { + Some(match type_id { + "Add" => Add::builder(), + "And" => And::builder(), + "Bias" => Bias::builder(), + "CarryCost" => CarryCost::builder(), + "ConstantCost" => ConstantCost::builder(), + "Delay" => Delay::builder(), + "EMA" => Ema::builder(), + "EqConst" => EqConst::builder(), + "FixedStop" => FixedStop::builder(), + "Gt" => Gt::builder(), + "Latch" => Latch::builder(), + "LongOnly" => LongOnly::builder(), + "Mul" => Mul::builder(), + "PositionManagement" => PositionManagement::builder(), + "Resample" => Resample::builder(), + "RollingMax" => RollingMax::builder(), + "RollingMin" => RollingMin::builder(), + "Sizer" => Sizer::builder(), + "SMA" => Sma::builder(), + "Sqrt" => Sqrt::builder(), + "Sub" => Sub::builder(), + "VolSlippageCost" => VolSlippageCost::builder(), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::std_vocabulary; + + #[test] + fn std_vocabulary_resolves_known_and_rejects_unknown() { + // a known zero-arg node resolves, and the builder carries the type label back + let sma = std_vocabulary("SMA").expect("SMA is in the vocabulary"); + assert_eq!(sma.label(), "SMA"); + // an unknown id, a construction-arg node, and a sink are all absent + assert!(std_vocabulary("nope").is_none()); + assert!(std_vocabulary("LinComb").is_none()); + assert!(std_vocabulary("Recorder").is_none()); + } +} +``` + +- [ ] **Step 2: Wire the module into `crates/aura-std/src/lib.rs`** + +Add `mod vocabulary;` to the private `mod` list (after `mod vol_slippage_cost;`): + +```rust +mod vocabulary; +``` + +Add the re-export at the end of the `pub use` block (after +`pub use vol_slippage_cost::VolSlippageCost;`): + +```rust +pub use vocabulary::std_vocabulary; +``` + +- [ ] **Step 3: Run the resolver test** + +Run: `cargo test -p aura-std std_vocabulary_resolves_known_and_rejects_unknown` +Expected: PASS. + +--- + +### Task 3: DTO layer + canonical serializer + envelope + +**Files:** +- Create: `crates/aura-engine/src/blueprint_serde.rs` +- Modify: `crates/aura-engine/src/lib.rs` (`mod` + re-export) +- Modify: `crates/aura-engine/src/test_fixtures.rs` (add the sink-free signal fixture) + +- [ ] **Step 1: Add the sink-free signal fixture to `test_fixtures.rs`** + +Append to `crates/aura-engine/src/test_fixtures.rs` (its imports already cover +`BlueprintNode`/`Composite`/`Edge`/`OutField`/`Role`/`Target`, `Scalar`, and +`aura_std::{Bias, Sma, Sub}`): + +```rust +/// The SMA-cross **signal** graph as a sink-free, param-generic composite: fast +/// SMA (length bound to 2) and slow SMA over one `price` role, their spread, a +/// `Bias`. Output is the `bias` field. No recorder/broker — observation is +/// attached by the caller. This is the canonical round-trip fixture (cycle 0087). +pub(crate) fn sink_free_sma_cross_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() }], + ) +} +``` + +- [ ] **Step 2: Create `crates/aura-engine/src/blueprint_serde.rs` (DTO + serializer)** + +```rust +//! Blueprint serialization (C24): a `Composite` blueprint is projected to a +//! canonical, versioned data value and reconstructed from it (`blueprint_from_json` +//! in this module's loader half). `Composite`/`PrimitiveBuilder` cannot derive +//! serde — they hold a `Box` build closure — so the format is a faithful +//! serde **projection**: it carries each primitive's compiled-in **type identity** +//! (`PrimitiveBuilder::label`), its instance name, and its bound params; the build +//! closure and the declared schema are re-derived on load from the injected +//! resolver. This is the inverse of the lossy render half (`model_to_json`); it +//! carries no node logic (C17) and references a closed vocabulary (C24). + +use crate::blueprint::{BlueprintNode, Composite, OutField, Role}; +use crate::harness::Edge; +use aura_core::{BoundParam, PrimitiveBuilder}; + +/// The format version the loader understands. Bumped only by a load-bearing +/// (Tier-2) change; additive optional fields do not bump it (#156). +pub const BLUEPRINT_FORMAT_VERSION: u32 = 1; + +/// Top-level envelope: the version is read before the payload is interpreted. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct BlueprintDoc { + pub format_version: u32, + pub blueprint: CompositeData, +} + +/// Serde mirror of a `Composite` (the in-memory type holds build closures and +/// cannot derive serde). Field order here is the canonical JSON field order. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct CompositeData { + pub name: String, + pub nodes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edges: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub input_roles: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub output: Vec, +} + +/// A blueprint item: a primitive (referenced by type identity) or a nested +/// composite (recursion). Externally tagged: `{"primitive": ..}` / `{"composite": ..}`. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeData { + Primitive(PrimitiveData), + Composite(CompositeData), +} + +/// A primitive node as data: its compiled-in type identity, optional instance +/// name, and bound params. The schema + build closure are re-derived on load. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct PrimitiveData { + #[serde(rename = "type")] + pub type_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bound: Vec, +} + +/// Serializer failure (typed, named). +#[derive(Debug)] +pub enum SerializeError { + Json(serde_json::Error), +} + +fn project(c: &Composite) -> CompositeData { + CompositeData { + name: c.name().to_string(), + nodes: c.nodes().iter().map(project_node).collect(), + edges: c.edges().to_vec(), + input_roles: c.input_roles().to_vec(), + output: c.output().to_vec(), + } +} + +fn project_node(n: &BlueprintNode) -> NodeData { + match n { + BlueprintNode::Primitive(b) => NodeData::Primitive(PrimitiveData { + type_id: b.label(), + name: b.instance_name().map(str::to_string), + bound: b.bound_params().to_vec(), + }), + BlueprintNode::Composite(c) => NodeData::Composite(project(c)), + } +} + +/// Serialize a blueprint to canonical, versioned JSON: compact, struct-declaration +/// field order, defaults omitted (`skip_serializing_if`). An absent optional is +/// byte-identical to the pre-extension form. +pub fn blueprint_to_json(c: &Composite) -> Result { + let doc = BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }; + serde_json::to_string(&doc).map_err(SerializeError::Json) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signal_serializes_to_canonical_golden() { + let signal = crate::test_fixtures::sink_free_sma_cross_signal(); + let json = blueprint_to_json(&signal).expect("serializes"); + let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#; + // pins: canonical field order, format_version envelope, AND omit-defaults + // (the unnamed/unbound `Sub` + `Bias` carry no `name`/`bound` keys; the + // `price` role carries no `source` key). + assert_eq!(json, golden); + assert!(json.contains(r#"{"primitive":{"type":"Sub"}}"#), "omit-defaults"); + } +} +``` + +- [ ] **Step 3: Wire the module into `crates/aura-engine/src/lib.rs`** + +Add to the private `mod` list (around `:45-52`, next to `mod blueprint;`): + +```rust +mod blueprint_serde; +``` + +Add to the re-export block (around `:54-79`): + +```rust +pub use blueprint_serde::{blueprint_to_json, BlueprintDoc, CompositeData, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION}; +``` + +- [ ] **Step 4: Run the golden test** + +Run: `cargo test -p aura-engine signal_serializes_to_canonical_golden` +Expected: PASS. + +--- + +### Task 4: the loader + typed load errors + +**Files:** +- Modify: `crates/aura-engine/src/blueprint_serde.rs` (add loader + `LoadError`) +- Modify: `crates/aura-engine/src/lib.rs` (extend the re-export) + +- [ ] **Step 1: Add the loader + `LoadError` to `blueprint_serde.rs`** + +Append after `blueprint_to_json` (before the `#[cfg(test)]` module): + +```rust +/// Loader failure (typed, named — never a panic, never a silent wrong graph). +#[derive(Debug)] +pub enum LoadError { + /// Malformed or structurally-invalid JSON. + Json(serde_json::Error), + /// `format_version` the loader does not understand. The full must-understand + /// horizon (multiple versions, unknown-section policy) is #156; #155 supports + /// exactly one version and refuses any other, cleanly. + UnsupportedVersion { found: u32, supported: u32 }, + /// `resolve` returned `None` for a serialized `type_id` (outside the injected + /// vocabulary — unknown node, or a construction-arg/sink node not in #155's set). + UnknownNodeType(String), +} + +fn reconstruct( + d: &CompositeData, + resolve: &dyn Fn(&str) -> Option, +) -> Result { + let mut nodes = Vec::with_capacity(d.nodes.len()); + for nd in &d.nodes { + nodes.push(match nd { + NodeData::Primitive(p) => { + let mut b = resolve(&p.type_id) + .ok_or_else(|| LoadError::UnknownNodeType(p.type_id.clone()))?; + if let Some(n) = &p.name { + b = b.named(n); + } + // Re-apply bound params BY NAME. The effective param vector is + // order-independent (each `bind` computes its slot against the + // shrunk schema); ascending original `pos` is the canonical order. + let mut bound: Vec<&BoundParam> = p.bound.iter().collect(); + bound.sort_by_key(|bp| bp.pos); + for bp in bound { + b = b.bind(&bp.name, bp.value); + } + BlueprintNode::Primitive(b) + } + NodeData::Composite(c) => BlueprintNode::Composite(reconstruct(c, resolve)?), + }); + } + Ok(Composite::new( + d.name.clone(), + nodes, + d.edges.clone(), + d.input_roles.clone(), + d.output.clone(), + )) +} + +/// Load a blueprint from canonical JSON, resolving each primitive's type identity +/// through the injected `resolve` (e.g. `aura_std::std_vocabulary`). The version +/// envelope is checked before the payload is reconstructed. +pub fn blueprint_from_json( + data: &str, + resolve: &dyn Fn(&str) -> Option, +) -> Result { + let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?; + if doc.format_version != BLUEPRINT_FORMAT_VERSION { + return Err(LoadError::UnsupportedVersion { + found: doc.format_version, + supported: BLUEPRINT_FORMAT_VERSION, + }); + } + reconstruct(&doc.blueprint, resolve) +} +``` + +- [ ] **Step 2: Extend the `lib.rs` re-export** + +Change the Task-3 re-export line to add `blueprint_from_json` and `LoadError`: + +```rust +pub use blueprint_serde::{blueprint_from_json, blueprint_to_json, BlueprintDoc, CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION}; +``` + +- [ ] **Step 3: Add the named-failure tests** + +In `blueprint_serde.rs`'s `#[cfg(test)] mod tests`, add (the resolver +`aura_std::std_vocabulary` is available — `aura-std` is a test-only dep of the +engine): + +```rust + #[test] + fn unknown_node_type_fails_named() { + // a valid envelope naming a type outside the vocabulary -> clean, named error + let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"NoSuchNode"}}]}}"#; + let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).unwrap_err(); + assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode")); + } + + #[test] + fn unsupported_version_fails_named() { + let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#; + let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).unwrap_err(); + assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 })); + } +``` + +- [ ] **Step 4: Run the loader failure tests** + +Run: `cargo test -p aura-engine unknown_node_type_fails_named` +Expected: PASS. + +Run: `cargo test -p aura-engine unsupported_version_fails_named` +Expected: PASS. + +--- + +### Task 5: round-trip integration — bit-identical run, idempotence, recursion + +**Files:** +- Modify: `crates/aura-engine/src/blueprint_serde.rs` (`#[cfg(test)] mod tests`) + +- [ ] **Step 1: Add a recorder-attaching run helper + the bit-identical test** + +In `blueprint_serde.rs`'s `#[cfg(test)] mod tests`, add the imports and helper. +The helper nests a signal composite under a root with one `Recorder` on its +`bias` output (the same `Composite::new` nesting idiom as +`composite_sma_cross_harness`), bootstraps with the open point, and returns the +recorded trace: + +```rust + use crate::blueprint::{Composite, Role}; + use crate::harness::{Edge, Target}; + use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923) + use aura_core::{Scalar, ScalarKind, Timestamp}; + use aura_std::Recorder; + use std::sync::mpsc; + + // Open param point for the sink-free signal (fast.length is bound): [slow.length, bias.scale]. + fn signal_point() -> Vec { + vec![Scalar::i64(4), Scalar::f64(0.5)] + } + + // Nest `signal` under a root that records its single `bias` output, run the + // 7-tick synthetic price fixture, and collect the recorded trace. + fn run_recording(signal: Composite) -> Vec<(Timestamp, Vec)> { + let (tx, rx) = mpsc::channel(); + let root = Composite::new( + "h", + vec![ + crate::blueprint::BlueprintNode::Composite(signal), + Recorder::builder(vec![ScalarKind::F64], aura_core::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 prices = crate::test_fixtures::synthetic_prices(); + let mut h = root.bootstrap_with_params(signal_point()).expect("bootstraps"); + h.run(vec![Box::new(VecSource::new(prices))]); + rx.try_iter().collect() + } + + #[test] + fn serialized_blueprint_runs_bit_identical_to_rust_built() { + let rust_built = crate::test_fixtures::sink_free_sma_cross_signal(); + let json = blueprint_to_json(&rust_built).expect("serializes"); + let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads"); + + let trace_rust = run_recording(rust_built); + let trace_loaded = run_recording(loaded); + + assert_eq!(trace_rust, trace_loaded, "serialized-then-loaded run diverged"); + assert!(!trace_loaded.is_empty(), "trace must be populated (non-degenerate)"); + } +``` + +- [ ] **Step 2: Run the bit-identical test** + +Run: `cargo test -p aura-engine serialized_blueprint_runs_bit_identical_to_rust_built` +Expected: PASS. + +- [ ] **Step 3: Add the canonical-idempotence and recursion tests** + +In the same test module: + +```rust + #[test] + fn serializer_and_loader_are_inverse() { + let original = crate::test_fixtures::sink_free_sma_cross_signal(); + let json = blueprint_to_json(&original).expect("serializes"); + let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads"); + // serialize -> load -> serialize is byte-stable (canonical round-trip identity) + assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes")); + } + + #[test] + fn nested_composite_round_trips() { + // recursion + a nested bound param + a role: wrap the signal composite in + // an outer composite that re-exports its `bias` output. Proves the loop is + // not flat-SMA-specific. + let inner = crate::test_fixtures::sink_free_sma_cross_signal(); + let outer = Composite::new( + "outer", + vec![crate::blueprint::BlueprintNode::Composite(inner)], + vec![], + vec![Role { + name: "price".into(), + targets: vec![Target { node: 0, slot: 0 }], + source: None, + }], + vec![crate::blueprint::OutField { node: 0, field: 0, name: "bias".into() }], + ); + let json = blueprint_to_json(&outer).expect("serializes"); + let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads"); + assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes")); + // the nested composite survived as a composite, not flattened + assert!(json.contains(r#"{"composite":{"name":"sma_cross""#), "nested composite preserved"); + } +``` + +- [ ] **Step 4: Run the idempotence + recursion tests** + +Run: `cargo test -p aura-engine serializer_and_loader_are_inverse` +Expected: PASS. + +Run: `cargo test -p aura-engine nested_composite_round_trips` +Expected: PASS. + +- [ ] **Step 5: Full workspace gate** + +Run: `cargo test --workspace` +Expected: PASS (all pre-existing tests + the new cycle-0087 tests; ~737 + new). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean (0 warnings).