Add web game (plan2.md) on the independent runtime, merge-blocking gates #1

Merged
linus-d merged 7 commits from web-game into main 2026-06-22 01:42:01 -05:00
8 changed files with 481 additions and 19 deletions
Showing only changes of commit bea076df43 - Show all commits
+1
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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));
}
}
+4
View File
@@ -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);
}
+26
View File
@@ -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,
}
}
+22
View File
@@ -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) {
+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)
+41
View File
@@ -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 })
}
}