Add web game (plan2.md) on the independent runtime, merge-blocking gates #1
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+178
-16
@@ -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 <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.
|
||||
#[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<u64>,
|
||||
pub traces_verified: usize,
|
||||
pub traces_total: usize,
|
||||
pub checks: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
@@ -100,12 +138,44 @@ pub fn verify_dir(dir: &Path) -> Result<Attestation, String> {
|
||||
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<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);
|
||||
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<usize>) {
|
||||
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<u64> = 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<String> = 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ fn main() {
|
||||
Some(r) => eprintln!(" claimed root: {:016x}", r),
|
||||
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 {
|
||||
eprintln!(" [{}] {}", if *ok { "PASS" } else { "FAIL" }, name);
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
pub fn all_failures(&self) -> Vec<(&'static str, &Vec<String>)> {
|
||||
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<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 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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