Compare commits

...
2 Commits
Author SHA1 Message Date
linus-d a90e27ab63 deleted
magicka-merge-gates / advisory-fast (pull_request) Failing after 7s
magicka-merge-gates / merge-gates (pull_request) Has been skipped
magicka-web-gates / web-rust-gates (pull_request) Failing after 6s
magicka-web-gates / rendered-browser-e2e (ADVISORY — blocked on CI infra) (pull_request) Has been skipped
2026-06-21 23:40:32 -07:00
linus-dandClaude Opus 4.8 f4c75fc8cf Audit pass: full-corpus evidence, full-gate mutation, 3-way metamorphic, recomputable causal, bundle attestation
1 (artifact bundle): ci_reports writes evidence/MANIFEST.tsv (content hash+len of
  every evidence file); attestation verifies completeness+integrity; merge-gates
  requires the bundle.
2 (every leaf full evidence): run_all_to streams a full trace+delta record for
  EVERY leaf (base 'b' and perturbation 'p'), not a sample.
3 (attestation completeness): attestation enforces a leaf<->trace bijection
  (traces == leaves and covered set == leaf set), not just consistency.
4 (collapse derives from full traces): trace_feature_row single-sourced into
  collapse_analysis; attestation recomputes each collapse row from the retained
  full trace and requires bit-exact match.
5 (mutants through full gates): evaluate_mutants runs each mutant through
  engine_acceptance over 128 generated cases (trace/equivalence/domain/
  metamorphic/causal), replacing the 64-input local predicates.
6 (trace+delta+future): metamorphic enforces consumed->trace per-case plus
  consumed-aggregate delta (>=0.65) and future (>=0.80) rates.
7 (recomputable causal): per-edge intervention records written to
  evidence/causal_evidence.tsv; attestation RE-EXECUTES each from its seed and
  recomputes base_dv/alt_dv.
8 (no string/comment proof): removed web_assets JS-substring test and the
  tautological string assert in ci_reports.

Verified at fast scale end-to-end: 6600/6600 traces reconstructed, leaf
bijection, 1600/1600 causal records recomputed, 600/600 collapse rows derived,
bundle intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:39:50 -07:00
10 changed files with 665 additions and 696 deletions
+3 -2
View File
@@ -74,8 +74,9 @@ jobs:
compliance_report; do
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; }
for e in leaves.tsv traces.tsv causal_evidence.tsv collapse_feature_rows.tsv MANIFEST.tsv claims.tsv; do
test -s "ci_out/evidence/${e}" || { echo "MISSING EVIDENCE: ${e}"; exit 1; }
done
# Independent attestation (findings 2, 3): a SEPARATE process recomputes
# the Merkle root from the retained leaves and checks it against the
+5
View File
@@ -15,3 +15,8 @@ path = "src/main.rs"
# and hashing the verifier reconstructs from raw evidence.
world_model = { path = "../world_model" }
trace_model = { path = "../trace_model" }
# For independent RE-EXECUTION of retained per-edge causal interventions.
generators = { path = "../generators" }
reference_runtime = { path = "../reference_runtime" }
# For recomputing collapse feature rows from full traces (proving derivation).
collapse_analysis = { path = "../collapse_analysis" }
+280 -15
View File
@@ -17,10 +17,57 @@
//! executions performed by a trusted third party — that requires external
//! re-execution / signing infrastructure (see the BLOCKED note in the report).
use generators::generate_accepted_case;
use reference_runtime::{execute, EngineConfig, ResolutionInput};
use std::collections::HashSet;
use std::path::Path;
use trace_model::{ExecutionTrace, ReplayRecord};
use world_model::{Hash, Hasher, WorldDelta};
use world_model::{Hash, Hasher, WorldDelta, HIDDEN_LANES, LANES, NUM_DOMAINS};
/// Re-execute one retained causal record from its seed and recompute the
/// destination delta before and after perturbing the recorded source lane.
/// Returns `(base_dv, alt_dv)`; the caller compares against the retained values.
pub fn recompute_causal_record(
seed: u64,
from_domain: usize,
from_lane: usize,
from_hidden: bool,
to_domain: usize,
to_lane: usize,
to_hidden: bool,
) -> (i64, i64) {
let (case, _) = generate_accepted_case(seed);
let cfg = EngineConfig::reference();
let input = ResolutionInput {
world: case.world.clone(),
program: case.program.clone(),
contexts: case.contexts.clone(),
contract_seed: case.contract_seed,
perturbation_seed: case.perturbation_seed,
};
let base = execute(&cfg, &input);
let mut w = input.world.clone();
let fd = from_domain % NUM_DOMAINS;
if from_hidden {
let l = from_lane % HIDDEN_LANES;
w.domains[fd].hidden[l] = w.domains[fd].hidden[l].wrapping_add(0x9_27c1);
} else {
let l = from_lane % LANES;
w.domains[fd].observed[l] = w.domains[fd].observed[l].wrapping_add(0x9_27c1);
}
let mut alt_input = input.clone();
alt_input.world = w;
let alt = execute(&cfg, &alt_input);
let dd = to_domain % NUM_DOMAINS;
let read = |d: &world_model::DomainDelta| -> i64 {
if to_hidden {
d.hidden[to_lane % HIDDEN_LANES]
} else {
d.observed[to_lane % LANES]
}
};
(read(&base.delta.domain_deltas[dd]), read(&alt.delta.domain_deltas[dd]))
}
/// Recompute the Merkle root over `leaves` (binary tree, FNV-combined). This is
/// an independent re-implementation of the producer's algorithm; agreement is
@@ -68,13 +115,17 @@ fn claim<'a>(claims: &'a [(String, String)], key: &str) -> Option<&'a str> {
/// 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)> {
/// `line` is `kind ws ps cs prs future leaf <TAB> <trace> <TAB> <delta>` where
/// `kind` is `b` (base) or `p` (perturbation). Returns `(recomputed_leaf,
/// claimed_leaf, is_base)`.
pub fn recompute_trace_leaf(line: &str) -> Option<(u64, u64, bool)> {
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 kind = h.next()?;
let is_base = kind == "b";
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())?;
@@ -95,7 +146,7 @@ pub fn recompute_trace_leaf(line: &str) -> Option<(u64, u64)> {
delta_hash: delta.hash(),
future_hash: Hash(future),
};
Some((rr.hash().0, claimed_leaf))
Some((rr.hash().0, claimed_leaf, is_base))
}
/// Outcome of an attestation.
@@ -107,6 +158,11 @@ pub struct Attestation {
pub claimed_root: Option<u64>,
pub traces_verified: usize,
pub traces_total: usize,
pub causal_total: usize,
pub causal_recomputed: usize,
pub causal_confirmed: usize,
pub collapse_total: usize,
pub collapse_derived: usize,
pub checks: Vec<(String, bool)>,
}
@@ -147,26 +203,174 @@ pub fn verify_dir(dir: &Path) -> Result<Attestation, String> {
.map_err(|e| format!("cannot read {}: {e}", traces_path.display()))?;
let mut traces_total = 0usize;
let mut traces_verified = 0usize;
let mut covered: HashSet<u64> = HashSet::new();
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 let Some((recomputed_leaf, claimed_leaf, _is_base)) = recompute_trace_leaf(line) {
if recomputed_leaf == claimed_leaf && leaf_set.contains(&recomputed_leaf) {
traces_verified += 1;
covered.insert(recomputed_leaf);
}
}
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(),
"every full trace recomputes to a retained leaf".into(),
traces_present && traces_verified == traces_total,
));
// Completeness (finding 3): every retained leaf must be covered by a full
// trace record, and there must be exactly one record per leaf — a bijection,
// not just a consistent count.
checks.push((
"every leaf has full retained evidence (bijection)".into(),
traces_present
&& traces_total == leaves.len()
&& covered.len() == leaf_set.len(),
));
// Causal intervention evidence (finding 7): RE-EXECUTE each retained record
// from its seed and confirm the retained base_dv/alt_dv reproduce, then
// require the confirmed fraction to clear the threshold.
let causal_path = dir.join("evidence/causal_evidence.tsv");
let causal_txt = std::fs::read_to_string(&causal_path)
.map_err(|e| format!("cannot read {}: {e}", causal_path.display()))?;
let mut causal_total = 0usize;
let mut causal_recomputed = 0usize;
let mut causal_confirmed = 0usize;
for line in causal_txt.lines() {
if line.starts_with("seed") || line.starts_with('#') || line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 9 {
continue;
}
let seed = match u64::from_str_radix(f[0].trim(), 16) {
Ok(v) => v,
Err(_) => continue,
};
let p = |i: usize| f[i].trim().parse::<i64>().ok();
let (Some(fd), Some(fl), Some(fh), Some(td), Some(tl), Some(th), Some(bdv), Some(adv)) =
(p(1), p(2), p(3), p(4), p(5), p(6), p(7), p(8))
else {
continue;
};
causal_total += 1;
let (rb, ra) = recompute_causal_record(
seed, fd as usize, fl as usize, fh != 0, td as usize, tl as usize, th != 0,
);
if rb == bdv && ra == adv {
causal_recomputed += 1;
}
if rb != ra {
causal_confirmed += 1;
}
}
// Collapse derivation (finding 4): recompute each collapse feature row from
// the corresponding retained FULL trace + delta and require bit-exact match,
// proving the summary the collapse gate consumed derives from the full trace.
let crows_path = dir.join("evidence/collapse_feature_rows.tsv");
let crows_txt = std::fs::read_to_string(&crows_path)
.map_err(|e| format!("cannot read {}: {e}", crows_path.display()))?;
let claimed_rows: Vec<Vec<u64>> = crows_txt
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.split_whitespace().filter_map(|t| t.parse::<u64>().ok()).collect())
.collect();
// Re-read the trace records in order to recompute their feature rows.
let mut collapse_total = 0usize;
let mut collapse_derived = 0usize;
{
let mut idx = 0usize;
for line in traces_txt.lines() {
// Collapse rows correspond to BASE executions in order.
if !line.starts_with("b ") {
continue;
}
if idx >= claimed_rows.len() {
break;
}
let mut parts = line.splitn(3, '\t');
let _hdr = parts.next();
let trace_s = parts.next();
let delta_s = parts.next();
if let (Some(ts), Some(ds)) = (trace_s, delta_s) {
if let (Some(trace), Some(delta)) =
(ExecutionTrace::deserialize(ts), WorldDelta::deserialize(ds))
{
let recomputed = collapse_analysis::trace_feature_row(&trace, &delta);
let recomputed_bits: Vec<u64> = recomputed.iter().map(|v| v.to_bits()).collect();
collapse_total += 1;
if recomputed_bits == claimed_rows[idx] {
collapse_derived += 1;
}
}
}
idx += 1;
}
}
let collapse_present = collapse_total > 0;
checks.push(("collapse feature rows present".into(), collapse_present));
checks.push((
"every collapse summary derives from a full trace".into(),
collapse_present && collapse_derived == collapse_total,
));
let causal_present = causal_total > 0;
let causal_frac = if causal_total > 0 { causal_confirmed as f64 / causal_total as f64 } else { 0.0 };
checks.push(("causal evidence present".into(), causal_present));
checks.push((
"every causal record recomputes (base_dv/alt_dv reproduce)".into(),
causal_present && causal_recomputed == causal_total,
));
checks.push((
"recomputed causal confirmation >= 0.50".into(),
causal_present && causal_frac >= 0.50,
));
// Artifact bundle (finding 1): the merge-scale claim requires a complete,
// content-hashed bundle. Verify the manifest lists the required files and
// each file's recomputed hash + length match.
let manifest_path = dir.join("evidence/MANIFEST.tsv");
let manifest_txt = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("cannot read {}: {e}", manifest_path.display()))?;
let required = [
"leaves.tsv",
"claims.tsv",
"causal_evidence.tsv",
"collapse_feature_rows.tsv",
"traces.tsv",
];
let mut listed: HashSet<String> = HashSet::new();
let mut bundle_intact = true;
for line in manifest_txt.lines() {
if line.starts_with("file") || line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 3 {
bundle_intact = false;
continue;
}
let name = f[0].trim();
let claimed_hash = u64::from_str_radix(f[1].trim(), 16).ok();
let claimed_len = f[2].trim().parse::<usize>().ok();
let bytes = std::fs::read(dir.join("evidence").join(name)).unwrap_or_default();
let mut h = Hasher::new();
h.write_tag("evidence-file");
h.write_bytes(&bytes);
if claimed_hash != Some(h.finish().0) || claimed_len != Some(bytes.len()) {
bundle_intact = false;
}
listed.insert(name.to_string());
}
let bundle_complete = required.iter().all(|r| listed.contains(*r));
checks.push(("artifact bundle manifest complete".into(), bundle_complete));
checks.push(("artifact bundle files intact (hash + length)".into(), bundle_intact && bundle_complete));
let ok = checks.iter().all(|(_, b)| *b);
Ok(Attestation {
@@ -176,6 +380,11 @@ pub fn verify_dir(dir: &Path) -> Result<Attestation, String> {
claimed_root,
traces_verified,
traces_total,
causal_total,
causal_recomputed,
causal_confirmed,
collapse_total,
collapse_derived,
checks,
})
}
@@ -227,12 +436,14 @@ mod tests {
}
fn sample_delta(seed: u64) -> WorldDelta {
let domain_deltas = (0..NUM_DOMAINS)
.map(|d| {
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,
}
observed[0] = seed as i64 + d as i64;
DomainDelta { domain: DomainId(d as u8), observed, hidden: [0i64; HIDDEN_LANES] }
})
.collect();
WorldDelta { domain_deltas, turn_advance: 0 }
}
fn record(seed: u64) -> (ReplayRecord, ExecutionTrace, WorldDelta) {
@@ -280,12 +491,66 @@ mod tests {
trace_s = trace_s.replacen("trace-v1 ", "trace-v1 999 ", 1);
}
traces.push_str(&format!(
"{:016x} {:016x} {:016x} {:016x} {:016x} {:016x}\t{}\t{}\n",
"b {: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();
// Real, recomputable causal evidence from actual reference executions.
let mut causal = String::from(
"seed\tfrom_domain\tfrom_lane\tfrom_hidden\tto_domain\tto_lane\tto_hidden\tbase_dv\talt_dv\n",
);
let cfg = EngineConfig::reference();
for &cseed in &[0xC0FFEEu64, 0xBEEF, 0x1234, 0x5EED, 0xABCD] {
let (case, _) = generate_accepted_case(cseed);
let input = ResolutionInput {
world: case.world.clone(),
program: case.program.clone(),
contexts: case.contexts.clone(),
contract_seed: case.contract_seed,
perturbation_seed: case.perturbation_seed,
};
let base = execute(&cfg, &input);
let edges = base.trace.causal_graph.edges.clone();
let stride = (edges.len() / 12).max(1);
for e in edges.iter().step_by(stride).take(12) {
let fd = e.from.domain as usize % NUM_DOMAINS;
let fl = e.from.lane as usize;
let fh = e.from.hidden;
let td = e.to.domain as usize % NUM_DOMAINS;
let tl = e.to.lane as usize;
let th = e.to.hidden;
let (b, a) = recompute_causal_record(cseed, fd, fl, fh, td, tl, th);
causal.push_str(&format!(
"{:016x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
cseed, fd, fl, fh as u8, td, tl, th as u8, b, a
));
}
}
std::fs::write(ev.join("causal_evidence.tsv"), causal).unwrap();
// Collapse feature rows derived from the same synthetic traces, in order.
let mut crows = String::new();
for (_, trace, delta) in &records {
let row = collapse_analysis::trace_feature_row(trace, delta);
let cells: Vec<String> = row.iter().map(|v| v.to_bits().to_string()).collect();
crows.push_str(&cells.join(" "));
crows.push('\n');
}
std::fs::write(ev.join("collapse_feature_rows.tsv"), crows).unwrap();
// Bundle manifest over the written files.
let mut manifest = String::from("file\thash\tbytes\n");
for name in ["leaves.tsv", "claims.tsv", "causal_evidence.tsv", "collapse_feature_rows.tsv", "traces.tsv"] {
let bytes = std::fs::read(ev.join(name)).unwrap_or_default();
let mut h = Hasher::new();
h.write_tag("evidence-file");
h.write_bytes(&bytes);
manifest.push_str(&format!("{}\t{:016x}\t{}\n", name, h.finish().0, bytes.len()));
}
std::fs::write(ev.join("MANIFEST.tsv"), manifest).unwrap();
}
#[test]
+8
View File
@@ -22,6 +22,14 @@ fn main() {
" full traces verified: {}/{} (reconstructed + leaf re-derived)",
att.traces_verified, att.traces_total
);
eprintln!(
" causal records recomputed: {}/{} ({} confirmed)",
att.causal_recomputed, att.causal_total, att.causal_confirmed
);
eprintln!(
" collapse rows derived from full traces: {}/{}",
att.collapse_derived, att.collapse_total
);
for (name, ok) in &att.checks {
eprintln!(" [{}] {}", if *ok { "PASS" } else { "FAIL" }, name);
}
+268 -196
View File
@@ -22,7 +22,7 @@ use reference_runtime::{
canonical, execute, Canonical, EngineConfig, ResolutionInput, ResolutionResult, Runtime,
};
use runtime_under_test::{native_resolve, RuntimeUnderTest};
use semantic_mutation::{generate_mutants, DetectionClass, MutationOutcome};
use semantic_mutation::{generate_mutants, MutationOutcome};
use std::collections::HashMap;
use std::io::Write;
use world_model::{
@@ -211,23 +211,39 @@ pub fn causal_trace_fails(median_edges: f64, p95_rank: f64) -> bool {
median_edges < TRACE_EDGES_MIN || p95_rank < TRACE_RANK_P95_MIN
}
/// Per-config causal gate over an input corpus (used to kill mutants by the same
/// predicate the acceptance trace gate uses).
fn config_causal_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
let edges: Vec<f64> =
inputs.iter().map(|i| execute(cfg, i).trace.causal_edge_count() as f64).collect();
let ranks: Vec<f64> =
inputs.iter().map(|i| execute(cfg, i).trace.causal_rank() as f64).collect();
causal_trace_fails(median(&edges), percentile(&ranks, 0.05))
}
/// Number of generated cases each mutant is run through (a real corpus, not a
/// fixed 64-input local sample).
pub const MUTATION_GATE_CASES: usize = 128;
/// Per-config domain-participation gate over an input corpus.
fn config_domain_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
let n = inputs.len().max(1) as f64;
/// Run an engine config through the FULL set of engine-behavior acceptance gates
/// over `n_cases` freshly generated cases (the same gate functions and corpus
/// shape the acceptance run uses) and return the names of the gates it fails.
/// The reference returns an empty vec; a broken engine returns the gates that
/// catch it.
pub fn engine_acceptance(cfg: &EngineConfig, n_cases: usize) -> Vec<String> {
let refcfg = EngineConfig::reference();
let mut edges = Vec::with_capacity(n_cases);
let mut ranks = Vec::with_capacity(n_cases);
let mut equiv_fail = false;
let mut appears = [0u64; NUM_DOMAINS];
let mut mutated = [0u64; NUM_DOMAINS];
for i in inputs {
let r = execute(cfg, i);
let mut influence_changed = [false; NUM_DOMAINS];
let mut consumed = 0usize;
let mut consumed_trace_violation = 0usize;
let mut consumed_delta = 0usize;
let mut consumed_future = 0usize;
let n = n_cases.max(1) as f64;
for i in 0..n_cases {
let (case, _) = generate_accepted_case(case_seed(i));
let input = input_from_case(&case);
let r = execute(cfg, &input);
let rr = execute(&refcfg, &input);
edges.push(r.trace.causal_edge_count() as f64);
ranks.push(r.trace.causal_rank() as f64);
if canonical(&r) != canonical(&rr) {
equiv_fail = true;
}
for d in 0..NUM_DOMAINS {
if r.trace.read_graph.access_count[d] > 0 || r.trace.write_graph.access_count[d] > 0 {
appears[d] += 1;
@@ -238,86 +254,90 @@ fn config_domain_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
mutated[dd.domain.0 as usize] += 1;
}
}
// Per-domain measured influence: masking each domain in `cfg` must change
// this config's own output.
let base_h = (r.trace.canonical_hash(), r.delta.hash());
for d in 0..NUM_DOMAINS {
if influence_changed[d] {
continue;
}
let mut mc = cfg.clone();
mc.domain_mask[d] = false;
let m = execute(&mc, &input);
if (m.trace.canonical_hash(), m.delta.hash()) != base_h {
influence_changed[d] = true;
}
}
// Metamorphic over this case's perturbations.
let bt = r.trace.canonical_hash();
let bd = r.delta.hash();
let bf = r.replay.future_hash;
for pc in &case.perturbations {
let pin = input_with_world(&case, pc.world.clone());
let pr = execute(cfg, &pin);
let read = r.trace.read_graph.access_count[pc.target_domain % NUM_DOMAINS] > 0;
if read {
consumed += 1;
if pr.delta.hash() != bd {
consumed_delta += 1;
}
if pr.replay.future_hash != bf {
consumed_future += 1;
}
if pc.expectation.expect_trace_change
&& pr.trace.canonical_hash() == bt
&& pc.expectation.neutral_explanation.is_none()
{
consumed_trace_violation += 1;
}
}
}
(0..NUM_DOMAINS).any(|d| {
(appears[d] as f64 / n) < DOMAIN_APPEARS_MIN || (mutated[d] as f64 / n) < DOMAIN_MUTATED_MIN
})
}
/// Per-config temporal/future gate: temporal edges present, future sensitive to
/// perturbation, and the 3-turn future reproduces the reference.
fn config_temporal_fails(
cfg: &EngineConfig,
inputs: &[ResolutionInput],
ref_future: &[Hash],
) -> bool {
let tedges: Vec<f64> =
inputs.iter().map(|i| execute(cfg, i).trace.temporal_graph.edge_count() as f64).collect();
if median(&tedges) < 1.0 {
return true;
let mut fails = Vec::new();
if causal_trace_fails(median(&edges), percentile(&ranks, 0.05)) {
fails.push("causal_rank/trace".to_string());
}
let mut altered = 0usize;
for i in inputs {
let base = execute(cfg, i).replay.future_hash;
let mut p = i.clone();
p.world.domains[0].observed[0] = p.world.domains[0].observed[0].wrapping_add(101);
p.world.mark_perturbed();
if execute(cfg, &p).replay.future_hash != base {
altered += 1;
if equiv_fail {
fails.push("runtime_equivalence".to_string());
}
if (0..NUM_DOMAINS).any(|d| {
(appears[d] as f64 / n) < DOMAIN_APPEARS_MIN
|| (mutated[d] as f64 / n) < DOMAIN_MUTATED_MIN
|| !influence_changed[d]
}) {
fails.push("domain_participation".to_string());
}
if (altered as f64 / inputs.len().max(1) as f64) < FUTURE_ALT_MIN {
return true;
let mc = consumed.max(1) as f64;
if consumed == 0
|| (consumed_trace_violation as f64 / mc) > 0.01
|| (consumed_delta as f64 / mc) < CONSUMED_DELTA_MIN
|| (consumed_future as f64 / mc) < CONSUMED_FUTURE_MIN
{
fails.push("metamorphic_response".to_string());
}
inputs
.iter()
.zip(ref_future)
.any(|(i, rf)| execute(cfg, i).replay.future_hash != *rf)
let ce = causal_explanation_gate(cfg, n_cases, 4);
if !ce.failures.is_empty() {
fails.push("causal_explanation".to_string());
}
fails
}
/// Per-config equivalence gate: the config diverges from the reference canonical
/// view on at least one input.
fn config_equivalence_fails(
cfg: &EngineConfig,
inputs: &[ResolutionInput],
ref_canon: &[Canonical],
) -> bool {
inputs
.iter()
.zip(ref_canon)
.any(|(i, rc)| canonical(&execute(cfg, i)) != *rc)
}
/// The mutation gate: every mutant must be rejected by the **real acceptance
/// gate predicate** it targets — the same functions `run_all` decides with.
pub fn evaluate_mutants(count: usize, inputs: &[ResolutionInput]) -> MutationOutcome {
let refcfg = EngineConfig::reference();
let ref_canon: Vec<Canonical> = inputs.iter().map(|i| canonical(&execute(&refcfg, i))).collect();
let ref_future: Vec<Hash> = inputs.iter().map(|i| execute(&refcfg, i).replay.future_hash).collect();
/// The mutation gate: every mutant must be rejected by the FULL engine-behavior
/// acceptance gates over a real generated-case corpus (not 64 fixed inputs).
pub fn evaluate_mutants(count: usize, _inputs: &[ResolutionInput]) -> MutationOutcome {
let mutants = generate_mutants(count);
let mut killed = 0;
let mut survivors = Vec::new();
for m in &mutants {
let rejected = match m.expected {
DetectionClass::RuntimeEquivalence => {
config_equivalence_fails(&m.config, inputs, &ref_canon)
}
DetectionClass::CausalGate => config_causal_fails(&m.config, inputs),
DetectionClass::TemporalGate => config_temporal_fails(&m.config, inputs, &ref_future),
DetectionClass::DomainParticipation => config_domain_fails(&m.config, inputs),
};
if rejected {
killed += 1;
} else {
let fails = engine_acceptance(&m.config, MUTATION_GATE_CASES);
if fails.is_empty() {
survivors.push((
m.id,
format!(
"mutant {} ({}) not rejected by the real acceptance gate {}",
m.id,
m.name,
m.expected.name()
),
format!("mutant {} ({}) passed all acceptance gates", m.id, m.name),
));
} else {
killed += 1;
}
}
MutationOutcome { total: mutants.len(), killed, survivors }
@@ -476,50 +496,11 @@ fn compressibility(features: &[i64]) -> f64 {
(1.0 - h / 8.0).clamp(0.0, 1.0)
}
/// Real serialized-trace feature row used by the collapse analysis. Layout per
/// domain block (width 9): `[infl_out, infl_in, flow_out, flow_in, read, write,
/// temporal, obs_delta, hid_delta]`, followed by globals `[causal_rank,
/// edge_count, touched, divergence_mean]`. This is genuine trace structure, not
/// a hash-derived proxy.
/// Trace feature row used by the collapse analysis — the single definition lives
/// in `collapse_analysis::trace_feature_row` so the attestor can recompute the
/// same row from the retained full trace and prove the summary derives from it.
fn trace_feature_row(r: &ResolutionResult) -> Vec<f64> {
let infl = r.trace.causal_graph.influence_matrix();
let mut flow = [[0.0f64; NUM_DOMAINS]; NUM_DOMAINS];
for &(a, b, bits) in &r.trace.information_flow.edges {
flow[a as usize % NUM_DOMAINS][b as usize % NUM_DOMAINS] += bits as f64;
}
let mut temporal = [0.0f64; NUM_DOMAINS];
for &(_s, _off, d) in &r.trace.temporal_graph.edges {
temporal[d as usize % NUM_DOMAINS] += 1.0;
}
let mut row = Vec::with_capacity(FEATURE_W);
for d in 0..NUM_DOMAINS {
let infl_out: f64 = (0..NUM_DOMAINS).map(|j| infl[d][j]).sum();
let infl_in: f64 = (0..NUM_DOMAINS).map(|i| infl[i][d]).sum();
let flow_out: f64 = (0..NUM_DOMAINS).map(|j| flow[d][j]).sum();
let flow_in: f64 = (0..NUM_DOMAINS).map(|i| flow[i][d]).sum();
let read = r.trace.read_graph.access_count[d] as f64;
let write = r.trace.write_graph.access_count[d] as f64;
let temp = temporal[d];
let obs_delta: f64 = r.delta.domain_deltas[d]
.observed
.iter()
.map(|&v| (v as f64).abs())
.sum();
let hid_delta: f64 = r.delta.domain_deltas[d]
.hidden
.iter()
.map(|&v| (v as f64).abs())
.sum();
row.extend_from_slice(&[
infl_out, infl_in, flow_out, flow_in, read, write, temp, obs_delta, hid_delta,
]);
}
row.push(r.trace.causal_rank() as f64);
row.push(r.trace.causal_edge_count() as f64);
row.push(r.trace.touched_domain_count() as f64);
row.push(r.trace.context_divergence());
row
collapse_analysis::trace_feature_row(&r.trace, &r.delta)
}
/// Outcome of checking a perturbation's **declared** metamorphic expectation
@@ -552,6 +533,8 @@ pub fn metamorphic_outcome(
if exp.expect_trace_change && perturbed_domain_read {
if trace_changed {
ExpectationOutcome::Upheld
} else if exp.neutral_explanation.is_some() {
ExpectationOutcome::ExplainedNeutral
} else {
ExpectationOutcome::Violation
}
@@ -562,6 +545,13 @@ pub fn metamorphic_outcome(
}
}
/// Consumed-perturbation aggregate thresholds for delta/future. Per-case strict
/// enforcement of delta/future is unsound (clamping and masking legitimately
/// leave them unchanged), so these are enforced as fractions over the consumed
/// perturbations. Reference rates measured at ~0.77 (delta) and ~0.90 (future).
pub const CONSUMED_DELTA_MIN: f64 = 0.65;
pub const CONSUMED_FUTURE_MIN: f64 = 0.80;
// ---------------------------------------------------------------------------
// Result aggregates.
// ---------------------------------------------------------------------------
@@ -611,6 +601,9 @@ pub struct MetamorphicGates {
/// Count of perturbations the program actually consumed (gate is vacuous
/// without these).
pub consumed: usize,
/// Fraction of consumed perturbations that changed the delta / the future.
pub consumed_delta_rate: f64,
pub consumed_future_rate: f64,
pub failures: Vec<String>,
}
@@ -665,6 +658,10 @@ pub struct CiResults {
/// 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>,
/// The collapse feature rows, in the same order as the first base executions
/// in the trace evidence, so the attestor can recompute each from the full
/// trace and prove the summary derives from it (finding 4).
pub collapse_feature_rows: Vec<Vec<f64>>,
}
/// One sampled full-trace evidence record.
@@ -755,6 +752,8 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
let mut meta_expect_violation = 0usize;
let mut meta_explained_neutral = 0usize;
let mut meta_consumed = 0usize;
let mut meta_consumed_delta = 0usize;
let mut meta_consumed_future = 0usize;
let mut min_perturbations = usize::MAX;
let mut contract_pass = 0usize;
@@ -814,11 +813,13 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
let mut c_expect_violation = 0usize;
let mut c_explained_neutral = 0usize;
let mut c_consumed = 0usize;
let mut c_consumed_delta = 0usize;
let mut c_consumed_future = 0usize;
let mut c_pert = 0usize;
// Capture each perturbation execution so the committed case can run
// the full 100% reference/runtime comparison without recomputing the
// reference side.
let mut pert_execs: Vec<(ResolutionInput, Canonical, Hash)> = Vec::new();
let mut pert_execs: Vec<(ResolutionInput, Canonical, Hash, String)> = Vec::new();
for pc in &case.perturbations {
let pinput = input_with_world(&case, pc.world.clone());
let pr = execute(&cfg, &pinput);
@@ -842,13 +843,32 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
r.trace.read_graph.access_count[pc.target_domain % NUM_DOMAINS] > 0;
if perturbed_read {
c_consumed += 1;
if ad {
c_consumed_delta += 1;
}
if af {
c_consumed_future += 1;
}
}
match metamorphic_outcome(&pc.expectation, perturbed_read, at) {
ExpectationOutcome::Upheld => {}
ExpectationOutcome::ExplainedNeutral => c_explained_neutral += 1,
ExpectationOutcome::Violation => c_expect_violation += 1,
}
pert_execs.push((pinput, canonical(&pr), pr.replay.hash()));
// Full-trace evidence line for THIS perturbation leaf (finding 2):
// every leaf, base and perturbation, carries a recomputable trace.
let pline = format!(
"p {:016x} {:016x} {:016x} {:016x} {:016x} {:016x}\t{}\t{}",
pr.replay.world_seed,
pr.replay.program_seed,
pr.replay.contract_seed,
pr.replay.perturbation_seed,
pr.replay.future_hash.0,
pr.replay.hash().0,
pr.trace.serialize(),
pr.delta.serialize(),
);
pert_execs.push((pinput, canonical(&pr), pr.replay.hash(), pline));
}
let future_sensitivity = if c_pert > 0 {
c_alt_future as f64 / c_pert as f64
@@ -901,7 +921,7 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
if let Some(w) = evidence_sink.as_deref_mut() {
let _ = writeln!(
w,
"{:016x} {:016x} {:016x} {:016x} {:016x} {:016x}\t{}\t{}",
"b {:016x} {:016x} {:016x} {:016x} {:016x} {:016x}\t{}\t{}",
r.replay.world_seed,
r.replay.program_seed,
r.replay.contract_seed,
@@ -919,7 +939,7 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
delta: r.delta.clone(),
});
}
for (pinput, pref_canon, pleaf) in &pert_execs {
for (pinput, pref_canon, pleaf, pline) in &pert_execs {
let prut = rut.resolve(pinput.clone());
equiv_total += 1;
if *pref_canon == canonical(&prut) {
@@ -929,6 +949,9 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
.push(format!("case {} (perturbation) reference != runtime_under_test", i));
}
merkle_leaves.push(*pleaf);
if let Some(w) = evidence_sink.as_deref_mut() {
let _ = writeln!(w, "{}", pline);
}
}
actual_executions += 1;
@@ -964,6 +987,8 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
meta_expect_violation += c_expect_violation;
meta_explained_neutral += c_explained_neutral;
meta_consumed += c_consumed;
meta_consumed_delta += c_consumed_delta;
meta_consumed_future += c_consumed_future;
perturbation_runs += c_pert;
min_perturbations = min_perturbations.min(c_pert);
@@ -1094,11 +1119,27 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
r_violation
));
}
// Non-vacuity: the relation must actually be exercised — there must be
// perturbations the program consumed for the enforcement to mean anything.
if meta_total > 0 && meta_consumed == 0 {
meta_failures.push("metamorphic enforcement vacuous: no consumed perturbations".into());
}
// Delta and future expectations: per-case strict enforcement is unsound
// (clamping/masking), so enforce them as fractions over consumed
// perturbations.
let mc = meta_consumed.max(1) as f64;
let consumed_delta_rate = meta_consumed_delta as f64 / mc;
let consumed_future_rate = meta_consumed_future as f64 / mc;
if consumed_delta_rate < CONSUMED_DELTA_MIN {
meta_failures.push(format!(
"consumed-perturbation delta-change rate {:.4} < {}",
consumed_delta_rate, CONSUMED_DELTA_MIN
));
}
if consumed_future_rate < CONSUMED_FUTURE_MIN {
meta_failures.push(format!(
"consumed-perturbation future-change rate {:.4} < {}",
consumed_future_rate, CONSUMED_FUTURE_MIN
));
}
let metamorphic = MetamorphicGates {
total: meta_total,
altered_trace: r_trace,
@@ -1107,6 +1148,8 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
expectation_violations: r_violation,
explained_neutral: r_explained,
consumed: meta_consumed,
consumed_delta_rate,
consumed_future_rate,
failures: meta_failures,
};
@@ -1116,7 +1159,7 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
// ---- Collapse gates (real trace information) ----
progress!("collapse analysis (11 attacks over real trace features)...");
let corpus = BehaviorCorpus::build(collapse_rows);
let corpus = BehaviorCorpus::build(collapse_rows.clone());
let collapse = analyze(&corpus);
// ---- Mutation gates (killed by named gate) ----
@@ -1262,6 +1305,7 @@ pub fn run_all_to(scale: Scale, mut evidence_sink: Option<&mut dyn Write>) -> Ci
coverage,
merkle_leaves: retained_leaves,
trace_evidence,
collapse_feature_rows: collapse_rows,
}
}
@@ -1279,6 +1323,31 @@ pub struct CausalExplanationGate {
pub edges_confirmed: usize,
pub confirmed_fraction: f64,
pub failures: Vec<String>,
/// Per-edge intervention records, sufficient to independently recompute each
/// confirmation (finding 7).
pub evidence: Vec<CausalEvidenceRecord>,
}
/// One recomputable per-edge intervention record: regenerate the case from
/// `case_seed`, perturb the recorded source lane, and the destination lane's
/// delta must move from `base_dv` to `alt_dv` (`confirmed = base_dv != alt_dv`).
#[derive(Clone, Copy, Debug)]
pub struct CausalEvidenceRecord {
pub case_seed: u64,
pub from_domain: u8,
pub from_lane: u8,
pub from_hidden: bool,
pub to_domain: u8,
pub to_lane: u8,
pub to_hidden: bool,
pub base_dv: i64,
pub alt_dv: i64,
}
impl CausalEvidenceRecord {
pub fn confirmed(&self) -> bool {
self.base_dv != self.alt_dv
}
}
/// Minimum fraction of recorded causal edges that must be intervention-confirmed.
@@ -1305,11 +1374,13 @@ pub fn causal_confirmation(
cases: usize,
edges_per_case: usize,
scramble: bool,
) -> (usize, usize) {
) -> (usize, usize, Vec<CausalEvidenceRecord>) {
let mut tested = 0usize;
let mut confirmed = 0usize;
let mut records = Vec::new();
for i in 0..cases {
let (case, _) = generate_accepted_case(case_seed(i));
let seed = case_seed(i);
let (case, _) = generate_accepted_case(seed);
let input = input_from_case(&case);
let base = execute(cfg, &input);
let edges = &base.trace.causal_graph.edges;
@@ -1340,9 +1411,22 @@ pub fn causal_confirmation(
if base_dv != alt_dv {
confirmed += 1;
}
if !scramble {
records.push(CausalEvidenceRecord {
case_seed: seed,
from_domain: sd as u8,
from_lane: sl as u8,
from_hidden: shidden,
to_domain: dd as u8,
to_lane: e.to.lane,
to_hidden: e.to.hidden,
base_dv,
alt_dv,
});
}
}
(tested, confirmed)
}
(tested, confirmed, records)
}
pub fn causal_explanation_gate(
@@ -1350,7 +1434,7 @@ pub fn causal_explanation_gate(
cases: usize,
edges_per_case: usize,
) -> CausalExplanationGate {
let (tested, confirmed) = causal_confirmation(cfg, cases, edges_per_case, false);
let (tested, confirmed, evidence) = causal_confirmation(cfg, cases, edges_per_case, false);
let frac = if tested > 0 { confirmed as f64 / tested as f64 } else { 0.0 };
let mut failures = Vec::new();
if tested < CAUSAL_CONFIRM_SAMPLE_MIN {
@@ -1370,6 +1454,7 @@ pub fn causal_explanation_gate(
edges_confirmed: confirmed,
confirmed_fraction: frac,
failures,
evidence,
}
}
@@ -1634,23 +1719,21 @@ mod tests {
#[test]
fn consumed_perturbation_must_alter_trace() {
let active = TraceDifferenceExpectation::active();
// Program consumed the perturbed domain but the trace did not change:
// a hard metamorphic violation.
assert_eq!(
metamorphic_outcome(&active, true, false),
ExpectationOutcome::Violation
);
// Consumed and the trace changed: upheld.
assert_eq!(
metamorphic_outcome(&active, true, true),
ExpectationOutcome::Upheld
);
// Not consumed and nothing changed: legitimately neutral, not a
// violation (the program cannot react to input it never reads).
assert_eq!(
metamorphic_outcome(&active, false, false),
ExpectationOutcome::ExplainedNeutral
);
assert_eq!(metamorphic_outcome(&active, true, true), ExpectationOutcome::Upheld);
// Consumed but trace did not change, not permitted neutral: violation.
assert_eq!(metamorphic_outcome(&active, true, false), ExpectationOutcome::Violation);
// Not consumed and nothing changed: legitimately neutral.
assert_eq!(metamorphic_outcome(&active, false, false), ExpectationOutcome::ExplainedNeutral);
}
/// Delta and future expectations are enforced as consumed-aggregate rates
/// (per-case is unsound). A run whose consumed perturbations rarely change
/// the delta or future must fail; the reference run is well above both.
#[test]
fn metamorphic_delta_future_rates_enforced() {
let r = run_all(Scale::tiny());
assert!(r.metamorphic.consumed_delta_rate >= CONSUMED_DELTA_MIN, "delta rate {}", r.metamorphic.consumed_delta_rate);
assert!(r.metamorphic.consumed_future_rate >= CONSUMED_FUTURE_MIN, "future rate {}", r.metamorphic.consumed_future_rate);
}
/// Finding 10: the causal-explanation gate measures real cause→effect, not
@@ -1666,7 +1749,7 @@ mod tests {
assert!(gate.failures.is_empty(), "reference fails causal gate: {:?}", gate.failures);
assert!(gate.confirmed_fraction >= CAUSAL_CONFIRM_MIN);
let (t, c) = causal_confirmation(&cfg, 120, 8, true);
let (t, c, _) = causal_confirmation(&cfg, 120, 8, true);
let scrambled = c as f64 / t.max(1) as f64;
assert!(
scrambled < CAUSAL_CONFIRM_MIN,
@@ -1679,6 +1762,32 @@ mod tests {
gate.confirmed_fraction,
scrambled
);
// Per-edge evidence is retained and independently recomputable: replay
// each record from its seed and confirm base_dv/alt_dv reproduce.
assert!(!gate.evidence.is_empty());
let mut rechecked = 0;
for rec in gate.evidence.iter().take(64) {
let (case, _) = generate_accepted_case(rec.case_seed);
let input = input_from_case(&case);
let base = execute(&cfg, &input);
let mut w = input.world.clone();
if rec.from_hidden {
let l = rec.from_lane as usize % HIDDEN_LANES;
w.domains[rec.from_domain as usize].hidden[l] =
w.domains[rec.from_domain as usize].hidden[l].wrapping_add(0x9_27c1);
} else {
let l = rec.from_lane as usize % LANES;
w.domains[rec.from_domain as usize].observed[l] =
w.domains[rec.from_domain as usize].observed[l].wrapping_add(0x9_27c1);
}
let alt = execute(&cfg, &input_with_world(&case, w));
let bdv = lane_delta(&base.delta.domain_deltas[rec.to_domain as usize], rec.to_lane as usize, rec.to_hidden);
let adv = lane_delta(&alt.delta.domain_deltas[rec.to_domain as usize], rec.to_lane as usize, rec.to_hidden);
assert_eq!(bdv, rec.base_dv, "retained base_dv not recomputable");
assert_eq!(adv, rec.alt_dv, "retained alt_dv not recomputable");
rechecked += 1;
}
assert!(rechecked >= 64);
}
/// Finding 5: mutants are killed by the SAME acceptance-gate predicates the
@@ -1687,52 +1796,15 @@ mod tests {
/// real predicate it targets.
#[test]
fn mutants_killed_by_real_acceptance_gates() {
use rune_ir::{RuneProgram, RuneToken, ALL_OPS};
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot};
fn rich_input(seed: u64) -> ResolutionInput {
let mut rng = Rng::new(seed);
let mut w = WorldSnapshot::blank(WorldId(seed), seed);
for d in &mut w.domains {
for l in 0..LANES {
d.observed[l] = rng.range_i64(-5000, 5000);
}
for l in 0..HIDDEN_LANES {
d.hidden[l] = rng.range_i64(-5000, 5000);
}
}
for j in 0..NUM_DOMAINS {
for i in 0..NUM_DOMAINS {
w.causal_state.coupling[j][i] = rng.range_i64(-17, 17);
}
}
let tokens: Vec<RuneToken> = (0..40)
.map(|i| RuneToken {
op: ALL_OPS[i % ALL_OPS.len()],
a: ((i * 3) % NUM_DOMAINS) as u8,
b: ((i * 5 + 1) % NUM_DOMAINS) as u8,
c: rng.next_u64() as u8,
imm: rng.range_i64(-100000, 100000),
})
.collect();
ResolutionInput {
world: w,
program: RuneProgram { id: ProgramId(seed), tokens, seed },
contexts: standard_executors(seed, 4),
contract_seed: seed,
perturbation_seed: seed,
}
}
let inputs: Vec<ResolutionInput> = (0..16).map(|s| rich_input(s + 1)).collect();
// Reference passes every per-config acceptance predicate.
// The reference passes the FULL engine-behavior acceptance gates over a
// real generated-case corpus (not 64 fixed inputs).
let refcfg = EngineConfig::reference();
let rc: Vec<Canonical> = inputs.iter().map(|i| canonical(&execute(&refcfg, i))).collect();
let rf: Vec<Hash> = inputs.iter().map(|i| execute(&refcfg, i).replay.future_hash).collect();
assert!(!config_causal_fails(&refcfg, &inputs));
assert!(!config_domain_fails(&refcfg, &inputs));
assert!(!config_temporal_fails(&refcfg, &inputs, &rf));
assert!(!config_equivalence_fails(&refcfg, &inputs, &rc));
// Every mutant is rejected by the real acceptance gate it targets.
let outcome = evaluate_mutants(520, &inputs);
assert!(
engine_acceptance(&refcfg, MUTATION_GATE_CASES).is_empty(),
"reference fails an acceptance gate"
);
// Every mutant is rejected by those same full gates.
let outcome = evaluate_mutants(520, &[]);
assert!(outcome.passed(), "survivors: {:?}", outcome.survivors);
assert_eq!(outcome.killed, outcome.total);
}
+54
View File
@@ -59,6 +59,58 @@ fn write_evidence(dir: &Path, r: &CiResults) {
r.provenance.engines_agree,
);
fs::write(ev.join("claims.tsv"), claims).expect("write claims");
// Per-edge causal intervention evidence (finding 7): one recomputable record
// per tested edge.
let mut causal = String::from(
"seed\tfrom_domain\tfrom_lane\tfrom_hidden\tto_domain\tto_lane\tto_hidden\tbase_dv\talt_dv\n",
);
for rec in &r.causal_explanation.evidence {
causal.push_str(&format!(
"{:016x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
rec.case_seed,
rec.from_domain,
rec.from_lane,
rec.from_hidden as u8,
rec.to_domain,
rec.to_lane,
rec.to_hidden as u8,
rec.base_dv,
rec.alt_dv,
));
}
fs::write(ev.join("causal_evidence.tsv"), causal).expect("write causal evidence");
// Collapse feature rows (finding 4): the summaries the collapse gate consumes,
// in the same order as the first base trace records. Stored as f64 bits so an
// attestor can recompute each row from the full trace and compare bit-exact.
let mut crows = String::new();
for row in &r.collapse_feature_rows {
let cells: Vec<String> = row.iter().map(|v| v.to_bits().to_string()).collect();
crows.push_str(&cells.join(" "));
crows.push('\n');
}
fs::write(ev.join("collapse_feature_rows.tsv"), crows).expect("write collapse rows");
// Artifact bundle manifest (finding 1): the merge-scale claim is only valid
// if this complete, content-hashed bundle is retained. The attestor verifies
// every listed file exists and its hash + length match.
let bundle = [
"leaves.tsv",
"claims.tsv",
"causal_evidence.tsv",
"collapse_feature_rows.tsv",
"traces.tsv",
];
let mut manifest = String::from("file\thash\tbytes\n");
for name in bundle {
let bytes = fs::read(ev.join(name)).unwrap_or_default();
let mut h = world_model::Hasher::new();
h.write_tag("evidence-file");
h.write_bytes(&bytes);
manifest.push_str(&format!("{}\t{:016x}\t{}\n", name, h.finish().0, bytes.len()));
}
fs::write(ev.join("MANIFEST.tsv"), manifest).expect("write manifest");
// Note: evidence/traces.tsv (the FULL per-execution trace corpus) is streamed
// during the run in main(), covering 100% of base executions — not written
// here from a capped sample.
@@ -147,6 +199,8 @@ fn build_reports(dir: &Path, r: &CiResults) {
("altered_delta".into(), Json::Num(r.metamorphic.altered_delta)),
("altered_future".into(), Json::Num(r.metamorphic.altered_future)),
("consumed_perturbation_trace_violations".into(), Json::Num(r.metamorphic.expectation_violations)),
("consumed_delta_change_rate".into(), Json::Num(r.metamorphic.consumed_delta_rate)),
("consumed_future_change_rate".into(), Json::Num(r.metamorphic.consumed_future_rate)),
("legitimately_neutral".into(), Json::Num(r.metamorphic.explained_neutral)),
("consumed_perturbations".into(), Json::Int(r.metamorphic.consumed as i64)),
("failures".into(), fails(&r.metamorphic.failures)),
+40 -1
View File
@@ -21,7 +21,46 @@
pub mod linalg;
use linalg::{ols_r2, pca_scores, Mat};
use world_model::NUM_DOMAINS;
use trace_model::ExecutionTrace;
use world_model::{WorldDelta, NUM_DOMAINS};
/// The single definition of a trace feature row. The collapse corpus is built
/// from these rows; an attestor recomputes the same row from the retained FULL
/// trace and delta and checks equality, which is how the summary is proven to
/// derive from the full trace. Layout per domain block (`BLOCK_W`):
/// `[infl_out, infl_in, flow_out, flow_in, read, write, temporal, obs_delta,
/// hid_delta]`, then globals `[causal_rank, edge_count, touched, divergence]`.
pub fn trace_feature_row(trace: &ExecutionTrace, delta: &WorldDelta) -> Vec<f64> {
let infl = trace.causal_graph.influence_matrix();
let mut flow = [[0.0f64; NUM_DOMAINS]; NUM_DOMAINS];
for &(a, b, bits) in &trace.information_flow.edges {
flow[a as usize % NUM_DOMAINS][b as usize % NUM_DOMAINS] += bits as f64;
}
let mut temporal = [0.0f64; NUM_DOMAINS];
for &(_s, _off, d) in &trace.temporal_graph.edges {
temporal[d as usize % NUM_DOMAINS] += 1.0;
}
let mut row = Vec::with_capacity(FEATURE_W);
for d in 0..NUM_DOMAINS {
let infl_out: f64 = (0..NUM_DOMAINS).map(|j| infl[d][j]).sum();
let infl_in: f64 = (0..NUM_DOMAINS).map(|i| infl[i][d]).sum();
let flow_out: f64 = (0..NUM_DOMAINS).map(|j| flow[d][j]).sum();
let flow_in: f64 = (0..NUM_DOMAINS).map(|i| flow[i][d]).sum();
let read = trace.read_graph.access_count[d] as f64;
let write = trace.write_graph.access_count[d] as f64;
let temp = temporal[d];
let obs_delta: f64 = delta.domain_deltas[d].observed.iter().map(|&v| (v as f64).abs()).sum();
let hid_delta: f64 = delta.domain_deltas[d].hidden.iter().map(|&v| (v as f64).abs()).sum();
row.extend_from_slice(&[
infl_out, infl_in, flow_out, flow_in, read, write, temp, obs_delta, hid_delta,
]);
}
row.push(trace.causal_rank() as f64);
row.push(trace.causal_edge_count() as f64);
row.push(trace.touched_domain_count() as f64);
row.push(trace.context_divergence());
row
}
/// Width of one per-domain feature block in a trace feature row.
/// `[infl_out, infl_in, flow_out, flow_in, read, write, temporal, obs_delta, hid_delta]`
-8
View File
@@ -33,12 +33,4 @@ mod tests {
assert!(resolve("/style.css").is_some());
assert!(resolve("/nope").is_none());
}
#[test]
fn client_only_sends_intent() {
// Guard against the client ever embedding a second simulation: the
// browser code must not reference the reference engine internals.
assert!(!APP_JS.contains("EngineConfig"));
assert!(APP_JS.contains("only ever sends INTENT"));
}
}
-196
View File
@@ -1,196 +0,0 @@
Finding 1
SEVERITY: CRITICAL
SPEC REQUIREMENT: Every acceptance requirement must have a merge-blocking enforcement point; merge blocked
unless all reports pass. See plan.md:20 and plan.md:298.
IMPLEMENTATION LOCATION: .github/workflows/merge-gates.yml:37, README.md:111
EXPLOIT PATH: The repo contains a workflow, but no enforceable branch-protection or merge-queue
configuration. The merge-gates job is skipped on ordinary pull_request events and only runs on merge_group
or push.
HOW THE IMPLEMENTATION STILL PASSES: The code and reports can pass locally or in CI while actual repository
settings do not require the job before merge.
WHY THIS VIOLATES THE SPEC: A workflow file plus README instruction is not proof that merge is blocked if
the gate is absent.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: A verifiable branch-protection or merge-queue ruleset
export showing merge-gates is a required pre-merge status check for main.
Finding 2
SEVERITY: CRITICAL
SPEC REQUIREMENT: Compliance evidence must not be self-validating; every obligation needs artifact,
provenance, merge-blocking enforcement, and failure if absent.
IMPLEMENTATION LOCATION: crates/ci_reports/src/main.rs:360, crates/ci_reports/src/main.rs:375
EXPLOIT PATH: The CI binary writes the reports, checks their presence, and emits "merge_blocking": true
itself.
HOW THE IMPLEMENTATION STILL PASSES: The same process that generates evidence declares the compliance model
satisfied.
WHY THIS VIOLATES THE SPEC: The merge-blocking claim is not independently measured; it is a constant in a
generated artifact.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Compliance report generated or attested by an external CI
controller with immutable run id, workflow id, and required-check status.
Finding 3
SEVERITY: HIGH
SPEC REQUIREMENT: Measured artifacts need a provenance chain from artifact to run.
IMPLEMENTATION LOCATION: crates/ci_reports/src/lib.rs:553, crates/ci_reports/src/lib.rs:667, crates/
ci_reports/src/main.rs:219
EXPLOIT PATH: The Merkle root is computed from in-memory replay hashes; the leaves, inputs, seeds, reference
outputs, and runtime-under-test outputs are not persisted.
HOW THE IMPLEMENTATION STILL PASSES: The report exposes only root and count, and internally checks only
merkle_leaves.len() == equiv_total.
WHY THIS VIOLATES THE SPEC: A root without independently replayable leaves is not a provenance chain; it is
a summary generated by the audited process.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Persisted per-execution records sufficient to recompute
the Merkle root and verify reference/runtime comparison independently.
Finding 4
SEVERITY: HIGH
SPEC REQUIREMENT: Full trace information may not be replaced by summarized proxy; collapse gates must prove
smaller models cannot predict behavior.
IMPLEMENTATION LOCATION: crates/ci_reports/src/lib.rs:343, crates/ci_reports/src/lib.rs:720, crates/
ci_reports/src/lib.rs:178
EXPLOIT PATH: Collapse analysis uses a 76-feature aggregate row and only scale.collapse_samples rows. Merge
default is 5,000 samples, and MAGICKA_COLLAPSE can lower it because no merge floor applies.
HOW THE IMPLEMENTATION STILL PASSES: Compression gates run on the aggregate subset, not on full serialized
traces or all executions.
WHY THIS VIOLATES THE SPEC: This is summary/subset/proxy laundering for a stronger trace-information
requirement.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Collapse artifacts over all merge executions using full
serialized ExecutionTrace records, with no lowering override.
Finding 5
SEVERITY: HIGH
SPEC REQUIREMENT: 500 semantic mutants minimum; every mutant must fail at least one named acceptance gate.
IMPLEMENTATION LOCATION: crates/ci_reports/src/lib.rs:723, crates/semantic_mutation/src/lib.rs:207, crates/
semantic_mutation/src/lib.rs:345
EXPLOIT PATH: Mutants are evaluated against at most 64 inputs and mirrored mini-gates, not the actual full
acceptance gates. Domain, temporal, and causal checks omit large parts of the real gates.
HOW THE IMPLEMENTATION STILL PASSES: mutation.passed() only requires no survivors under these local
evaluators.
WHY THIS VIOLATES THE SPEC: A mirrored evaluator over a representative input slice is not “the named
acceptance gate.”
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Survivor report showing each mutant executed against the
actual merge gates and full acceptance corpus.
Finding 6
SEVERITY: HIGH
SPEC REQUIREMENT: Replay corpus: every failure becomes permanent.
IMPLEMENTATION LOCATION: crates/replay_corpus/src/lib.rs:61, crates/replay_corpus/src/lib.rs:151
EXPLOIT PATH: The corpus is generated from deterministic master seeds and current reference outputs. There
is no path that captures CI failures and appends them to the committed corpus.
HOW THE IMPLEMENTATION STILL PASSES: Replay verifies 10,000 static rows have no drift.
WHY THIS VIOLATES THE SPEC: Static seed replay is not permanent retention of every discovered failure.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Corpus history or artifact proving failing cases from
prior CI runs are persisted and rechecked.
Finding 7
SEVERITY: HIGH
SPEC REQUIREMENT: Web Phase H requires Playwright end-to-end tests and 100 browser E2E matches.
IMPLEMENTATION LOCATION: plan2.md:210, .github/workflows/web-gates.yml:45, crates/web_tests/tests/e2e.rs:1
EXPLOIT PATH: The merge-blocking “100 E2E” test is explicitly headless protocol/socket coverage. Rendered-
browser Playwright is advisory and continue-on-error.
HOW THE IMPLEMENTATION STILL PASSES: Browser UI can fail while merge-blocking Rust socket tests pass.
WHY THIS VIOLATES THE SPEC: Browser E2E is substituted with protocol E2E.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Required, non-advisory Playwright browser E2E job running
the 100-match browser gate before merge.
Finding 8
SEVERITY: MEDIUM
SPEC REQUIREMENT: Generated case gates include future dependence within 3 turns and hidden/observed
divergence.
IMPLEMENTATION LOCATION: crates/generators/src/lib.rs:240, crates/generators/src/lib.rs:242, crates/
generators/src/lib.rs:278
EXPLOIT PATH: Future dependence is approximated by presence of a Schedule opcode. Hidden/observed divergence
is approximated by nonzero hidden state or any masked lane, not measured behavior.
HOW THE IMPLEMENTATION STILL PASSES: A case can pass generated gates based on structure even if runtime
behavior does not satisfy the stated property.
WHY THIS VIOLATES THE SPEC: Structural indicators are reported as generated-case requirements.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Generated-gate artifact based on measured execution
traces and measured 3-turn future sensitivity.
Finding 9
SEVERITY: MEDIUM
SPEC REQUIREMENT: Perturbations are generated from domain surfaces, not a fixed list, and expected trace
differences must be meaningful.
IMPLEMENTATION LOCATION: crates/world_model/src/domain.rs:184, crates/generators/src/lib.rs:143, crates/
ci_reports/src/lib.rs:606
EXPLOIT PATH: Each domain exposes a small hard-coded axis set. The metamorphic gate mostly compares hashes
and only uses neutral_explanation; it ignores expect_trace_change, expect_delta_change, and
expect_future_change.
HOW THE IMPLEMENTATION STILL PASSES: Aggregate perturbation thresholds can pass without proving surface-
derived coverage or per-axis expectations.
WHY THIS VIOLATES THE SPEC: Fixed-axis perturbations and unused expectations are weaker than the required
metamorphic contract.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Per-axis report proving generated axes derive from read/
write surfaces and each expected difference is enforced.
Finding 10
SEVERITY: MEDIUM
SPEC REQUIREMENT: Trace evidence must explain causality; reject if trace evidence cannot explain causality.
IMPLEMENTATION LOCATION: crates/trace_model/src/lib.rs:353, crates/ci_reports/src/lib.rs:741
EXPLOIT PATH: Trace gates check counts, rank, touched domains, fingerprint collisions, and largest cluster.
They do not verify that causal edges are independently reconstructable from opcode semantics and world
state.
HOW THE IMPLEMENTATION STILL PASSES: A runtime can emit plausible high-rank causal edges and pass aggregate
metrics.
WHY THIS VIOLATES THE SPEC: Trace quantity is treated as causal explanation.
MINIMUM EVIDENCE REQUIRED TO DISPROVE THE FINDING: Independent causal audit artifact mapping trace edges
back to executed tokens, source values, destination values, and state transitions.
-271
View File
@@ -1,271 +0,0 @@
=============
README.md
=============
# Magicka VM — Phase 0/1
> The deliverable is a Rust engine whose tests make a fake universe fail.
This repository implements the Phase 0/1 specification in `plan.md`: an
**adversarial testing framework first**, then a **reference runtime** that
passes it, then a **runtime under test** that matches the reference. No spell
content, templates, or cosmetic runes — the value is in the tests that refuse
to let the universe collapse into a single score, resource, effect axis,
executor, rune, hidden formula, or decorative domain.
## Compliance model
No gate may pass from configuration, naming, shared implementation, smoke-scale
runs, regenerated expectations, proxy metrics, a default profile, or a
locally-runnable binary. A gate passes only from persisted, independently
reproducible, full-scale adversarial evidence enforced at merge. Every
acceptance obligation has all four of: a **measured artifact**, a **provenance
chain** to the run that produced it, a **merge-blocking enforcement point**, and
a **failure condition if the artifact or provenance is absent**.
- The merge-blocking enforcement point is `.github/workflows/merge-gates.yml`,
whose `merge-gates` job runs `MAGICKA_PROFILE=merge` (full gates) and must be a
**required status check** on the protected branch / merge queue. It is not a
local binary, and the fast profile is advisory only — it can never stand in for
acceptance.
- `compliance_report.json` enumerates every obligation, its artifact, its floor,
the actual measured value, and whether the artifact is present. A missing
required report fails acceptance.
- The merge floors (50k worlds, 250k programs, 1,000,000 executions, 10
perturbations/exec, 100% reference/runtime comparison over base **and**
perturbations, 500 mutants, 10,000 replay cases) cannot be lowered by
environment overrides: a lowering override is recorded as a provenance failure
and the floor is kept.
Every gate is built to be *able to fail*, and a negative-control test proves it does:
| Gate | How it is made unbypassable | Negative control proving it can fail |
|------|-----------------------------|--------------------------------------|
| runtime_equivalence | Compares two **independent implementations** (the reference engine vs. `runtime_under_test::native`, which never calls the reference engine) | `buggy_runtime_is_rejected` — a runtime with one dropped causal edge is caught |
| compression_resistance | Attacks operate on the **real serialized trace** (causal influence, info-flow, access, temporal, deltas), not a hash proxy; info loss is genuine unexplained variance | `single_factor_corpus_is_rejected` — a rank-1 universe is rejected |
| mutation_survivor | Each mutant must fail the **named gate** it targets, not merely differ from the reference | `reference_passes_every_named_gate` + `no_mutant_survives_its_named_gate` |
| replay | Expectations are **loaded from a committed file**, not regenerated in the same run | `corrupted_expectation_is_detected` |
| domain_participation | Decorative/redundant domains are flagged directly | `decorative_domain_is_rejected` |
| merge scale floor | Env overrides may only **raise** merge counts; a lowering attempt is recorded and the floor kept; executions actually performed are counted | `merge_floor_cannot_be_lowered_by_override`, `merge_profile_at_smoke_scale_is_rejected` |
| 100% comparison | Reference vs. runtime-under-test compared for **every** execution — base and all perturbations, never base only | `runtime_equivalence` gate fails unless `equiv_total == base + perturbations` |
| provenance | A Merkle root over per-execution records, plus independent engine identities, binds reported numbers to executed work | `merkle_root_binds_to_leaves` |
## Workspace layout
Built in the mandatory order from the spec:
| # | Crate | Role |
|---|-------|------|
| 1 | `world_model` | 8 independent domains, world snapshot, perturbation axes, deltas, deterministic primitives (ids, stable hash, RNG) |
| | `rune_ir` | Rune token / program model (no stream is ever rejected) |
| 2 | `trace_model` | Execution trace + all graphs, behavior fingerprint, replay record, fault log, trace metrics |
| 3 | `generators` | Worlds, programs, executors, contracts, perturbations; rejects flat cases |
| 4 | `collapse_analysis` | The 11 compression attacks over real trace structure + collapse gates |
| 5 | `semantic_mutation` | Structurally generated mutant runtimes; proves every one fails its named gate |
| 6 | `replay_corpus` | Permanent, bit-exact replay cases persisted to `corpus/replay_corpus.tsv` |
| 7 | `reference_runtime` | The executable spec engine (`Runtime` trait, `resolve`) |
| 8 | `runtime_under_test` | An **independent** interpreter (`native`) proven equivalent to the reference |
| | `ci_reports` | Orchestrator + `ci` binary; emits 8 gate reports + a provenance report |
The runtime under test does not call the reference engine. It re-derives the
canonical behavior from the spec in a different code organization, so 100%
agreement is *evidence* the spec is implemented correctly rather than a
tautology. (`native_matches_reference_bit_for_bit` checks this over a 2000-seed
sweep.)
## The engine in one paragraph
A world is 8 domains, each with 4 observed + 2 hidden integer lanes, a dense
8×8 coupling matrix, partial observability, and pending scheduled effects. A
rune program is interpreted under ≥3 executors; each opcode reads several
domains, mixes them through a nonlinear avalanche keyed by per-domain
constants, the world coupling, and the executor's salt, then writes back —
recording causal/read/write/information-flow/temporal edges as it goes.
Scheduled effects and coupling diffusion propagate changes 3 turns into the
future.
## Running CI
```bash
cargo test # unit tests + negative controls
MAGICKA_PROFILE=fast cargo run --release -p ci_reports --bin ci # advisory PR slice
MAGICKA_PROFILE=merge cargo run --release -p ci_reports --bin ci # acceptance (full gates)
```
Reports are written to the output dir (8 gate reports + `provenance_report.json`
+ `compliance_report.json` + `ci_summary.md`). The binary exits non-zero if any
gate fails or any required artifact is absent.
### Profiles
`MAGICKA_PROFILE` (or `MAGICKA_SCALE`) selects the run profile.
| Profile | executions | replay | mutants | role |
|---------|-----------|--------|---------|------|
| `fast` (default) | 600 | 10,000 (committed) | 520 | **advisory only — never acceptance** |
| `tiny` | 120 | 10,000 | 520 | smoke |
| `merge` (`MAGICKA_SCALE=full`) | 1,000,000 | 10,000 | 600 | **acceptance — hard floors** |
The fast/tiny profiles print `ADVISORY … NOT a merge-blocking acceptance run`
and are labelled non-acceptance in `compliance_report.json`. Acceptance comes
only from the merge profile, run by the merge-gates workflow. The merge floors
cannot be lowered by environment overrides (a lowering override is recorded as a
provenance failure and the floor kept).
### Merge-blocking enforcement (required check)
`.github/workflows/merge-gates.yml` defines the enforcement point. Configure
branch protection / the merge queue to **require** the `merge-gates` job. That
job runs the full merge profile, verifies the committed corpus has ≥10,000
cases, and fails if any required artifact is missing. The full run executes
~1M base executions × (1 base + 10 perturbations) with 100% reference/runtime
comparison; it completes in minutes on a CI runner.
### Replay corpus
The replay corpus is committed at
`crates/replay_corpus/corpus/replay_corpus.tsv` (10,000 cases). Replay loads
those expectations and re-executes the reference, so any engine change that
alters a hash makes the committed file and the fresh run disagree and CI fails.
Regenerate it only as a deliberate, reviewed migration:
```bash
cargo run --release -p replay_corpus --bin freeze -- 10000
```
## Determinism
Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content
hashing, wrapping/guarded arithmetic). No floating point enters a canonical
hash, so replay is bit-exact across machines and runs. No external crates.
## The web game (plan2.md)
A browser game is built **around** the existing runtime — it is a playable
window into the Rust universe, never a second simulation. The browser sends only
*intent*; the server is the sole authority; every rune program executes through
the **independent** interpreter (`runtime_under_test::native_resolve`) against
the shared world. The game deliberately does **not** call the reference engine —
the interpreter it uses is the one the runtime-equivalence gate proves correct
(with a negative control proving that gate can fail). Same constraints as the
rest of the repo: pure `std`, no external crates (the WebSocket server
hand-rolls SHA-1, base64, and RFC 6455 framing; JSON is hand-rolled with a total
parser).
> Audit note: the hand-rolled SHA-1 / base64 / RFC-6455 framing and JSON parser
> are checked against published test vectors (RFC 6455 §1.3 accept key, SHA-1
> "abc", base64 length cases) and a fuzz gate, but they are bespoke
> cryptographic/parsing code and carry audit risk relative to a reviewed
> library. They exist to honor the repo's no-external-crates rule; a future
> hardening pass could swap in vetted implementations behind the same interface.
```
Rust runtime → game_runtime (authority) → protocol (WS messages) → server → browser
```
| Crate | Role |
|-------|------|
| `protocol` | Versioned, hashable, **total-decode** client/server messages + JSON value/parser. A malformed packet yields `Err`, never a panic. |
| `game_runtime` | Authoritative match state. Resolves turns through the **independent interpreter** (`runtime_under_test`, not the reference engine), filters visibility/knowledge, records + regenerates replays. A match is a pure function of `(seed, roster, ordered inputs)`. |
| `web_assets` | The embedded browser client (HTML/CSS/JS): arena, rune editor, domain/knowledge panels, replay viewer. |
| `web_client` | Static-asset HTTP delivery (keeps raw assets separate from framing). |
| `server` | `std::net` HTTP + WebSocket server: turn timer, action collection, disconnect handling, panic-proof dispatch. |
| `web_tests` | A dependency-free WebSocket test client + the Phase H gates. |
### Running it
```bash
cargo run --release -p server --bin magicka-server # serve on 127.0.0.1:8080
# then open http://127.0.0.1:8080 in a browser
MAGICKA_ADDR=0.0.0.0:9000 MAGICKA_TURN_MS=8000 cargo run --release -p server --bin magicka-server
```
Join is immediate (1 player + a training dummy). A duel shares a match by id:
two browsers that `JoinMatch` the same `match_id` take slots 1 and 2.
### Web CI gates (Phase H)
These gates are **merge-blocking**: they run inside the merge-required job in
`.github/workflows/merge-gates.yml` (and as fast PR feedback in
`web-gates.yml`). They are the Rust suite in `crates/web_tests`, run with
`cargo test -p web_tests`:
| Gate | Test | Minimum | Status |
|------|------|---------|--------|
| Replay determinism | `determinism.rs` | 1,000 simulated matches, **0 hash mismatches** | merge-blocking |
| Protocol fuzz | `fuzz.rs` | 10,000 fuzz cases, **0 panics** (+ a live server survives a malformed-packet burst) | merge-blocking |
| End-to-end matches | `e2e.rs` | **100** full matches over real sockets; recorded replay reproduces every live per-turn hash | merge-blocking |
| Hidden-state leaks | `visibility.rs` | **0 leaks** — no client-bound frame carries a hidden key; redaction counts every withheld value | merge-blocking |
| Disconnect / timer edges | `resilience.rs` | mid-match disconnect does not corrupt the match; wrong-turn / late submits are rejected deterministically | merge-blocking |
| Rendered-browser E2E | `e2e/specs/play.spec.js` | a real browser joins, casts, and replays a match | **external-blocked (advisory only)** |
Scope honesty — two distinct things, not conflated:
- The "100 E2E matches" merge-blocking gate drives the full
HTTP→WebSocket→protocol→runtime path **headlessly over real sockets**. This is
protocol-level coverage. It is **not** rendered-browser coverage and is not
claimed as such.
- Rendered-browser coverage is **blocked on CI infrastructure**: this CI has no
real browser, so the Playwright suite under `crates/web_tests/e2e/` cannot be
merge-blocking yet. It runs **advisory-only** (`continue-on-error`) in the
`rendered-browser-e2e` job and uploads its report as an artifact. Until a CI
runner with a browser exists, rendered-browser E2E is treated as
**unsatisfied**, not green. Run it locally with:
```bash
cd crates/web_tests/e2e && npm install && npx playwright install chromium && npm test
```
### Acceptance criteria mapping (plan2.md)
| Criterion | Where it holds |
|-----------|----------------|
| A player can join a browser match | `server` join + `web_assets` client; `e2e.rs::single_match_full_playthrough` |
| A turn timer runs | `server` timer thread; client header countdown |
| Inspect / move / attack / cast | `Action` in `protocol`; `game_runtime::apply_action` |
| Rune programs execute only on the server | `game_runtime` is the only caller of the interpreter (`runtime_under_test::native_resolve`); client never imports `EngineConfig` (asserted in `web_assets`) |
| Results return as filtered observations | `VisibleWorldSnapshot`; `visibility.rs` |
| Replay can reproduce the match | `game_runtime::replay`; `determinism.rs`, `e2e.rs` |
| Browser cannot alter hidden truth | intent-only protocol; `visibility.rs` leak gate |
| CI proves protocol, replay, visibility, authority | merge-blocking gates in `merge-gates.yml` (+ `web-gates.yml`); rendered-browser E2E remains external-blocked |
=============
Output
=============
All four directives are done and verified.
What changed
Committed — branch web-game, commit 9d9d5ce. Full workspace is green (every test passes, zero warnings); the
claims are now in git and reproducible. I put it on a branch rather than main; fast-forward it when you're
ready.
Removed reference-engine delegation. game_runtime no longer calls reference_runtime::execute. It now drives
runtime_under_test::native_resolve — the independent interpreter that the runtime-equivalence gate proves
correct (with a negative control proving that gate can fail). The grep confirms the only remaining
reference_runtime use is the shared I/O contract types (ResolutionInput/canonical), not execution. So the
game exercises an independent runtime, not a re-run of the reference.
Protocol/socket gates are merge-blocking. Added an explicit named step to the merge_group-required job in
merge-gates.yml running the protocol + socket + replay/visibility/resilience suite (1k matches/0 drift, 10k
fuzz/0 panics, 100 headless socket E2E, 0 leaks). web-gates.yml keeps the same suite as fast PR feedback.
Rendered-browser E2E is external-blocked, not satisfied. The Playwright job is renamed rendered-browser-e2e
(ADVISORY — blocked on CI infra), set continue-on-error, and uploads its report as an artifact only. The
README states plainly that until a CI runner with a browser exists, rendered-browser coverage is
unsatisfied, and that the 100-match gate is protocol-level coverage — not a browser.
Two things I want to be straight about rather than let them read as resolved:
- The hand-rolled SHA-1/base64/RFC-6455/JSON code carries real audit risk. I checked it against published
vectors and a fuzz gate and documented the risk in the README, but it's bespoke parsing/crypto written to
honor the no-external-crates rule — not a substitute for a reviewed library. I did not eliminate that risk;
I labeled it.
- The independent interpreter is independent of the reference engine's code path, but both live in this repo
and are proven equivalent by the same gate suite — so "independent runtime correctness" rests on that gate,
not on a third-party oracle.