cdd2da6337
The blueprint data format (C24) extends additively under one codified two-tier discipline: - Tier-1 (additive-optional): a new optional field/section is tolerated by an older reader (serde ignores unknown fields — no deny_unknown_fields), with no format_version bump; by C1 a new optional field defaults to prior behaviour, so an old blueprint reproduces the same graph. - Tier-2 (must-understand: a new node type, edge semantics, or structural-axis kind): bumps format_version, so an old reader refuses cleanly (LoadError::UnsupportedVersion / UnknownNodeType) rather than silently building a different graph. Most of #156 already shipped with #155 (cycle 0087): the envelope, omit-defaults canonical form, and both Tier-2 load-path refusals were present and green. This cycle pins the one previously-unproven property — Tier-1 forward-tolerance of an unknown optional field on the current loader — and codifies the discipline. Changes: - New characterization pin (in-crate, blueprint_serde.rs): unknown_optional_field_is_tolerated_byte_identically — an unknown optional key at doc and primitive level loads byte-identically and runs bit-identically. - Two public-seam E2E pins (blueprint_serde_e2e.rs, added by the E2E phase): unknown_envelope_field_tolerated_through_public_api and unknown_primitive_field_tolerated_through_public_api — split the two distinct tolerance surfaces (envelope vs primitive) across the World/cdylib crate boundary, where a deny_unknown_fields added to one would break forward-compat while leaving the in-crate test green. - LoadError::UnsupportedVersion doc reworded from the deferred "is #156" note to the settled two-tier discipline statement. - C24 ledger (docs/design/INDEX.md): Status codification sentence added; #156 dropped from Remaining (#158/#159 stay). Derived fork (recorded on #156): the per-section required-flag mechanism (PNG critical-vs-ancillary) is NOT built — the version envelope already gives the must-understand refusal at version granularity, no current Tier-2 section validates a finer scheme, and C1 holds either way. The pin is a characterization test (GREEN on first run) — it documents existing serde silent-ignore behaviour, no production code changed. Verified: cargo test -p aura-engine green (all targets, three new tests confirmed by name); cargo clippy --workspace --all-targets -- -D warnings clean.
332 lines
15 KiB
Rust
332 lines
15 KiB
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<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) => {
|
|
// 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<String, SerializeError> {
|
|
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 **Tier-2
|
|
/// (must-understand)** refusal. The format extends under a two-tier discipline
|
|
/// (#156): a **Tier-1** additive-optional change (a new optional field /
|
|
/// section) does NOT bump the version — an older reader tolerates it (serde
|
|
/// ignores unknown fields; see `unknown_optional_field_is_tolerated_byte_identically`)
|
|
/// and, by C1, a new optional field defaults to prior behaviour. A **Tier-2**
|
|
/// load-bearing change (a new node type, edge semantics, or structural-axis
|
|
/// kind) MUST bump `BLUEPRINT_FORMAT_VERSION`, so an old reader refuses here
|
|
/// rather than silently building a different graph (C1 / C18). The
|
|
/// per-section required-flag scheme is deferred until a real sub-version Tier-2
|
|
/// addition needs finer granularity than a version bump.
|
|
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)
|
|
}
|
|
|
|
#[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<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)");
|
|
}
|
|
|
|
#[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 }));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_optional_field_is_tolerated_byte_identically() {
|
|
// Forward-compat (C24 / #156, Tier-1): a future writer that has additively
|
|
// extended the format emits optional keys the CURRENT loader does not know.
|
|
// The loader must silently ignore them and reconstruct the IDENTICAL graph —
|
|
// never refuse, never let an unknown key leak into the built blueprint. serde
|
|
// ignores unknown fields by default (no deny_unknown_fields). This pins the
|
|
// additive property the canonical-golden + omit-defaults discipline claims but
|
|
// no existing test exercises.
|
|
let canonical =
|
|
blueprint_to_json(&crate::test_fixtures::sink_free_sma_cross_signal()).expect("serializes");
|
|
|
|
// Inject unknown optional keys at two additive shapes a new writer might emit:
|
|
// a doc-level section ("metadata") and a per-primitive key ("annotations").
|
|
let with_unknown = canonical
|
|
.replacen(
|
|
r#"{"format_version":1,"#,
|
|
r#"{"format_version":1,"metadata":{"author":"future"},"#,
|
|
1,
|
|
)
|
|
.replacen(r#"{"type":"Sub"}"#, r#"{"type":"Sub","annotations":["future"]}"#, 1);
|
|
assert_ne!(with_unknown, canonical, "probe must actually inject unknown keys");
|
|
|
|
// tolerated, not refused
|
|
let loaded = blueprint_from_json(&with_unknown, &|t| aura_std::std_vocabulary(t))
|
|
.expect("an unknown optional field is tolerated, not refused");
|
|
|
|
// the unknown keys did not leak into the graph: re-serialization equals the
|
|
// canonical form WITHOUT them (byte-identical reconstruction).
|
|
assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical);
|
|
|
|
// and the loaded graph runs bit-identically to the plain canonical one (C1).
|
|
let plain = blueprint_from_json(&canonical, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
|
assert_eq!(
|
|
run_recording(loaded),
|
|
run_recording(plain),
|
|
"run diverged under an unknown optional field",
|
|
);
|
|
}
|
|
}
|