Files
Aura/docs/plans/0087-blueprint-serialization-loader.md
T
Brummel b2278538ab plan: 0087 blueprint serialization & loader
Five tasks for #155: serde derives on the serialized plain types; the closed
aura-std std_vocabulary resolver (22 zero-arg builders); the DTO layer +
canonical serializer + format_version envelope; the resolver-injected loader +
typed LoadError; and the round-trip tests (canonical golden, named failures,
bit-identical run vs Rust twin, idempotence, recursion).

refs #155
2026-06-29 13:45:31 +02:00

27 KiB

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.rsstd_vocabulary(type_id) closed match (22 zero-arg builders)
  • Modify: crates/aura-std/src/lib.rsmod 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-79mod 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:

    #[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:

#[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:

#[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:

#[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:

#[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:

#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Role {
    pub name: String,
    pub targets: Vec<Target>,
    /// `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<ScalarKind>,
}

(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

//! 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<PrimitiveBuilder> {
    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;):

mod vocabulary;

Add the re-export at the end of the pub use block (after pub use vol_slippage_cost::VolSlippageCost;):

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}):

/// 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)
//! 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<dyn Fn>` 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<NodeData>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub edges: Vec<Edge>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub input_roles: Vec<Role>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub output: Vec<OutField>,
}

/// 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<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub bound: Vec<BoundParam>,
}

/// 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<String, SerializeError> {
    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;):

mod blueprint_serde;

Add to the re-export block (around :54-79):

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):

/// 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<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
    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<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
    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:

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):

    #[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:

    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<Scalar> {
        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<Scalar>)> {
        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:

    #[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).