Files
Aura/crates/aura-engine/src/blueprint_serde.rs
T
claude a8b1ba45c5 feat(aura-engine, aura-vocabulary, aura-cli): the args channel end to end
The data plane reaches non-scalar structural config (closes #271, the
final Data-authorability-boundary item). Op::Add gains args
(string pairs, applied via try_args after resolve and before the bind
loop — no pending builder ever enters a graph; an arg-bearing type
without args refuses as MissingArg). OpError::BadArg carries the
precise ArgOpError; the CLI renders it as prose naming the arg, its
kind, and the kind's closed hint (exit 1, #175 content-fault class).

Serde: PrimitiveData.args (skip-if-empty — args-free documents stay
byte-identical, golden-pinned, so every existing content id is stable
per C18) with a data-driven format version: the writer emits 1 for
args-free documents and 2 when any primitive recursively carries args;
the loader accepts 1..=2. The old v2-refusal pin flips deliberately to
v3 (named contract change). Identity JSON keeps args — they are
structural, and the Rust configured() path and the args op-script
bridge to the same identity id (pinned).

Roster: Session / LinComb / CostSum enter (33 -> 36, both count pins;
the LinComb-rejection assertion flips to the resolved side). introspect
--node shows arg rows (name, kind, hint) plus the pending note;
OP_REFERENCE's add row teaches the args form. Registry comment
de-staled (LinComb is now roster-reachable).

Sequencing note: this commit lands op seam and roster together so no
intermediate tree state exposes a pending builder.
2026-07-24 21:43:02 +02:00

1021 lines
47 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).
//!
//! Construction args (#271) are structural, id-bearing data — like `bound`
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
//! `PrimitiveData.args` untouched.
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
/// The CEILING format version this build's loader understands (and the
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
/// 2026-07-10): while the project is unshipped every document lives in-repo
/// and reader/writer change atomically, so the Tier-2 bump discipline
/// activates at the first external ship.
///
/// #271 (data-driven version): the writer no longer emits a single fixed
/// version. A document emits `1` when args-free (byte-identical to every
/// pre-#271 document — content ids stable, C18) and `2` the moment any
/// primitive, at any nesting depth, carries construction `args` — the
/// must-understand signal a pre-#271 loader needs. The loader accepts the
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
/// 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,
/// The authored rationale (the prose twin of `name`; a C23 debug symbol,
/// #125). Tier-1 additive-optional: omitted when absent, defaulted on
/// load — no `BLUEPRINT_FORMAT_VERSION` bump, and absent-field documents
/// keep their exact pre-change bytes (content ids untouched).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub doc: Option<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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub taps: Vec<crate::blueprint::Tap>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gangs: Vec<Gang>,
}
/// 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, its accepted construction args (#271), 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>,
/// The consumed `(name, value)` construction-arg pairs (#271) — the
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
/// (empty) for every args-free primitive, so a document with no args
/// anywhere stays byte-identical to its pre-#271 form.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound: Vec<BoundParam>,
}
/// One serialized construction-arg pair (#271) — the wire twin of
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
/// string, stored verbatim (never re-normalized).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ArgData {
pub name: String,
pub value: String,
}
/// Serializer failure (typed, named).
#[derive(Debug)]
pub enum SerializeError {
Json(serde_json::Error),
}
fn project(c: &Composite) -> CompositeData {
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
// bound-param canonicalization below): each gang's members ascending
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
// first member.
let mut gangs = c.gangs().to_vec();
for g in &mut gangs {
g.members.sort_by_key(|m| (m.node, m.pos));
}
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
CompositeData {
name: c.name().to_string(),
doc: c.doc().map(str::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(),
taps: c.taps().to_vec(),
gangs,
}
}
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);
// Construction args (#271) are declared in a fixed `ArgSpec` order
// (not player-chosen like binds), so no re-canonicalization is
// needed — `construction_args()` already returns them in that
// deterministic order.
let args: Vec<ArgData> = b
.construction_args()
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
NodeData::Primitive(PrimitiveData {
type_id: b.label(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}
BlueprintNode::Composite(c) => NodeData::Composite(project(c)),
}
}
/// Any primitive, at any nesting depth, carries construction args (#271) —
/// the must-understand signal for the data-driven version (spec §Data-driven
/// format version).
fn has_args(b: &CompositeData) -> bool {
b.nodes.iter().any(|n| match n {
NodeData::Primitive(p) => !p.args.is_empty(),
NodeData::Composite(c) => has_args(c),
})
}
/// The version THIS document must declare (#271): `1` for an args-free
/// document (byte-identical to every pre-#271 document, C18) or `2` the
/// moment any primitive anywhere carries args — never the fixed
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
fn document_version(b: &CompositeData) -> u32 {
if has_args(b) {
2
} else {
1
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
let blueprint = project(c);
let format_version = document_version(&blueprint);
BlueprintDoc { format_version, blueprint }
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
serde_json::to_string(doc).map_err(SerializeError::Json)
}
/// 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> {
serialize_doc(&build_doc(c))
}
/// The identity-canonical form (#171): the canonical document with every
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
/// bound-param names, role names, output re-export names, tap names. Everything
/// load-bearing survives: type ids, node order, edges, role targets/order, output
/// pairs/order, tap interior wires, and bound positions/kinds/values (param
/// openness stays identity-bearing: a bound slot is textually present, an open
/// one absent). Research-side comparison
/// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
let mut doc = build_doc(c);
strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc)
}
fn strip_debug_symbols(b: &mut CompositeData) {
b.name = String::new();
b.doc = None; // the prose twin of the name (identity-blind, #171/#125)
for node in &mut b.nodes {
match node {
NodeData::Primitive(p) => {
p.name = None;
for bp in &mut p.bound {
bp.name = String::new(); // pos/kind/value survive
}
}
NodeData::Composite(c) => strip_debug_symbols(c),
}
}
for role in &mut b.input_roles {
role.name = String::new(); // targets + order survive
}
for out in &mut b.output {
out.name = String::new(); // (node, field) + order survive
}
for tap in &mut b.taps {
tap.name = String::new(); // interior wire (node, field) survives
}
for gang in &mut b.gangs {
gang.name = String::new();
for m in &mut gang.members {
m.name = String::new(); // node/pos/kind survive
}
}
}
/// 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 sink node not in #155's set).
UnknownNodeType(String),
/// The document's gangs section fails structural validation against its
/// own nodes (a hand-edited or corrupted document).
Gang(crate::blueprint::CompileError),
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
/// an arg-bearing type serialized/hand-written with an empty `args` —
/// a document naming an arg-bearing type without args must not load.
BadArg(aura_core::ArgOpError),
}
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);
}
// Apply construction args (#271) BEFORE bound params — the
// `try_args` seam. An arg-bearing type with no `args` in the
// document refuses HERE as `MissingArg` (no pending builder
// ever reaches `bind`/enters the built graph); a `Plain` type
// has empty `p.args`, so this is `Ok(self)` unchanged for
// every pre-#271 document.
let args: Vec<(String, String)> =
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
b = b.try_args(&args).map_err(LoadError::BadArg)?;
// 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)?),
});
}
let composite = Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
.with_taps(d.taps.clone())
.with_gangs(d.gangs.clone())
.map_err(LoadError::Gang)?;
Ok(match &d.doc {
Some(doc) => composite.with_doc(doc.clone()),
None => composite,
})
}
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
/// through the injected `resolve` (e.g. `aura_vocabulary::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)?;
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.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, GangMember, OutField, 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::{ArgOpError, Scalar, ScalarKind, Timestamp};
use aura_market::Session;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
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_vocabulary::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_vocabulary::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![OutField { node: 0, field: 0, name: "bias".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
let loaded = blueprint_from_json(&json, &|t| aura_vocabulary::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);
}
/// #125: a doc'd composite round-trips byte-stably, the doc is
/// canonical-byte-bearing (content-id-affecting), absent doc emits no key
/// (pre-change bytes untouched), and the identity projection strips it
/// exactly like the name it sits beside.
#[test]
fn doc_round_trips_canonically_and_is_identity_blind() {
let plain = crate::test_fixtures::sink_free_sma_cross_signal();
let doced = crate::test_fixtures::sink_free_sma_cross_signal()
.with_doc("why this graph: the spread detects trend turns");
let plain_json = blueprint_to_json(&plain).expect("serializes");
let doced_json = blueprint_to_json(&doced).expect("serializes");
assert_ne!(plain_json, doced_json, "the doc is canonical-byte-bearing");
assert!(doced_json.contains("why this graph"), "doc serialized: {doced_json}");
assert!(!plain_json.contains("\"doc\""), "absent doc emits no key: {plain_json}");
let loaded = blueprint_from_json(&doced_json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
assert_eq!(loaded.doc(), Some("why this graph: the spread detects trend turns"));
assert_eq!(doced_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
assert_eq!(
blueprint_identity_json(&doced).expect("identity"),
blueprint_identity_json(&plain).expect("identity"),
"identity form is doc-blind"
);
}
/// #282: a composite's declared taps round-trip through project→reconstruct
/// byte-stably (mirroring `doc_round_trips_canonically_and_is_identity_blind`),
/// and an un-tapped composite's canonical bytes carry no `taps` key at all
/// (skip_serializing_if empty) — the golden-pinned fixture's bytes stay
/// untouched by this additive field.
#[test]
fn taps_round_trip_canonically_and_absent_is_byte_stable() {
use crate::blueprint::{Tap, TapWire};
let plain = crate::test_fixtures::sink_free_sma_cross_signal();
let tapped = crate::test_fixtures::sink_free_sma_cross_signal()
.with_taps(vec![Tap { name: "d".into(), from: TapWire { node: 0, field: 0 } }]);
let plain_json = blueprint_to_json(&plain).expect("serializes");
let tapped_json = blueprint_to_json(&tapped).expect("serializes");
assert_ne!(plain_json, tapped_json, "taps are canonical-byte-bearing");
assert!(!plain_json.contains("\"taps\""), "absent taps emit no key: {plain_json}");
let loaded = blueprint_from_json(&tapped_json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
assert_eq!(loaded.taps(), tapped.taps());
assert_eq!(tapped_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
}
/// #282: a tap's `name` is a debug symbol like `Role.name`/`OutField.name` —
/// two blueprints differing only in tap name share identity JSON (name
/// blanked, interior wire survives) while their canonical JSON differs,
/// mirroring `renamed_role_and_output_share_identity_json_not_canonical_json`.
#[test]
fn renamed_tap_shares_identity_json_not_canonical_json() {
use crate::blueprint::{Tap, TapWire};
fn probe(tap_name: &str) -> Composite {
crate::test_fixtures::sink_free_sma_cross_signal()
.with_taps(vec![Tap { name: tap_name.into(), from: TapWire { node: 0, field: 0 } }])
}
let a = probe("p_long");
let b = probe("p_short");
assert_ne!(
blueprint_to_json(&a).expect("serializes"),
blueprint_to_json(&b).expect("serializes"),
"canonical bytes keep tap names apart"
);
assert_eq!(
blueprint_identity_json(&a).expect("identity-serializes"),
blueprint_identity_json(&b).expect("identity-serializes"),
"identity form is tap-name-blind"
);
}
#[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_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
}
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
/// used to be refused (v1 was the only understood version); now that v2 loads
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
#[test]
fn unsupported_version_fails_named() {
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
}
#[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_vocabulary::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_vocabulary::std_vocabulary(t)).expect("loads");
assert_eq!(
run_recording(loaded),
run_recording(plain),
"run diverged under an unknown optional field",
);
}
// A minimal SMA-cross variant with controllable debug names, boundness, and
// wiring for the identity-projection property tests — the same 4-node shape
// as `test_fixtures::sink_free_sma_cross_signal`.
fn identity_probe(
comp_name: &str,
fast_name: &str,
fast_len: Option<i64>,
swap_sub_slots: bool,
) -> Composite {
let mut fast = Sma::builder().named(fast_name);
if let Some(l) = fast_len {
fast = fast.bind("length", Scalar::i64(l));
}
let (fast_slot, slow_slot) = if swap_sub_slots { (1, 0) } else { (0, 1) };
Composite::new(
comp_name,
vec![
fast.into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
Bias::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: fast_slot, from_field: 0 },
Edge { from: 1, to: 2, slot: slow_slot, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
],
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() }],
)
}
/// #171 acc 1 (engine layer): twins identical up to debug names (composite
/// name, instance name) share the identity JSON while their canonical JSON
/// differs.
#[test]
fn renamed_twins_share_identity_json_not_canonical_json() {
let a = identity_probe("sma_cross", "fast", Some(2), false);
let b = identity_probe("renamed", "speedy", Some(2), false);
assert_ne!(
blueprint_to_json(&a).expect("serializes"),
blueprint_to_json(&b).expect("serializes"),
"canonical bytes keep the debug names apart"
);
let ia = blueprint_identity_json(&a).expect("identity-serializes");
let ib = blueprint_identity_json(&b).expect("identity-serializes");
assert_eq!(ia, ib, "identity form is debug-name-blind");
assert!(!ia.contains("speedy") && !ia.contains("sma_cross"), "no debug symbol leaks: {ia}");
}
/// #171 acc 2 (openness is identity-bearing): a blueprint with `fast.length`
/// bound and its open twin never share an identity JSON.
#[test]
fn open_vs_bound_param_changes_identity() {
let bound = identity_probe("x", "fast", Some(2), false);
let open = identity_probe("x", "fast", None, false);
assert_ne!(
blueprint_identity_json(&bound).expect("identity-serializes"),
blueprint_identity_json(&open).expect("identity-serializes"),
"boundness survives the identity projection"
);
}
/// #171 acc 2 (bound values are identity-bearing): value twins differing in
/// one bound value (I64:2 vs I64:3) never share an identity JSON.
#[test]
fn bound_value_changes_identity() {
assert_ne!(
blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
.expect("identity-serializes"),
blueprint_identity_json(&identity_probe("x", "fast", Some(3), false))
.expect("identity-serializes"),
"a bound value survives the identity projection"
);
}
/// Wiring is identity-bearing: swapping the two Sub input slots (fast->rhs,
/// slow->lhs) is a different topology, hence a different identity JSON.
#[test]
fn edge_swap_changes_identity() {
assert_ne!(
blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
.expect("identity-serializes"),
blueprint_identity_json(&identity_probe("x", "fast", Some(2), true))
.expect("identity-serializes"),
"edge slots survive the identity projection"
);
}
/// Important (nested-composite recursion, C23/invariant 12 — fractal
/// composition): the interior debug names of a NESTED composite (composite
/// name, instance names) must vanish through the identity projection too —
/// proves the `NodeData::Composite(c) => strip_debug_symbols(c)` recursion
/// arm is load-bearing, not dead code the flat-composite tests never reach.
#[test]
fn nested_composite_interior_names_are_identity_blind() {
fn wrap(inner: Composite) -> Composite {
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![OutField { node: 0, field: 0, name: "bias".into() }],
)
}
let a = wrap(identity_probe("sma_cross", "fast", Some(2), false));
let b = wrap(identity_probe("renamed", "speedy", Some(2), false));
assert_ne!(
blueprint_to_json(&a).expect("serializes"),
blueprint_to_json(&b).expect("serializes"),
"canonical bytes keep the nested debug names apart"
);
let ia = blueprint_identity_json(&a).expect("identity-serializes");
let ib = blueprint_identity_json(&b).expect("identity-serializes");
assert_eq!(ia, ib, "identity form is blind to nested-composite interior debug names");
assert!(
!ia.contains("speedy") && !ia.contains("sma_cross"),
"no interior debug symbol leaks: {ia}"
);
}
/// #171 acc 1 (role names and output re-export names are debug symbols
/// too, C23): twins differing only in the input-role name or the output
/// re-export name share the identity JSON while their canonical JSON
/// differs — proves the role/output strip loops in `strip_debug_symbols`
/// are load-bearing, not dead code the differential tests never exercise.
#[test]
fn renamed_role_and_output_share_identity_json_not_canonical_json() {
fn probe(role_name: &str, out_name: &str) -> Composite {
Composite::new(
"x",
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 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: role_name.into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 3, field: 0, name: out_name.into() }],
)
}
let a = probe("price", "bias");
let b = probe("close", "signal");
assert_ne!(
blueprint_to_json(&a).expect("serializes"),
blueprint_to_json(&b).expect("serializes"),
"canonical bytes keep role/output names apart"
);
assert_eq!(
blueprint_identity_json(&a).expect("identity-serializes"),
blueprint_identity_json(&b).expect("identity-serializes"),
"identity form is role-name- and output-name-blind"
);
}
// The resolver used by the gang-serde tests below (mirrors the inline
// `&|t| aura_vocabulary::std_vocabulary(t)` closure the rest of this module uses).
fn fixture_resolver() -> impl Fn(&str) -> Option<PrimitiveBuilder> {
|t: &str| aura_vocabulary::std_vocabulary(t)
}
// Shared topology: two open SMAs feeding a `Sub`. `ganged_fixture_named`
// and `unganged_twin` both build on this single definition so the gang
// and gang-free fixtures cannot drift apart independently.
fn two_sma_sub() -> Composite {
Composite::new(
"sig",
vec![Sma::builder().named("a").into(), Sma::builder().named("b").into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
// `two_sma_sub`, one gang fusing the two SMAs' lengths — Task 2's
// `ganged_pair` shape, reproduced here for the serde-layer tests.
// `gang_name` is the ONLY thing that varies across callers.
fn ganged_fixture_named(gang_name: &str) -> Composite {
two_sma_sub()
.with_gangs(vec![Gang {
name: gang_name.into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 1, pos: 0, name: "length".into() }, // declared backwards
GangMember { node: 0, pos: 0, name: "length".into() },
],
}])
.expect("two open siblings gang")
}
fn ganged_fixture() -> Composite {
ganged_fixture_named("length")
}
// The un-ganged twin of `ganged_fixture`: identical topology, no gang table.
fn unganged_twin() -> Composite {
two_sma_sub()
}
// Four open SMAs, two gangs, BOTH declared out of canonical order: the
// gangs vec itself is swapped (the gang whose sorted first member is
// (0,0) comes SECOND) and each gang's own members are swapped too — probes
// the round-trip's re-canonicalization independently from `with_gangs`'s
// own (order-blind) structural gate.
fn ganged_fixture_declared_backwards() -> Composite {
Composite::new(
"sig",
vec![
Sma::builder().named("a").into(),
Sma::builder().named("b").into(),
Sma::builder().named("c").into(),
Sma::builder().named("d").into(),
],
vec![],
vec![],
vec![],
)
.with_gangs(vec![
Gang {
name: "hi".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 3, pos: 0, name: "length".into() },
GangMember { node: 2, pos: 0, name: "length".into() },
],
},
Gang {
name: "lo".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 1, pos: 0, name: "length".into() },
GangMember { node: 0, pos: 0, name: "length".into() },
],
},
])
.expect("two structurally-valid gangs, declared backwards")
}
// The canonical form `ganged_fixture_declared_backwards` must settle into:
// members ascending (node,pos) within each gang, gangs ordered by their
// (sorted) first member — "lo" (first member (0,0)) precedes "hi" ((2,0)).
fn c_sorted_expectation() -> Vec<Gang> {
vec![
Gang {
name: "lo".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 0, pos: 0, name: "length".into() },
GangMember { node: 1, pos: 0, name: "length".into() },
],
},
Gang {
name: "hi".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 2, pos: 0, name: "length".into() },
GangMember { node: 3, pos: 0, name: "length".into() },
],
},
]
}
/// Gangs round-trip through the canonical form, in canonical order
/// (members ascending (node,pos); gangs by first member) regardless of
/// declaration order.
#[test]
fn gangs_round_trip_canonically() {
let c = ganged_fixture_declared_backwards(); // members/gangs deliberately unsorted
let json = blueprint_to_json(&c).expect("serialize");
let back = blueprint_from_json(&json, &fixture_resolver()).expect("load");
assert_eq!(blueprint_to_json(&back).expect("re-serialize"), json, "canonical fixpoint");
assert_eq!(back.gangs().to_vec(), c_sorted_expectation(), "sorted members/gangs");
assert_eq!(back.param_space().len(), c.param_space().len());
}
/// A hand-corrupted gangs section (node out of range) is refused as a
/// typed load fault, never a silent wrong graph.
#[test]
fn corrupt_gang_section_is_refused_on_load() {
let mut doc: serde_json::Value =
serde_json::from_str(&blueprint_to_json(&ganged_fixture()).unwrap()).unwrap();
doc["blueprint"]["gangs"][0]["members"][0]["node"] = 99.into();
// `.err().unwrap()` (not `.unwrap_err()`): the Ok type `Composite` holds a
// build closure and is not `Debug`.
let err = blueprint_from_json(&doc.to_string(), &fixture_resolver()).err().unwrap();
assert!(matches!(err, LoadError::Gang(_)), "{err:?}");
}
/// Identity is gang-name-blind and gang-structure-sensitive.
#[test]
fn gang_identity_blanks_names_keeps_structure() {
let a = ganged_fixture_named("length");
let b = ganged_fixture_named("window"); // same structure, different gang name
assert_ne!(blueprint_to_json(&a).unwrap(), blueprint_to_json(&b).unwrap());
assert_eq!(
blueprint_identity_json(&a).unwrap(),
blueprint_identity_json(&b).unwrap(),
"gang names are debug symbols"
);
let unganged = unganged_twin();
assert_ne!(
blueprint_identity_json(&a).unwrap(),
blueprint_identity_json(&unganged).unwrap(),
"the gang itself is identity-bearing"
);
}
// ---- construction args (#271) --------------------------------------
// A single-node composite around an arg-configured `Session` (arity-free —
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
Composite::new(
"sess",
vec![Session::configured(9, 30, tz, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
)
}
/// #271 acceptance (engine layer): an args-bearing composite serializes its
/// construction-arg pairs, declares `format_version: 2` (the data-driven
/// must-understand tier, spec §Data-driven format version), and round-trips
/// byte-stably through load -> re-serialize.
#[test]
fn args_round_trip_preserves_pairs_and_version_2() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
let json = blueprint_to_json(&c).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
assert!(
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
"carries the verbatim accepted arg pairs: {json}"
);
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
json,
"args round-trip byte-stably"
);
}
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
/// here under the #271-specific test name the plan names.
#[test]
fn args_free_document_serializes_byte_identical_and_version_1() {
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&signal).expect("serializes");
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
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"}]}}"#;
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
}
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
/// outer composite still trips the outer document's version to 2 — the
/// version walk recurses through `NodeData::Composite`, not just the
/// top-level node list.
#[test]
fn nested_spliced_args_composite_is_version_2() {
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
let outer = Composite::new(
"outer",
vec![crate::blueprint::BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
}
/// #271 (identity is arg-bearing): construction args are structural,
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
/// two builds differing ONLY in one accepted arg value never share an
/// identity JSON, while renaming the composite (a pure debug symbol)
/// still does not affect it.
#[test]
fn identity_json_retains_args() {
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
assert_ne!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&paris).expect("identity-serializes"),
"a differing construction-arg value survives the identity projection"
);
// renaming the composite (a debug symbol) does not affect identity.
let renamed = Composite::new(
"sess_renamed",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
assert_eq!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&renamed).expect("identity-serializes"),
"the composite's own debug name does not affect identity"
);
}
/// #271 (refuse, don't guess): a hand-written document naming an
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
/// must never silently enter the built graph.
#[test]
fn loading_arg_bearing_type_without_args_refuses() {
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
assert!(
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
}