From 008692c0434158b77c269ae6be748f40194db2f0 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 1 Jul 2026 02:55:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(0094):=20content-addressed=20reproduction?= =?UTF-8?q?=20=E2=80=94=20aura=20reproduce=20=20(iter=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore C18 reproducibility for generated (blueprint-sweep) runs (#158, acc 2): a sweep's topology is stored once, content-addressed by the topology_hash the manifest already carries, and `aura reproduce ` re-derives every persisted member bit-identically (C1) from the stored blueprint. - aura-registry: a dumb content-addressed bytes store — Registry::put_blueprint / get_blueprint / blueprints_dir over runs/blueprints/.json. No sha2 dep (caller owns the hash; reproduction's bit-identical compare is the integrity check); absent id -> Ok(None) (treat-as-empty, like load). - aura-cli: run_blueprint_member extracted from blueprint_sweep_family's member closure (behaviour-preserving; keystone stays green) so the sweep AND reproduce re-run the identical reduce-mode path — bit-identity by construction. The store write hooks the sweep persist seam (one blueprint per family, shared topo). reproduce_family_in loads each member's blueprint by hash, reconstructs the point from the recorded params (point_from_params, inverse of zip_params, over the WRAPPED signal's param_space so the prefixed knob names match), re-runs, and compares metrics. `["reproduce", id]` dispatch arm; refuse-don't-guess on an unknown id / missing stored blueprint (exit 2), DIVERGED -> exit 1. Dependency decision (per-case review, ratified): enabled serde_json `float_roundtrip`. Stored member metrics round-trip through families.jsonl as f64 JSON; the default parser can be 1 ULP off, which would make disk-loaded != recomputed and "bit-identical" (C1) physically impossible. The feature guarantees exact f64 round-trip. Blast-radius clean (full workspace suite green); it serves determinism, the invariant reproduction rests on. Canary: f64_blueprint_param_survives_store_round_trip_bit_identically. Two plan bugs caught + fixed by the implement loop and verified: reproduce's param_space must come from the wrapped signal (prefixed names, the #167 lockstep), and the family id is 0-indexed (live-0 / smacross-0, not -1). Verified: full workspace suite green (51 suites); clippy -D warnings clean; and the binary end-to-end — `aura sweep` (3 members, one shared stored blueprint) then `aura reproduce live-0` (3/3 bit-identical incl the open-at-end member), unknown id -> exit 2. acc 1 (graph introspect --content-id) + acc 3 (Tier-1 id stability) land in iteration 2 (plan 0094b). refs #158 --- Cargo.toml | 8 +- crates/aura-cli/src/main.rs | 296 +++++++++++++++++++++++++++---- crates/aura-cli/tests/cli_run.rs | 202 +++++++++++++++++++++ crates/aura-registry/src/lib.rs | 48 +++++ 4 files changed, 521 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8032dfe..a32e770 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,4 +27,10 @@ publish = false [workspace.dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" +# float_roundtrip is load-bearing for bit-identical reproduction (`aura reproduce`, +# #158): an f64 blueprint param (e.g. a Bias `scale`) is round-tripped through the +# content-addressed store as JSON, and the DEFAULT serde_json parser can be 1 ULP off, +# which would re-derive a member as DIVERGED. This feature guarantees the exact f64 is +# parsed back. Exercised (with a 1-ULP canary value) by aura-cli's +# `f64_blueprint_param_survives_store_round_trip_bit_identically`. +serde_json = { version = "1", features = ["float_roundtrip"] } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 5f23e52..e1c2729 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2469,6 +2469,128 @@ fn runs_family(id: &str, rank: Option<&str>) { } } +/// The outcome of reproducing one persisted family: per member, whether its re-run +/// metrics are bit-identical to the stored metrics (C1). +struct ReproduceReport { + outcomes: Vec<(String, bool)>, +} + +/// Reconstruct a member's bootstrap point from its recorded named params — the inverse +/// of `zip_params(space, point)`. Walks the reloaded signal's `param_space` in order +/// (deterministic for the same blueprint) and reads each knob's value from the manifest. +fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec { + space + .iter() + .map(|ps| { + params + .iter() + .find(|(n, _)| n == &ps.name) + .map(|(_, s)| s.cell()) + .unwrap_or_else(|| { + // A manifest missing a param the reloaded space expects is + // corrupted-on-disk data — exit cleanly (`aura:` + exit 2) like + // every other persisted-data failure on the reproduce path, not + // a panic. + eprintln!("aura: manifest is missing param {}", ps.name); + std::process::exit(2); + }) + }) + .collect() +} + +/// Re-derive every member of a persisted sweep family from the content-addressed store +/// and compare to the stored result, against an explicit registry (testable seam). +fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> ReproduceReport { + let members = reg.load_family_members().unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(2); + }); + let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else { + // reproduce is an action, not a lookup: an unknown id is a hard error (distinct + // from `runs family `'s treat-as-empty exit 0). + eprintln!("aura: no such family '{id}'"); + std::process::exit(2); + }; + let pip = data.pip_size(); + let window = data.full_window(); + let mut outcomes = Vec::new(); + for member in &family.members { + let stored = &member.report; + let hash = stored.manifest.topology_hash.clone().unwrap_or_else(|| { + eprintln!("aura: family member has no topology_hash; not a generated run"); + std::process::exit(2); + }); + let doc = reg + .get_blueprint(&hash) + .unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(2); + }) + .unwrap_or_else(|| { + eprintln!("aura: blueprint {hash} missing from store"); + std::process::exit(2); + }); + // Reload the stored blueprint per use: a Composite is !Clone, and both the + // param-space probe (below) and the re-run each consume one. The doc was + // canonical-serialized at store time, so every reload is infallible. + let reload = || { + blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap_or_else(|e| { + eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); + std::process::exit(2); + }) + }; + // The param_space of the WRAPPED signal — its knobs carry the `stage1_r` + // wrapper's `stage1_signal.` node-path prefix, exactly the names the manifest + // recorded at write time. Mirrors `blueprint_sweep_family`'s probe so the + // reproduce-side space name-matches the stored params (raw `signal.param_space()` + // would drop the prefix and `point_from_params` could not find the knobs). + let (tx_eq, _) = mpsc::channel(); + let (tx_ex, _) = mpsc::channel(); + let (tx_r, _) = mpsc::channel(); + let (tx_req, _) = mpsc::channel(); + let space = + wrap_stage1r(reload(), tx_eq, tx_ex, tx_r, tx_req, false, true, None).param_space(); + let point = point_from_params(&space, &stored.manifest.params); + let label = stored + .manifest + .params + .iter() + .map(|(n, v)| format!("{n}={}", render_value(v))) + .collect::>() + .join(", "); + let rerun = run_blueprint_member( + reload(), + &point, + &space, + data.run_sources(), + window, + stored.manifest.seed, + pip, + &hash, + ); + outcomes.push((label, rerun.metrics == stored.metrics)); + } + ReproduceReport { outcomes } +} + +/// `aura reproduce `: re-derive a persisted sweep family from the +/// content-addressed blueprint store and verify each member reproduces bit-identically +/// (C18 "re-derives full results on demand"). Synthetic data this cycle (deterministic); +/// recorded-dataset reproduction rides the DataServer seam (#124). +fn reproduce_family(id: &str) { + let rep = reproduce_family_in(&default_registry(), id, &DataSource::Synthetic); + let total = rep.outcomes.len(); + let ok = rep.outcomes.iter().filter(|(_, b)| *b).count(); + for (label, identical) in &rep.outcomes { + let verdict = if *identical { "bit-identical" } else { "DIVERGED" }; + println!("{id} member {label} reproduced: {verdict}"); + } + println!("reproduced {ok}/{total} members bit-identically"); + if ok != total { + std::process::exit(1); + } +} + // --- MACD proof-of-concept (a richer, nested indicator + strategy) ----------- /// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line @@ -2970,6 +3092,48 @@ fn run_signal_stage1r(signal: Composite, params: &[Scalar], data: RunData, seed: RunReport { manifest, metrics } } +/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path +/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the +/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a +/// reproduced member re-derives bit-identically (C1). `signal` is a freshly +/// reloaded blueprint (`Composite` is `!Clone`); `point` is the member's bound +/// cells; `space` gives the by-name manifest params; `topo` the shared signal hash. +#[allow(clippy::too_many_arguments)] +fn run_blueprint_member( + signal: Composite, + point: &[Cell], + space: &[ParamSpec], + sources: Vec>, + window: (Timestamp, Timestamp), + seed: u64, + pip: f64, + topo: &str, +) -> RunReport { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let mut h = wrap_stage1r(signal, tx_eq, tx_ex, tx_r, tx_req, false, true, None) + .bootstrap_with_cells(point) + .expect("member bootstraps (point kind-checked against param_space)"); + h.run(sources); + let named = zip_params(space, point); // by-name params for the manifest record + let mut manifest = sim_optimal_manifest(named, window, seed, pip); + manifest.broker = stage1_r_broker_label(pip); + manifest.topology_hash = Some(topo.to_string()); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let (total_pips, max_drawdown) = rx_eq + .try_iter() + .next() + .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())) + .unwrap_or((0.0, 0.0)); + let bias_sign_flips = + rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); + let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; + m.r = Some(summarize_r(&r_rows, &[])); + RunReport { manifest, metrics: m } +} + /// Sweep a serialized signal `doc` over user-named param-space axes — the structural /// twin of [`stage1_r_sweep_family`], with three deviations. (1) The signal source is /// `wrap_stage1r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built @@ -3015,33 +3179,9 @@ fn blueprint_sweep_family( binder = binder.axis(n, vals.clone()); } binder.sweep(|point| { - // fresh per-member graph (deviation 2: reload + fresh channels) bootstrapped - // with the point's cells. - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let mut h = wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, false, true, None) - .bootstrap_with_cells(point) - .expect("member bootstraps (point kind-checked against param_space by resolve_axes)"); - h.run(data.run_sources()); - let named = zip_params(&space, point); // by-name params for the manifest record - let mut manifest = sim_optimal_manifest(named, window, 0, pip); - manifest.broker = stage1_r_broker_label(pip); - manifest.topology_hash = Some(topo.clone()); // deviation 3: the loaded signal's hash - // reduce-mode fold, EXACTLY as stage1_r_sweep_family's no-trace arm: SeriesReducer - // folds eq/ex to one summary row, GatedRecorder retains the gated R rows. - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let (total_pips, max_drawdown) = rx_eq - .try_iter() - .next() - .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())) - .unwrap_or((0.0, 0.0)); - let bias_sign_flips = - rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); - let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; - m.r = Some(summarize_r(&r_rows, &[])); - RunReport { manifest, metrics: m } + // fresh per-member graph (Composite is !Clone, reload per member) run through + // the shared reduce-mode member path — the same fn reproduction re-runs. + run_blueprint_member(reload(doc), point, &space, data.run_sources(), window, 0, pip, &topo) }) } @@ -3066,10 +3206,27 @@ fn run_blueprint_sweep(doc: &str, axes: &[(String, Vec)], name: &str, pe eprintln!("aura: {e:?}"); std::process::exit(2); }); - // Record the family unconditionally (C18/C21 lineage), exactly like `run_sweep`, and - // keep the assigned id so every printed member line carries it — the stdout is then - // linkable to the stored `FamilyKind::Sweep` family. - let id = match default_registry().append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { + let reg = default_registry(); + // Store the canonical blueprint ONCE, keyed by the family's shared topology_hash — + // exactly the bytes whose SHA256 the members carry (#164 byte-canonical, round-trip + // idempotent). One stored topology per family (C18/C11/C12). + let topo = family.points[0] + .report + .manifest + .topology_hash + .clone() + .expect("a blueprint sweep stamps every member's topology_hash"); + let canonical = blueprint_to_json( + &blueprint_from_json(doc, &|t| std_vocabulary(t)) + .expect("doc parse-validated at the dispatch boundary"), + ) + .expect("a loaded blueprint re-serializes"); + reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(2); + }); + // Record the family unconditionally (C18/C21 lineage), exactly like `run_sweep`. + let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { Ok(id) => id, Err(e) => { eprintln!("aura: {e}"); @@ -3710,7 +3867,7 @@ const COST_FLAGS_NOTE: &str = "\ncost flags (stage1-r only; all >= 0, charged in --carry-per-cycle = carry in price units per ENGINE cycle (not per day, not an overnight swap)"; const USAGE: &str = - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura mc --strategy stage1-r [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ] | aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ] | aura generalize [--strategy stage1-r] --real --fast --slow --stop-length --stop-k [--from ] [--to ] [--metric ] [--name ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura mc --strategy stage1-r [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ] | aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ] | aura generalize [--strategy stage1-r] --real --fast --slow --stop-length --stop-k [--from ] [--to ] [--metric ] [--name ] | aura runs families | aura runs family [rank ] | aura reproduce "; fn main() { // Restore the default SIGPIPE disposition. Rust's runtime sets SIGPIPE to SIG_IGN @@ -3852,6 +4009,7 @@ fn main() { ["runs", "families"] => runs_families(), ["runs", "family", id] => runs_family(id, None), ["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)), + ["reproduce", id] => reproduce_family(id), _ => { eprintln!("aura: {USAGE}"); std::process::exit(2); @@ -5025,6 +5183,80 @@ mod tests { assert_ne!(k4, k6, "distinct grid points key distinctly"); } + #[test] + fn reproduce_family_re_derives_every_member_bit_identically() { + // a unique temp runs store so the on-disk family + blueprint store do not collide. + let dir = std::env::temp_dir().join(format!("aura-repro-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let reg = Registry::open(dir.join("runs.jsonl")); + + let open = stage1_signal(None, None); + let doc = blueprint_to_json(&open).expect("serializes"); + let data = DataSource::Synthetic; + // 2x grid over slow.length {4,6} at fast=2 — slow=4 is the open-at-end member. + let axes = vec![ + ("stage1_signal.fast.length".to_string(), vec![Scalar::i64(2)]), + ("stage1_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), + ]; + let family = blueprint_sweep_family(&doc, &axes, &data).expect("axes resolve"); + + // persist exactly as run_blueprint_sweep does: store the blueprint, append the family. + let topo = family.points[0].report.manifest.topology_hash.clone().expect("topo"); + let canonical = + blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap(); + reg.put_blueprint(&topo, &canonical).expect("store blueprint"); + let id = reg + .append_family("repro", FamilyKind::Sweep, &sweep_member_reports(&family)) + .expect("append"); + + // reproduce: every member re-derives bit-identically (incl the open-at-end member). + let rep = reproduce_family_in(®, &id, &data); + assert_eq!(rep.outcomes.len(), 2, "two members reproduced"); + assert!( + rep.outcomes.iter().all(|(_, ok)| *ok), + "every member re-derives bit-identically: {:?}", + rep.outcomes + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Property: an f64 blueprint param survives the content-addressed store's + /// serialize -> parse -> re-serialize round-trip **bit-identically**, so `aura + /// reproduce` re-derives an f64-bearing member without DIVERGED. This is exactly + /// what workspace `serde_json/float_roundtrip` buys: the constant below is a + /// full-precision f64 (`0.12387080150408619`) that the DEFAULT serde_json parser + /// mis-parses by 1 ULP — with `float_roundtrip` on it parses back exactly, so the + /// canonical bytes are stable and the re-run reproduces. The i64-axis reproduce + /// tests exercise a `scale=0.5` blueprint (exactly representable), so this is the + /// only test that actually depends on the feature. + #[test] + fn f64_blueprint_param_survives_store_round_trip_bit_identically() { + // 1-ULP canary for serde_json float_roundtrip: parses back off-by-one without it. + const HARD_SCALE: f64 = 0.12387080150408619; + // A one-node signal whose only knob is the non-short-decimal f64 scale. + let mut g = GraphBuilder::new("scale_probe"); + let bias = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(HARD_SCALE))); + let signal = g.source_role("signal", ScalarKind::F64); + g.feed(signal, vec![bias.input("signal")]); + g.expose(bias.output("bias"), "bias"); + let sig = g.build().expect("one-node bias signal wiring resolves"); + + // the store keeps canonical bytes verbatim (dumb bytes-by-key), so the f64 + // fidelity lives entirely in this serialize -> parse -> re-serialize hop. + let doc = blueprint_to_json(&sig).expect("serializes"); + assert!( + doc.contains("0.12387080150408619"), + "canonical JSON carries the full-precision f64: {doc}" + ); + let reloaded = blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let doc2 = blueprint_to_json(&reloaded).expect("re-serializes"); + // Without float_roundtrip the reparsed scale drifts 1 ULP and ryu re-serializes + // it to a different string, so these canonical bytes would differ. + assert_eq!(doc, doc2, "f64 blueprint param survives the store round-trip bit-identically"); + } + /// `parse_walkforward_args` defaults to synthetic / name "walkforward" / no /// persist, admits `--real ` (window-less here) yielding `DataChoice::Real`, /// and rejects two name flags or a `--real` missing its symbol. diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 68eb418..078e1f7 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3287,6 +3287,68 @@ fn aura_sweep_loads_a_blueprint_and_persists_a_sweep_family() { let _ = std::fs::remove_dir_all(&cwd); } +/// Property (#158/#164, C18/C11/C12): a blueprint sweep content-addresses its topology +/// — the canonical blueprint is persisted ONCE under `runs/blueprints/.json`, +/// keyed by the very SHA256 every member's manifest carries, holding exactly the bytes +/// `blueprint_to_json(blueprint_from_json(doc))` yields. That stored topology is what makes +/// a generated sweep family reproducible from disk (the `aura reproduce` re-derivation). +#[test] +fn aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash() { + use aura_engine::{blueprint_from_json, blueprint_to_json}; + use aura_std::std_vocabulary; + use sha2::{Digest, Sha256}; + + let cwd = temp_cwd("blueprint-sweep-store"); + let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let out = Command::new(BIN) + .args([ + "sweep", &fixture, + "--axis", "stage1_signal.fast.length=2,4", + "--axis", "stage1_signal.slow.length=8,16", + "--trace", "f", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep blueprint"); + assert!(out.status.success(), "exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr)); + + // exactly one blueprint stored for the whole family (one topology per family). + let store = cwd.join("runs/blueprints"); + let mut entries: Vec<_> = std::fs::read_dir(&store) + .unwrap_or_else(|e| panic!("the sweep must create the blueprint store {store:?}: {e}")) + .map(|e| e.expect("dir entry").path()) + .collect(); + entries.sort(); + assert_eq!(entries.len(), 1, "one stored topology per family: {entries:?}"); + + // keyed by a 64-hex topology hash, holding the canonical re-serialization. + let stored_path = &entries[0]; + let stem = stored_path.file_stem().expect("stem").to_str().expect("utf-8"); + assert_eq!(stem.len(), 64, "content id is a 64-hex SHA256: {stem}"); + assert!(stem.bytes().all(|b| b.is_ascii_hexdigit()), "content id is hex: {stem}"); + + let doc = std::fs::read_to_string(&fixture).expect("read fixture"); + let expected = blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads")) + .expect("re-serializes"); + let stored = std::fs::read_to_string(stored_path).expect("read stored blueprint"); + assert_eq!(stored, expected, "the store holds the canonical blueprint bytes"); + + // content-addressing, the feature's load-bearing property: the key IS the SHA256 of + // the stored bytes — `get_blueprint(hash)` addresses exactly these bytes by identity, + // not by coincidence of the store and the members computing the hash separately. + let content_id: String = + Sha256::digest(stored.as_bytes()).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(stem, content_id, "the store key is the SHA256 of its content"); + + // and that key is exactly what every family member's manifest carries — the hash the + // reproduce path reads back to fetch the topology. All 4 members share the one topology. + let members = std::fs::read_to_string(cwd.join("runs/families.jsonl")).expect("read family store"); + let carried = members.matches(&format!("\"topology_hash\":\"{stem}\"")).count(); + assert_eq!(carried, 4, "every member records the store key as its topology_hash: {members}"); + + let _ = std::fs::remove_dir_all(&cwd); +} + /// An `--axis` name that does not resolve against the loaded blueprint's param_space /// fails clean (a named BindError, non-zero exit), not a panic. The fully-bound /// fixture has an empty param_space, so any axis name is unknown. @@ -3304,3 +3366,143 @@ fn aura_sweep_rejects_an_unknown_axis() { "names the unresolved axis: {stderr}" ); } + +/// Property (#158, C18 "re-derives full results on demand"): `aura reproduce ` +/// re-derives every member of a persisted sweep family from the content-addressed +/// blueprint store and reports each bit-identical, exiting 0. Drives the verb through the +/// binary — the `reproduce_family` stdout contract (a per-member `bit-identical` line plus +/// a `reproduced N/N` summary) that the `reproduce_family_in` unit seam bypasses. +#[test] +fn aura_reproduce_re_derives_a_persisted_sweep_family() { + let cwd = temp_cwd("blueprint-reproduce"); + let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let sweep = Command::new(BIN) + .args([ + "sweep", &fixture, + "--axis", "stage1_signal.fast.length=2,4", + "--axis", "stage1_signal.slow.length=8,16", + "--trace", "f", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep blueprint"); + assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr)); + + // `--trace f` names the family `f-0`; reproduce it from disk. + let out = Command::new(BIN) + .args(["reproduce", "f-0"]) + .current_dir(&cwd) + .output() + .expect("spawn aura reproduce"); + assert!(out.status.success(), "reproduce exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + // one verdict line per member (all bit-identical) + the summary; the label renders + // param values portably (`length=2`, not the `I64(2)` Debug form). + assert_eq!( + stdout.lines().filter(|l| l.contains("reproduced: bit-identical")).count(), + 4, + "every member re-derives bit-identically: {stdout}" + ); + assert!(stdout.contains("stage1_signal.fast.length=2"), "label renders values portably: {stdout}"); + assert!(!stdout.contains("I64("), "label must not leak the Scalar Debug form: {stdout}"); + assert!(stdout.contains("reproduced 4/4 members bit-identically"), "summary line: {stdout}"); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property (C1/C18 integrity net): when a persisted member's stored metrics no longer +/// match its re-derivation, `aura reproduce` reports that member `DIVERGED` and exits 1 +/// — the non-happy branch of the verb. Forced by tampering one member's on-disk +/// `total_pips` after the sweep (the store's bit-identical compare IS the integrity check). +#[test] +fn aura_reproduce_reports_a_diverged_member_and_exits_one() { + let cwd = temp_cwd("blueprint-reproduce-diverge"); + let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let sweep = Command::new(BIN) + .args([ + "sweep", &fixture, + "--axis", "stage1_signal.fast.length=2,4", + "--axis", "stage1_signal.slow.length=8,16", + "--trace", "f", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep blueprint"); + assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr)); + + // corrupt the first member's stored `total_pips` so its re-run metrics differ. + let store = cwd.join("runs/families.jsonl"); + let s = std::fs::read_to_string(&store).expect("read family store"); + let key = "\"total_pips\":"; + let at = s.find(key).expect("a member records total_pips") + key.len(); + let end = at + s[at..].find(',').expect("total_pips value terminates"); + let corrupted = format!("{}999.0{}", &s[..at], &s[end..]); + std::fs::write(&store, corrupted).expect("write corrupted store"); + + let out = Command::new(BIN) + .args(["reproduce", "f-0"]) + .current_dir(&cwd) + .output() + .expect("spawn aura reproduce"); + assert_eq!(out.status.code(), Some(1), "a diverged member exits 1: stderr={}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert!(stdout.contains("reproduced: DIVERGED"), "the tampered member is reported DIVERGED: {stdout}"); + assert!(stdout.contains("reproduced 3/4 members bit-identically"), "summary counts the diverged member: {stdout}"); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// E2E (acc 2): persist a blueprint sweep, then `aura reproduce ` re-derives every +/// member bit-identically from the content-addressed store and exits 0. +#[test] +fn aura_reproduce_re_derives_a_persisted_sweep_bit_identically() { + let cwd = temp_cwd("reproduce_roundtrip"); + let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR")); + + let sweep = std::process::Command::new(BIN) + .args([ + "sweep", + &fixture, + "--axis", + "stage1_signal.fast.length=2", + "--axis", + "stage1_signal.slow.length=4,6", + "--name", + "smacross", + ]) + .current_dir(&cwd) + .output() + .expect("run aura sweep"); + assert!(sweep.status.success(), "sweep ok: {}", String::from_utf8_lossy(&sweep.stderr)); + + let repro = std::process::Command::new(BIN) + .args(["reproduce", "smacross-0"]) + .current_dir(&cwd) + .output() + .expect("run aura reproduce"); + let stdout = String::from_utf8_lossy(&repro.stdout); + assert!(repro.status.success(), "reproduce exits 0: {}", String::from_utf8_lossy(&repro.stderr)); + assert!( + stdout.contains("reproduced 2/2 members bit-identically"), + "every member re-derives: {stdout}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// E2E (negative): `aura reproduce ` is a hard error (exit non-zero + named +/// cause on stderr), distinct from `runs family `'s treat-as-empty exit 0. +#[test] +fn aura_reproduce_rejects_an_unknown_family() { + let cwd = temp_cwd("reproduce_unknown"); + let out = std::process::Command::new(BIN) + .args(["reproduce", "no-such-family-1"]) + .current_dir(&cwd) + .output() + .expect("run aura reproduce"); + assert!(!out.status.success(), "unknown family exits non-zero"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("no such family 'no-such-family-1'"), + "names the cause: {}", + String::from_utf8_lossy(&out.stderr) + ); + let _ = std::fs::remove_dir_all(&cwd); +} diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 7cb6b38..93166ba 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -94,6 +94,43 @@ impl Registry { } Ok(reports) } + + /// The content-addressed blueprint store dir — a sibling of the runs store, + /// `.with_file_name("blueprints")`. A topology is stored once, + /// keyed by its content id (`topology_hash`), so a whole sweep family's + /// members share one stored blueprint (C18 tiny manifest; C11/C12 dedup). + fn blueprints_dir(&self) -> PathBuf { + self.path.with_file_name("blueprints") + } + + /// The single content-id→path mapping `put_blueprint` and `get_blueprint` both + /// route through, so the store can never write one path and read another (a + /// drifted key would silently break round-trip — `get` returns `None` and + /// reproduction cannot re-derive the member, rather than erroring). + fn blueprint_path(&self, hash: &str) -> PathBuf { + self.blueprints_dir().join(format!("{hash}.json")) + } + + /// Write-once content-addressed put: `blueprints/.json` = `canonical_json`. + /// Idempotent — the same content id always addresses identical canonical bytes, + /// so a repeated write re-writes identical content. The registry does NOT + /// verify `sha256(bytes) == hash` (no `sha2` dep here): the caller owns the + /// hash, and reproduction's bit-identical metric compare is the integrity check. + pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> { + fs::create_dir_all(self.blueprints_dir())?; + fs::write(self.blueprint_path(hash), canonical_json)?; + Ok(()) + } + + /// Read a stored blueprint by content id; `Ok(None)` if absent — the same + /// treat-as-empty discipline `load` applies to a missing runs store. + pub fn get_blueprint(&self, hash: &str) -> Result, RegistryError> { + match fs::read_to_string(self.blueprint_path(hash)) { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(RegistryError::Io(e)), + } + } } /// Which metric a best-first comparison keys on. Resolves a metric *name* (a @@ -662,6 +699,17 @@ mod tests { assert_eq!(reg.load().expect("load missing"), Vec::::new()); } + #[test] + fn blueprint_store_round_trips_by_content_id() { + let reg = Registry::open(temp_family_dir("blueprint_store_round_trip")); + let canonical = r#"{"format_version":1,"input_roles":[],"nodes":[],"edges":[]}"#; + reg.put_blueprint("deadbeef", canonical).expect("put"); + // exact bytes come back, keyed by content id + assert_eq!(reg.get_blueprint("deadbeef").expect("get"), Some(canonical.to_string())); + // an absent id is Ok(None), not an error (treat-as-empty discipline) + assert_eq!(reg.get_blueprint("never-written").expect("get"), None); + } + #[test] fn corrupt_line_is_a_parse_error_with_line_number() { let path = temp_path("corrupt");