feat(engine+cli): topology-identity hash — --identity-id beside --content-id

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
This commit is contained in:
2026-07-02 21:24:18 +02:00
parent 39cbd44f5b
commit 45fb06dba3
5 changed files with 383 additions and 32 deletions
+219 -5
View File
@@ -94,12 +94,54 @@ fn project_node(n: &BlueprintNode) -> NodeData {
}
}
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> {
let doc = BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) };
serde_json::to_string(&doc).map_err(SerializeError::Json)
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).
@@ -179,11 +221,11 @@ pub fn blueprint_from_json(
#[cfg(test)]
mod tests {
use super::*;
use crate::blueprint::{Composite, Role};
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::Recorder;
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].
@@ -252,7 +294,7 @@ mod tests {
targets: vec![Target { node: 0, slot: 0 }],
source: None,
}],
vec![crate::blueprint::OutField { node: 0, field: 0, name: "bias".into() }],
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");
@@ -328,4 +370,176 @@ mod tests {
"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"
);
}
}
+2 -2
View File
@@ -58,8 +58,8 @@ pub use blueprint::{
Role, SweepBinder,
};
pub use blueprint_serde::{
blueprint_from_json, blueprint_to_json, BlueprintDoc, CompositeData, LoadError, NodeData,
PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
pub use construction::{replay, GraphSession, Op, OpError};