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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user