diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index a043a55..ceda7e9 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -208,8 +208,29 @@ pub fn introspect_cmd(rest: &[&str]) { } } } + ["--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. + 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(2); + } + match build_from_str(&doc) { + Ok(json) => println!("{}", crate::content_id(&json)), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + } + } _ => { - eprintln!("aura: usage: aura graph introspect --vocabulary | --node | --unwired"); + eprintln!( + "aura: usage: aura graph introspect --vocabulary | --node | --unwired | --content-id" + ); std::process::exit(2); } } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index e1c2729..a22ace8 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2816,16 +2816,23 @@ fn stage1_signal(fast_len: Option, slow_len: Option) -> Composite { g.build().expect("stage1 signal wiring resolves") } -/// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a -/// signal blueprint — the run's `topology_hash` (#158). Research-side (aura-cli), -/// off the frozen engine (invariant 8). -fn topology_hash(signal: &Composite) -> String { +/// SHA256 (hex) of a canonical (#164) blueprint JSON string — the content id (#158). +/// The single hashing primitive, shared by [`topology_hash`] (from a live `Composite`) +/// and the op-script `graph introspect --content-id` path (`crate::content_id`), so the +/// two surfaces agree by construction over the same canonical bytes. Research-side +/// (aura-cli), off the frozen engine (invariant 8). +fn content_id(canonical_json: &str) -> String { use sha2::{Digest, Sha256}; - let canonical = blueprint_to_json(signal).expect("a buildable signal serializes"); - let digest = Sha256::digest(canonical.as_bytes()); + let digest = Sha256::digest(canonical_json.as_bytes()); digest.iter().map(|b| format!("{b:02x}")).collect() } +/// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a +/// signal blueprint — the run's `topology_hash` (#158). +fn topology_hash(signal: &Composite) -> String { + content_id(&blueprint_to_json(signal).expect("a buildable signal serializes")) +} + /// The Stage-1 R harness topology, shared by the single run and the sweep. The two /// signal knobs are bound when `Some` (single run — identical to the old /// build-time bind, output byte-unchanged) and left free when `None` (sweep — they @@ -5506,6 +5513,43 @@ mod tests { assert_ne!(h, topology_hash(&stage1_signal(Some(3), Some(4))), "different topology -> different hash"); } + /// #158 acc 1 (content-id stability across the store round-trip): a blueprint's content + /// id survives serialize -> reload -> re-serialize (#164 idempotence) — the property + /// content-addressed reproduction rests on (the stored bytes re-hash to the members' + /// topology_hash). `content_id` is the shared primitive `topology_hash` uses. + #[test] + fn content_id_is_stable_across_the_store_round_trip() { + let json = blueprint_to_json(&stage1_signal(Some(2), Some(4))).expect("serializes"); + let id = content_id(&json); + assert_eq!(id.len(), 64, "a 64-hex sha256"); + let reloaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("reloads"); + assert_eq!( + content_id(&blueprint_to_json(&reloaded).expect("re-serializes")), + id, + "content id survives serialize -> reload -> re-serialize (#164)" + ); + } + + /// #158 acc 3 (Tier-1 format addition leaves the content id unchanged): a Tier-1 + /// optional field the blueprint does not use is tolerated by the loader (#156) and + /// absent from the canonical omit-defaults form, so re-serializing yields the same + /// bytes and thus the same content id. + #[test] + fn content_id_is_stable_across_a_tolerated_tier1_field() { + let base = blueprint_to_json(&stage1_signal(Some(2), Some(4))).expect("serializes"); + // a future Tier-1 optional field injected at the top level (the blueprint does not use it). + let with_extra = + base.replacen("{\"format_version\":1,", "{\"format_version\":1,\"future_optional\":123,", 1); + assert_ne!(base, with_extra, "the doc actually carries the extra field"); + let reparsed = blueprint_from_json(&with_extra, &|t| std_vocabulary(t)) + .expect("loader tolerates an unknown Tier-1 field (#156)"); + assert_eq!( + content_id(&blueprint_to_json(&reparsed).expect("re-serializes")), + content_id(&base), + "a Tier-1 optional the blueprint does not use leaves the content id unchanged" + ); + } + #[test] fn parse_mc_args_bare_and_name_route_to_synthetic() { assert_eq!(parse_mc_args(&[]), Ok(McArgs::Synthetic { name: "mc".to_string(), persist: false })); diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 278b72e..31efbd0 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -212,3 +212,42 @@ fn graph_build_renders_role_kind_mismatch_at_finalize_by_identifier() { assert!(!stderr.contains("RoleKindMismatch"), "does not leak the Debug variant name: {stderr}"); } + +/// Property (#158 acc 1, the content-id surface): `aura graph introspect --content-id` +/// prints the 64-hex SHA256 of the op-script's canonical blueprint — deterministic across +/// runs and distinct for a structurally different topology. It is the same hash `aura run` +/// stamps as `topology_hash` (both via the one `content_id` primitive over the canonical +/// `blueprint_to_json` bytes), so a data-authored topology has a stable, comparable content +/// id. (Note: an op-script and the Rust `stage1_signal` builder produce *different* +/// canonical forms — composite debug-name, an unbound `Bias.scale` — so they are different +/// topologies by the byte definition; content-addressing keys on the canonical form.) +#[test] +fn graph_introspect_content_id_is_deterministic_and_distinguishes() { + let (a, _e, ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC); + assert!(ok, "exit success"); + let id = a.trim(); + assert_eq!(id.len(), 64, "a 64-hex SHA256 content id: {id:?}"); + assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}"); + + // deterministic: the same op-script yields the same content id. + let (b, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC); + assert!(ok2, "exit success"); + assert_eq!(id, b.trim(), "same op-script -> same content id"); + + // distinguishes: a structurally different topology (slow length 4 -> 5) differs. + let other = SIGNAL_DOC.replace("\"I64\":4", "\"I64\":5"); + let (c, _e3, ok3) = run(&["graph", "introspect", "--content-id"], &other); + assert!(ok3, "exit success"); + assert_ne!(id, c.trim(), "a different topology -> a different content id"); +} + +/// Property (negative): `aura graph introspect --content-id` on a malformed op-list exits +/// non-zero with the cause on stderr and prints no content id — it fails cleanly, never a +/// partial/garbage hash. +#[test] +fn graph_introspect_content_id_rejects_a_bad_document() { + let (stdout, stderr, ok) = run(&["graph", "introspect", "--content-id"], r#"[{"op":"add","type":"Nope"}]"#); + assert!(!ok, "non-zero exit on a bad op-list"); + assert!(stdout.is_empty(), "no content id emitted on error: {stdout}"); + assert!(stderr.contains("Nope"), "names the cause: {stderr}"); +}