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:
@@ -6,7 +6,10 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind};
|
||||
use aura_engine::{
|
||||
blueprint_identity_json, blueprint_to_json, replay, BindOpError, Composite, GraphSession, Op,
|
||||
OpError, Scalar, ScalarKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
|
||||
@@ -106,24 +109,28 @@ fn format_op_error(e: &OpError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a JSON op-list document, replay it through `std_vocabulary`, and return
|
||||
/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op
|
||||
/// fault, attributed by the retained op-kind list) / `finalize: cause` (a
|
||||
/// holistic fault past the last op).
|
||||
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
/// Parse a JSON op-list document and replay it through the env's vocabulary into
|
||||
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
|
||||
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
|
||||
/// past the last op).
|
||||
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
match replay("graph", ops, &|t| env.resolve(t)) {
|
||||
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
|
||||
Err((idx, err)) => {
|
||||
let cause = format_op_error(&err);
|
||||
match labels.get(idx) {
|
||||
Some(kind) => Err(format!("op {idx} ({kind}): {cause}")),
|
||||
None => Err(format!("finalize: {cause}")),
|
||||
}
|
||||
replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
|
||||
let cause = format_op_error(&err);
|
||||
match labels.get(idx) {
|
||||
Some(kind) => format!("op {idx} ({kind}): {cause}"),
|
||||
None => format!("finalize: {cause}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a JSON op-list document, replay it through the env's vocabulary, and
|
||||
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
|
||||
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
let composite = composite_from_str(doc, env)?;
|
||||
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
|
||||
}
|
||||
|
||||
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
||||
@@ -184,16 +191,18 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
|
||||
}
|
||||
|
||||
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
|
||||
/// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set;
|
||||
/// zero or more than one is the usage error (exit 2).
|
||||
/// `--vocabulary` / `--node <T>` / `--unwired` / the id group must be set; zero
|
||||
/// or more than one is the usage error (exit 2). The id group is `--content-id`
|
||||
/// and/or `--identity-id` — the two id flags may combine (one build, both ids,
|
||||
/// content id first).
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() as usize
|
||||
+ cmd.unwired as usize
|
||||
+ cmd.content_id as usize;
|
||||
+ (cmd.content_id || cmd.identity_id) as usize;
|
||||
if count != 1 {
|
||||
eprintln!(
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id"
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id | --identity-id (the two id flags may be combined)"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -224,22 +233,43 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --content-id: the content id (#158) of the op-list's canonical blueprint:
|
||||
// SHA256 (hex) of the same `blueprint_to_json` bytes `graph build` emits, via
|
||||
// the one shared `crate::content_id` primitive `topology_hash` also uses — so
|
||||
// this surface and hashing a `graph build` output agree by construction.
|
||||
// --content-id / --identity-id (combinable): one build of the op-list, then
|
||||
// each requested id on its own line, content id first. The content id (#158)
|
||||
// is the SHA256 of the same `blueprint_to_json` bytes `graph build` emits;
|
||||
// the identity id (#171) the SHA256 of the debug-name-blind
|
||||
// `blueprint_identity_json` form — both via the one shared
|
||||
// `crate::content_id` primitive `topology_hash` also uses, so all surfaces
|
||||
// agree by construction.
|
||||
use std::io::Read;
|
||||
let mut doc = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||
eprintln!("aura: reading stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
match build_from_str(&doc, env) {
|
||||
Ok(json) => println!("{}", crate::content_id(&json)),
|
||||
let composite = match composite_from_str(&doc, env) {
|
||||
Ok(c) => c,
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if cmd.content_id {
|
||||
match blueprint_to_json(&composite) {
|
||||
Ok(json) => println!("{}", crate::content_id(&json)),
|
||||
Err(e) => {
|
||||
eprintln!("aura: serialize error: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cmd.identity_id {
|
||||
match blueprint_identity_json(&composite) {
|
||||
Ok(json) => println!("{}", crate::content_id(&json)),
|
||||
Err(e) => {
|
||||
eprintln!("aura: serialize error: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3817,6 +3817,9 @@ struct GraphIntrospectCmd {
|
||||
/// Print the graph's content id (topology hash).
|
||||
#[arg(long)]
|
||||
content_id: bool,
|
||||
/// Print the graph's topology-identity id (debug names stripped).
|
||||
#[arg(long)]
|
||||
identity_id: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -6015,6 +6018,50 @@ mod tests {
|
||||
assert_eq!(topology_hash(&sig), content_id(&blueprint_to_json(&sig).expect("serializes")));
|
||||
}
|
||||
|
||||
/// The op-script twin of `sma_signal(Some(2), Some(4))`: same topology —
|
||||
/// SMA(2)/SMA(4) over one `price` role, spread, Bias with `scale` BOUND to
|
||||
/// 0.5 (= `R_SMA_BIAS_SCALE`; boundness is identity-bearing) — differing
|
||||
/// only in debug names (composite "graph" vs "sma_signal", unnamed Bias vs
|
||||
/// `.named("bias")`).
|
||||
const IDENTITY_TWIN_DOC: &str = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias","bind":{"scale":{"F64":0.5}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// #171 acc 1 (cross-path identity): the Rust `sma_signal` builder and its
|
||||
/// op-script twin differ in canonical bytes (debug names) — distinct content
|
||||
/// ids — but project to the same identity JSON, hence one identity id across
|
||||
/// authoring paths.
|
||||
#[test]
|
||||
fn identity_id_bridges_the_rust_builder_and_op_script_paths() {
|
||||
let rust_built = sma_signal(Some(2), Some(4));
|
||||
let env = project::Env::std();
|
||||
let json = crate::graph_construct::build_from_str(IDENTITY_TWIN_DOC, &env)
|
||||
.expect("op-script twin builds");
|
||||
assert_ne!(
|
||||
content_id(&json),
|
||||
topology_hash(&rust_built),
|
||||
"authoring paths keep distinct content ids"
|
||||
);
|
||||
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads");
|
||||
let identity = |c: &Composite| {
|
||||
content_id(&aura_engine::blueprint_identity_json(c).expect("identity-serializes"))
|
||||
};
|
||||
assert_eq!(
|
||||
identity(&loaded),
|
||||
identity(&rust_built),
|
||||
"one topology -> one identity id across authoring paths"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() {
|
||||
// Property: the `mc_r_bootstrap` output line is the full RBootstrap shape —
|
||||
|
||||
@@ -296,3 +296,63 @@ fn graph_introspect_unknown_node_flag_is_usage_exit_2() {
|
||||
assert_eq!(code, Some(2), "an invalid --node flag value is a usage error -> exit 2; stderr: {stderr}");
|
||||
assert!(stderr.contains("Bogus"), "names the bad type: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#171 acc 1 at the binary seam): `--identity-id` bridges op-scripts
|
||||
/// that differ only in debug names — the renamed twin keeps the same identity id
|
||||
/// while its content id moves. Rename via blanket replace: "fast" occurs as the
|
||||
/// instance name and in every `fast.*` reference, so the replaced document stays
|
||||
/// internally consistent.
|
||||
#[test]
|
||||
fn graph_introspect_identity_id_bridges_renamed_op_scripts() {
|
||||
let renamed = SIGNAL_DOC.replace("fast", "speedy");
|
||||
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
|
||||
assert!(ok, "exit success");
|
||||
let ia = ia.trim().to_string();
|
||||
assert_eq!(ia.len(), 64, "a 64-hex identity id: {ia:?}");
|
||||
assert!(ia.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {ia:?}");
|
||||
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], &renamed);
|
||||
assert!(ok2, "exit success");
|
||||
assert_eq!(ia, ib.trim(), "renamed twin -> same identity id");
|
||||
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
|
||||
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], &renamed);
|
||||
assert!(ok3 && ok4, "exit success");
|
||||
assert_ne!(ca.trim(), cb.trim(), "renamed twin -> different content ids");
|
||||
}
|
||||
|
||||
/// Property (combinable id flags): `--content-id --identity-id` prints both ids,
|
||||
/// one per line, content id first — each byte-equal to its single-flag output,
|
||||
/// and the two differ (SIGNAL_DOC carries debug names the identity form blanks).
|
||||
#[test]
|
||||
fn graph_introspect_content_and_identity_id_combine() {
|
||||
let (both, _e, ok) = run(&["graph", "introspect", "--content-id", "--identity-id"], SIGNAL_DOC);
|
||||
assert!(ok, "exit success");
|
||||
let lines: Vec<&str> = both.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "two lines, one id each: {both:?}");
|
||||
let (content, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
|
||||
let (identity, _e3, ok3) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
|
||||
assert!(ok2 && ok3, "exit success");
|
||||
assert_eq!(lines[0], content.trim(), "content id first");
|
||||
assert_eq!(lines[1], identity.trim(), "identity id second");
|
||||
assert_ne!(lines[0], lines[1], "the two ids differ on a debug-named document");
|
||||
}
|
||||
|
||||
/// Property (negative, mirror of the content-id rejection): `--identity-id` on a
|
||||
/// bad op-list exits non-zero with the cause on stderr and prints no id.
|
||||
#[test]
|
||||
fn graph_introspect_identity_id_rejects_a_bad_document() {
|
||||
let (stdout, stderr, ok) =
|
||||
run(&["graph", "introspect", "--identity-id"], r#"[{"op":"add","type":"Nope"}]"#);
|
||||
assert!(!ok, "non-zero exit on a bad op-list");
|
||||
assert!(stdout.is_empty(), "no identity id emitted on error: {stdout}");
|
||||
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (usage gate — the previously untested count!=1 dispatch path):
|
||||
/// `graph introspect` with no selection flag is a usage fault — exit 2 with the
|
||||
/// usage line on stderr.
|
||||
#[test]
|
||||
fn graph_introspect_no_flag_is_usage_exit_2() {
|
||||
let (stderr, code) = run_code(&["graph", "introspect"], "");
|
||||
assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
|
||||
assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user