From d5602ec5ad996c9e7dabccec5d94c75de312cffd Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 29 Jun 2026 14:54:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(0087):=20blueprint=20serialization=20&=20l?= =?UTF-8?q?oader=20=E2=80=94=20close=20the=20graph-as-data=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`; 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 --- crates/aura-core/src/lib.rs | 3 +- crates/aura-core/src/node.rs | 2 +- crates/aura-core/src/scalar.rs | 11 +- crates/aura-engine/src/blueprint.rs | 5 +- crates/aura-engine/src/blueprint_serde.rs | 283 ++++++++++++++++++ crates/aura-engine/src/harness.rs | 4 +- crates/aura-engine/src/lib.rs | 5 + crates/aura-engine/src/test_fixtures.rs | 27 ++ .../aura-engine/tests/blueprint_serde_e2e.rs | 164 ++++++++++ crates/aura-std/src/lib.rs | 2 + crates/aura-std/src/vocabulary.rs | 110 +++++++ 11 files changed, 609 insertions(+), 7 deletions(-) create mode 100644 crates/aura-engine/src/blueprint_serde.rs create mode 100644 crates/aura-engine/tests/blueprint_serde_e2e.rs create mode 100644 crates/aura-std/src/vocabulary.rs diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index b68e8aa..77b83f5 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -43,7 +43,8 @@ pub use column::{Column, Window}; pub use ctx::Ctx; pub use error::KindMismatch; pub use node::{ - zip_params, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, + zip_params, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, + PrimitiveBuilder, }; pub use scalar::{Scalar, ScalarKind, Timestamp}; pub use series_fold::SeriesFold; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 438c2ef..c51608e 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -88,7 +88,7 @@ pub fn zip_params(space: &[ParamSpec], point: &[Cell]) -> Vec<(String, Scalar)> /// render/debug surface only (C23), dropped at lowering. `pos` is the slot's /// position in the node's ORIGINAL (pre-bind) param list, so the model serializer /// and viewer can place it back into slot order regardless of bind sequence. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BoundParam { pub pos: usize, pub name: String, diff --git a/crates/aura-core/src/scalar.rs b/crates/aura-core/src/scalar.rs index 8cf497b..2d55bfa 100644 --- a/crates/aura-core/src/scalar.rs +++ b/crates/aura-core/src/scalar.rs @@ -12,7 +12,7 @@ use crate::cell::Cell; pub struct Timestamp(pub i64); /// The kind tag of a scalar / column, used for the edge-time type check (C7). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ScalarKind { I64, F64, @@ -277,4 +277,13 @@ mod tests { assert_eq!(back, s, "round-trip failed: {json}"); } } + + #[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); + } } diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 4e3ca49..afca2de 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -26,7 +26,7 @@ use crate::{GridSpace, ParamRange, RandomSpace, RunReport, SweepFamily}; /// `(node, output-field)` surfaced at the boundary under `name`. `name` is a /// non-load-bearing render/debug symbol (C23) — like `FieldSpec.name` and /// `Composite.name`, it does not reach the flat graph. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct OutField { pub node: usize, pub field: usize, @@ -123,13 +123,14 @@ fn interior_slot_kind(nodes: &[BlueprintNode], _edges: &[Edge], t: &Target) -> S /// One named input role: role `r` (by position) fans the source value into /// `targets`. The `name` is a non-load-bearing render symbol (C23); identity is /// the role index, which survives lowering — the name does not. -#[derive(Clone, Debug, PartialEq, Eq)] +#[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, } diff --git a/crates/aura-engine/src/blueprint_serde.rs b/crates/aura-engine/src/blueprint_serde.rs new file mode 100644 index 0000000..2404d28 --- /dev/null +++ b/crates/aura-engine/src/blueprint_serde.rs @@ -0,0 +1,283 @@ +//! 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) => { + // Canonical: bound params in ascending original-slot order, mirroring + // the loader's re-bind canonicalization, so serialization is + // bind-order-independent (a precondition for content-addressing, #158). + // Identity today — every #155 node has <=1 param — but it holds by + // construction once a multi-param node enters the vocabulary. + let mut bound = b.bound_params().to_vec(); + bound.sort_by_key(|bp| bp.pos); + NodeData::Primitive(PrimitiveData { + type_id: b.label(), + name: b.instance_name().map(str::to_string), + bound, + }) + } + 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) +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use super::*; + 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)"); + } + + #[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"); + } + + #[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); + } + + #[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"}}]}}"#; + // `.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| aura_std::std_vocabulary(t)).err().unwrap(); + 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)).err().unwrap(); + assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 })); + } +} diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index 8af001b..b008cc0 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -26,7 +26,7 @@ use aura_core::{AnyColumn, Cell, Ctx, Firing, Node, NodeSchema, Scalar, ScalarKi /// 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 /// (there is no "bind whole record" mechanism). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Edge { pub from: usize, pub to: usize, @@ -35,7 +35,7 @@ pub struct Edge { } /// An input slot the source value is forwarded into each cycle. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Target { pub node: usize, pub slot: usize, diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 5c74a07..7a92100 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -43,6 +43,7 @@ //! Visualization is never here: it is a downstream consumer node on the streams. mod blueprint; +mod blueprint_serde; mod builder; mod graph_model; mod harness; @@ -55,6 +56,10 @@ pub use blueprint::{ BindError, Binder, BlueprintNode, CompileError, Composite, OutField, RandomBinder, Role, SweepBinder, }; +pub use blueprint_serde::{ + blueprint_from_json, blueprint_to_json, BlueprintDoc, CompositeData, LoadError, NodeData, + PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION, +}; pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle}; pub use graph_model::model_to_json; pub use harness::{ diff --git a/crates/aura-engine/src/test_fixtures.rs b/crates/aura-engine/src/test_fixtures.rs index d4929a5..ce63926 100644 --- a/crates/aura-engine/src/test_fixtures.rs +++ b/crates/aura-engine/src/test_fixtures.rs @@ -85,3 +85,30 @@ pub(crate) fn composite_sma_cross_harness() -> ( ); (bp, rx_eq, rx_ex) } + +/// 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() }], + ) +} diff --git a/crates/aura-engine/tests/blueprint_serde_e2e.rs b/crates/aura-engine/tests/blueprint_serde_e2e.rs new file mode 100644 index 0000000..bca252e --- /dev/null +++ b/crates/aura-engine/tests/blueprint_serde_e2e.rs @@ -0,0 +1,164 @@ +//! 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 { + 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)> { + 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:?}", + ); +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index ece6038..ae2fa9a 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -46,6 +46,7 @@ mod sma; mod sqrt; mod stop_rule; mod sub; +mod vocabulary; mod vol_slippage_cost; pub use add::Add; pub use and::And; @@ -82,4 +83,5 @@ pub use sma::Sma; pub use sqrt::Sqrt; pub use stop_rule::FixedStop; pub use sub::Sub; +pub use vocabulary::std_vocabulary; pub use vol_slippage_cost::VolSlippageCost; diff --git a/crates/aura-std/src/vocabulary.rs b/crates/aura-std/src/vocabulary.rs new file mode 100644 index 0000000..4275a0a --- /dev/null +++ b/crates/aura-std/src/vocabulary.rs @@ -0,0 +1,110 @@ +//! 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; + + /// Every key in the closed vocabulary resolves to a builder that carries + /// that exact type label back, and only the zero-arg keys do. The label + /// round-trip is the property: a typo in any match key (the silent- + /// unresolvable failure this resolver guards against) breaks it, as does a + /// key mapped to the wrong builder; construction-arg nodes and sinks stay + /// absent so the loader fails cleanly rather than guessing. + #[test] + fn std_vocabulary_resolves_known_and_rejects_unknown() { + // every zero-arg key round-trips: lookup -> builder -> same type label. + // (this list is the resolver's whole vocabulary; a missing/typo'd arm + // fails the lookup, a mis-mapped arm fails the label assertion.) + for type_id in [ + "Add", + "And", + "Bias", + "CarryCost", + "ConstantCost", + "Delay", + "EMA", + "EqConst", + "FixedStop", + "Gt", + "Latch", + "LongOnly", + "Mul", + "PositionManagement", + "Resample", + "RollingMax", + "RollingMin", + "Sizer", + "SMA", + "Sqrt", + "Sub", + "VolSlippageCost", + ] { + let builder = + std_vocabulary(type_id).unwrap_or_else(|| panic!("{type_id} is in the vocabulary")); + assert_eq!( + builder.label(), + type_id, + "resolver key must round-trip to the same type label" + ); + } + // 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()); + } +}