feat(0094b): content-id surface + Tier-1 stability — aura graph introspect --content-id (iter 2)
Completes #158 acceptance (acc 1 + acc 3) on top of iteration 1's reproduction: - acc 1: `aura graph introspect --content-id` reads an op-script and prints the content id of its canonical blueprint — the SHA256 (hex) of the same blueprint_to_json bytes `graph build` emits. Extracted the single `content_id` primitive shared by this surface AND topology_hash, so the two command paths (this surface / hashing a `graph build` output) agree by construction. Verified live: both e2be81b2… for the SMA-cross op-script. Deterministic + distinguishes topologies; a malformed op-list fails cleanly (exit 2, no partial hash). - acc 3: 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 the content id is unchanged (content_id_is_stable_across_a_tolerated_tier1_field); plus content-id stability across the serialize -> reload -> re-serialize store round-trip (#164), the property reproduction rests on. Finding (documented, filed forward): an op-script and the Rust `stage1_signal` builder produce DIFFERENT canonical forms — the composite debug-name ("graph" vs "stage1_signal"), a named vs unnamed Sub, and an unbound vs bound Bias.scale — so they are different topologies by the byte definition and get different content ids. Content-addressing keys on the canonical form, which currently includes the composite debug-name (a non-load-bearing symbol, invariant 11). Whether the content id should exclude the debug-name (a topology-canonical form distinct from the byte-canonical form, which would also change the shipped cycle-0092 topology_hash values) is a forward design question, not this cycle. Verified: full workspace suite green (51 suites); clippy -D warnings clean. refs #158
This commit is contained in:
@@ -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 <T> | --unwired");
|
||||
eprintln!(
|
||||
"aura: usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2816,16 +2816,23 @@ fn stage1_signal(fast_len: Option<i64>, slow_len: Option<i64>) -> 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 }));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user