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:
2026-06-21 21:22:55 -07:00
co-authored by Claude Opus 4.8
parent 1e50c80627
commit bea076df43
8 changed files with 481 additions and 19 deletions
+204
View File
@@ -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)]
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<Vec<f64>> = (0..5)