update
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
//! Freeze the committed replay corpus. Run this only as a deliberate, reviewed
|
||||
//! migration when the engine semantics legitimately change.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p replay_corpus --bin freeze -- [count]
|
||||
//! ```
|
||||
|
||||
fn main() {
|
||||
let count: usize = std::env::args()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(600);
|
||||
let base = replay_corpus::DEFAULT_BASE_SEED;
|
||||
match replay_corpus::freeze_to_disk(count, base) {
|
||||
Ok(n) => {
|
||||
println!(
|
||||
"froze {} replay cases to {}",
|
||||
n,
|
||||
replay_corpus::corpus_path().display()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("failed to freeze corpus: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
-29
@@ -1,12 +1,24 @@
|
||||
//! `replay_corpus` — every case is permanent and must replay bit-for-bit.
|
||||
//! A replay case stores the seeds plus the three canonical hashes (trace,
|
||||
//! delta, future). Replaying regenerates the case deterministically from its
|
||||
//! master seed, re-executes the reference runtime, and asserts zero hash drift.
|
||||
//!
|
||||
//! The corpus is **persisted to a committed file** (`corpus/replay_corpus.tsv`).
|
||||
//! Replay loads the expected hashes from that file — produced by an earlier
|
||||
//! `freeze` run — regenerates the case deterministically from its master seed,
|
||||
//! re-executes the reference runtime, and asserts zero drift against the stored
|
||||
//! expectation. Because the expectation is read from disk rather than recomputed
|
||||
//! and compared to itself in the same run, drift is genuinely possible: any
|
||||
//! change to the engine that alters a hash makes the committed expectation and
|
||||
//! the fresh execution disagree, and CI fails. (Proven by the negative-control
|
||||
//! test, which corrupts a stored hash and checks the drift is detected.)
|
||||
|
||||
use generators::generate_accepted_case;
|
||||
use reference_runtime::{execute, EngineConfig, ResolutionInput};
|
||||
use std::path::PathBuf;
|
||||
use world_model::Hash;
|
||||
|
||||
/// Format version of the persisted corpus file. Bump only with a deliberate,
|
||||
/// reviewed migration of the committed corpus.
|
||||
pub const CORPUS_VERSION: u32 = 1;
|
||||
|
||||
/// A permanent replay case (per spec) plus the master seed needed to
|
||||
/// regenerate the full case deterministically.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -22,8 +34,7 @@ pub struct ReplayCase {
|
||||
}
|
||||
|
||||
fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
|
||||
let (case, accepted_seed) = generate_accepted_case(master_seed);
|
||||
let _ = accepted_seed;
|
||||
let (case, _accepted_seed) = generate_accepted_case(master_seed);
|
||||
let input = ResolutionInput {
|
||||
world: case.world.clone(),
|
||||
program: case.program.clone(),
|
||||
@@ -40,7 +51,13 @@ fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a single replay case from a master seed.
|
||||
/// Master seed for the `i`-th corpus case (stable, deterministic).
|
||||
pub fn master_seed_for(base_seed: u64, i: usize) -> u64 {
|
||||
base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15)
|
||||
}
|
||||
|
||||
/// Build a single replay case from a master seed using the *current* reference.
|
||||
/// Used when freezing the corpus to disk.
|
||||
pub fn build_case(master_seed: u64) -> ReplayCase {
|
||||
let (input, ws, ps, cs, prs) = input_for(master_seed);
|
||||
let r = execute(&EngineConfig::reference(), &input);
|
||||
@@ -56,13 +73,93 @@ pub fn build_case(master_seed: u64) -> ReplayCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a replay corpus of `n` cases.
|
||||
/// Build an in-memory corpus of `n` cases (used by `freeze`).
|
||||
pub fn build_corpus(n: usize, base_seed: u64) -> Vec<ReplayCase> {
|
||||
(0..n)
|
||||
.map(|i| build_case(base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15)))
|
||||
.collect()
|
||||
(0..n).map(|i| build_case(master_seed_for(base_seed, i))).collect()
|
||||
}
|
||||
|
||||
// --- Persistence. -----------------------------------------------------------
|
||||
|
||||
/// Path to the committed corpus file, anchored to this crate.
|
||||
pub fn corpus_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("corpus/replay_corpus.tsv")
|
||||
}
|
||||
|
||||
/// The default base seed the committed corpus is frozen with.
|
||||
pub const DEFAULT_BASE_SEED: u64 = 0x5EED;
|
||||
|
||||
/// Serialize a corpus to the on-disk TSV format (with a provenance header).
|
||||
pub fn serialize_corpus(corpus: &[ReplayCase]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(&format!("# magicka-replay-corpus v{} cases={}\n", CORPUS_VERSION, corpus.len()));
|
||||
s.push_str("master\tworld\tprogram\tcontract\tperturb\ttrace\tdelta\tfuture\n");
|
||||
for c in corpus {
|
||||
s.push_str(&format!(
|
||||
"{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\n",
|
||||
c.master_seed,
|
||||
c.world_seed,
|
||||
c.program_seed,
|
||||
c.contract_seed,
|
||||
c.perturbation_seed,
|
||||
c.expected_trace_hash.0,
|
||||
c.expected_delta_hash.0,
|
||||
c.expected_future_hash.0,
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> Option<u64> {
|
||||
u64::from_str_radix(s.trim(), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a corpus from the on-disk TSV format.
|
||||
pub fn parse_corpus(text: &str) -> Vec<ReplayCase> {
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines() {
|
||||
if line.starts_with('#') || line.starts_with("master") || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split('\t').collect();
|
||||
if f.len() != 8 {
|
||||
continue;
|
||||
}
|
||||
let vals: Option<Vec<u64>> = f.iter().map(|x| parse_hex(x)).collect();
|
||||
if let Some(v) = vals {
|
||||
out.push(ReplayCase {
|
||||
master_seed: v[0],
|
||||
world_seed: v[1],
|
||||
program_seed: v[2],
|
||||
contract_seed: v[3],
|
||||
perturbation_seed: v[4],
|
||||
expected_trace_hash: Hash(v[5]),
|
||||
expected_delta_hash: Hash(v[6]),
|
||||
expected_future_hash: Hash(v[7]),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Load the committed corpus from disk.
|
||||
pub fn load_persisted_corpus() -> std::io::Result<Vec<ReplayCase>> {
|
||||
let text = std::fs::read_to_string(corpus_path())?;
|
||||
Ok(parse_corpus(&text))
|
||||
}
|
||||
|
||||
/// Freeze a fresh corpus of `n` cases to the committed file.
|
||||
pub fn freeze_to_disk(n: usize, base_seed: u64) -> std::io::Result<usize> {
|
||||
let corpus = build_corpus(n, base_seed);
|
||||
let path = corpus_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, serialize_corpus(&corpus))?;
|
||||
Ok(corpus.len())
|
||||
}
|
||||
|
||||
// --- Replay verification (against persisted expectations). ------------------
|
||||
|
||||
/// A single replay verification outcome.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ReplayDrift {
|
||||
@@ -78,15 +175,16 @@ impl ReplayDrift {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay one case and check for drift.
|
||||
pub fn replay(case: &ReplayCase) -> ReplayDrift {
|
||||
let (input, ..) = input_for(case.master_seed);
|
||||
/// Replay one *stored* case: regenerate it and compare a fresh reference run to
|
||||
/// the expectation read from disk.
|
||||
pub fn replay_against_stored(stored: &ReplayCase) -> ReplayDrift {
|
||||
let (input, ..) = input_for(stored.master_seed);
|
||||
let r = execute(&EngineConfig::reference(), &input);
|
||||
ReplayDrift {
|
||||
master_seed: case.master_seed,
|
||||
trace_ok: r.trace.canonical_hash() == case.expected_trace_hash,
|
||||
delta_ok: r.delta.hash() == case.expected_delta_hash,
|
||||
future_ok: r.replay.future_hash == case.expected_future_hash,
|
||||
master_seed: stored.master_seed,
|
||||
trace_ok: r.trace.canonical_hash() == stored.expected_trace_hash,
|
||||
delta_ok: r.delta.hash() == stored.expected_delta_hash,
|
||||
future_ok: r.replay.future_hash == stored.expected_future_hash,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,20 +194,21 @@ pub struct ReplayReport {
|
||||
pub total: usize,
|
||||
pub deterministic: usize,
|
||||
pub drift: Vec<u64>,
|
||||
pub loaded_from_disk: bool,
|
||||
}
|
||||
|
||||
impl ReplayReport {
|
||||
pub fn passed(&self, minimum: usize) -> bool {
|
||||
self.drift.is_empty() && self.total >= minimum
|
||||
self.loaded_from_disk && self.drift.is_empty() && self.total >= minimum
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the whole corpus replays deterministically.
|
||||
/// Verify a corpus (already loaded from disk) replays without drift.
|
||||
pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
|
||||
let mut drift = Vec::new();
|
||||
let mut deterministic = 0;
|
||||
for case in corpus {
|
||||
let d = replay(case);
|
||||
let d = replay_against_stored(case);
|
||||
if d.ok() {
|
||||
deterministic += 1;
|
||||
} else {
|
||||
@@ -120,6 +219,20 @@ pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
|
||||
total: corpus.len(),
|
||||
deterministic,
|
||||
drift,
|
||||
loaded_from_disk: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the committed corpus and verify it. The single entry point CI uses.
|
||||
pub fn verify_persisted_corpus() -> ReplayReport {
|
||||
match load_persisted_corpus() {
|
||||
Ok(corpus) => verify_corpus(&corpus),
|
||||
Err(_) => ReplayReport {
|
||||
total: 0,
|
||||
deterministic: 0,
|
||||
drift: Vec::new(),
|
||||
loaded_from_disk: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,18 +241,35 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replay_is_deterministic_zero_drift() {
|
||||
let corpus = build_corpus(80, 0x5EED);
|
||||
fn committed_corpus_loads_and_replays_without_drift() {
|
||||
let corpus = load_persisted_corpus().expect("committed corpus must exist; run `freeze`");
|
||||
assert!(!corpus.is_empty(), "committed corpus is empty");
|
||||
let report = verify_corpus(&corpus);
|
||||
assert_eq!(report.total, 80);
|
||||
assert_eq!(report.deterministic, 80);
|
||||
assert!(report.drift.is_empty());
|
||||
assert!(report.drift.is_empty(), "drift in committed corpus: {:?}", report.drift);
|
||||
assert_eq!(report.deterministic, report.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_hashes_are_stable() {
|
||||
let a = build_case(123);
|
||||
let b = build_case(123);
|
||||
assert_eq!(a, b);
|
||||
fn serialize_roundtrips() {
|
||||
let corpus = build_corpus(20, 0x1234);
|
||||
let text = serialize_corpus(&corpus);
|
||||
let parsed = parse_corpus(&text);
|
||||
assert_eq!(corpus, parsed);
|
||||
}
|
||||
|
||||
/// Negative control: a corrupted stored expectation is detected as drift.
|
||||
/// Proves the replay gate is not vacuous.
|
||||
#[test]
|
||||
fn corrupted_expectation_is_detected() {
|
||||
let mut corpus = build_corpus(10, 0x9999);
|
||||
// Flip one stored hash — as if the committed corpus disagreed with the
|
||||
// engine. Replay must flag it.
|
||||
corpus[3].expected_trace_hash = Hash(corpus[3].expected_trace_hash.0 ^ 0xdead_beef);
|
||||
let report = verify_corpus(&corpus);
|
||||
assert!(
|
||||
!report.drift.is_empty(),
|
||||
"replay failed to detect a corrupted expectation"
|
||||
);
|
||||
assert!(report.drift.contains(&corpus[3].master_seed));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user