a8b1ba45c5
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.
1021 lines
47 KiB
Rust
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:?}"
|
|
);
|
|
}
|
|
}
|