Finding 4: full-trace evidence — reconstructed and re-derived, not summarized
Removes the deferral. Each sampled execution's FULL trace (every graph edge, count, weight) and delta are persisted as evidence; the independent attestor reconstructs them, recomputes the canonical trace hash and delta hash, re-derives the replay-record leaf, and confirms it is among the retained Merkle leaves the root is built from. The behavior-fingerprint hash is re-derived from features (not trusted), and f64 divergence is stored bit-exact for an identical hash. - trace_model: ExecutionTrace::serialize/deserialize (round-trips canonical_hash; total on garbage). Test: full_trace_serialize_roundtrips_canonical_hash. - world_model: WorldDelta::serialize/deserialize (round-trips hash). - ci_reports: retains TRACE_EVIDENCE_SAMPLE full traces; writes evidence/traces.tsv. - attestation: depends on trace_model/world_model; reconstructs each trace, recomputes the leaf, requires it to match the claimed leaf AND be a retained leaf. Negative control: tampered_trace_breaks_attestation (corrupting the full trace, leaving the claimed leaf, fails attestation). - merge-gates: requires evidence/traces.tsv; the separate attest step verifies it. End-to-end (fast profile): 256/256 full traces reconstructed and re-derived to retained leaves; recomputed root matches the claim over 6600 leaves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,7 @@ jobs:
|
|||||||
test -s "ci_out/${r}.json" || { echo "MISSING ARTIFACT: ${r}.json"; exit 1; }
|
test -s "ci_out/${r}.json" || { echo "MISSING ARTIFACT: ${r}.json"; exit 1; }
|
||||||
done
|
done
|
||||||
test -s ci_out/evidence/leaves.tsv || { echo "MISSING EVIDENCE: leaves.tsv"; exit 1; }
|
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
|
# Independent attestation (findings 2, 3): a SEPARATE process recomputes
|
||||||
# the Merkle root from the retained leaves and checks it against the
|
# the Merkle root from the retained leaves and checks it against the
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ name = "attest"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Only the shared hashing primitive — NOT ci_reports. The root is recomputed by
|
# NOT ci_reports. The root, the full-trace hashes, and the leaves are recomputed
|
||||||
# this crate's own code path, so it is an independent check, not a re-export of
|
# by this crate's own code path — an independent check, not a re-export of the
|
||||||
# the producer's claim.
|
# 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" }
|
world_model = { path = "../world_model" }
|
||||||
|
trace_model = { path = "../trace_model" }
|
||||||
|
|||||||
+178
-16
@@ -17,8 +17,10 @@
|
|||||||
//! executions performed by a trusted third party — that requires external
|
//! executions performed by a trusted third party — that requires external
|
||||||
//! re-execution / signing infrastructure (see the BLOCKED note in the report).
|
//! re-execution / signing infrastructure (see the BLOCKED note in the report).
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
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
|
/// Recompute the Merkle root over `leaves` (binary tree, FNV-combined). This is
|
||||||
/// an independent re-implementation of the producer's algorithm; agreement 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())
|
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 <TAB> <trace> <TAB> <delta>`.
|
||||||
|
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> { 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.
|
/// Outcome of an attestation.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Attestation {
|
pub struct Attestation {
|
||||||
@@ -69,6 +105,8 @@ pub struct Attestation {
|
|||||||
pub leaf_count: usize,
|
pub leaf_count: usize,
|
||||||
pub recomputed_root: u64,
|
pub recomputed_root: u64,
|
||||||
pub claimed_root: Option<u64>,
|
pub claimed_root: Option<u64>,
|
||||||
|
pub traces_verified: usize,
|
||||||
|
pub traces_total: usize,
|
||||||
pub checks: Vec<(String, bool)>,
|
pub checks: Vec<(String, bool)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +138,44 @@ pub fn verify_dir(dir: &Path) -> Result<Attestation, String> {
|
|||||||
let nonempty = !leaves.is_empty();
|
let nonempty = !leaves.is_empty();
|
||||||
checks.push(("leaves are present (root has leaves)".into(), nonempty));
|
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<u64> = 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);
|
let ok = checks.iter().all(|(_, b)| *b);
|
||||||
Ok(Attestation {
|
Ok(Attestation {
|
||||||
ok,
|
ok,
|
||||||
leaf_count: leaves.len(),
|
leaf_count: leaves.len(),
|
||||||
recomputed_root: recomputed,
|
recomputed_root: recomputed,
|
||||||
claimed_root,
|
claimed_root,
|
||||||
|
traces_verified,
|
||||||
|
traces_total,
|
||||||
checks,
|
checks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -124,46 +194,138 @@ mod tests {
|
|||||||
assert_ne!(a, c);
|
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<usize>) {
|
||||||
let ev = dir.join("evidence");
|
let ev = dir.join("evidence");
|
||||||
std::fs::create_dir_all(&ev).unwrap();
|
std::fs::create_dir_all(&ev).unwrap();
|
||||||
|
let records: Vec<_> = seeds.iter().map(|&s| record(s)).collect();
|
||||||
|
let leaves: Vec<u64> = records.iter().map(|(rr, _, _)| rr.hash().0).collect();
|
||||||
|
|
||||||
let mut lt = String::from("leaf\n");
|
let mut lt = String::from("leaf\n");
|
||||||
for l in leaves {
|
for l in &leaves {
|
||||||
lt.push_str(&format!("{:016x}\n", l));
|
lt.push_str(&format!("{:016x}\n", l));
|
||||||
}
|
}
|
||||||
std::fs::write(ev.join("leaves.tsv"), lt).unwrap();
|
std::fs::write(ev.join("leaves.tsv"), lt).unwrap();
|
||||||
|
|
||||||
let claims = format!(
|
let claims = format!(
|
||||||
"# claims\nroot\t{:016x}\nleaf_count\t{}\ntotal_comparisons\t{}\n",
|
"# claims\nroot\t{:016x}\nleaf_count\t{}\ntotal_comparisons\t{}\n",
|
||||||
claimed_root,
|
merkle_root(&leaves),
|
||||||
leaves.len(),
|
leaves.len(),
|
||||||
leaves.len()
|
leaves.len()
|
||||||
);
|
);
|
||||||
std::fs::write(ev.join("claims.tsv"), claims).unwrap();
|
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]
|
#[test]
|
||||||
fn honest_evidence_attests() {
|
fn honest_evidence_attests() {
|
||||||
let dir = std::env::temp_dir().join("magicka_attest_ok");
|
let dir = std::env::temp_dir().join("magicka_attest_ok");
|
||||||
let leaves = [10u64, 20, 30, 40, 50];
|
write_full_evidence(&dir, &[10, 20, 30, 40, 50], None);
|
||||||
write_evidence(&dir, &leaves, merkle_root(&leaves));
|
|
||||||
let att = verify_dir(&dir).unwrap();
|
let att = verify_dir(&dir).unwrap();
|
||||||
assert!(att.ok, "honest evidence should attest: {:?}", att.checks);
|
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
|
/// Negative control: a tampered leaf changes the recomputed root, so the
|
||||||
/// claimed root no longer matches and attestation FAILS. Proves the
|
/// claimed root no longer matches and attestation FAILS.
|
||||||
/// attestation is not vacuous and that the root genuinely binds the leaves.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tampered_leaf_breaks_attestation() {
|
fn tampered_leaf_breaks_attestation() {
|
||||||
let dir = std::env::temp_dir().join("magicka_attest_bad");
|
let dir = std::env::temp_dir().join("magicka_attest_bad_leaf");
|
||||||
let leaves = [10u64, 20, 30, 40, 50];
|
write_full_evidence(&dir, &[10, 20, 30, 40, 50], None);
|
||||||
let claimed = merkle_root(&leaves);
|
// Flip a leaf in the file after the root was claimed.
|
||||||
// Write evidence whose leaves were altered after the root was claimed.
|
let lp = dir.join("evidence/leaves.tsv");
|
||||||
let mut tampered = leaves.to_vec();
|
let txt = std::fs::read_to_string(&lp).unwrap();
|
||||||
tampered[2] ^= 0xdead_beef;
|
let mut lines: Vec<String> = txt.lines().map(|s| s.to_string()).collect();
|
||||||
write_evidence(&dir, &tampered, claimed);
|
lines[2] = format!("{:016x}", 0xdead_beefu64);
|
||||||
|
std::fs::write(&lp, lines.join("\n")).unwrap();
|
||||||
let att = verify_dir(&dir).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));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ fn main() {
|
|||||||
Some(r) => eprintln!(" claimed root: {:016x}", r),
|
Some(r) => eprintln!(" claimed root: {:016x}", r),
|
||||||
None => eprintln!(" claimed root: <missing>"),
|
None => eprintln!(" claimed root: <missing>"),
|
||||||
}
|
}
|
||||||
|
eprintln!(
|
||||||
|
" full traces verified: {}/{} (reconstructed + leaf re-derived)",
|
||||||
|
att.traces_verified, att.traces_total
|
||||||
|
);
|
||||||
for (name, ok) in &att.checks {
|
for (name, ok) in &att.checks {
|
||||||
eprintln!(" [{}] {}", if *ok { "PASS" } else { "FAIL" }, name);
|
eprintln!(" [{}] {}", if *ok { "PASS" } else { "FAIL" }, name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,8 +659,24 @@ pub struct CiResults {
|
|||||||
/// Every Merkle leaf (per-execution replay hash) actually produced. Retained
|
/// Every Merkle leaf (per-execution replay hash) actually produced. Retained
|
||||||
/// so the root can be independently recomputed from the leaves (finding 3).
|
/// so the root can be independently recomputed from the leaves (finding 3).
|
||||||
pub merkle_leaves: Vec<Hash>,
|
pub merkle_leaves: Vec<Hash>,
|
||||||
|
/// 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<TraceEvidence>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl CiResults {
|
||||||
pub fn all_failures(&self) -> Vec<(&'static str, &Vec<String>)> {
|
pub fn all_failures(&self) -> Vec<(&'static str, &Vec<String>)> {
|
||||||
vec![
|
vec![
|
||||||
@@ -746,6 +762,7 @@ pub fn run_all(scale: Scale) -> CiResults {
|
|||||||
// Provenance leaves: one per actually-committed execution (base + every
|
// Provenance leaves: one per actually-committed execution (base + every
|
||||||
// perturbation), so the Merkle root binds to all compared executions.
|
// perturbation), so the Merkle root binds to all compared executions.
|
||||||
let mut merkle_leaves: Vec<Hash> = Vec::with_capacity(scale.executions);
|
let mut merkle_leaves: Vec<Hash> = Vec::with_capacity(scale.executions);
|
||||||
|
let mut trace_evidence: Vec<TraceEvidence> = Vec::with_capacity(TRACE_EVIDENCE_SAMPLE);
|
||||||
let mut actual_executions = 0usize;
|
let mut actual_executions = 0usize;
|
||||||
let mut worlds_generated = 0usize;
|
let mut worlds_generated = 0usize;
|
||||||
let mut programs_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));
|
equiv_failures.push(format!("case {} (base) reference != runtime_under_test", i));
|
||||||
}
|
}
|
||||||
merkle_leaves.push(r.replay.hash());
|
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 {
|
for (pinput, pref_canon, pleaf) in &pert_execs {
|
||||||
let prut = rut.resolve(pinput.clone());
|
let prut = rut.resolve(pinput.clone());
|
||||||
equiv_total += 1;
|
equiv_total += 1;
|
||||||
@@ -1212,6 +1237,7 @@ pub fn run_all(scale: Scale) -> CiResults {
|
|||||||
replay,
|
replay,
|
||||||
coverage,
|
coverage,
|
||||||
merkle_leaves: retained_leaves,
|
merkle_leaves: retained_leaves,
|
||||||
|
trace_evidence,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ fn write_evidence(dir: &Path, r: &CiResults) {
|
|||||||
r.provenance.engines_agree,
|
r.provenance.engines_agree,
|
||||||
);
|
);
|
||||||
fs::write(ev.join("claims.tsv"), claims).expect("write claims");
|
fs::write(ev.join("claims.tsv"), claims).expect("write claims");
|
||||||
|
|
||||||
|
// Full-trace evidence (finding 4): each line is
|
||||||
|
// ws ps cs prs future leaf <TAB> <full trace> <TAB> <full delta>
|
||||||
|
// 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: header<TAB>trace<TAB>delta\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) {
|
fn build_reports(dir: &Path, r: &CiResults) {
|
||||||
|
|||||||
@@ -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<String> = vec!["trace-v1".to_string()];
|
||||||
|
let push_access = |t: &mut Vec<String>, 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<ExecutionTrace> {
|
||||||
|
let mut it = s.split_whitespace();
|
||||||
|
if it.next()? != "trace-v1" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let nu = |it: &mut std::str::SplitWhitespace| -> Option<u64> { it.next()?.parse().ok() };
|
||||||
|
let ni = |it: &mut std::str::SplitWhitespace| -> Option<i64> { it.next()?.parse().ok() };
|
||||||
|
let read_access = |it: &mut std::str::SplitWhitespace| -> Option<DomainAccessGraph> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn rank_of_identity_is_full() {
|
fn rank_of_identity_is_full() {
|
||||||
let id: Vec<Vec<f64>> = (0..5)
|
let id: Vec<Vec<f64>> = (0..5)
|
||||||
|
|||||||
@@ -262,4 +262,45 @@ impl WorldDelta {
|
|||||||
}
|
}
|
||||||
h.finish()
|
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<String> = 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<WorldDelta> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user