diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 2360382..cfa6481 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -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 { +/// 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 { let docs: Vec = 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 = 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 { + 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` / `--unwired` / `--content-id` must be set; -/// zero or more than one is the usage error (exit 2). +/// `--vocabulary` / `--node ` / `--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 | --unwired | --content-id" + "aura: Usage: aura graph introspect --vocabulary | --node | --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); + } + } } } } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index db711ea..e9105e1 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 — diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 46c198a..7c6207e 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -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}"); +} diff --git a/crates/aura-engine/src/blueprint_serde.rs b/crates/aura-engine/src/blueprint_serde.rs index 4f0ab96..05aac5f 100644 --- a/crates/aura-engine/src/blueprint_serde.rs +++ b/crates/aura-engine/src/blueprint_serde.rs @@ -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 { + 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 { - 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 { + 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, + 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" + ); + } } diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 6b1fba5..20a72c5 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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};