45fb06dba3
Engine (aura-engine/blueprint_serde): blueprint_to_json factored into build_doc/serialize_doc (byte-preserving — canonical golden unchanged) and blueprint_identity_json added: 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) while everything load-bearing survives (type ids, node order, edges, role targets/order, output pairs/order, bound pos/kind/value — openness stays identity-bearing). Re-exported from lib.rs. Six property tests: renamed twins (equal identity, unequal canonical), open-vs-bound, bound-value, edge-swap, nested-composite interior names, role/output renames — the last two are additive beyond the plan and cover the recursion and role/output arms the planned four did not. CLI (aura-cli): graph introspect gains --identity-id, a sibling of --content-id through the same shared content_id SHA-256 primitive; composite_from_str factored out of build_from_str (fault strings byte-identical). The exactly-one introspect dispatch is DELIBERATELY relaxed: the two id flags form one group and may combine — one build, both ids, one per line, content id first. In-crate cross-path twin test (Rust sma_signal vs op-script twin: distinct content ids, one identity id) beside the existing cross-surface pins; four e2e tests incl. the previously uncovered count!=1 usage exit-2 path. topology_hash, the blueprint store, reproduce, and every --content-id byte stay untouched (spec acceptance 3; all existing pins green). Verification: cargo build clean; cargo test --workspace 884 passed / 0 failed (873 baseline + 11 new); clippy -D warnings clean; doc build 0 warnings. The implement-loop's Task-4 spec-compliance block was a plan-byte count mismatch only (plan under-counted pre-existing blueprint_serde tests 6-vs-7 and did not anticipate the two sibling-accepted extra tests); gates re-run by hand, all green. closes #171, refs #180
546 lines
24 KiB
Rust
546 lines
24 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)),
|
|
}
|
|
}
|
|
|
|
fn build_doc(c: &Composite) -> BlueprintDoc {
|
|
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
|
|
}
|
|
|
|
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. Everything load-bearing
|
|
/// survives: type ids, node order, edges, role targets/order, output pairs/order,
|
|
/// 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();
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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, 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::{Scalar, ScalarKind, Timestamp};
|
|
use aura_std::{Bias, Recorder, Sma, Sub};
|
|
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![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",
|
|
);
|
|
}
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
}
|