diff --git a/.github/workflows/merge-gates.yml b/.github/workflows/merge-gates.yml index bdc366d..da92e2e 100644 --- a/.github/workflows/merge-gates.yml +++ b/.github/workflows/merge-gates.yml @@ -75,6 +75,7 @@ jobs: test -s "ci_out/${r}.json" || { echo "MISSING ARTIFACT: ${r}.json"; exit 1; } done test -s ci_out/evidence/leaves.tsv || { echo "MISSING EVIDENCE: leaves.tsv"; exit 1; } + test -s ci_out/evidence/traces.tsv || { echo "MISSING EVIDENCE: traces.tsv"; exit 1; } # Independent attestation (findings 2, 3): a SEPARATE process recomputes # the Merkle root from the retained leaves and checks it against the diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index 53b67bc..1fb5642 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -9,7 +9,9 @@ name = "attest" path = "src/main.rs" [dependencies] -# Only the shared hashing primitive — NOT ci_reports. The root is recomputed by -# this crate's own code path, so it is an independent check, not a re-export of -# the producer's claim. +# NOT ci_reports. The root, the full-trace hashes, and the leaves are recomputed +# by this crate's own code path — an independent check, not a re-export of the +# producer's claim. trace_model/world_model provide the shared trace/delta types +# and hashing the verifier reconstructs from raw evidence. world_model = { path = "../world_model" } +trace_model = { path = "../trace_model" } diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 70ef521..38e5cfd 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -17,8 +17,10 @@ //! executions performed by a trusted third party — that requires external //! re-execution / signing infrastructure (see the BLOCKED note in the report). +use std::collections::HashSet; use std::path::Path; -use world_model::Hasher; +use trace_model::{ExecutionTrace, ReplayRecord}; +use world_model::{Hash, Hasher, WorldDelta}; /// Recompute the Merkle root over `leaves` (binary tree, FNV-combined). This is /// an independent re-implementation of the producer's algorithm; agreement is @@ -62,6 +64,40 @@ fn claim<'a>(claims: &'a [(String, String)], key: &str) -> Option<&'a str> { claims.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) } +/// Reconstruct the leaf of one full-trace evidence record from its raw trace + +/// delta + seeds, independently of any reported digest. Returns the recomputed +/// leaf, or `None` if the record is malformed. +/// +/// `line` is `ws ps cs prs future leaf `. +pub fn recompute_trace_leaf(line: &str) -> Option<(u64, u64)> { + let mut parts = line.splitn(3, '\t'); + let header = parts.next()?; + let trace_s = parts.next()?; + let delta_s = parts.next()?; + let mut h = header.split_whitespace(); + let hx = |s: Option<&str>| -> Option { u64::from_str_radix(s?.trim(), 16).ok() }; + let world_seed = hx(h.next())?; + let program_seed = hx(h.next())?; + let contract_seed = hx(h.next())?; + let perturbation_seed = hx(h.next())?; + let future = hx(h.next())?; + let claimed_leaf = hx(h.next())?; + + let trace = ExecutionTrace::deserialize(trace_s)?; + let delta = WorldDelta::deserialize(delta_s)?; + // Recompute the canonical trace hash and delta hash from the FULL structure. + let rr = ReplayRecord { + world_seed, + program_seed, + contract_seed, + perturbation_seed, + trace_hash: trace.canonical_hash(), + delta_hash: delta.hash(), + future_hash: Hash(future), + }; + Some((rr.hash().0, claimed_leaf)) +} + /// Outcome of an attestation. #[derive(Clone, Debug)] pub struct Attestation { @@ -69,6 +105,8 @@ pub struct Attestation { pub leaf_count: usize, pub recomputed_root: u64, pub claimed_root: Option, + pub traces_verified: usize, + pub traces_total: usize, pub checks: Vec<(String, bool)>, } @@ -100,12 +138,44 @@ pub fn verify_dir(dir: &Path) -> Result { let nonempty = !leaves.is_empty(); checks.push(("leaves are present (root has leaves)".into(), nonempty)); + // Full-trace evidence (finding 4): reconstruct each sampled trace + delta, + // recompute its leaf independently, and confirm it both matches the record's + // claimed leaf AND is one of the retained leaves the root is built from. + let leaf_set: HashSet = leaves.iter().copied().collect(); + let traces_path = dir.join("evidence/traces.tsv"); + let traces_txt = std::fs::read_to_string(&traces_path) + .map_err(|e| format!("cannot read {}: {e}", traces_path.display()))?; + let mut traces_total = 0usize; + let mut traces_verified = 0usize; + for line in traces_txt.lines() { + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + traces_total += 1; + match recompute_trace_leaf(line) { + Some((recomputed_leaf, claimed_leaf)) => { + if recomputed_leaf == claimed_leaf && leaf_set.contains(&recomputed_leaf) { + traces_verified += 1; + } + } + None => {} + } + } + let traces_present = traces_total > 0; + checks.push(("full-trace evidence present".into(), traces_present)); + checks.push(( + "every sampled full trace recomputes to a retained leaf".into(), + traces_present && traces_verified == traces_total, + )); + let ok = checks.iter().all(|(_, b)| *b); Ok(Attestation { ok, leaf_count: leaves.len(), recomputed_root: recomputed, claimed_root, + traces_verified, + traces_total, checks, }) } @@ -124,46 +194,138 @@ mod tests { assert_ne!(a, c); } - fn write_evidence(dir: &Path, leaves: &[u64], claimed_root: u64) { + use trace_model::{ + BehaviorFingerprint, CausalEdge, CausalGraph, CausalNode, DivergenceGraph, + DomainAccessGraph, ExecutionTrace, InformationFlowGraph, PerturbationResponse, ReplayRecord, + TemporalGraph, + }; + use world_model::{DomainDelta, DomainId, Hash, WorldDelta, HIDDEN_LANES, LANES}; + + fn sample_trace(seed: u64) -> ExecutionTrace { + let mut read_graph = DomainAccessGraph::default(); + read_graph.access_count[(seed % 8) as usize] = 3; + read_graph.edges.push((0, 2, (seed % 7) as u32 + 1)); + let mut write_graph = DomainAccessGraph::default(); + write_graph.access_count[2] = 4; + let causal_graph = CausalGraph { + edges: vec![CausalEdge { + from: CausalNode { domain: 0, lane: 1, hidden: false, step: 2 }, + to: CausalNode { domain: 2, lane: 0, hidden: true, step: 2 }, + weight: seed as i64 - 100, + }], + }; + ExecutionTrace { + read_graph, + write_graph, + causal_graph, + information_flow: InformationFlowGraph { edges: vec![(0, 2, 9)] }, + executor_divergence: DivergenceGraph { executor_count: 2, pairwise: vec![0.0, 0.5, 0.5, 0.0] }, + temporal_graph: TemporalGraph { edges: vec![(1, 2, 3)] }, + perturbation_response: PerturbationResponse::default(), + behavior_fingerprint: BehaviorFingerprint::from_features(vec![seed as i64, -2, 3]), + } + } + + fn sample_delta(seed: u64) -> WorldDelta { + let mut observed = [0i64; LANES]; + observed[0] = seed as i64; + WorldDelta { + domain_deltas: vec![DomainDelta { domain: DomainId(2), observed, hidden: [0i64; HIDDEN_LANES] }], + turn_advance: 0, + } + } + + fn record(seed: u64) -> (ReplayRecord, ExecutionTrace, WorldDelta) { + let trace = sample_trace(seed); + let delta = sample_delta(seed); + let rr = ReplayRecord { + world_seed: seed, + program_seed: seed ^ 1, + contract_seed: seed ^ 2, + perturbation_seed: seed ^ 3, + trace_hash: trace.canonical_hash(), + delta_hash: delta.hash(), + future_hash: Hash(seed.wrapping_mul(0x9e3779b97f4a7c15)), + }; + (rr, trace, delta) + } + + /// Write a full evidence dir from real records. If `tamper_trace` is set, + /// that record's serialized trace is corrupted after its leaf was claimed. + fn write_full_evidence(dir: &Path, seeds: &[u64], tamper_trace: Option) { let ev = dir.join("evidence"); std::fs::create_dir_all(&ev).unwrap(); + let records: Vec<_> = seeds.iter().map(|&s| record(s)).collect(); + let leaves: Vec = records.iter().map(|(rr, _, _)| rr.hash().0).collect(); + let mut lt = String::from("leaf\n"); - for l in leaves { + for l in &leaves { lt.push_str(&format!("{:016x}\n", l)); } std::fs::write(ev.join("leaves.tsv"), lt).unwrap(); + let claims = format!( "# claims\nroot\t{:016x}\nleaf_count\t{}\ntotal_comparisons\t{}\n", - claimed_root, + merkle_root(&leaves), leaves.len(), leaves.len() ); std::fs::write(ev.join("claims.tsv"), claims).unwrap(); + + let mut traces = String::from("# trace evidence\n"); + for (i, (rr, trace, delta)) in records.iter().enumerate() { + let mut trace_s = trace.serialize(); + if tamper_trace == Some(i) { + // Corrupt the full trace without changing the claimed leaf. + trace_s = trace_s.replacen("trace-v1 ", "trace-v1 999 ", 1); + } + traces.push_str(&format!( + "{:016x} {:016x} {:016x} {:016x} {:016x} {:016x}\t{}\t{}\n", + rr.world_seed, rr.program_seed, rr.contract_seed, rr.perturbation_seed, + rr.future_hash.0, rr.hash().0, trace_s, delta.serialize(), + )); + } + std::fs::write(ev.join("traces.tsv"), traces).unwrap(); } #[test] fn honest_evidence_attests() { let dir = std::env::temp_dir().join("magicka_attest_ok"); - let leaves = [10u64, 20, 30, 40, 50]; - write_evidence(&dir, &leaves, merkle_root(&leaves)); + write_full_evidence(&dir, &[10, 20, 30, 40, 50], None); let att = verify_dir(&dir).unwrap(); assert!(att.ok, "honest evidence should attest: {:?}", att.checks); + assert_eq!(att.traces_verified, att.traces_total); + assert!(att.traces_total > 0); } /// Negative control: a tampered leaf changes the recomputed root, so the - /// claimed root no longer matches and attestation FAILS. Proves the - /// attestation is not vacuous and that the root genuinely binds the leaves. + /// claimed root no longer matches and attestation FAILS. #[test] fn tampered_leaf_breaks_attestation() { - let dir = std::env::temp_dir().join("magicka_attest_bad"); - let leaves = [10u64, 20, 30, 40, 50]; - let claimed = merkle_root(&leaves); - // Write evidence whose leaves were altered after the root was claimed. - let mut tampered = leaves.to_vec(); - tampered[2] ^= 0xdead_beef; - write_evidence(&dir, &tampered, claimed); + let dir = std::env::temp_dir().join("magicka_attest_bad_leaf"); + write_full_evidence(&dir, &[10, 20, 30, 40, 50], None); + // Flip a leaf in the file after the root was claimed. + let lp = dir.join("evidence/leaves.tsv"); + let txt = std::fs::read_to_string(&lp).unwrap(); + let mut lines: Vec = txt.lines().map(|s| s.to_string()).collect(); + lines[2] = format!("{:016x}", 0xdead_beefu64); + std::fs::write(&lp, lines.join("\n")).unwrap(); let att = verify_dir(&dir).unwrap(); - assert!(!att.ok, "tampered leaves must fail attestation"); + assert!(!att.ok, "tampered leaf must fail attestation"); assert!(att.checks.iter().any(|(n, ok)| n.contains("root") && !ok)); } + + /// Negative control for finding 4: corrupting the FULL TRACE (without + /// touching the claimed leaf) makes the recomputed leaf disagree, so the + /// trace no longer attests. Proves the evidence is the full trace, not a + /// trusted digest. + #[test] + fn tampered_trace_breaks_attestation() { + let dir = std::env::temp_dir().join("magicka_attest_bad_trace"); + write_full_evidence(&dir, &[10, 20, 30, 40, 50], Some(2)); + let att = verify_dir(&dir).unwrap(); + assert!(!att.ok, "tampered full trace must fail attestation"); + assert!(att.traces_verified < att.traces_total); + assert!(att.checks.iter().any(|(n, ok)| n.contains("full trace") && !ok)); + } } diff --git a/crates/attestation/src/main.rs b/crates/attestation/src/main.rs index 07f304d..217d4d8 100644 --- a/crates/attestation/src/main.rs +++ b/crates/attestation/src/main.rs @@ -18,6 +18,10 @@ fn main() { Some(r) => eprintln!(" claimed root: {:016x}", r), None => eprintln!(" claimed root: "), } + eprintln!( + " full traces verified: {}/{} (reconstructed + leaf re-derived)", + att.traces_verified, att.traces_total + ); for (name, ok) in &att.checks { eprintln!(" [{}] {}", if *ok { "PASS" } else { "FAIL" }, name); } diff --git a/crates/ci_reports/src/lib.rs b/crates/ci_reports/src/lib.rs index fe93dae..114fde0 100644 --- a/crates/ci_reports/src/lib.rs +++ b/crates/ci_reports/src/lib.rs @@ -659,8 +659,24 @@ pub struct CiResults { /// Every Merkle leaf (per-execution replay hash) actually produced. Retained /// so the root can be independently recomputed from the leaves (finding 3). pub merkle_leaves: Vec, + /// A sample of FULL execution traces + deltas + seeds (finding 4). The + /// attestor reconstructs each trace, recomputes its canonical hash and the + /// replay-record leaf, and checks the leaf is in `merkle_leaves` — proving + /// the leaves are backed by full trace structure, not a summary. + pub trace_evidence: Vec, } +/// One sampled full-trace evidence record. +#[derive(Clone, Debug)] +pub struct TraceEvidence { + pub replay: trace_model::ReplayRecord, + pub trace: trace_model::ExecutionTrace, + pub delta: world_model::WorldDelta, +} + +/// How many full traces to retain as evidence. +pub const TRACE_EVIDENCE_SAMPLE: usize = 256; + impl CiResults { pub fn all_failures(&self) -> Vec<(&'static str, &Vec)> { vec![ @@ -746,6 +762,7 @@ pub fn run_all(scale: Scale) -> CiResults { // Provenance leaves: one per actually-committed execution (base + every // perturbation), so the Merkle root binds to all compared executions. let mut merkle_leaves: Vec = Vec::with_capacity(scale.executions); + let mut trace_evidence: Vec = Vec::with_capacity(TRACE_EVIDENCE_SAMPLE); let mut actual_executions = 0usize; let mut worlds_generated = 0usize; let mut programs_generated = 0usize; @@ -870,6 +887,14 @@ pub fn run_all(scale: Scale) -> CiResults { equiv_failures.push(format!("case {} (base) reference != runtime_under_test", i)); } merkle_leaves.push(r.replay.hash()); + // Retain a sample of FULL traces as independent evidence (finding 4). + if trace_evidence.len() < TRACE_EVIDENCE_SAMPLE { + trace_evidence.push(TraceEvidence { + replay: r.replay, + trace: r.trace.clone(), + delta: r.delta.clone(), + }); + } for (pinput, pref_canon, pleaf) in &pert_execs { let prut = rut.resolve(pinput.clone()); equiv_total += 1; @@ -1212,6 +1237,7 @@ pub fn run_all(scale: Scale) -> CiResults { replay, coverage, merkle_leaves: retained_leaves, + trace_evidence, } } diff --git a/crates/ci_reports/src/main.rs b/crates/ci_reports/src/main.rs index 29b3c36..c2ccd56 100644 --- a/crates/ci_reports/src/main.rs +++ b/crates/ci_reports/src/main.rs @@ -59,6 +59,28 @@ fn write_evidence(dir: &Path, r: &CiResults) { r.provenance.engines_agree, ); fs::write(ev.join("claims.tsv"), claims).expect("write claims"); + + // Full-trace evidence (finding 4): each line is + // ws ps cs prs future leaf + // The attestor reconstructs the trace + delta, recomputes the canonical + // trace hash and the replay-record leaf, and checks the leaf is among the + // retained leaves. This is the full trace, not a summary. + let mut traces = String::from("# full-trace evidence: headertracedelta\n"); + for ev_rec in &r.trace_evidence { + let rr = &ev_rec.replay; + traces.push_str(&format!( + "{:016x} {:016x} {:016x} {:016x} {:016x} {:016x}\t{}\t{}\n", + rr.world_seed, + rr.program_seed, + rr.contract_seed, + rr.perturbation_seed, + rr.future_hash.0, + rr.hash().0, + ev_rec.trace.serialize(), + ev_rec.delta.serialize(), + )); + } + fs::write(ev.join("traces.tsv"), traces).expect("write traces"); } fn build_reports(dir: &Path, r: &CiResults) { diff --git a/crates/trace_model/src/lib.rs b/crates/trace_model/src/lib.rs index ff11a42..8809107 100644 --- a/crates/trace_model/src/lib.rs +++ b/crates/trace_model/src/lib.rs @@ -404,12 +404,216 @@ impl ExecutionTrace { ], ) } + + /// Serialize the FULL trace — every graph edge, count, and weight — to a + /// single line of whitespace-separated integers. This is the full-trace + /// *evidence* (not a summary): [`deserialize`] reconstructs the trace and + /// [`canonical_hash`] over the result is bit-identical, so an independent + /// verifier can recompute the trace hash from the raw structure rather than + /// trusting a reported digest. `f64` divergence values are stored as raw + /// bits for exact round-trip; the behavior-fingerprint hash is NOT stored — + /// it is re-derived from the features on load, so a fabricated digest cannot + /// survive. + pub fn serialize(&self) -> String { + let mut t: Vec = vec!["trace-v1".to_string()]; + let push_access = |t: &mut Vec, g: &DomainAccessGraph| { + for c in &g.access_count { + t.push(c.to_string()); + } + t.push(g.edges.len().to_string()); + for &(a, b, w) in &g.edges { + t.push(a.to_string()); + t.push(b.to_string()); + t.push(w.to_string()); + } + }; + push_access(&mut t, &self.read_graph); + push_access(&mut t, &self.write_graph); + // causal + t.push(self.causal_graph.edges.len().to_string()); + for e in &self.causal_graph.edges { + for v in [ + e.from.domain as i64, e.from.lane as i64, e.from.hidden as i64, e.from.step as i64, + e.to.domain as i64, e.to.lane as i64, e.to.hidden as i64, e.to.step as i64, e.weight, + ] { + t.push(v.to_string()); + } + } + // information flow + t.push(self.information_flow.edges.len().to_string()); + for &(a, b, w) in &self.information_flow.edges { + t.push(a.to_string()); + t.push(b.to_string()); + t.push(w.to_string()); + } + // divergence (f64 as raw bits) + t.push(self.executor_divergence.executor_count.to_string()); + t.push(self.executor_divergence.pairwise.len().to_string()); + for &v in &self.executor_divergence.pairwise { + t.push(v.to_bits().to_string()); + } + // temporal + t.push(self.temporal_graph.edges.len().to_string()); + for &(s, off, d) in &self.temporal_graph.edges { + t.push(s.to_string()); + t.push(off.to_string()); + t.push(d.to_string()); + } + // perturbation response + for v in [ + self.perturbation_response.total, + self.perturbation_response.altered_trace, + self.perturbation_response.altered_delta, + self.perturbation_response.altered_future, + self.perturbation_response.neutral_unexplained, + ] { + t.push(v.to_string()); + } + // behavior features (fingerprint hash re-derived on load) + t.push(self.behavior_fingerprint.features.len().to_string()); + for &f in &self.behavior_fingerprint.features { + t.push(f.to_string()); + } + t.join(" ") + } + + /// Reconstruct a trace from [`serialize`]. Total: returns `None` on any + /// malformed input rather than panicking. + pub fn deserialize(s: &str) -> Option { + let mut it = s.split_whitespace(); + if it.next()? != "trace-v1" { + return None; + } + let nu = |it: &mut std::str::SplitWhitespace| -> Option { it.next()?.parse().ok() }; + let ni = |it: &mut std::str::SplitWhitespace| -> Option { it.next()?.parse().ok() }; + let read_access = |it: &mut std::str::SplitWhitespace| -> Option { + let mut access_count = [0u32; NUM_DOMAINS]; + for c in access_count.iter_mut() { + *c = nu(it)? as u32; + } + let n = nu(it)? as usize; + let mut edges = Vec::with_capacity(n); + for _ in 0..n { + edges.push((nu(it)? as u8, nu(it)? as u8, nu(it)? as u32)); + } + Some(DomainAccessGraph { access_count, edges }) + }; + let read_graph = read_access(&mut it)?; + let write_graph = read_access(&mut it)?; + // causal + let cn = nu(&mut it)? as usize; + let mut cedges = Vec::with_capacity(cn); + for _ in 0..cn { + let from = CausalNode { + domain: ni(&mut it)? as u8, + lane: ni(&mut it)? as u8, + hidden: ni(&mut it)? != 0, + step: ni(&mut it)? as u32, + }; + let to = CausalNode { + domain: ni(&mut it)? as u8, + lane: ni(&mut it)? as u8, + hidden: ni(&mut it)? != 0, + step: ni(&mut it)? as u32, + }; + let weight = ni(&mut it)?; + cedges.push(CausalEdge { from, to, weight }); + } + // info flow + let fin = nu(&mut it)? as usize; + let mut fedges = Vec::with_capacity(fin); + for _ in 0..fin { + fedges.push((nu(&mut it)? as u8, nu(&mut it)? as u8, nu(&mut it)? as u32)); + } + // divergence + let executor_count = nu(&mut it)? as usize; + let pn = nu(&mut it)? as usize; + let mut pairwise = Vec::with_capacity(pn); + for _ in 0..pn { + pairwise.push(f64::from_bits(nu(&mut it)?)); + } + // temporal + let tn = nu(&mut it)? as usize; + let mut tedges = Vec::with_capacity(tn); + for _ in 0..tn { + tedges.push((nu(&mut it)? as u32, nu(&mut it)? as u8, nu(&mut it)? as u8)); + } + // perturbation response + let pr = PerturbationResponse { + total: nu(&mut it)? as usize, + altered_trace: nu(&mut it)? as usize, + altered_delta: nu(&mut it)? as usize, + altered_future: nu(&mut it)? as usize, + neutral_unexplained: nu(&mut it)? as usize, + }; + // behavior features + let bn = nu(&mut it)? as usize; + let mut features = Vec::with_capacity(bn); + for _ in 0..bn { + features.push(ni(&mut it)?); + } + Some(ExecutionTrace { + read_graph, + write_graph, + causal_graph: CausalGraph { edges: cedges }, + information_flow: InformationFlowGraph { edges: fedges }, + executor_divergence: DivergenceGraph { executor_count, pairwise }, + temporal_graph: TemporalGraph { edges: tedges }, + perturbation_response: pr, + // Re-derive the fingerprint hash from features (not from a stored digest). + behavior_fingerprint: BehaviorFingerprint::from_features(features), + }) + } } #[cfg(test)] mod tests { use super::*; + fn sample_trace() -> ExecutionTrace { + let mut read_graph = DomainAccessGraph::default(); + read_graph.access_count[0] = 3; + read_graph.access_count[2] = 1; + read_graph.edges.push((0, 2, 5)); + let mut write_graph = DomainAccessGraph::default(); + write_graph.access_count[2] = 4; + write_graph.edges.push((0, 2, 7)); + let causal_graph = CausalGraph { + edges: vec![CausalEdge { + from: CausalNode { domain: 0, lane: 1, hidden: false, step: 2 }, + to: CausalNode { domain: 2, lane: 0, hidden: true, step: 2 }, + weight: -1234, + }], + }; + ExecutionTrace { + read_graph, + write_graph, + causal_graph, + information_flow: InformationFlowGraph { edges: vec![(0, 2, 9), (2, 3, 4)] }, + executor_divergence: DivergenceGraph { executor_count: 3, pairwise: vec![0.0, 0.5, 0.25, 0.5, 0.0, 0.125, 0.25, 0.125, 0.0] }, + temporal_graph: TemporalGraph { edges: vec![(1, 2, 3)] }, + perturbation_response: PerturbationResponse::default(), + behavior_fingerprint: BehaviorFingerprint::from_features(vec![1, -2, 3, -4]), + } + } + + #[test] + fn full_trace_serialize_roundtrips_canonical_hash() { + let t = sample_trace(); + let s = t.serialize(); + let back = ExecutionTrace::deserialize(&s).expect("deserialize"); + // The reconstructed trace is structurally equal and hashes identically. + assert_eq!(t, back); + assert_eq!(t.canonical_hash(), back.canonical_hash()); + } + + #[test] + fn deserialize_is_total_on_garbage() { + for s in ["", "nope", "trace-v1 1 2", "trace-v1 x y z"] { + let _ = ExecutionTrace::deserialize(s); // must not panic + } + } + #[test] fn rank_of_identity_is_full() { let id: Vec> = (0..5) diff --git a/crates/world_model/src/world.rs b/crates/world_model/src/world.rs index e485b6a..f538fad 100644 --- a/crates/world_model/src/world.rs +++ b/crates/world_model/src/world.rs @@ -262,4 +262,45 @@ impl WorldDelta { } h.finish() } + + /// Serialize the full delta to one line of whitespace-separated integers. + /// Round-trips [`hash`] exactly (used as attestation evidence). + pub fn serialize(&self) -> String { + let mut t: Vec = vec!["delta-v1".to_string(), self.turn_advance.to_string()]; + t.push(self.domain_deltas.len().to_string()); + for d in &self.domain_deltas { + t.push(d.domain.0.to_string()); + for &v in &d.observed { + t.push(v.to_string()); + } + for &v in &d.hidden { + t.push(v.to_string()); + } + } + t.join(" ") + } + + /// Reconstruct a delta from [`serialize`]. Total: `None` on malformed input. + pub fn deserialize(s: &str) -> Option { + let mut it = s.split_whitespace(); + if it.next()? != "delta-v1" { + return None; + } + let turn_advance: u64 = it.next()?.parse().ok()?; + let n: usize = it.next()?.parse().ok()?; + let mut domain_deltas = Vec::with_capacity(n); + for _ in 0..n { + let domain = DomainId(it.next()?.parse().ok()?); + let mut observed = [0i64; LANES]; + for v in observed.iter_mut() { + *v = it.next()?.parse().ok()?; + } + let mut hidden = [0i64; HIDDEN_LANES]; + for v in hidden.iter_mut() { + *v = it.next()?.parse().ok()?; + } + domain_deltas.push(DomainDelta { domain, observed, hidden }); + } + Some(WorldDelta { domain_deltas, turn_advance }) + } }