update
This commit is contained in:
+515
-79
@@ -1,83 +1,183 @@
|
||||
//! `ci_reports` — the CI orchestrator. Runs the whole adversarial framework
|
||||
//! against the reference runtime and the runtime under test, evaluates every
|
||||
//! gate, and emits machine-readable JSON plus a human-readable markdown
|
||||
//! summary. Merge is blocked unless all reports pass.
|
||||
//! against the reference runtime and the (independently implemented) runtime
|
||||
//! under test, evaluates every gate, and emits machine-readable JSON plus a
|
||||
//! human-readable markdown summary. Merge is blocked unless all reports pass.
|
||||
//!
|
||||
//! Governing rule (enforced structurally, not by naming): *no gate may pass from
|
||||
//! configuration, naming, shared implementation, smoke-scale runs, regenerated
|
||||
//! expectations, or proxy metrics.* Concretely:
|
||||
//! - the equivalence gate compares two independent implementations;
|
||||
//! - the merge profile's scale floor cannot be lowered by environment overrides,
|
||||
//! and the executions actually performed are counted and checked;
|
||||
//! - the replay corpus is loaded from a committed file, not regenerated;
|
||||
//! - collapse analysis measures real trace information;
|
||||
//! - mutants must fail their named gate;
|
||||
//! - every report carries provenance binding its numbers to executed work.
|
||||
|
||||
pub mod json;
|
||||
|
||||
use collapse_analysis::{analyze, BehaviorCorpus, CollapseSummary};
|
||||
use collapse_analysis::{analyze, BehaviorCorpus, CollapseSummary, FEATURE_W};
|
||||
use generators::{generate_accepted_case, generated_gate_failures, GeneratedCase};
|
||||
use reference_runtime::{canonical, execute, EngineConfig, ResolutionInput, ResolutionResult, Runtime};
|
||||
use runtime_under_test::RuntimeUnderTest;
|
||||
use reference_runtime::{
|
||||
canonical, execute, Canonical, EngineConfig, ResolutionInput, ResolutionResult, Runtime,
|
||||
};
|
||||
use runtime_under_test::{native_resolve, RuntimeUnderTest};
|
||||
use semantic_mutation::{run_suite, MutationOutcome};
|
||||
use std::collections::HashMap;
|
||||
use world_model::{Hash, WorldSnapshot, HIDDEN_LANES, LANES, NUM_DOMAINS};
|
||||
use world_model::{Hash, Hasher, WorldSnapshot, NUM_DOMAINS};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scale configuration.
|
||||
// Run profile + scale, with an unbypassable merge floor.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Profile {
|
||||
/// Representative slice; explicitly NOT merge-blocking.
|
||||
Fast,
|
||||
/// Full, merge-blocking gates with hard floors that overrides cannot lower.
|
||||
Merge,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Profile::Fast => "fast",
|
||||
Profile::Merge => "merge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hard minimums the merge profile mandates (spec "Hard CI Gates").
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScaleFloor {
|
||||
pub worlds: usize,
|
||||
pub programs: usize,
|
||||
pub executions: usize,
|
||||
pub perturbations_per_exec: usize,
|
||||
pub mutants: usize,
|
||||
pub replay_cases: usize,
|
||||
}
|
||||
|
||||
impl ScaleFloor {
|
||||
pub const NONE: ScaleFloor = ScaleFloor {
|
||||
worlds: 0,
|
||||
programs: 0,
|
||||
executions: 0,
|
||||
perturbations_per_exec: 0,
|
||||
mutants: 0,
|
||||
replay_cases: 0,
|
||||
};
|
||||
pub const MERGE: ScaleFloor = ScaleFloor {
|
||||
worlds: 50_000,
|
||||
programs: 250_000,
|
||||
executions: 1_000_000,
|
||||
perturbations_per_exec: 10,
|
||||
mutants: 500,
|
||||
replay_cases: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Scale {
|
||||
pub profile: Profile,
|
||||
pub floor: ScaleFloor,
|
||||
pub executions: usize,
|
||||
pub mutants: usize,
|
||||
pub replay_cases: usize,
|
||||
pub collapse_samples: usize,
|
||||
pub domain_probe_cases: usize,
|
||||
pub domain_probe_variations: usize,
|
||||
/// Overrides that tried to lower a merge floor (recorded, never applied).
|
||||
pub override_violations: Vec<String>,
|
||||
}
|
||||
|
||||
impl Scale {
|
||||
/// The full, merge-blocking gates mandated by the spec.
|
||||
pub fn full() -> Self {
|
||||
pub fn merge() -> Self {
|
||||
Scale {
|
||||
profile: Profile::Merge,
|
||||
floor: ScaleFloor::MERGE,
|
||||
executions: 1_000_000,
|
||||
mutants: 600,
|
||||
replay_cases: 10_000,
|
||||
collapse_samples: 5_000,
|
||||
domain_probe_cases: 2_000,
|
||||
domain_probe_variations: 64,
|
||||
override_violations: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Fast CI (~10% spirit): small but exercises every gate.
|
||||
pub fn fast() -> Self {
|
||||
Scale {
|
||||
profile: Profile::Fast,
|
||||
floor: ScaleFloor::NONE,
|
||||
executions: 600,
|
||||
mutants: 520,
|
||||
replay_cases: 600,
|
||||
collapse_samples: 600,
|
||||
domain_probe_cases: 200,
|
||||
domain_probe_variations: 48,
|
||||
override_violations: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub fn tiny() -> Self {
|
||||
Scale {
|
||||
profile: Profile::Fast,
|
||||
floor: ScaleFloor::NONE,
|
||||
executions: 120,
|
||||
mutants: 520,
|
||||
replay_cases: 120,
|
||||
collapse_samples: 120,
|
||||
domain_probe_cases: 60,
|
||||
domain_probe_variations: 32,
|
||||
override_violations: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an override to a field. On the merge profile an override may only
|
||||
/// raise the value; attempting to lower it below the floor is recorded as a
|
||||
/// violation and the floor is kept.
|
||||
fn apply_override(&mut self, name: &str, value: usize, field_floor: usize, set: impl Fn(&mut Scale, usize)) {
|
||||
if self.profile == Profile::Merge && value < field_floor {
|
||||
self.override_violations.push(format!(
|
||||
"override {name}={value} would lower merge floor {field_floor}; ignored"
|
||||
));
|
||||
set(self, field_floor);
|
||||
} else {
|
||||
set(self, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Self {
|
||||
let mut s = match std::env::var("MAGICKA_SCALE").as_deref() {
|
||||
Ok("full") => Scale::full(),
|
||||
Ok("tiny") => Scale::tiny(),
|
||||
_ => Scale::fast(),
|
||||
let scale_var = std::env::var("MAGICKA_SCALE").ok();
|
||||
let profile_var = std::env::var("MAGICKA_PROFILE").ok();
|
||||
let profile = if profile_var.as_deref() == Some("merge")
|
||||
|| scale_var.as_deref() == Some("full")
|
||||
{
|
||||
Profile::Merge
|
||||
} else {
|
||||
Profile::Fast
|
||||
};
|
||||
|
||||
let mut s = match (profile, scale_var.as_deref()) {
|
||||
(Profile::Merge, _) => Scale::merge(),
|
||||
(Profile::Fast, Some("tiny")) => Scale::tiny(),
|
||||
(Profile::Fast, _) => Scale::fast(),
|
||||
};
|
||||
|
||||
if let Some(v) = env_usize("MAGICKA_EXECUTIONS") {
|
||||
s.executions = v;
|
||||
let f = s.floor.executions;
|
||||
s.apply_override("MAGICKA_EXECUTIONS", v, f, |s, x| s.executions = x);
|
||||
}
|
||||
if let Some(v) = env_usize("MAGICKA_MUTANTS") {
|
||||
s.mutants = v;
|
||||
let f = s.floor.mutants;
|
||||
s.apply_override("MAGICKA_MUTANTS", v, f, |s, x| s.mutants = x);
|
||||
}
|
||||
if let Some(v) = env_usize("MAGICKA_REPLAY") {
|
||||
s.replay_cases = v;
|
||||
let f = s.floor.replay_cases;
|
||||
s.apply_override("MAGICKA_REPLAY", v, f, |s, x| s.replay_cases = x);
|
||||
}
|
||||
if let Some(v) = env_usize("MAGICKA_COLLAPSE") {
|
||||
s.collapse_samples = v;
|
||||
// collapse sample count has no spec floor; never below 1.
|
||||
s.collapse_samples = v.max(1);
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -87,6 +187,81 @@ fn env_usize(key: &str) -> Option<usize> {
|
||||
std::env::var(key).ok().and_then(|v| v.parse().ok())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provenance: bind the reported numbers to executed work.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A tamper-evident record of what actually ran. The Merkle root is computed
|
||||
/// over the per-execution replay-record hashes, so the report cannot claim more
|
||||
/// executions than were performed. The two engine identities are independent
|
||||
/// fingerprints of the reference and the runtime under test; they must agree,
|
||||
/// which is evidence the two implementations match.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Provenance {
|
||||
pub profile: Profile,
|
||||
pub floor: ScaleFloor,
|
||||
pub worlds_generated: usize,
|
||||
pub programs_generated: usize,
|
||||
pub actual_executions: usize,
|
||||
pub min_perturbations_per_exec: usize,
|
||||
pub total_comparisons: usize,
|
||||
pub actual_mutants: usize,
|
||||
pub replay_total: usize,
|
||||
pub reference_engine_id: Hash,
|
||||
pub rut_engine_id: Hash,
|
||||
pub engines_agree: bool,
|
||||
pub execution_merkle_root: Hash,
|
||||
pub merkle_leaf_count: usize,
|
||||
pub collapse_feature_width: usize,
|
||||
pub failures: Vec<String>,
|
||||
}
|
||||
|
||||
/// Merkle root over execution leaves (FNV-combined binary tree).
|
||||
pub fn merkle_root(leaves: &[Hash]) -> Hash {
|
||||
if leaves.is_empty() {
|
||||
return Hash(0);
|
||||
}
|
||||
let mut level: Vec<Hash> = leaves.to_vec();
|
||||
while level.len() > 1 {
|
||||
let mut next = Vec::with_capacity(level.len().div_ceil(2));
|
||||
for pair in level.chunks(2) {
|
||||
let mut h = Hasher::new();
|
||||
h.write_tag("merkle");
|
||||
h.write_u64(pair[0].0);
|
||||
h.write_u64(if pair.len() > 1 { pair[1].0 } else { pair[0].0 });
|
||||
next.push(h.finish());
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
level[0]
|
||||
}
|
||||
|
||||
/// Fixed probe inputs for engine fingerprinting (independent of the corpus).
|
||||
fn probe_inputs(count: usize) -> Vec<ResolutionInput> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let seed = 0xF1A9_E000_u64 ^ (i as u64).wrapping_mul(0x9E3779B97F4A7C15);
|
||||
let (case, _) = generate_accepted_case(seed);
|
||||
input_from_case(&case)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fingerprint of a runtime: hash of its canonical outputs over the probe set.
|
||||
fn engine_fingerprint(probes: &[ResolutionInput], resolve: impl Fn(&ResolutionInput) -> ResolutionResult) -> Hash {
|
||||
let mut h = Hasher::new();
|
||||
h.write_tag("engine-id");
|
||||
for inp in probes {
|
||||
let c = canonical(&resolve(inp));
|
||||
h.write_u64(c.delta_hash.0);
|
||||
h.write_u64(c.trace_hash.0);
|
||||
h.write_u64(c.fault_hash.0);
|
||||
h.write_u64(c.replay_hash.0);
|
||||
h.write_u64(c.future_hash.0);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -165,26 +340,50 @@ fn compressibility(features: &[i64]) -> f64 {
|
||||
(1.0 - h / 8.0).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn world_input_features(w: &WorldSnapshot) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(NUM_DOMAINS * (LANES + HIDDEN_LANES));
|
||||
for d in &w.domains {
|
||||
for &v in &d.observed {
|
||||
out.push(v as f64);
|
||||
}
|
||||
for &v in &d.hidden {
|
||||
out.push(v as f64);
|
||||
}
|
||||
/// 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.
|
||||
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;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn behavior_output_features(r: &ResolutionResult) -> Vec<f64> {
|
||||
r.trace
|
||||
.behavior_fingerprint
|
||||
.features
|
||||
.iter()
|
||||
.map(|&v| v as f64)
|
||||
.collect()
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -207,6 +406,7 @@ pub struct TraceGates {
|
||||
pub struct EquivalenceGates {
|
||||
pub total: usize,
|
||||
pub matched: usize,
|
||||
pub independent: bool,
|
||||
pub failures: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -244,6 +444,7 @@ pub struct ReplayGates {
|
||||
pub total: usize,
|
||||
pub deterministic: usize,
|
||||
pub drift: usize,
|
||||
pub loaded_from_disk: bool,
|
||||
pub failures: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -259,6 +460,7 @@ pub struct CoverageGates {
|
||||
|
||||
pub struct CiResults {
|
||||
pub scale: Scale,
|
||||
pub provenance: Provenance,
|
||||
pub trace: TraceGates,
|
||||
pub equivalence: EquivalenceGates,
|
||||
pub domain: DomainGates,
|
||||
@@ -281,6 +483,7 @@ impl CiResults {
|
||||
("contract", &self.contract.failures),
|
||||
("replay", &self.replay.failures),
|
||||
("coverage", &self.coverage.failures),
|
||||
("provenance", &self.provenance.failures),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -297,7 +500,26 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
let cfg = EngineConfig::reference();
|
||||
let rut = RuntimeUnderTest::new();
|
||||
|
||||
// Main execution + metamorphic + contract pass.
|
||||
// Progress logging: a long merge run must be trackable, not a silent black
|
||||
// box. Emit periodic stderr lines with elapsed time and counts. Suppressed
|
||||
// for small runs (tests) to keep their output clean.
|
||||
let started = std::time::Instant::now();
|
||||
let report_progress = scale.executions >= 10_000;
|
||||
let prog_step = (scale.executions / 20).max(1);
|
||||
macro_rules! progress {
|
||||
($($arg:tt)*) => {
|
||||
if report_progress {
|
||||
eprintln!(" [{:7.1}s] {}", started.elapsed().as_secs_f64(), format_args!($($arg)*));
|
||||
}
|
||||
};
|
||||
}
|
||||
progress!(
|
||||
"start: {} executions x (1 base + {} perturbations) = {} comparisons planned",
|
||||
scale.executions,
|
||||
scale.floor.perturbations_per_exec.max(10),
|
||||
scale.executions * 11
|
||||
);
|
||||
|
||||
let mut ranks = Vec::with_capacity(scale.executions);
|
||||
let mut edges = Vec::with_capacity(scale.executions);
|
||||
let mut touched = Vec::with_capacity(scale.executions);
|
||||
@@ -316,25 +538,40 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
let mut meta_alt_delta = 0usize;
|
||||
let mut meta_alt_future = 0usize;
|
||||
let mut meta_neutral_unexpl = 0usize;
|
||||
let mut min_perturbations = usize::MAX;
|
||||
|
||||
let mut contract_pass = 0usize;
|
||||
let mut contract_failures: Vec<String> = Vec::new();
|
||||
|
||||
let mut collapse_inputs: Vec<Vec<f64>> = Vec::new();
|
||||
let mut collapse_outputs: Vec<Vec<f64>> = Vec::new();
|
||||
let mut collapse_fps: Vec<Hash> = Vec::new();
|
||||
let mut collapse_rows: Vec<Vec<f64>> = Vec::new();
|
||||
|
||||
let mut mutation_inputs: Vec<ResolutionInput> = Vec::new();
|
||||
let mut generated_rejected = 0usize;
|
||||
let mut perturbation_runs = 0usize;
|
||||
|
||||
let mut contract_rejected = 0usize;
|
||||
|
||||
// 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 actual_executions = 0usize;
|
||||
let mut worlds_generated = 0usize;
|
||||
let mut programs_generated = 0usize;
|
||||
// 100% reference/runtime comparison covers EVERY execution, base and
|
||||
// perturbation alike — never base executions only.
|
||||
let mut equiv_total = 0usize;
|
||||
|
||||
for i in 0..scale.executions {
|
||||
// Admission: a case is admitted only if its *measured* trace behavior
|
||||
// satisfies its contract. Structurally-flat cases are already rejected
|
||||
// by `generate_accepted_case`; here we additionally reject cases whose
|
||||
// behavior fails the contract, retrying with fresh seeds.
|
||||
if report_progress && i > 0 && i % prog_step == 0 {
|
||||
progress!(
|
||||
"main loop {}/{} ({:.0}%): {} comparisons, {} matched, {} contract-retries",
|
||||
i,
|
||||
scale.executions,
|
||||
100.0 * i as f64 / scale.executions as f64,
|
||||
equiv_total,
|
||||
equiv_matched,
|
||||
contract_rejected
|
||||
);
|
||||
}
|
||||
let mut seed = case_seed(i);
|
||||
for attempt in 0..48 {
|
||||
let last_attempt = attempt == 47;
|
||||
@@ -350,7 +587,6 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
let comp = compressibility(&r.trace.behavior_fingerprint.features);
|
||||
let div = r.trace.context_divergence();
|
||||
|
||||
// Run the 10 perturbations for metamorphic stats + future sensitivity.
|
||||
let base_trace_h = r.trace.canonical_hash();
|
||||
let base_delta_h = r.delta.hash();
|
||||
let base_future_h = r.replay.future_hash;
|
||||
@@ -359,8 +595,13 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
let mut c_alt_future = 0usize;
|
||||
let mut c_neutral = 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();
|
||||
for pc in &case.perturbations {
|
||||
let pr = execute(&cfg, &input_with_world(&case, pc.world.clone()));
|
||||
let pinput = input_with_world(&case, pc.world.clone());
|
||||
let pr = execute(&cfg, &pinput);
|
||||
c_pert += 1;
|
||||
let at = pr.trace.canonical_hash() != base_trace_h;
|
||||
let ad = pr.delta.hash() != base_delta_h;
|
||||
@@ -377,6 +618,7 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
if !at && pc.expectation.neutral_explanation.is_none() {
|
||||
c_neutral += 1;
|
||||
}
|
||||
pert_execs.push((pinput, canonical(&pr), pr.replay.hash()));
|
||||
}
|
||||
let future_sensitivity = if c_pert > 0 {
|
||||
c_alt_future as f64 / c_pert as f64
|
||||
@@ -408,12 +650,34 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
}
|
||||
|
||||
// ---- Commit the admitted case ----
|
||||
let r2 = rut.resolve(input.clone());
|
||||
if canonical(&r) == canonical(&r2) {
|
||||
// Every admitted case used exactly one generated world + program.
|
||||
worlds_generated += 1;
|
||||
programs_generated += 1;
|
||||
|
||||
// The runtime under test is an INDEPENDENT implementation. The
|
||||
// equivalence gate compares 100% of executions: the base AND every
|
||||
// perturbation execution, never base executions only.
|
||||
let base_rut = rut.resolve(input.clone());
|
||||
equiv_total += 1;
|
||||
if canonical(&r) == canonical(&base_rut) {
|
||||
equiv_matched += 1;
|
||||
} else if equiv_failures.len() < 16 {
|
||||
equiv_failures.push(format!("case {} reference != runtime_under_test", i));
|
||||
equiv_failures.push(format!("case {} (base) reference != runtime_under_test", i));
|
||||
}
|
||||
merkle_leaves.push(r.replay.hash());
|
||||
for (pinput, pref_canon, pleaf) in &pert_execs {
|
||||
let prut = rut.resolve(pinput.clone());
|
||||
equiv_total += 1;
|
||||
if *pref_canon == canonical(&prut) {
|
||||
equiv_matched += 1;
|
||||
} else if equiv_failures.len() < 16 {
|
||||
equiv_failures
|
||||
.push(format!("case {} (perturbation) reference != runtime_under_test", i));
|
||||
}
|
||||
merkle_leaves.push(*pleaf);
|
||||
}
|
||||
|
||||
actual_executions += 1;
|
||||
|
||||
ranks.push(rank as f64);
|
||||
edges.push(r.trace.causal_edge_count() as f64);
|
||||
@@ -445,6 +709,7 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
meta_alt_future += c_alt_future;
|
||||
meta_neutral_unexpl += c_neutral;
|
||||
perturbation_runs += c_pert;
|
||||
min_perturbations = min_perturbations.min(c_pert);
|
||||
|
||||
if creasons.is_empty() {
|
||||
contract_pass += 1;
|
||||
@@ -452,10 +717,8 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
contract_failures.push(format!("case {} violates {:?}", i, creasons));
|
||||
}
|
||||
|
||||
if collapse_inputs.len() < scale.collapse_samples {
|
||||
collapse_inputs.push(world_input_features(&case.world));
|
||||
collapse_outputs.push(behavior_output_features(&r));
|
||||
collapse_fps.push(r.trace.behavior_fingerprint.hash);
|
||||
if collapse_rows.len() < scale.collapse_samples {
|
||||
collapse_rows.push(trace_feature_row(&r));
|
||||
}
|
||||
if mutation_inputs.len() < 64 {
|
||||
mutation_inputs.push(input.clone());
|
||||
@@ -464,6 +727,15 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
}
|
||||
}
|
||||
|
||||
if min_perturbations == usize::MAX {
|
||||
min_perturbations = 0;
|
||||
}
|
||||
progress!(
|
||||
"main loop complete: {} executions, {} comparisons, {} matched",
|
||||
actual_executions,
|
||||
equiv_total,
|
||||
equiv_matched
|
||||
);
|
||||
let n = scale.executions.max(1) as f64;
|
||||
|
||||
// ---- Trace gates ----
|
||||
@@ -471,7 +743,7 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
let largest_cluster = fp_counts.values().copied().max().unwrap_or(0) as f64 / n;
|
||||
let mut trace_failures = Vec::new();
|
||||
let med_edges = median(&edges);
|
||||
let p95_rank = percentile(&ranks, 0.05); // 95% of executions have rank >= this
|
||||
let p95_rank = percentile(&ranks, 0.05);
|
||||
let med_touched = median(&touched);
|
||||
let p95_touched = percentile(&touched, 0.05);
|
||||
let med_rank = median(&ranks);
|
||||
@@ -505,22 +777,31 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
failures: trace_failures,
|
||||
};
|
||||
|
||||
// ---- Equivalence gates ----
|
||||
if equiv_matched != scale.executions {
|
||||
// ---- Equivalence gates (two independent implementations, 100% coverage) ----
|
||||
let expected_comparisons = actual_executions + perturbation_runs;
|
||||
if equiv_total != expected_comparisons {
|
||||
equiv_failures.push(format!(
|
||||
"compared {} executions but {} ran (must compare 100%, base + perturbations)",
|
||||
equiv_total, expected_comparisons
|
||||
));
|
||||
}
|
||||
if equiv_matched != equiv_total {
|
||||
equiv_failures.push(format!(
|
||||
"{}/{} executions matched (require 100%)",
|
||||
equiv_matched, scale.executions
|
||||
equiv_matched, equiv_total
|
||||
));
|
||||
}
|
||||
let equivalence = EquivalenceGates {
|
||||
total: scale.executions,
|
||||
total: equiv_total,
|
||||
matched: equiv_matched,
|
||||
independent: true,
|
||||
failures: equiv_failures,
|
||||
};
|
||||
|
||||
// ---- Domain participation gates ----
|
||||
progress!("domain participation probes (influence/removal/merge)...");
|
||||
let domain = domain_gates(
|
||||
scale,
|
||||
&scale,
|
||||
&domain_appear,
|
||||
&domain_mutated,
|
||||
&domain_read_any,
|
||||
@@ -556,11 +837,13 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
failures: meta_failures,
|
||||
};
|
||||
|
||||
// ---- Collapse gates ----
|
||||
let corpus = BehaviorCorpus::build(collapse_inputs, collapse_outputs, collapse_fps);
|
||||
// ---- Collapse gates (real trace information) ----
|
||||
progress!("collapse analysis (11 attacks over real trace features)...");
|
||||
let corpus = BehaviorCorpus::build(collapse_rows);
|
||||
let collapse = analyze(&corpus);
|
||||
|
||||
// ---- Mutation gates ----
|
||||
// ---- Mutation gates (killed by named gate) ----
|
||||
progress!("mutation suite ({} mutants, killed by named gate)...", scale.mutants);
|
||||
let mutation = run_suite(scale.mutants, &mutation_inputs);
|
||||
|
||||
// ---- Contract gates ----
|
||||
@@ -578,10 +861,11 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
failures: contract_gate_failures,
|
||||
};
|
||||
|
||||
// ---- Replay gates ----
|
||||
let replay = replay_gates(scale);
|
||||
// ---- Replay gates (persisted corpus) ----
|
||||
progress!("replay corpus verification (committed file)...");
|
||||
let replay = replay_gates(&scale);
|
||||
|
||||
// ---- Coverage gates ----
|
||||
// ---- Coverage gates + merge floor enforcement ----
|
||||
let mut cov_failures = Vec::new();
|
||||
if generated_rejected != 0 {
|
||||
cov_failures.push(format!(
|
||||
@@ -601,8 +885,93 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
failures: cov_failures,
|
||||
};
|
||||
|
||||
// ---- Provenance ----
|
||||
progress!("engine fingerprints + provenance (merkle over {} leaves)...", merkle_leaves.len());
|
||||
let probes = probe_inputs(32);
|
||||
let reference_engine_id = engine_fingerprint(&probes, |inp| execute(&cfg, inp));
|
||||
let rut_engine_id = engine_fingerprint(&probes, native_resolve);
|
||||
let engines_agree = reference_engine_id == rut_engine_id;
|
||||
let root = merkle_root(&merkle_leaves);
|
||||
|
||||
let mut prov_failures = Vec::new();
|
||||
prov_failures.extend(scale.override_violations.iter().cloned());
|
||||
if !engines_agree {
|
||||
prov_failures.push("reference and runtime-under-test engine identities differ".into());
|
||||
}
|
||||
if merkle_leaves.len() != equiv_total {
|
||||
prov_failures.push("merkle leaf count does not match executions compared".into());
|
||||
}
|
||||
// Merge-floor enforcement: the floor cannot be bypassed.
|
||||
if scale.profile == Profile::Merge {
|
||||
if worlds_generated < scale.floor.worlds {
|
||||
prov_failures.push(format!(
|
||||
"worlds generated {} < merge floor {}",
|
||||
worlds_generated, scale.floor.worlds
|
||||
));
|
||||
}
|
||||
if programs_generated < scale.floor.programs {
|
||||
prov_failures.push(format!(
|
||||
"programs generated {} < merge floor {}",
|
||||
programs_generated, scale.floor.programs
|
||||
));
|
||||
}
|
||||
if actual_executions < scale.floor.executions {
|
||||
prov_failures.push(format!(
|
||||
"executions {} < merge floor {}",
|
||||
actual_executions, scale.floor.executions
|
||||
));
|
||||
}
|
||||
if min_perturbations < scale.floor.perturbations_per_exec {
|
||||
prov_failures.push(format!(
|
||||
"min perturbations/exec {} < merge floor {}",
|
||||
min_perturbations, scale.floor.perturbations_per_exec
|
||||
));
|
||||
}
|
||||
// 100% comparison must cover base + all perturbations.
|
||||
let required_comparisons =
|
||||
scale.floor.executions * (1 + scale.floor.perturbations_per_exec);
|
||||
if equiv_total < required_comparisons {
|
||||
prov_failures.push(format!(
|
||||
"comparisons {} < merge floor {} (100% of base + perturbations)",
|
||||
equiv_total, required_comparisons
|
||||
));
|
||||
}
|
||||
if mutation.total < scale.floor.mutants {
|
||||
prov_failures.push(format!(
|
||||
"mutants {} < merge floor {}",
|
||||
mutation.total, scale.floor.mutants
|
||||
));
|
||||
}
|
||||
if replay.total < scale.floor.replay_cases {
|
||||
prov_failures.push(format!(
|
||||
"replay cases {} < merge floor {}",
|
||||
replay.total, scale.floor.replay_cases
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let provenance = Provenance {
|
||||
profile: scale.profile,
|
||||
floor: scale.floor,
|
||||
worlds_generated,
|
||||
programs_generated,
|
||||
actual_executions,
|
||||
min_perturbations_per_exec: min_perturbations,
|
||||
total_comparisons: equiv_total,
|
||||
actual_mutants: mutation.total,
|
||||
replay_total: replay.total,
|
||||
reference_engine_id,
|
||||
rut_engine_id,
|
||||
engines_agree,
|
||||
execution_merkle_root: root,
|
||||
merkle_leaf_count: merkle_leaves.len(),
|
||||
collapse_feature_width: FEATURE_W,
|
||||
failures: prov_failures,
|
||||
};
|
||||
|
||||
CiResults {
|
||||
scale,
|
||||
provenance,
|
||||
trace,
|
||||
equivalence,
|
||||
domain,
|
||||
@@ -616,7 +985,7 @@ pub fn run_all(scale: Scale) -> CiResults {
|
||||
}
|
||||
|
||||
fn domain_gates(
|
||||
scale: Scale,
|
||||
scale: &Scale,
|
||||
appear: &[u64; NUM_DOMAINS],
|
||||
mutated: &[u64; NUM_DOMAINS],
|
||||
read_any: &[bool; NUM_DOMAINS],
|
||||
@@ -632,7 +1001,6 @@ fn domain_gates(
|
||||
mutated_f[d] = mutated[d] as f64 / n;
|
||||
}
|
||||
|
||||
// Influence probe: mask each domain and see how often the trace changes.
|
||||
let mut influences = [0.0; NUM_DOMAINS];
|
||||
let probe_cases = scale.domain_probe_cases.max(1);
|
||||
for d in 0..NUM_DOMAINS {
|
||||
@@ -652,8 +1020,6 @@ fn domain_gates(
|
||||
influences[d] = changed as f64 / probe_cases as f64;
|
||||
}
|
||||
|
||||
// Removal diversity probe: vary only domain d across K worlds; compare the
|
||||
// count of distinct behaviors with the domain present vs masked.
|
||||
let mut removal_loss = [0.0; NUM_DOMAINS];
|
||||
let k = scale.domain_probe_variations.max(8);
|
||||
for d in 0..NUM_DOMAINS {
|
||||
@@ -667,7 +1033,7 @@ fn domain_gates(
|
||||
let mcfg = masked_config(d);
|
||||
for v in 0..k {
|
||||
let mut w = base_world.clone();
|
||||
for l in 0..LANES {
|
||||
for l in 0..world_model::LANES {
|
||||
w.domains[d].observed[l] = (v as i64 + 1).wrapping_mul(7919 + l as i64);
|
||||
}
|
||||
w.domains[d].hidden[0] = (v as i64).wrapping_mul(104729);
|
||||
@@ -680,7 +1046,6 @@ fn domain_gates(
|
||||
removal_loss[d] = (1.0 - dm / df).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Merge probe: alias domain b := domain a, see how often behavior changes.
|
||||
let mut min_merge_loss = 1.0f64;
|
||||
let merge_cases = (scale.domain_probe_cases / 2).max(20);
|
||||
for a in 0..NUM_DOMAINS {
|
||||
@@ -749,21 +1114,29 @@ fn domain_gates(
|
||||
}
|
||||
}
|
||||
|
||||
fn replay_gates(scale: Scale) -> ReplayGates {
|
||||
let corpus = replay_corpus::build_corpus(scale.replay_cases, 0x5EED);
|
||||
let report = replay_corpus::verify_corpus(&corpus);
|
||||
fn replay_gates(scale: &Scale) -> ReplayGates {
|
||||
let report = replay_corpus::verify_persisted_corpus();
|
||||
let mut failures = Vec::new();
|
||||
if !report.loaded_from_disk {
|
||||
failures.push(format!(
|
||||
"committed replay corpus not found at {}",
|
||||
replay_corpus::corpus_path().display()
|
||||
));
|
||||
}
|
||||
if !report.drift.is_empty() {
|
||||
failures.push(format!("{} replay cases drifted", report.drift.len()));
|
||||
}
|
||||
// Minimum corpus size is a merge-blocking gate only at full scale.
|
||||
if matches!(std::env::var("MAGICKA_SCALE").as_deref(), Ok("full")) && report.total < 10_000 {
|
||||
failures.push(format!("replay corpus {} < 10000", report.total));
|
||||
if scale.profile == Profile::Merge && report.total < scale.floor.replay_cases {
|
||||
failures.push(format!(
|
||||
"replay corpus {} < merge floor {}",
|
||||
report.total, scale.floor.replay_cases
|
||||
));
|
||||
}
|
||||
ReplayGates {
|
||||
total: report.total,
|
||||
deterministic: report.deterministic,
|
||||
drift: report.drift.len(),
|
||||
loaded_from_disk: report.loaded_from_disk,
|
||||
failures,
|
||||
}
|
||||
}
|
||||
@@ -779,6 +1152,69 @@ mod tests {
|
||||
assert!(f.is_empty(), "{name} failed: {:?}", f);
|
||||
}
|
||||
assert!(r.mutation.passed(), "mutants survived: {:?}", r.mutation.survivors);
|
||||
assert!(r.provenance.engines_agree, "engine identities disagree");
|
||||
assert!(r.passed());
|
||||
}
|
||||
|
||||
/// Negative control: a smoke-scale run that claims the merge profile must be
|
||||
/// REJECTED, because the merge floors (1M executions, 10k replay, etc.) are
|
||||
/// not met. Proves a default/small profile cannot stand in for acceptance.
|
||||
#[test]
|
||||
fn merge_profile_at_smoke_scale_is_rejected() {
|
||||
let mut s = Scale::tiny();
|
||||
s.profile = Profile::Merge;
|
||||
s.floor = ScaleFloor::MERGE;
|
||||
let r = run_all(s);
|
||||
assert!(
|
||||
!r.passed(),
|
||||
"a smoke-scale run was accepted as a merge run (floors not enforced)"
|
||||
);
|
||||
assert!(
|
||||
!r.provenance.failures.is_empty(),
|
||||
"merge floors were not flagged in provenance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_floor_cannot_be_lowered_by_override() {
|
||||
// Simulate an attempt to lower the merge execution floor.
|
||||
let mut s = Scale::merge();
|
||||
s.apply_override("MAGICKA_EXECUTIONS", 10, s.floor.executions, |s, x| s.executions = x);
|
||||
assert_eq!(s.executions, s.floor.executions, "floor was lowered by override");
|
||||
assert!(!s.override_violations.is_empty(), "violation not recorded");
|
||||
}
|
||||
|
||||
/// Negative control: a decorative (never-appearing, never-mutated) domain
|
||||
/// must be rejected by the domain-participation gate. Proves the gate can
|
||||
/// fail and discriminates a decorative domain from a live one.
|
||||
#[test]
|
||||
fn decorative_domain_is_rejected() {
|
||||
let scale = Scale::tiny();
|
||||
let n = 100.0;
|
||||
let mut appear = [100u64; NUM_DOMAINS];
|
||||
let mut mutated = [100u64; NUM_DOMAINS];
|
||||
appear[3] = 0; // domain 3 is decorative
|
||||
mutated[3] = 0;
|
||||
let read_any = [true; NUM_DOMAINS];
|
||||
let write_any = [true; NUM_DOMAINS];
|
||||
let gates = domain_gates(&scale, &appear, &mutated, &read_any, &write_any, n);
|
||||
assert!(
|
||||
!gates.failures.is_empty(),
|
||||
"domain gate failed to reject a decorative domain"
|
||||
);
|
||||
assert!(
|
||||
gates.failures.iter().any(|f| f.contains("domain 3")),
|
||||
"decorative domain 3 not flagged: {:?}",
|
||||
gates.failures
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merkle_root_binds_to_leaves() {
|
||||
let a = merkle_root(&[Hash(1), Hash(2), Hash(3)]);
|
||||
let b = merkle_root(&[Hash(1), Hash(2), Hash(3)]);
|
||||
let c = merkle_root(&[Hash(1), Hash(2), Hash(4)]);
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
}
|
||||
|
||||
+307
-21
@@ -1,9 +1,10 @@
|
||||
//! The `ci` binary: runs the full adversarial framework against the reference
|
||||
//! and runtime-under-test, writes the eight required reports (JSON + markdown),
|
||||
//! and exits nonzero if any gate fails.
|
||||
//! and the independently-implemented runtime under test, writes the required
|
||||
//! reports (JSON + markdown) including a provenance report binding the numbers
|
||||
//! to executed work, and exits nonzero if any gate fails.
|
||||
|
||||
use ci_reports::json::Json;
|
||||
use ci_reports::{run_all, CiResults, Scale};
|
||||
use ci_reports::{run_all, CiResults, Profile, Scale};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
@@ -18,6 +19,9 @@ fn fails(v: &[String]) -> Json {
|
||||
fn pass_field(v: &[String]) -> Json {
|
||||
Json::Bool(v.is_empty())
|
||||
}
|
||||
fn hex(h: world_model::Hash) -> Json {
|
||||
Json::s(format!("{:016x}", h.0))
|
||||
}
|
||||
|
||||
fn write_report(dir: &Path, name: &str, j: &Json) {
|
||||
let path = dir.join(format!("{name}.json"));
|
||||
@@ -74,7 +78,7 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
||||
.map(|rep| {
|
||||
Json::Obj(vec![
|
||||
("attack".into(), Json::s(rep.attack.clone())),
|
||||
("predicts".into(), Json::Num(rep.predicts)),
|
||||
("reconstructs".into(), Json::Num(rep.predicts)),
|
||||
("info_loss".into(), Json::Num(rep.info_loss)),
|
||||
("detail".into(), Json::s(rep.detail.clone())),
|
||||
])
|
||||
@@ -85,6 +89,7 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
||||
"compression_resistance_report",
|
||||
&Json::Obj(vec![
|
||||
("pass".into(), pass_field(&r.collapse.failures)),
|
||||
("measures".into(), Json::s("real serialized trace structure")),
|
||||
("best_1factor".into(), Json::Num(r.collapse.best_1factor)),
|
||||
("best_2factor".into(), Json::Num(r.collapse.best_2factor)),
|
||||
("best_4factor".into(), Json::Num(r.collapse.best_4factor)),
|
||||
@@ -116,10 +121,10 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
||||
.mutation
|
||||
.survivors
|
||||
.iter()
|
||||
.map(|(id, name)| {
|
||||
.map(|(id, reason)| {
|
||||
Json::Obj(vec![
|
||||
("id".into(), Json::Int(*id as i64)),
|
||||
("name".into(), Json::s(name.clone())),
|
||||
("reason".into(), Json::s(reason.clone())),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
@@ -128,6 +133,7 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
||||
"mutation_survivor_report",
|
||||
&Json::Obj(vec![
|
||||
("pass".into(), Json::Bool(r.mutation.passed())),
|
||||
("killed_by".into(), Json::s("named acceptance gate")),
|
||||
("total_mutants".into(), Json::Int(r.mutation.total as i64)),
|
||||
("killed".into(), Json::Int(r.mutation.killed as i64)),
|
||||
("survivors".into(), Json::Arr(survivors)),
|
||||
@@ -140,6 +146,10 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
||||
"runtime_equivalence_report",
|
||||
&Json::Obj(vec![
|
||||
("pass".into(), pass_field(&r.equivalence.failures)),
|
||||
("independent_implementations".into(), Json::Bool(r.equivalence.independent)),
|
||||
("reference_engine_id".into(), hex(r.provenance.reference_engine_id)),
|
||||
("rut_engine_id".into(), hex(r.provenance.rut_engine_id)),
|
||||
("engines_agree".into(), Json::Bool(r.provenance.engines_agree)),
|
||||
("total".into(), Json::Int(r.equivalence.total as i64)),
|
||||
("matched".into(), Json::Int(r.equivalence.matched as i64)),
|
||||
("failures".into(), fails(&r.equivalence.failures)),
|
||||
@@ -152,6 +162,7 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
||||
"replay_report",
|
||||
&Json::Obj(vec![
|
||||
("pass".into(), pass_field(&r.replay.failures)),
|
||||
("loaded_from_committed_corpus".into(), Json::Bool(r.replay.loaded_from_disk)),
|
||||
("total".into(), Json::Int(r.replay.total as i64)),
|
||||
("deterministic".into(), Json::Int(r.replay.deterministic as i64)),
|
||||
("drift".into(), Json::Int(r.replay.drift as i64)),
|
||||
@@ -175,6 +186,230 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
||||
("failures".into(), fails(&r.coverage.failures)),
|
||||
]),
|
||||
);
|
||||
|
||||
// 9. provenance_report — binds the run to executed work.
|
||||
let p = &r.provenance;
|
||||
write_report(
|
||||
dir,
|
||||
"provenance_report",
|
||||
&Json::Obj(vec![
|
||||
("pass".into(), pass_field(&p.failures)),
|
||||
("profile".into(), Json::s(p.profile.name())),
|
||||
(
|
||||
"merge_floor".into(),
|
||||
Json::Obj(vec![
|
||||
("worlds".into(), Json::Int(p.floor.worlds as i64)),
|
||||
("programs".into(), Json::Int(p.floor.programs as i64)),
|
||||
("executions".into(), Json::Int(p.floor.executions as i64)),
|
||||
("perturbations_per_exec".into(), Json::Int(p.floor.perturbations_per_exec as i64)),
|
||||
("mutants".into(), Json::Int(p.floor.mutants as i64)),
|
||||
("replay_cases".into(), Json::Int(p.floor.replay_cases as i64)),
|
||||
]),
|
||||
),
|
||||
("worlds_generated".into(), Json::Int(p.worlds_generated as i64)),
|
||||
("programs_generated".into(), Json::Int(p.programs_generated as i64)),
|
||||
("actual_executions".into(), Json::Int(p.actual_executions as i64)),
|
||||
("min_perturbations_per_exec".into(), Json::Int(p.min_perturbations_per_exec as i64)),
|
||||
("total_comparisons".into(), Json::Int(p.total_comparisons as i64)),
|
||||
("actual_mutants".into(), Json::Int(p.actual_mutants as i64)),
|
||||
("replay_total".into(), Json::Int(p.replay_total as i64)),
|
||||
("reference_engine_id".into(), hex(p.reference_engine_id)),
|
||||
("rut_engine_id".into(), hex(p.rut_engine_id)),
|
||||
("engines_agree".into(), Json::Bool(p.engines_agree)),
|
||||
("execution_merkle_root".into(), hex(p.execution_merkle_root)),
|
||||
("merkle_leaf_count".into(), Json::Int(p.merkle_leaf_count as i64)),
|
||||
("collapse_feature_width".into(), Json::Int(p.collapse_feature_width as i64)),
|
||||
("failures".into(), fails(&p.failures)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// The reports the spec requires every CI run to produce. A missing or empty
|
||||
/// artifact is itself an acceptance failure (compliance rule 4).
|
||||
const REQUIRED_REPORTS: [&str; 9] = [
|
||||
"domain_participation_report",
|
||||
"causal_rank_report",
|
||||
"compression_resistance_report",
|
||||
"metamorphic_response_report",
|
||||
"mutation_survivor_report",
|
||||
"runtime_equivalence_report",
|
||||
"replay_report",
|
||||
"coverage_report",
|
||||
"provenance_report",
|
||||
];
|
||||
|
||||
fn report_present(dir: &Path, name: &str) -> bool {
|
||||
fs::metadata(dir.join(format!("{name}.json")))
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// One acceptance obligation: a measured artifact, a provenance chain, a
|
||||
/// merge-blocking enforcement point, and a failure if the artifact/provenance is
|
||||
/// absent. This is the compliance model made machine-checkable.
|
||||
struct Obligation {
|
||||
requirement: &'static str,
|
||||
artifact: &'static str,
|
||||
floor: i64,
|
||||
actual: i64,
|
||||
gate_pass: bool,
|
||||
}
|
||||
|
||||
fn obligations(r: &CiResults) -> Vec<Obligation> {
|
||||
let p = &r.provenance;
|
||||
let merge = p.profile == Profile::Merge;
|
||||
let f = |v: usize| v as i64;
|
||||
let floor = |v: usize| if merge { v as i64 } else { 0 };
|
||||
vec![
|
||||
Obligation {
|
||||
requirement: "generated worlds >= 50,000",
|
||||
artifact: "provenance_report",
|
||||
floor: floor(p.floor.worlds),
|
||||
actual: f(p.worlds_generated),
|
||||
gate_pass: !merge || p.worlds_generated >= p.floor.worlds,
|
||||
},
|
||||
Obligation {
|
||||
requirement: "generated programs >= 250,000",
|
||||
artifact: "provenance_report",
|
||||
floor: floor(p.floor.programs),
|
||||
actual: f(p.programs_generated),
|
||||
gate_pass: !merge || p.programs_generated >= p.floor.programs,
|
||||
},
|
||||
Obligation {
|
||||
requirement: "executions >= 1,000,000",
|
||||
artifact: "provenance_report",
|
||||
floor: floor(p.floor.executions),
|
||||
actual: f(p.actual_executions),
|
||||
gate_pass: !merge || p.actual_executions >= p.floor.executions,
|
||||
},
|
||||
Obligation {
|
||||
requirement: "perturbations per execution >= 10",
|
||||
artifact: "metamorphic_response_report",
|
||||
floor: floor(p.floor.perturbations_per_exec),
|
||||
actual: f(p.min_perturbations_per_exec),
|
||||
gate_pass: !merge || p.min_perturbations_per_exec >= p.floor.perturbations_per_exec,
|
||||
},
|
||||
Obligation {
|
||||
requirement: "reference/runtime comparison = 100% of executions",
|
||||
artifact: "runtime_equivalence_report",
|
||||
floor: floor(p.floor.executions * (1 + p.floor.perturbations_per_exec)),
|
||||
actual: f(p.total_comparisons),
|
||||
gate_pass: r.equivalence.failures.is_empty(),
|
||||
},
|
||||
Obligation {
|
||||
requirement: "semantic mutants >= 500, 0 survivors, killed by named gate",
|
||||
artifact: "mutation_survivor_report",
|
||||
floor: floor(p.floor.mutants),
|
||||
actual: f(r.mutation.total),
|
||||
gate_pass: r.mutation.passed() && (!merge || r.mutation.total >= p.floor.mutants),
|
||||
},
|
||||
Obligation {
|
||||
requirement: "replay corpus >= 10,000, persisted, 0 drift",
|
||||
artifact: "replay_report",
|
||||
floor: floor(p.floor.replay_cases),
|
||||
actual: f(r.replay.total),
|
||||
gate_pass: r.replay.failures.is_empty() && r.replay.loaded_from_disk,
|
||||
},
|
||||
Obligation {
|
||||
requirement: "collapse attacks fail to simplify (real trace info)",
|
||||
artifact: "compression_resistance_report",
|
||||
floor: 0,
|
||||
actual: 0,
|
||||
gate_pass: r.collapse.failures.is_empty(),
|
||||
},
|
||||
Obligation {
|
||||
requirement: "trace gates (causal edges/rank/touched/fingerprints)",
|
||||
artifact: "causal_rank_report",
|
||||
floor: 0,
|
||||
actual: 0,
|
||||
gate_pass: r.trace.failures.is_empty(),
|
||||
},
|
||||
Obligation {
|
||||
requirement: "domain participation (no decorative/read-only/write-only)",
|
||||
artifact: "domain_participation_report",
|
||||
floor: 0,
|
||||
actual: 0,
|
||||
gate_pass: r.domain.failures.is_empty(),
|
||||
},
|
||||
Obligation {
|
||||
requirement: "metamorphic response thresholds",
|
||||
artifact: "metamorphic_response_report",
|
||||
floor: 0,
|
||||
actual: 0,
|
||||
gate_pass: r.metamorphic.failures.is_empty(),
|
||||
},
|
||||
Obligation {
|
||||
requirement: "every admitted case satisfies its contract",
|
||||
artifact: "coverage_report",
|
||||
floor: 0,
|
||||
actual: 0,
|
||||
gate_pass: r.contract.failures.is_empty() && r.coverage.failures.is_empty(),
|
||||
},
|
||||
Obligation {
|
||||
requirement: "provenance binds reports to executed work",
|
||||
artifact: "provenance_report",
|
||||
floor: 0,
|
||||
actual: 0,
|
||||
gate_pass: r.provenance.failures.is_empty(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Write the compliance report and return whether the compliance model holds:
|
||||
/// every obligation's artifact is present and every obligation passes.
|
||||
fn build_compliance_report(dir: &Path, r: &CiResults) -> bool {
|
||||
let items = obligations(r);
|
||||
let mut all_ok = true;
|
||||
let mut json_items = Vec::new();
|
||||
for ob in &items {
|
||||
let present = report_present(dir, ob.artifact);
|
||||
let pass = present && ob.gate_pass;
|
||||
if !pass {
|
||||
all_ok = false;
|
||||
}
|
||||
json_items.push(Json::Obj(vec![
|
||||
("requirement".into(), Json::s(ob.requirement)),
|
||||
("measured_artifact".into(), Json::s(format!("{}.json", ob.artifact))),
|
||||
("artifact_present".into(), Json::Bool(present)),
|
||||
("provenance".into(), Json::s("provenance_report.json (merkle root + engine ids)")),
|
||||
("merge_blocking".into(), Json::Bool(true)),
|
||||
("floor".into(), Json::Int(ob.floor)),
|
||||
("actual".into(), Json::Int(ob.actual)),
|
||||
("pass".into(), Json::Bool(pass)),
|
||||
]));
|
||||
}
|
||||
// Every required report must exist and be non-empty.
|
||||
let mut missing = Vec::new();
|
||||
for rep in REQUIRED_REPORTS {
|
||||
if !report_present(dir, rep) {
|
||||
missing.push(rep.to_string());
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
write_report(
|
||||
dir,
|
||||
"compliance_report",
|
||||
&Json::Obj(vec![
|
||||
("pass".into(), Json::Bool(all_ok)),
|
||||
("profile".into(), Json::s(r.provenance.profile.name())),
|
||||
(
|
||||
"merge_blocking_run".into(),
|
||||
Json::Bool(r.provenance.profile == Profile::Merge),
|
||||
),
|
||||
(
|
||||
"note".into(),
|
||||
Json::s(if r.provenance.profile == Profile::Merge {
|
||||
"merge profile: floors enforced, all obligations acceptance-blocking"
|
||||
} else {
|
||||
"advisory profile: NOT acceptance; floors not enforced (fast/tiny)"
|
||||
}),
|
||||
),
|
||||
("required_reports".into(), Json::Arr(REQUIRED_REPORTS.iter().map(|s| Json::s(*s)).collect())),
|
||||
("missing_reports".into(), Json::Arr(missing.iter().map(|s| Json::s(s.clone())).collect())),
|
||||
("obligations".into(), Json::Arr(json_items)),
|
||||
]),
|
||||
);
|
||||
all_ok
|
||||
}
|
||||
|
||||
fn status(v: bool) -> &'static str {
|
||||
@@ -186,22 +421,38 @@ fn status(v: bool) -> &'static str {
|
||||
}
|
||||
|
||||
fn write_markdown(dir: &Path, r: &CiResults) {
|
||||
let p = &r.provenance;
|
||||
let mut s = String::new();
|
||||
s.push_str("# Magicka VM — Phase 0/1 CI Report\n\n");
|
||||
s.push_str(&format!(
|
||||
"Overall: **{}**\n\n",
|
||||
status(r.passed())
|
||||
));
|
||||
s.push_str(&format!("Overall: **{}**\n\n", status(r.passed())));
|
||||
s.push_str(&format!("Profile: **{}**", p.profile.name()));
|
||||
if p.profile == Profile::Fast {
|
||||
s.push_str(" (representative slice — NOT merge-blocking)");
|
||||
}
|
||||
s.push_str("\n\n");
|
||||
s.push_str(&format!(
|
||||
"Scale: executions={}, mutants={}, replay={}, collapse_samples={}\n\n",
|
||||
r.scale.executions, r.scale.mutants, r.scale.replay_cases, r.scale.collapse_samples
|
||||
));
|
||||
s.push_str("## Provenance\n\n");
|
||||
s.push_str(&format!("- Execution Merkle root: `{:016x}` over {} leaves\n", p.execution_merkle_root.0, p.merkle_leaf_count));
|
||||
s.push_str(&format!(
|
||||
"- Reference engine id: `{:016x}`; runtime-under-test engine id: `{:016x}`; agree: **{}**\n",
|
||||
p.reference_engine_id.0, p.rut_engine_id.0, p.engines_agree
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"- Actual executions: {} (merge floor {}), min perturbations/exec: {} (floor {})\n",
|
||||
p.actual_executions, p.floor.executions, p.min_perturbations_per_exec, p.floor.perturbations_per_exec
|
||||
));
|
||||
s.push_str(&format!("- Collapse measures real trace structure ({} features/trace)\n\n", p.collapse_feature_width));
|
||||
|
||||
s.push_str("| Report | Status | Key metrics |\n|---|---|---|\n");
|
||||
s.push_str(&format!(
|
||||
"| runtime_equivalence | {} | {}/{} matched |\n",
|
||||
"| runtime_equivalence | {} | {}/{} matched, independent impls, engines agree={} |\n",
|
||||
status(r.equivalence.failures.is_empty()),
|
||||
r.equivalence.matched,
|
||||
r.equivalence.total
|
||||
r.equivalence.total,
|
||||
p.engines_agree
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"| causal_rank/trace | {} | rank med={} p95={}, edges med={}, touched med={} |\n",
|
||||
@@ -234,7 +485,7 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
||||
r.collapse.min_info_loss
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"| mutation_survivor | {} | killed {}/{} |\n",
|
||||
"| mutation_survivor | {} | killed {}/{} by named gate |\n",
|
||||
status(r.mutation.passed()),
|
||||
r.mutation.killed,
|
||||
r.mutation.total
|
||||
@@ -246,7 +497,7 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
||||
r.contract.total
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"| replay | {} | {}/{} deterministic, drift={} |\n",
|
||||
"| replay | {} | {}/{} deterministic (committed corpus), drift={} |\n",
|
||||
status(r.replay.failures.is_empty()),
|
||||
r.replay.deterministic,
|
||||
r.replay.total,
|
||||
@@ -259,6 +510,11 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
||||
r.coverage.perturbations,
|
||||
r.coverage.generated_rejected
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"| provenance | {} | merkle over {} leaves, floor enforced |\n",
|
||||
status(r.provenance.failures.is_empty()),
|
||||
r.provenance.merkle_leaf_count
|
||||
));
|
||||
|
||||
s.push_str("\n## Failures\n\n");
|
||||
let mut any = false;
|
||||
@@ -268,9 +524,9 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
||||
s.push_str(&format!("- **{}**: {}\n", name, msg));
|
||||
}
|
||||
}
|
||||
for (id, name) in &r.mutation.survivors {
|
||||
for (id, reason) in &r.mutation.survivors {
|
||||
any = true;
|
||||
s.push_str(&format!("- **mutation_survivor**: mutant {} ({}) survived\n", id, name));
|
||||
s.push_str(&format!("- **mutation_survivor**: mutant {} survived ({})\n", id, reason));
|
||||
}
|
||||
if !any {
|
||||
s.push_str("None. The fake universe failed to collapse. ✅\n");
|
||||
@@ -287,15 +543,20 @@ fn main() {
|
||||
fs::create_dir_all(dir).expect("create out dir");
|
||||
|
||||
eprintln!(
|
||||
"running CI: executions={} mutants={} replay={} collapse_samples={}",
|
||||
scale.executions, scale.mutants, scale.replay_cases, scale.collapse_samples
|
||||
"running CI: profile={} executions={} mutants={} replay={} collapse_samples={}",
|
||||
scale.profile.name(), scale.executions, scale.mutants, scale.replay_cases, scale.collapse_samples
|
||||
);
|
||||
for v in &scale.override_violations {
|
||||
eprintln!(" override rejected: {v}");
|
||||
}
|
||||
let merge = scale.profile == Profile::Merge;
|
||||
let start = Instant::now();
|
||||
let results = run_all(scale);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
build_reports(dir, &results);
|
||||
write_markdown(dir, &results);
|
||||
let compliance_ok = build_compliance_report(dir, &results);
|
||||
|
||||
println!("\n=== Magicka VM CI ({:?}) ===", elapsed);
|
||||
for (name, f) in results.all_failures() {
|
||||
@@ -313,13 +574,38 @@ fn main() {
|
||||
format!(", {} survivors", results.mutation.survivors.len())
|
||||
}
|
||||
);
|
||||
println!(" {:<24} {}", "compliance", status(compliance_ok));
|
||||
println!(
|
||||
" provenance: merkle={:016x} over {} leaves, engines_agree={}, comparisons={}",
|
||||
results.provenance.execution_merkle_root.0,
|
||||
results.provenance.merkle_leaf_count,
|
||||
results.provenance.engines_agree,
|
||||
results.provenance.total_comparisons,
|
||||
);
|
||||
println!("reports written to {}/", out_dir);
|
||||
|
||||
if results.passed() {
|
||||
println!("\nOVERALL: PASS — the adversarial framework could not collapse the universe.");
|
||||
let gates_pass = results.passed() && compliance_ok;
|
||||
|
||||
if !merge {
|
||||
// The fast/tiny profiles are advisory only — they may never stand in for
|
||||
// the merge-blocking acceptance run (compliance: no default-profile
|
||||
// substitution). Report status but make clear this is not acceptance.
|
||||
println!(
|
||||
"\nADVISORY ({} profile): {} — NOT a merge-blocking acceptance run. \
|
||||
Acceptance requires MAGICKA_PROFILE=merge (full gates).",
|
||||
results.provenance.profile.name(),
|
||||
status(gates_pass)
|
||||
);
|
||||
// A failing advisory run still fails the PR check; a passing one is green
|
||||
// but explicitly non-acceptance.
|
||||
std::process::exit(if gates_pass { 0 } else { 1 });
|
||||
}
|
||||
|
||||
if gates_pass {
|
||||
println!("\nACCEPTANCE: PASS — full merge gates satisfied with persisted, independently reproducible evidence.");
|
||||
std::process::exit(0);
|
||||
} else {
|
||||
println!("\nOVERALL: FAIL");
|
||||
println!("\nACCEPTANCE: FAIL");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+224
-163
@@ -1,28 +1,41 @@
|
||||
//! `collapse_analysis` — the framework's compression attacks. Each attack
|
||||
//! tries to predict execution behavior with a *simpler* model. If any small
|
||||
//! model predicts above the configured thresholds, the universe has collapsed
|
||||
//! and CI must fail.
|
||||
//! `collapse_analysis` — the framework's compression attacks.
|
||||
//!
|
||||
//! The corpus is a set of (input, output) samples: `input` is the world ground
|
||||
//! truth (8 domains x (observed+hidden) lanes) and `output` is the behavior
|
||||
//! feature vector produced by the runtime. Inputs/outputs are standardized so
|
||||
//! that no single high-magnitude axis dominates the variance accounting.
|
||||
//! Each attack tries to reconstruct the *actual serialized trace* from a
|
||||
//! simpler (compressed) representation of it. If a small model reconstructs the
|
||||
//! trace above the configured thresholds, the universe has collapsed and CI must
|
||||
//! fail.
|
||||
//!
|
||||
//! The earlier version of this crate operated on a 22-element behavior
|
||||
//! *fingerprint* — a hash-derived proxy the avalanche engine guarantees is
|
||||
//! near-random — and defined information loss circularly as `1 - predicts`. That
|
||||
//! made every attack pass for free: the metric never touched real trace content.
|
||||
//!
|
||||
//! This version feeds the genuine trace structure (per-domain causal influence,
|
||||
//! information flow, access counts, temporal reach, and state deltas, plus the
|
||||
//! global rank/edge/divergence summaries) into the attacks, and defines
|
||||
//! information loss as the real unexplained-variance fraction of reconstructing
|
||||
//! the trace. The negative-control test builds a deliberately collapsible
|
||||
//! (single-factor) corpus and verifies the gate rejects it, demonstrating the
|
||||
//! gate discriminates a rich universe from a degenerate one.
|
||||
|
||||
pub mod linalg;
|
||||
|
||||
use linalg::{ols_r2, pca_scores, Mat};
|
||||
use world_model::{Hash, HIDDEN_LANES, LANES, NUM_DOMAINS};
|
||||
use world_model::NUM_DOMAINS;
|
||||
|
||||
/// Input columns belonging to one domain (observed + hidden lanes).
|
||||
pub const DOMAIN_BLOCK: usize = LANES + HIDDEN_LANES;
|
||||
/// 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]`
|
||||
pub const BLOCK_W: usize = 9;
|
||||
/// Number of trailing global (non-domain) feature columns.
|
||||
/// `[causal_rank, edge_count, touched_count, divergence_mean]`
|
||||
pub const GLOBALS: usize = 4;
|
||||
/// Total trace feature width.
|
||||
pub const FEATURE_W: usize = NUM_DOMAINS * BLOCK_W + GLOBALS;
|
||||
|
||||
/// A standardized behavior corpus.
|
||||
/// A standardized corpus of real trace feature rows.
|
||||
pub struct BehaviorCorpus {
|
||||
/// `n x (NUM_DOMAINS*DOMAIN_BLOCK)` standardized input.
|
||||
pub x: Mat,
|
||||
/// `n x m` standardized output (behavior features).
|
||||
pub y: Mat,
|
||||
pub fingerprints: Vec<Hash>,
|
||||
/// `n x FEATURE_W` standardized trace features.
|
||||
pub traces: Mat,
|
||||
}
|
||||
|
||||
fn standardize(rows: &[Vec<f64>]) -> Mat {
|
||||
@@ -34,7 +47,6 @@ fn standardize(rows: &[Vec<f64>]) -> Mat {
|
||||
m.set(r, c, rows[r][c]);
|
||||
}
|
||||
}
|
||||
// center + scale to unit std per column
|
||||
for c in 0..cols {
|
||||
let mut mean = 0.0;
|
||||
for r in 0..n {
|
||||
@@ -57,47 +69,65 @@ fn standardize(rows: &[Vec<f64>]) -> Mat {
|
||||
}
|
||||
|
||||
impl BehaviorCorpus {
|
||||
pub fn build(inputs: Vec<Vec<f64>>, outputs: Vec<Vec<f64>>, fingerprints: Vec<Hash>) -> Self {
|
||||
pub fn build(rows: Vec<Vec<f64>>) -> Self {
|
||||
BehaviorCorpus {
|
||||
x: standardize(&inputs),
|
||||
y: standardize(&outputs),
|
||||
fingerprints,
|
||||
traces: standardize(&rows),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn n(&self) -> usize {
|
||||
self.x.rows
|
||||
self.traces.rows
|
||||
}
|
||||
|
||||
fn cols(&self) -> usize {
|
||||
self.traces.cols
|
||||
}
|
||||
|
||||
fn block_cols(d: usize) -> Vec<usize> {
|
||||
(d * BLOCK_W..(d + 1) * BLOCK_W).collect()
|
||||
}
|
||||
|
||||
fn select(&self, cols: &[usize]) -> Mat {
|
||||
let mut m = Mat::zeros(self.x.rows, cols.len());
|
||||
for r in 0..self.x.rows {
|
||||
let mut m = Mat::zeros(self.traces.rows, cols.len());
|
||||
for r in 0..self.traces.rows {
|
||||
for (j, &c) in cols.iter().enumerate() {
|
||||
m.set(r, j, self.x.at(r, c));
|
||||
m.set(r, j, self.traces.at(r, c));
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn domain_cols(domain: usize) -> Vec<usize> {
|
||||
(domain * DOMAIN_BLOCK..(domain + 1) * DOMAIN_BLOCK).collect()
|
||||
fn complement(&self, cols: &[usize]) -> Vec<usize> {
|
||||
(0..self.cols()).filter(|c| !cols.contains(c)).collect()
|
||||
}
|
||||
|
||||
/// R² of predicting output from the top-`k` PCA factors of the full input.
|
||||
pub fn predict_k_factor(&self, k: usize) -> f64 {
|
||||
if self.n() == 0 {
|
||||
/// R² of reconstructing the columns `target` from the columns `source`.
|
||||
fn reconstruct(&self, source: &[usize], target: &[usize]) -> f64 {
|
||||
if source.is_empty() || target.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let scores = pca_scores(&self.x, k);
|
||||
ols_r2(&scores, &self.y)
|
||||
let x = self.select(source);
|
||||
let y = self.select(target);
|
||||
ols_r2(&x, &y)
|
||||
}
|
||||
|
||||
/// Best R² obtainable using just a single domain's input block.
|
||||
/// Explained-variance fraction of the whole trace from its top-`k` PCA
|
||||
/// factors (a genuine k-factor reconstruction quality).
|
||||
pub fn predict_k_factor(&self, k: usize) -> f64 {
|
||||
if self.n() == 0 || self.cols() == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let scores = pca_scores(&self.traces, k);
|
||||
ols_r2(&scores, &self.traces)
|
||||
}
|
||||
|
||||
/// Best fraction of the *rest of the trace* explained by a single domain.
|
||||
pub fn max_single_domain(&self) -> (usize, f64) {
|
||||
let mut best = (0usize, 0.0);
|
||||
for d in 0..NUM_DOMAINS {
|
||||
let sub = self.select(&Self::domain_cols(d));
|
||||
let r2 = ols_r2(&sub, &self.y);
|
||||
let src = Self::block_cols(d);
|
||||
let tgt = self.complement(&src);
|
||||
let r2 = self.reconstruct(&src, &tgt);
|
||||
if r2 > best.1 {
|
||||
best = (d, r2);
|
||||
}
|
||||
@@ -105,15 +135,15 @@ impl BehaviorCorpus {
|
||||
best
|
||||
}
|
||||
|
||||
/// Best R² obtainable using any pair of domain blocks.
|
||||
/// Best fraction of the rest explained by any pair of domains.
|
||||
pub fn max_pair(&self) -> ((usize, usize), f64) {
|
||||
let mut best = ((0usize, 1usize), 0.0);
|
||||
for a in 0..NUM_DOMAINS {
|
||||
for b in (a + 1)..NUM_DOMAINS {
|
||||
let mut cols = Self::domain_cols(a);
|
||||
cols.extend(Self::domain_cols(b));
|
||||
let sub = self.select(&cols);
|
||||
let r2 = ols_r2(&sub, &self.y);
|
||||
let mut src = Self::block_cols(a);
|
||||
src.extend(Self::block_cols(b));
|
||||
let tgt = self.complement(&src);
|
||||
let r2 = self.reconstruct(&src, &tgt);
|
||||
if r2 > best.1 {
|
||||
best = ((a, b), r2);
|
||||
}
|
||||
@@ -121,13 +151,9 @@ impl BehaviorCorpus {
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn full_r2(&self) -> f64 {
|
||||
ols_r2(&self.x, &self.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// A compressed model's predictive power and information loss.
|
||||
/// A compressed model's reconstruction power and (genuine) information loss.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompressedModel {
|
||||
pub predicts: f64,
|
||||
@@ -139,6 +165,15 @@ pub struct CompressedModel {
|
||||
pub trait CollapseAttack {
|
||||
fn name(&self) -> &'static str;
|
||||
fn compress(&self, corpus: &BehaviorCorpus) -> CompressedModel;
|
||||
/// Whether this attack produces a dimensionality-reduced representation of
|
||||
/// the *entire* trace (a genuine compressed model), as opposed to an
|
||||
/// ablation probe that reconstructs one facet from the rest. Only
|
||||
/// whole-trace compressors define the "compressed model loses ≥ 35%
|
||||
/// information" gate; ablation probes are reported for the record and feed
|
||||
/// their own structural gates (domain participation, etc.).
|
||||
fn whole_trace(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// One attack's outcome.
|
||||
@@ -150,8 +185,21 @@ pub struct CollapseReport {
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Build a compressed-model result. `info_loss` is the genuine unexplained
|
||||
/// fraction of the reconstructed trace variance.
|
||||
fn model(predicts: f64, detail: &str) -> CompressedModel {
|
||||
CompressedModel {
|
||||
predicts,
|
||||
info_loss: (1.0 - predicts).clamp(0.0, 1.0),
|
||||
detail: detail.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! attack {
|
||||
($name:ident, $label:expr, $body:expr) => {
|
||||
attack!($name, $label, false, $body);
|
||||
};
|
||||
($name:ident, $label:expr, $whole:expr, $body:expr) => {
|
||||
pub struct $name;
|
||||
impl CollapseAttack for $name {
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -161,130 +209,129 @@ macro_rules! attack {
|
||||
let f: fn(&BehaviorCorpus) -> CompressedModel = $body;
|
||||
f(corpus)
|
||||
}
|
||||
fn whole_trace(&self) -> bool {
|
||||
$whole
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn model(predicts: f64, detail: &str) -> CompressedModel {
|
||||
CompressedModel {
|
||||
predicts,
|
||||
info_loss: (1.0 - predicts).clamp(0.0, 1.0),
|
||||
detail: detail.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
attack!(DomainRemoval, "domain_removal", |c| {
|
||||
// Best prediction achievable while *removing* each domain in turn.
|
||||
// Can the rest of the trace reconstruct each removed domain's own block?
|
||||
let mut best = 0.0;
|
||||
for d in 0..NUM_DOMAINS {
|
||||
let cols: Vec<usize> = (0..NUM_DOMAINS)
|
||||
.filter(|&x| x != d)
|
||||
.flat_map(BehaviorCorpus::domain_cols)
|
||||
.collect();
|
||||
let sub = c.select(&cols);
|
||||
best = f64::max(best, ols_r2(&sub, &c.y));
|
||||
let tgt = BehaviorCorpus::block_cols(d);
|
||||
let src = c.complement(&tgt);
|
||||
best = f64::max(best, c.reconstruct(&src, &tgt));
|
||||
}
|
||||
model(best, "predict with one domain removed")
|
||||
model(best, "reconstruct a removed domain from the rest")
|
||||
});
|
||||
|
||||
attack!(DomainMerging, "domain_merging", |c| {
|
||||
// Merge each pair into a summed block; best prediction over pairs.
|
||||
// Merge each pair (sum their blocks); can the merged sum reconstruct the two
|
||||
// separate blocks? If so, distinguishing the domains is redundant.
|
||||
let mut best = 0.0;
|
||||
for a in 0..NUM_DOMAINS {
|
||||
for b in (a + 1)..NUM_DOMAINS {
|
||||
let mut merged = Mat::zeros(c.x.rows, (NUM_DOMAINS - 1) * DOMAIN_BLOCK);
|
||||
for r in 0..c.x.rows {
|
||||
let mut out_col = 0;
|
||||
for d in 0..NUM_DOMAINS {
|
||||
if d == b {
|
||||
continue;
|
||||
}
|
||||
for l in 0..DOMAIN_BLOCK {
|
||||
let mut v = c.x.at(r, d * DOMAIN_BLOCK + l);
|
||||
if d == a {
|
||||
v += c.x.at(r, b * DOMAIN_BLOCK + l);
|
||||
}
|
||||
merged.set(r, out_col, v);
|
||||
out_col += 1;
|
||||
}
|
||||
let mut merged = Mat::zeros(c.traces.rows, BLOCK_W);
|
||||
for r in 0..c.traces.rows {
|
||||
for l in 0..BLOCK_W {
|
||||
let v = c.traces.at(r, a * BLOCK_W + l) + c.traces.at(r, b * BLOCK_W + l);
|
||||
merged.set(r, l, v);
|
||||
}
|
||||
}
|
||||
best = f64::max(best, ols_r2(&merged, &c.y));
|
||||
let mut tgt_cols = BehaviorCorpus::block_cols(a);
|
||||
tgt_cols.extend(BehaviorCorpus::block_cols(b));
|
||||
let tgt = c.select(&tgt_cols);
|
||||
best = f64::max(best, ols_r2(&merged, &tgt));
|
||||
}
|
||||
}
|
||||
model(best, "predict with two domains merged")
|
||||
model(best, "reconstruct two domains from their merged sum")
|
||||
});
|
||||
|
||||
attack!(ConstantFolding, "constant_folding", |_c| {
|
||||
// Folding the world to constants leaves no predictive features at all.
|
||||
model(0.0, "world folded to constants")
|
||||
attack!(ConstantFolding, "constant_folding", true, |_c| {
|
||||
// Folding the trace to constants reconstructs nothing.
|
||||
model(0.0, "trace folded to constants")
|
||||
});
|
||||
|
||||
attack!(CausalEdgeDeletion, "causal_edge_deletion", |c| {
|
||||
// Keep only each domain's first observed lane (no cross-domain structure).
|
||||
let cols: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * DOMAIN_BLOCK).collect();
|
||||
let sub = c.select(&cols);
|
||||
model(ols_r2(&sub, &c.y), "diagonal-only features")
|
||||
// Drop all cross-domain causal/flow columns; can local features
|
||||
// (counts/deltas) reconstruct the deleted causal structure?
|
||||
let mut deleted = Vec::new();
|
||||
for d in 0..NUM_DOMAINS {
|
||||
for l in 0..4 {
|
||||
// infl_out, infl_in, flow_out, flow_in
|
||||
deleted.push(d * BLOCK_W + l);
|
||||
}
|
||||
}
|
||||
let kept = c.complement(&deleted);
|
||||
model(c.reconstruct(&kept, &deleted), "reconstruct deleted causal edges from local features")
|
||||
});
|
||||
|
||||
attack!(StateAliasing, "state_aliasing", |c| {
|
||||
// Alias all domains into a single aggregate column.
|
||||
let mut agg = Mat::zeros(c.x.rows, 1);
|
||||
for r in 0..c.x.rows {
|
||||
attack!(StateAliasing, "state_aliasing", true, |c| {
|
||||
// Alias the whole trace into one aggregate column; reconstruct the full trace.
|
||||
let mut agg = Mat::zeros(c.traces.rows, 1);
|
||||
for r in 0..c.traces.rows {
|
||||
let mut s = 0.0;
|
||||
for col in 0..c.x.cols {
|
||||
s += c.x.at(r, col);
|
||||
for col in 0..c.traces.cols {
|
||||
s += c.traces.at(r, col);
|
||||
}
|
||||
agg.set(r, 0, s);
|
||||
}
|
||||
model(ols_r2(&agg, &c.y), "single aliased aggregate")
|
||||
model(ols_r2(&agg, &c.traces), "reconstruct trace from a single aliased aggregate")
|
||||
});
|
||||
|
||||
attack!(LatentFactorModeling, "latent_factor_modeling", |c| {
|
||||
attack!(LatentFactorModeling, "latent_factor_modeling", true, |c| {
|
||||
model(c.predict_k_factor(4), "top-4 latent factors")
|
||||
});
|
||||
|
||||
attack!(BehaviorClustering, "behavior_clustering", |c| {
|
||||
let recon = kmeans_reconstruct(&c.y, 4);
|
||||
let scores = pca_scores(&c.x, 4);
|
||||
model(ols_r2(&scores, &recon), "4-cluster behavior model")
|
||||
attack!(BehaviorClustering, "behavior_clustering", true, |c| {
|
||||
let recon = kmeans_reconstruct(&c.traces, 4);
|
||||
model(reconstruction_r2(&c.traces, &recon), "4-cluster behavior model")
|
||||
});
|
||||
|
||||
attack!(SurrogatePrediction, "surrogate_prediction", |c| {
|
||||
model(c.full_r2(), "full linear surrogate")
|
||||
attack!(SurrogatePrediction, "surrogate_prediction", true, |c| {
|
||||
// A small (2-factor) linear surrogate of the whole trace.
|
||||
model(c.predict_k_factor(2), "2-factor linear surrogate")
|
||||
});
|
||||
|
||||
attack!(TemporalFlattening, "temporal_flattening", |c| {
|
||||
// Drop hidden lanes (time-carrying state); predict from observed only.
|
||||
let cols: Vec<usize> = (0..NUM_DOMAINS)
|
||||
.flat_map(|d| (0..LANES).map(move |l| d * DOMAIN_BLOCK + l))
|
||||
.collect();
|
||||
let sub = c.select(&cols);
|
||||
model(ols_r2(&sub, &c.y), "time-flattened (observed lanes only)")
|
||||
// Drop temporal columns; can the rest reconstruct temporal reach?
|
||||
let temporal: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * BLOCK_W + 6).collect();
|
||||
let kept = c.complement(&temporal);
|
||||
model(c.reconstruct(&kept, &temporal), "reconstruct temporal reach from non-temporal features")
|
||||
});
|
||||
|
||||
attack!(ObservationFlattening, "observation_flattening", |c| {
|
||||
// Use only hidden lanes (collapse the observed surface).
|
||||
let cols: Vec<usize> = (0..NUM_DOMAINS)
|
||||
.flat_map(|d| (0..HIDDEN_LANES).map(move |l| d * DOMAIN_BLOCK + LANES + l))
|
||||
.collect();
|
||||
let sub = c.select(&cols);
|
||||
model(ols_r2(&sub, &c.y), "observation-flattened (hidden lanes only)")
|
||||
// Drop hidden-state delta columns; reconstruct them from the observed side.
|
||||
let hidden: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * BLOCK_W + 8).collect();
|
||||
let kept = c.complement(&hidden);
|
||||
model(c.reconstruct(&kept, &hidden), "reconstruct hidden deltas from observed features")
|
||||
});
|
||||
|
||||
attack!(ExecutorIdentityErasure, "executor_identity_erasure", |c| {
|
||||
// Predict all-but-last output dim (the divergence summary) from input.
|
||||
if c.y.cols <= 1 {
|
||||
return model(0.0, "no executor dim");
|
||||
}
|
||||
let mut y2 = Mat::zeros(c.y.rows, c.y.cols - 1);
|
||||
for r in 0..c.y.rows {
|
||||
for col in 0..c.y.cols - 1 {
|
||||
y2.set(r, col, c.y.at(r, col));
|
||||
// The divergence summary is the last global column; reconstruct it from the
|
||||
// rest (erasing executor identity).
|
||||
let div = vec![c.cols() - 1];
|
||||
let kept = c.complement(&div);
|
||||
model(c.reconstruct(&kept, &div), "reconstruct executor divergence from the rest")
|
||||
});
|
||||
|
||||
fn reconstruction_r2(original: &Mat, recon: &Mat) -> f64 {
|
||||
let mut ss_res = 0.0;
|
||||
let mut ss_tot = 0.0;
|
||||
for r in 0..original.rows {
|
||||
for col in 0..original.cols {
|
||||
let o = original.at(r, col);
|
||||
ss_res += (o - recon.at(r, col)).powi(2);
|
||||
ss_tot += o.powi(2);
|
||||
}
|
||||
}
|
||||
model(ols_r2(&c.x, &y2), "executor identity erased")
|
||||
});
|
||||
if ss_tot <= 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
(1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
|
||||
let n = y.rows;
|
||||
@@ -292,11 +339,11 @@ fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
|
||||
return y.clone();
|
||||
}
|
||||
let k = k.min(n);
|
||||
// deterministic init: spread initial centroids across the data
|
||||
let mut centroids: Vec<Vec<f64>> = (0..k).map(|i| y.data[(i * n / k) * y.cols..(i * n / k) * y.cols + y.cols].to_vec()).collect();
|
||||
let mut centroids: Vec<Vec<f64>> = (0..k)
|
||||
.map(|i| y.data[(i * n / k) * y.cols..(i * n / k) * y.cols + y.cols].to_vec())
|
||||
.collect();
|
||||
let mut assign = vec![0usize; n];
|
||||
for _ in 0..12 {
|
||||
// assign
|
||||
for r in 0..n {
|
||||
let mut best = 0;
|
||||
let mut bestd = f64::MAX;
|
||||
@@ -312,7 +359,6 @@ fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
|
||||
}
|
||||
assign[r] = best;
|
||||
}
|
||||
// update
|
||||
let mut sums = vec![vec![0.0; y.cols]; k];
|
||||
let mut counts = vec![0usize; k];
|
||||
for r in 0..n {
|
||||
@@ -377,10 +423,16 @@ impl CollapseSummary {
|
||||
/// Run every attack and check all collapse gates.
|
||||
pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary {
|
||||
let mut reports = Vec::new();
|
||||
// `min_info_loss` is defined only over whole-trace compressors — the genuine
|
||||
// "compressed model loses ≥ 35% information" gate. Ablation probes run and
|
||||
// report, but reconstructing one facet from the rest is redundancy, not
|
||||
// whole-trace compression, and is gated structurally elsewhere.
|
||||
let mut min_info_loss: f64 = 1.0;
|
||||
for atk in all_attacks() {
|
||||
let m = atk.compress(corpus);
|
||||
min_info_loss = min_info_loss.min(m.info_loss);
|
||||
if atk.whole_trace() {
|
||||
min_info_loss = min_info_loss.min(m.info_loss);
|
||||
}
|
||||
reports.push(CollapseReport {
|
||||
attack: atk.name().to_string(),
|
||||
predicts: m.predicts,
|
||||
@@ -397,13 +449,13 @@ pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary {
|
||||
|
||||
let mut failures = Vec::new();
|
||||
if best_1 >= 0.40 {
|
||||
failures.push(format!("1-factor predicts {:.3} >= 0.40", best_1));
|
||||
failures.push(format!("1-factor reconstructs {:.3} >= 0.40", best_1));
|
||||
}
|
||||
if best_2 >= 0.55 {
|
||||
failures.push(format!("2-factor predicts {:.3} >= 0.55", best_2));
|
||||
failures.push(format!("2-factor reconstructs {:.3} >= 0.55", best_2));
|
||||
}
|
||||
if best_4 >= 0.70 {
|
||||
failures.push(format!("4-factor predicts {:.3} >= 0.70", best_4));
|
||||
failures.push(format!("4-factor reconstructs {:.3} >= 0.70", best_4));
|
||||
}
|
||||
if max_single > 0.30 {
|
||||
failures.push(format!("single domain explains {:.3} > 0.30", max_single));
|
||||
@@ -430,41 +482,50 @@ pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use world_model::{Hasher, Rng};
|
||||
use world_model::Rng;
|
||||
|
||||
fn random_trace_rows(n: usize, seed: u64) -> Vec<Vec<f64>> {
|
||||
let mut rng = Rng::new(seed);
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
(0..FEATURE_W)
|
||||
.map(|_| rng.range_i64(-5000, 5000) as f64)
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_behavior_resists_collapse() {
|
||||
// Inputs random; outputs an avalanche hash of inputs -> no small linear
|
||||
// model should predict them.
|
||||
let mut rng = Rng::new(1);
|
||||
let mut inputs = Vec::new();
|
||||
let mut outputs = Vec::new();
|
||||
let mut fps = Vec::new();
|
||||
for _ in 0..400 {
|
||||
let inp: Vec<f64> = (0..NUM_DOMAINS * DOMAIN_BLOCK)
|
||||
.map(|_| rng.range_i64(-5000, 5000) as f64)
|
||||
.collect();
|
||||
let mut h = Hasher::new();
|
||||
for &v in &inp {
|
||||
h.write_i64(v as i64);
|
||||
}
|
||||
let base = h.finish().0;
|
||||
let out: Vec<f64> = (0..12)
|
||||
.map(|k| {
|
||||
let mut hh = Hasher::new();
|
||||
hh.write_u64(base);
|
||||
hh.write_u64(k);
|
||||
(hh.finish().0 as i64) as f64
|
||||
})
|
||||
.collect();
|
||||
fps.push(world_model::Hash(base));
|
||||
inputs.push(inp);
|
||||
outputs.push(out);
|
||||
}
|
||||
let corpus = BehaviorCorpus::build(inputs, outputs, fps);
|
||||
fn high_entropy_trace_resists_collapse() {
|
||||
let corpus = BehaviorCorpus::build(random_trace_rows(400, 1));
|
||||
let summary = analyze(&corpus);
|
||||
assert!(summary.passed(), "collapse failures: {:?}", summary.failures);
|
||||
assert!(summary.best_1factor < 0.40);
|
||||
assert!(summary.min_info_loss >= 0.35);
|
||||
}
|
||||
|
||||
/// Negative control: a single-factor (rank-1) corpus is genuinely
|
||||
/// collapsible. Every feature is a fixed loading times one latent value plus
|
||||
/// tiny noise, so a 1-factor model reconstructs almost everything. The gate
|
||||
/// MUST reject it — proving the collapse analysis discriminates.
|
||||
#[test]
|
||||
fn single_factor_corpus_is_rejected() {
|
||||
let mut rng = Rng::new(7);
|
||||
let loadings: Vec<f64> = (0..FEATURE_W).map(|i| 1.0 + (i % 5) as f64).collect();
|
||||
let rows: Vec<Vec<f64>> = (0..400)
|
||||
.map(|_| {
|
||||
let latent = rng.range_i64(-1000, 1000) as f64;
|
||||
loadings
|
||||
.iter()
|
||||
.map(|&load| load * latent + rng.range_i64(-2, 2) as f64)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let corpus = BehaviorCorpus::build(rows);
|
||||
let summary = analyze(&corpus);
|
||||
assert!(
|
||||
!summary.passed(),
|
||||
"collapse gate failed to reject a single-factor universe (1f={:.3}, info_loss={:.3})",
|
||||
summary.best_1factor,
|
||||
summary.min_info_loss
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
//! Freeze the committed replay corpus. Run this only as a deliberate, reviewed
|
||||
//! migration when the engine semantics legitimately change.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p replay_corpus --bin freeze -- [count]
|
||||
//! ```
|
||||
|
||||
fn main() {
|
||||
let count: usize = std::env::args()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(600);
|
||||
let base = replay_corpus::DEFAULT_BASE_SEED;
|
||||
match replay_corpus::freeze_to_disk(count, base) {
|
||||
Ok(n) => {
|
||||
println!(
|
||||
"froze {} replay cases to {}",
|
||||
n,
|
||||
replay_corpus::corpus_path().display()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("failed to freeze corpus: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
-29
@@ -1,12 +1,24 @@
|
||||
//! `replay_corpus` — every case is permanent and must replay bit-for-bit.
|
||||
//! A replay case stores the seeds plus the three canonical hashes (trace,
|
||||
//! delta, future). Replaying regenerates the case deterministically from its
|
||||
//! master seed, re-executes the reference runtime, and asserts zero hash drift.
|
||||
//!
|
||||
//! The corpus is **persisted to a committed file** (`corpus/replay_corpus.tsv`).
|
||||
//! Replay loads the expected hashes from that file — produced by an earlier
|
||||
//! `freeze` run — regenerates the case deterministically from its master seed,
|
||||
//! re-executes the reference runtime, and asserts zero drift against the stored
|
||||
//! expectation. Because the expectation is read from disk rather than recomputed
|
||||
//! and compared to itself in the same run, drift is genuinely possible: any
|
||||
//! change to the engine that alters a hash makes the committed expectation and
|
||||
//! the fresh execution disagree, and CI fails. (Proven by the negative-control
|
||||
//! test, which corrupts a stored hash and checks the drift is detected.)
|
||||
|
||||
use generators::generate_accepted_case;
|
||||
use reference_runtime::{execute, EngineConfig, ResolutionInput};
|
||||
use std::path::PathBuf;
|
||||
use world_model::Hash;
|
||||
|
||||
/// Format version of the persisted corpus file. Bump only with a deliberate,
|
||||
/// reviewed migration of the committed corpus.
|
||||
pub const CORPUS_VERSION: u32 = 1;
|
||||
|
||||
/// A permanent replay case (per spec) plus the master seed needed to
|
||||
/// regenerate the full case deterministically.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -22,8 +34,7 @@ pub struct ReplayCase {
|
||||
}
|
||||
|
||||
fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
|
||||
let (case, accepted_seed) = generate_accepted_case(master_seed);
|
||||
let _ = accepted_seed;
|
||||
let (case, _accepted_seed) = generate_accepted_case(master_seed);
|
||||
let input = ResolutionInput {
|
||||
world: case.world.clone(),
|
||||
program: case.program.clone(),
|
||||
@@ -40,7 +51,13 @@ fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a single replay case from a master seed.
|
||||
/// Master seed for the `i`-th corpus case (stable, deterministic).
|
||||
pub fn master_seed_for(base_seed: u64, i: usize) -> u64 {
|
||||
base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15)
|
||||
}
|
||||
|
||||
/// Build a single replay case from a master seed using the *current* reference.
|
||||
/// Used when freezing the corpus to disk.
|
||||
pub fn build_case(master_seed: u64) -> ReplayCase {
|
||||
let (input, ws, ps, cs, prs) = input_for(master_seed);
|
||||
let r = execute(&EngineConfig::reference(), &input);
|
||||
@@ -56,13 +73,93 @@ pub fn build_case(master_seed: u64) -> ReplayCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a replay corpus of `n` cases.
|
||||
/// Build an in-memory corpus of `n` cases (used by `freeze`).
|
||||
pub fn build_corpus(n: usize, base_seed: u64) -> Vec<ReplayCase> {
|
||||
(0..n)
|
||||
.map(|i| build_case(base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15)))
|
||||
.collect()
|
||||
(0..n).map(|i| build_case(master_seed_for(base_seed, i))).collect()
|
||||
}
|
||||
|
||||
// --- Persistence. -----------------------------------------------------------
|
||||
|
||||
/// Path to the committed corpus file, anchored to this crate.
|
||||
pub fn corpus_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("corpus/replay_corpus.tsv")
|
||||
}
|
||||
|
||||
/// The default base seed the committed corpus is frozen with.
|
||||
pub const DEFAULT_BASE_SEED: u64 = 0x5EED;
|
||||
|
||||
/// Serialize a corpus to the on-disk TSV format (with a provenance header).
|
||||
pub fn serialize_corpus(corpus: &[ReplayCase]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(&format!("# magicka-replay-corpus v{} cases={}\n", CORPUS_VERSION, corpus.len()));
|
||||
s.push_str("master\tworld\tprogram\tcontract\tperturb\ttrace\tdelta\tfuture\n");
|
||||
for c in corpus {
|
||||
s.push_str(&format!(
|
||||
"{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\n",
|
||||
c.master_seed,
|
||||
c.world_seed,
|
||||
c.program_seed,
|
||||
c.contract_seed,
|
||||
c.perturbation_seed,
|
||||
c.expected_trace_hash.0,
|
||||
c.expected_delta_hash.0,
|
||||
c.expected_future_hash.0,
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> Option<u64> {
|
||||
u64::from_str_radix(s.trim(), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a corpus from the on-disk TSV format.
|
||||
pub fn parse_corpus(text: &str) -> Vec<ReplayCase> {
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines() {
|
||||
if line.starts_with('#') || line.starts_with("master") || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split('\t').collect();
|
||||
if f.len() != 8 {
|
||||
continue;
|
||||
}
|
||||
let vals: Option<Vec<u64>> = f.iter().map(|x| parse_hex(x)).collect();
|
||||
if let Some(v) = vals {
|
||||
out.push(ReplayCase {
|
||||
master_seed: v[0],
|
||||
world_seed: v[1],
|
||||
program_seed: v[2],
|
||||
contract_seed: v[3],
|
||||
perturbation_seed: v[4],
|
||||
expected_trace_hash: Hash(v[5]),
|
||||
expected_delta_hash: Hash(v[6]),
|
||||
expected_future_hash: Hash(v[7]),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Load the committed corpus from disk.
|
||||
pub fn load_persisted_corpus() -> std::io::Result<Vec<ReplayCase>> {
|
||||
let text = std::fs::read_to_string(corpus_path())?;
|
||||
Ok(parse_corpus(&text))
|
||||
}
|
||||
|
||||
/// Freeze a fresh corpus of `n` cases to the committed file.
|
||||
pub fn freeze_to_disk(n: usize, base_seed: u64) -> std::io::Result<usize> {
|
||||
let corpus = build_corpus(n, base_seed);
|
||||
let path = corpus_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, serialize_corpus(&corpus))?;
|
||||
Ok(corpus.len())
|
||||
}
|
||||
|
||||
// --- Replay verification (against persisted expectations). ------------------
|
||||
|
||||
/// A single replay verification outcome.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ReplayDrift {
|
||||
@@ -78,15 +175,16 @@ impl ReplayDrift {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay one case and check for drift.
|
||||
pub fn replay(case: &ReplayCase) -> ReplayDrift {
|
||||
let (input, ..) = input_for(case.master_seed);
|
||||
/// Replay one *stored* case: regenerate it and compare a fresh reference run to
|
||||
/// the expectation read from disk.
|
||||
pub fn replay_against_stored(stored: &ReplayCase) -> ReplayDrift {
|
||||
let (input, ..) = input_for(stored.master_seed);
|
||||
let r = execute(&EngineConfig::reference(), &input);
|
||||
ReplayDrift {
|
||||
master_seed: case.master_seed,
|
||||
trace_ok: r.trace.canonical_hash() == case.expected_trace_hash,
|
||||
delta_ok: r.delta.hash() == case.expected_delta_hash,
|
||||
future_ok: r.replay.future_hash == case.expected_future_hash,
|
||||
master_seed: stored.master_seed,
|
||||
trace_ok: r.trace.canonical_hash() == stored.expected_trace_hash,
|
||||
delta_ok: r.delta.hash() == stored.expected_delta_hash,
|
||||
future_ok: r.replay.future_hash == stored.expected_future_hash,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,20 +194,21 @@ pub struct ReplayReport {
|
||||
pub total: usize,
|
||||
pub deterministic: usize,
|
||||
pub drift: Vec<u64>,
|
||||
pub loaded_from_disk: bool,
|
||||
}
|
||||
|
||||
impl ReplayReport {
|
||||
pub fn passed(&self, minimum: usize) -> bool {
|
||||
self.drift.is_empty() && self.total >= minimum
|
||||
self.loaded_from_disk && self.drift.is_empty() && self.total >= minimum
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the whole corpus replays deterministically.
|
||||
/// Verify a corpus (already loaded from disk) replays without drift.
|
||||
pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
|
||||
let mut drift = Vec::new();
|
||||
let mut deterministic = 0;
|
||||
for case in corpus {
|
||||
let d = replay(case);
|
||||
let d = replay_against_stored(case);
|
||||
if d.ok() {
|
||||
deterministic += 1;
|
||||
} else {
|
||||
@@ -120,6 +219,20 @@ pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
|
||||
total: corpus.len(),
|
||||
deterministic,
|
||||
drift,
|
||||
loaded_from_disk: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the committed corpus and verify it. The single entry point CI uses.
|
||||
pub fn verify_persisted_corpus() -> ReplayReport {
|
||||
match load_persisted_corpus() {
|
||||
Ok(corpus) => verify_corpus(&corpus),
|
||||
Err(_) => ReplayReport {
|
||||
total: 0,
|
||||
deterministic: 0,
|
||||
drift: Vec::new(),
|
||||
loaded_from_disk: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,18 +241,35 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replay_is_deterministic_zero_drift() {
|
||||
let corpus = build_corpus(80, 0x5EED);
|
||||
fn committed_corpus_loads_and_replays_without_drift() {
|
||||
let corpus = load_persisted_corpus().expect("committed corpus must exist; run `freeze`");
|
||||
assert!(!corpus.is_empty(), "committed corpus is empty");
|
||||
let report = verify_corpus(&corpus);
|
||||
assert_eq!(report.total, 80);
|
||||
assert_eq!(report.deterministic, 80);
|
||||
assert!(report.drift.is_empty());
|
||||
assert!(report.drift.is_empty(), "drift in committed corpus: {:?}", report.drift);
|
||||
assert_eq!(report.deterministic, report.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_hashes_are_stable() {
|
||||
let a = build_case(123);
|
||||
let b = build_case(123);
|
||||
assert_eq!(a, b);
|
||||
fn serialize_roundtrips() {
|
||||
let corpus = build_corpus(20, 0x1234);
|
||||
let text = serialize_corpus(&corpus);
|
||||
let parsed = parse_corpus(&text);
|
||||
assert_eq!(corpus, parsed);
|
||||
}
|
||||
|
||||
/// Negative control: a corrupted stored expectation is detected as drift.
|
||||
/// Proves the replay gate is not vacuous.
|
||||
#[test]
|
||||
fn corrupted_expectation_is_detected() {
|
||||
let mut corpus = build_corpus(10, 0x9999);
|
||||
// Flip one stored hash — as if the committed corpus disagreed with the
|
||||
// engine. Replay must flag it.
|
||||
corpus[3].expected_trace_hash = Hash(corpus[3].expected_trace_hash.0 ^ 0xdead_beef);
|
||||
let report = verify_corpus(&corpus);
|
||||
assert!(
|
||||
!report.drift.is_empty(),
|
||||
"replay failed to detect a corrupted expectation"
|
||||
);
|
||||
assert!(report.drift.contains(&corpus[3].master_seed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,8 @@ rune_ir = { path = "../rune_ir" }
|
||||
trace_model = { path = "../trace_model" }
|
||||
reference_runtime = { path = "../reference_runtime" }
|
||||
|
||||
[features]
|
||||
negative_controls = []
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
@@ -1,53 +1,52 @@
|
||||
//! `runtime_under_test` — the runtime that CI proves equivalent to the
|
||||
//! reference. It is configuration-driven: the canonical configuration must
|
||||
//! match the reference bit-for-bit, while semantic mutation swaps in a mutated
|
||||
//! configuration to verify the test suite can detect any divergence.
|
||||
//!
|
||||
//! Per the spec's mandatory order, the *optimized* runtime may not begin until
|
||||
//! steps 1–7 pass CI; until then this runtime is the reference engine driven
|
||||
//! through the same config surface, which is by construction equivalent.
|
||||
//! reference. Unlike the reference, this crate does **not** call the reference
|
||||
//! engine: it carries its own independent interpreter ([`native::native_resolve`])
|
||||
//! re-derived from the spec. The runtime-equivalence gate therefore compares
|
||||
//! two genuinely separate implementations, so 100% agreement is *evidence* that
|
||||
//! the spec is implemented correctly rather than a tautology. A transcription
|
||||
//! error in either implementation surfaces as an equivalence failure (proven by
|
||||
//! the negative-control test below).
|
||||
|
||||
use reference_runtime::{execute, EngineConfig, ResolutionInput, ResolutionResult, Runtime};
|
||||
pub mod native;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeUnderTest {
|
||||
pub config: EngineConfig,
|
||||
}
|
||||
use reference_runtime::{ResolutionInput, ResolutionResult, Runtime};
|
||||
|
||||
impl Default for RuntimeUnderTest {
|
||||
fn default() -> Self {
|
||||
RuntimeUnderTest {
|
||||
config: EngineConfig::reference(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use native::native_resolve;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RuntimeUnderTest;
|
||||
|
||||
impl RuntimeUnderTest {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Construct with a specific engine config (used by semantic mutation to
|
||||
/// install a mutated artifact).
|
||||
pub fn with_config(config: EngineConfig) -> Self {
|
||||
RuntimeUnderTest { config }
|
||||
RuntimeUnderTest
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime for RuntimeUnderTest {
|
||||
fn resolve(&self, input: ResolutionInput) -> ResolutionResult {
|
||||
execute(&self.config, &input)
|
||||
native_resolve(&input)
|
||||
}
|
||||
}
|
||||
|
||||
/// A deliberately broken runtime used as a negative control: it shares the
|
||||
/// independent interpreter but corrupts one recorded value. The equivalence
|
||||
/// gate **must** reject it. This proves the gate can fail.
|
||||
#[cfg(any(test, feature = "negative_controls"))]
|
||||
pub fn buggy_resolve(input: &ResolutionInput) -> ResolutionResult {
|
||||
let mut r = native_resolve(input);
|
||||
// Drop a single causal edge — a subtle bug an honest gate has to catch.
|
||||
r.trace.causal_graph.edges.pop();
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use reference_runtime::{canonical, execute, ReferenceRuntime};
|
||||
use rune_ir::{Op, RuneProgram, RuneToken};
|
||||
use reference_runtime::{canonical, execute, EngineConfig};
|
||||
use rune_ir::{Op, RuneProgram, RuneToken, ALL_OPS};
|
||||
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, NUM_DOMAINS};
|
||||
|
||||
fn random_input(seed: u64) -> ResolutionInput {
|
||||
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 {
|
||||
@@ -63,9 +62,9 @@ mod tests {
|
||||
w.causal_state.coupling[j][i] = rng.range_i64(-17, 17);
|
||||
}
|
||||
}
|
||||
let tokens: Vec<RuneToken> = (0..30)
|
||||
.map(|_| RuneToken {
|
||||
op: Op::from_u8(rng.next_u64() as u8),
|
||||
let tokens: Vec<RuneToken> = (0..40)
|
||||
.map(|i| RuneToken {
|
||||
op: if i % 3 == 0 { ALL_OPS[i % 12] } else { Op::from_u8(rng.next_u64() as u8) },
|
||||
a: rng.next_u64() as u8,
|
||||
b: rng.next_u64() as u8,
|
||||
c: rng.next_u64() as u8,
|
||||
@@ -75,25 +74,40 @@ mod tests {
|
||||
ResolutionInput {
|
||||
world: w,
|
||||
program: RuneProgram { id: ProgramId(seed), tokens, seed },
|
||||
contexts: standard_executors(seed, 3),
|
||||
contexts: standard_executors(seed, 4),
|
||||
contract_seed: seed,
|
||||
perturbation_seed: seed,
|
||||
}
|
||||
}
|
||||
|
||||
/// The core honesty property: the independent interpreter reproduces the
|
||||
/// reference engine bit-for-bit over a large seed sweep. This is what makes
|
||||
/// the equivalence gate meaningful rather than vacuous.
|
||||
#[test]
|
||||
fn rut_matches_reference() {
|
||||
use reference_runtime::Runtime;
|
||||
let rut = RuntimeUnderTest::new();
|
||||
let reference = ReferenceRuntime::new();
|
||||
for s in 0..200 {
|
||||
let input = random_input(s);
|
||||
let a = canonical(&reference.resolve(input.clone()));
|
||||
let b = canonical(&rut.resolve(input.clone()));
|
||||
assert_eq!(a, b, "divergence at seed {s}");
|
||||
// and against the raw engine path
|
||||
let c = canonical(&execute(&EngineConfig::reference(), &input));
|
||||
assert_eq!(a, c);
|
||||
fn native_matches_reference_bit_for_bit() {
|
||||
let cfg = EngineConfig::reference();
|
||||
for s in 0..2000u64 {
|
||||
let input = rich_input(s.wrapping_mul(0x9e3779b97f4a7c15) ^ 0xabc);
|
||||
let a = canonical(&execute(&cfg, &input));
|
||||
let b = canonical(&native_resolve(&input));
|
||||
assert_eq!(a, b, "independent interpreter diverged at seed {s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative control: a runtime with a real bug is rejected by the canonical
|
||||
/// comparison. Proves the equivalence gate is not vacuous.
|
||||
#[test]
|
||||
fn buggy_runtime_is_rejected() {
|
||||
let cfg = EngineConfig::reference();
|
||||
let mut caught = 0;
|
||||
for s in 0..200u64 {
|
||||
let input = rich_input(s + 1);
|
||||
let a = canonical(&execute(&cfg, &input));
|
||||
let b = canonical(&buggy_resolve(&input));
|
||||
if a != b {
|
||||
caught += 1;
|
||||
}
|
||||
}
|
||||
assert!(caught > 0, "the equivalence gate failed to catch a buggy runtime");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! An *independent* interpreter for the canonical engine behavior.
|
||||
//!
|
||||
//! This is the whole point of the runtime-equivalence gate: if the runtime
|
||||
//! under test merely called `reference_runtime::execute`, agreement would be a
|
||||
//! tautology and a bug in the shared interpreter would hide in both. This file
|
||||
//! re-derives the executable spec's canonical behavior from scratch, in a
|
||||
//! different code organization (a register-machine `Vm` rather than the
|
||||
//! reference's free-function dispatch), depending only on the shared *data*
|
||||
//! crates (`world_model`, `trace_model`, `rune_ir`) and never on the
|
||||
//! reference's engine. When the two implementations agree it is evidence; when
|
||||
//! a transcription error is introduced, the equivalence gate catches it (see
|
||||
//! the negative-control tests).
|
||||
//!
|
||||
//! Because the runtime under test only ever needs to reproduce the *canonical*
|
||||
//! reference configuration, the engine constants are inlined here as literals —
|
||||
//! they are the spec, independently restated, not imported.
|
||||
|
||||
use reference_runtime::{ResolutionInput, ResolutionResult};
|
||||
use rune_ir::{Op, RuneProgram, RuneToken};
|
||||
use trace_model::{
|
||||
BehaviorFingerprint, CausalEdge, CausalGraph, CausalNode, DivergenceGraph, DomainAccessGraph,
|
||||
ExecutionTrace, FaultCode, FaultLog, InformationFlowGraph, PerturbationResponse, ReplayRecord,
|
||||
TemporalGraph,
|
||||
};
|
||||
use world_model::{
|
||||
DomainKind, ExecutionContext, Hash, Hasher, ScheduledEffect, WorldDelta, WorldSnapshot,
|
||||
DomainId, HIDDEN_LANES, LANES, NUM_DOMAINS, REGS,
|
||||
};
|
||||
|
||||
// --- The canonical engine constants, independently restated. ---------------
|
||||
const C1: u64 = 0xff51afd7ed558ccd;
|
||||
const C2: u64 = 0xc4ceb9fe1a85ec53;
|
||||
const S1: u32 = 33;
|
||||
const S2: u32 = 29;
|
||||
const S3: u32 = 32;
|
||||
const FUTURE_TURNS: usize = 3;
|
||||
const DIFFUSE_SPAN: usize = 3;
|
||||
|
||||
#[inline]
|
||||
fn avalanche(z: i64) -> i64 {
|
||||
let mut u = z as u64;
|
||||
u ^= u >> S1;
|
||||
u = u.wrapping_mul(C1);
|
||||
u ^= u >> S2;
|
||||
u = u.wrapping_mul(C2);
|
||||
u ^= u >> S3;
|
||||
u as i64
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn combine(ctx: &ExecutionContext, a: i64, b: i64, coupling: i64, kc: u64) -> i64 {
|
||||
let mut z = a.wrapping_mul(kc as i64);
|
||||
z ^= b.rotate_left(((kc & 31) as u32) + 1);
|
||||
z = z.wrapping_add(coupling.wrapping_mul(b & 0xffff));
|
||||
z ^= ctx.salt() as i64;
|
||||
z = z.wrapping_add(ctx.profile.bias);
|
||||
z = z.rotate_left((ctx.profile.rotate % 63) + 1);
|
||||
avalanche(z)
|
||||
}
|
||||
|
||||
/// A register-machine view of one single-executor run. Holds the working world,
|
||||
/// the trace graphs accumulated as the program executes, and the accumulator
|
||||
/// file. Organised as a stateful object with `&mut self` methods, deliberately
|
||||
/// unlike the reference's stateless free functions.
|
||||
struct Vm<'a> {
|
||||
w: WorldSnapshot,
|
||||
ctx: &'a ExecutionContext,
|
||||
read_graph: DomainAccessGraph,
|
||||
write_graph: DomainAccessGraph,
|
||||
causal_graph: CausalGraph,
|
||||
info_flow: InformationFlowGraph,
|
||||
temporal: TemporalGraph,
|
||||
faults: FaultLog,
|
||||
acc: [i64; REGS],
|
||||
acc_src: [usize; REGS],
|
||||
}
|
||||
|
||||
impl<'a> Vm<'a> {
|
||||
fn new(world: &WorldSnapshot, ctx: &'a ExecutionContext) -> Self {
|
||||
let acc = world.execution_state.accumulator;
|
||||
Vm {
|
||||
w: world.clone(),
|
||||
ctx,
|
||||
read_graph: DomainAccessGraph::default(),
|
||||
write_graph: DomainAccessGraph::default(),
|
||||
causal_graph: CausalGraph::default(),
|
||||
info_flow: InformationFlowGraph::default(),
|
||||
temporal: TemporalGraph::default(),
|
||||
faults: FaultLog::default(),
|
||||
acc,
|
||||
acc_src: [0; REGS],
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rd(&self, dom: usize, lane: usize, hidden: bool) -> i64 {
|
||||
if hidden {
|
||||
self.w.domains[dom].hidden[lane % HIDDEN_LANES]
|
||||
} else {
|
||||
self.w.domains[dom].observed[lane % LANES]
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn wr(&mut self, dom: usize, lane: usize, hidden: bool, val: i64) {
|
||||
if hidden {
|
||||
self.w.domains[dom].hidden[lane % HIDDEN_LANES] = val;
|
||||
} else {
|
||||
self.w.domains[dom].observed[lane % LANES] = val;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn coupling(&self, to: usize, from: usize) -> i64 {
|
||||
self.w.causal_state.coupling[to][from]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn mix(&self, a: i64, b: i64, coupling: i64, kc: u64) -> i64 {
|
||||
combine(self.ctx, a, b, coupling, kc)
|
||||
}
|
||||
|
||||
/// Record one data-movement edge across every graph, in the canonical order.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn flow(
|
||||
&mut self,
|
||||
from_dom: usize,
|
||||
from_lane: usize,
|
||||
from_hidden: bool,
|
||||
to_dom: usize,
|
||||
to_lane: usize,
|
||||
to_hidden: bool,
|
||||
step: u32,
|
||||
weight: i64,
|
||||
) {
|
||||
self.read_graph.access_count[from_dom] += 1;
|
||||
self.write_graph.access_count[to_dom] += 1;
|
||||
self.read_graph.edges.push((from_dom as u8, to_dom as u8, 1));
|
||||
self.write_graph.edges.push((from_dom as u8, to_dom as u8, 1));
|
||||
self.info_flow
|
||||
.edges
|
||||
.push((from_dom as u8, to_dom as u8, (weight as u64).count_ones()));
|
||||
self.causal_graph.edges.push(CausalEdge {
|
||||
from: CausalNode {
|
||||
domain: from_dom as u8,
|
||||
lane: from_lane as u8,
|
||||
hidden: from_hidden,
|
||||
step,
|
||||
},
|
||||
to: CausalNode {
|
||||
domain: to_dom as u8,
|
||||
lane: to_lane as u8,
|
||||
hidden: to_hidden,
|
||||
step,
|
||||
},
|
||||
weight,
|
||||
});
|
||||
}
|
||||
|
||||
fn run(&mut self, program: &RuneProgram) {
|
||||
for (i, tok) in program.tokens.iter().enumerate() {
|
||||
let step = i as u32;
|
||||
self.step(tok, step);
|
||||
let dst = tok.dst_domain();
|
||||
let lane = tok.lane();
|
||||
let r = (step as usize) % REGS;
|
||||
self.acc[r] = self.acc[r].wrapping_add(self.w.domains[dst].observed[lane]);
|
||||
}
|
||||
self.w.execution_state.accumulator = self.acc;
|
||||
}
|
||||
|
||||
fn step(&mut self, tok: &RuneToken, step: u32) {
|
||||
let src = tok.src_domain();
|
||||
let dst = tok.dst_domain();
|
||||
let lane = tok.lane();
|
||||
let lane2 = tok.lane2();
|
||||
let kc = DomainKind::from_index(dst).mix_const();
|
||||
let coupling = self.coupling(dst, src);
|
||||
|
||||
match tok.op {
|
||||
Op::Mix => {
|
||||
let a = self.rd(src, lane, false);
|
||||
let b = self.rd(dst, lane2, false);
|
||||
let v = self.mix(a, b, coupling, kc);
|
||||
self.wr(dst, lane, false, v);
|
||||
self.flow(src, lane, false, dst, lane, false, step, v);
|
||||
self.flow(dst, lane2, false, dst, lane, false, step, v);
|
||||
}
|
||||
Op::Channel => {
|
||||
let a = self.rd(src, lane, false);
|
||||
let v = self.mix(a, coupling, coupling, kc);
|
||||
self.wr(dst, lane2, false, v);
|
||||
self.flow(src, lane, false, dst, lane2, false, step, v);
|
||||
}
|
||||
Op::Branch => {
|
||||
let probe = self.rd(src, lane, false);
|
||||
let take_hot =
|
||||
probe.wrapping_add(self.ctx.profile.bias) > self.ctx.profile.branch_threshold;
|
||||
if take_hot {
|
||||
let b = self.rd(dst, lane, false);
|
||||
let v = self.mix(probe, b, coupling, kc);
|
||||
self.wr(dst, lane, false, v);
|
||||
self.flow(src, lane, false, dst, lane, false, step, v);
|
||||
} else {
|
||||
let b = self.rd(dst, lane2, false);
|
||||
let v = self.mix(b, probe, coupling, kc).wrapping_add(0x5bd1e9);
|
||||
self.wr(dst, lane2, false, v);
|
||||
self.flow(src, lane, false, dst, lane2, false, step, v);
|
||||
self.faults.push(FaultCode::UnreachableBranch, step, 0);
|
||||
}
|
||||
}
|
||||
Op::Schedule => {
|
||||
let a = self.rd(src, lane, false);
|
||||
let b = self.rd(dst, lane, false);
|
||||
let v = self.mix(a, b, coupling, kc);
|
||||
let offset = 1 + (tok.imm.rem_euclid(3)) as u8;
|
||||
let hidden = tok.mode() & 1 == 1;
|
||||
self.w.time_state.pending.push(ScheduledEffect {
|
||||
turn_offset: offset,
|
||||
domain: DomainId(dst as u8),
|
||||
lane,
|
||||
hidden,
|
||||
value: v,
|
||||
});
|
||||
self.temporal.edges.push((step, offset, dst as u8));
|
||||
self.flow(src, lane, false, dst, lane, hidden, step, v);
|
||||
}
|
||||
Op::Resonate => {
|
||||
let a = self.rd(src, lane, false);
|
||||
let b = self.rd(dst, lane, false);
|
||||
let m = self.mix(a, b, coupling, kc);
|
||||
let va = a.wrapping_add(m);
|
||||
let vb = b ^ m;
|
||||
self.wr(src, lane, false, va);
|
||||
self.wr(dst, lane, false, vb);
|
||||
self.flow(dst, lane, false, src, lane, false, step, va);
|
||||
self.flow(src, lane, false, dst, lane, false, step, vb);
|
||||
}
|
||||
Op::Observe => {
|
||||
let reg = tok.mode() % REGS;
|
||||
let mut z: i64 = self.acc[reg];
|
||||
let proj = self.w.observed_projection();
|
||||
for k in 0..3 {
|
||||
let d = (src + k) % NUM_DOMAINS;
|
||||
let idx = d * LANES + (lane + k) % LANES;
|
||||
let cpl = self.coupling(dst, d);
|
||||
z = self.mix(z, proj[idx], cpl, kc);
|
||||
self.flow(d, (lane + k) % LANES, false, dst, lane, true, step, z);
|
||||
}
|
||||
self.acc[reg] = z;
|
||||
self.acc_src[reg] = src;
|
||||
self.wr(dst, tok.mode() % HIDDEN_LANES, true, z);
|
||||
}
|
||||
Op::Collapse => {
|
||||
let reg = tok.mode() % REGS;
|
||||
let a = self.acc[reg];
|
||||
let b = self.rd(dst, lane, false);
|
||||
if a == 0 {
|
||||
self.faults.push(FaultCode::EmptyAccumulator, step, reg as i64);
|
||||
}
|
||||
let v = self.mix(a, b, coupling, kc);
|
||||
self.wr(dst, lane, false, v);
|
||||
let asrc = self.acc_src[reg];
|
||||
self.flow(asrc, 0, true, dst, lane, false, step, v);
|
||||
}
|
||||
Op::Invert => {
|
||||
let b = self.rd(dst, lane, false);
|
||||
let mut v = avalanche((!b).wrapping_add(tok.imm));
|
||||
v ^= self.ctx.salt() as i64;
|
||||
v = v.wrapping_add(self.ctx.profile.bias);
|
||||
self.wr(dst, lane, false, v);
|
||||
self.flow(dst, lane, false, dst, lane, false, step, v);
|
||||
}
|
||||
Op::Diffuse => {
|
||||
let a = self.rd(src, lane, false);
|
||||
for k in 0..DIFFUSE_SPAN {
|
||||
let d = (src + 1 + k) % NUM_DOMAINS;
|
||||
let tl = (lane + k) % LANES;
|
||||
let prev = self.rd(d, tl, false);
|
||||
let cpl = self.coupling(d, src);
|
||||
let kc2 = DomainKind::from_index(d).mix_const();
|
||||
let v = self.mix(a, prev, cpl, kc2);
|
||||
self.wr(d, tl, false, prev.wrapping_add(v));
|
||||
self.flow(src, lane, false, d, tl, false, step, v);
|
||||
}
|
||||
}
|
||||
Op::Anchor => {
|
||||
let bound = (tok.imm.unsigned_abs() % 1_000_000) as i64 + 1;
|
||||
let b = self.rd(dst, lane, false);
|
||||
let diag = self.coupling(dst, dst);
|
||||
let mut mixed = b.wrapping_add(diag);
|
||||
mixed = mixed
|
||||
.wrapping_add(self.ctx.profile.bias)
|
||||
.wrapping_add((self.ctx.salt() & 0xffff) as i64);
|
||||
let clamped = mixed.clamp(-bound, bound);
|
||||
if clamped != mixed {
|
||||
self.faults.push(FaultCode::Saturated, step, bound);
|
||||
}
|
||||
self.wr(dst, lane, false, clamped);
|
||||
self.flow(dst, lane, false, dst, lane, false, step, clamped);
|
||||
}
|
||||
Op::Echoback => {
|
||||
let h = self.rd(dst, tok.mode() % HIDDEN_LANES, true);
|
||||
let b = self.rd(dst, lane, false);
|
||||
let v = self.mix(h, b, coupling, kc);
|
||||
self.wr(dst, lane, false, v);
|
||||
self.flow(dst, tok.mode() % HIDDEN_LANES, true, dst, lane, false, step, v);
|
||||
}
|
||||
Op::Imprint => {
|
||||
let b = self.rd(dst, lane, false);
|
||||
let hl = tok.mode() % HIDDEN_LANES;
|
||||
let prevh = self.rd(dst, hl, true);
|
||||
let v = self.mix(b, prevh, coupling, kc);
|
||||
self.wr(dst, hl, true, v);
|
||||
self.flow(dst, lane, false, dst, hl, true, step, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the world one turn: resolve due scheduled effects, then run coupling
|
||||
/// diffusion. Identical semantics to the reference's `step_world`, restated.
|
||||
fn step_world(w: &mut WorldSnapshot) {
|
||||
let pending = std::mem::take(&mut w.time_state.pending);
|
||||
let mut still = Vec::new();
|
||||
for e in pending {
|
||||
if e.turn_offset <= 1 {
|
||||
let d = e.domain.0 as usize;
|
||||
if e.hidden {
|
||||
let l = e.lane % HIDDEN_LANES;
|
||||
w.domains[d].hidden[l] = w.domains[d].hidden[l].wrapping_add(e.value);
|
||||
} else {
|
||||
let l = e.lane % LANES;
|
||||
w.domains[d].observed[l] = w.domains[d].observed[l].wrapping_add(e.value);
|
||||
}
|
||||
} else {
|
||||
still.push(ScheduledEffect {
|
||||
turn_offset: e.turn_offset - 1,
|
||||
..e
|
||||
});
|
||||
}
|
||||
}
|
||||
w.time_state.pending = still;
|
||||
|
||||
let snap = w.domains.clone();
|
||||
for j in 0..NUM_DOMAINS {
|
||||
for lane in 0..LANES {
|
||||
let mut z = w.domains[j].observed[lane];
|
||||
for i in 0..NUM_DOMAINS {
|
||||
let c = w.causal_state.coupling[j][i];
|
||||
z = z.wrapping_add(c.wrapping_mul(snap[i].observed[lane] & 0xff));
|
||||
}
|
||||
w.domains[j].observed[lane] = avalanche(z);
|
||||
}
|
||||
for hl in 0..HIDDEN_LANES {
|
||||
let base = w.domains[j].hidden[hl].wrapping_add(snap[j].observed[0]);
|
||||
w.domains[j].hidden[hl] = avalanche(base);
|
||||
}
|
||||
}
|
||||
w.turn = w.turn.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn future_hash(start: &WorldSnapshot) -> Hash {
|
||||
let mut w = start.clone();
|
||||
let mut h = Hasher::new();
|
||||
h.write_tag("future-3");
|
||||
for _ in 0..FUTURE_TURNS {
|
||||
step_world(&mut w);
|
||||
for v in w.ground_truth() {
|
||||
h.write_i64(v);
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn compute_divergence(finals: &[WorldSnapshot]) -> DivergenceGraph {
|
||||
let n = finals.len();
|
||||
let mut pairwise = vec![0.0f64; n * n];
|
||||
let total = (NUM_DOMAINS * LANES) as f64;
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let mut diff = 0usize;
|
||||
for d in 0..NUM_DOMAINS {
|
||||
for l in 0..LANES {
|
||||
if finals[i].domains[d].observed[l] != finals[j].domains[d].observed[l] {
|
||||
diff += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
pairwise[i * n + j] = diff as f64 / total;
|
||||
}
|
||||
}
|
||||
DivergenceGraph {
|
||||
executor_count: n,
|
||||
pairwise,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn behavior_fingerprint(
|
||||
delta: &WorldDelta,
|
||||
causal: &CausalGraph,
|
||||
read_graph: &DomainAccessGraph,
|
||||
write_graph: &DomainAccessGraph,
|
||||
info_flow: &InformationFlowGraph,
|
||||
temporal: &TemporalGraph,
|
||||
divergence: &DivergenceGraph,
|
||||
future: Hash,
|
||||
) -> BehaviorFingerprint {
|
||||
let mut features: Vec<i64> = Vec::new();
|
||||
for dd in &delta.domain_deltas {
|
||||
let mut s = 0i64;
|
||||
for &v in &dd.observed {
|
||||
s = s.wrapping_add(v);
|
||||
}
|
||||
features.push(s);
|
||||
}
|
||||
for dd in &delta.domain_deltas {
|
||||
let mut s = 0i64;
|
||||
for &v in &dd.hidden {
|
||||
s = s.wrapping_add(v);
|
||||
}
|
||||
features.push(s);
|
||||
}
|
||||
features.push(causal.causal_rank() as i64);
|
||||
features.push(causal.edge_count() as i64);
|
||||
features.push(read_graph.touched_count() as i64);
|
||||
features.push(write_graph.touched_count() as i64);
|
||||
features.push(info_flow.total_bits() as i64);
|
||||
features.push(temporal.edge_count() as i64);
|
||||
features.push((divergence.mean_divergence() * 1_000_000.0) as i64);
|
||||
features.push(future.0 as i64);
|
||||
BehaviorFingerprint::from_features(features)
|
||||
}
|
||||
|
||||
/// The independent implementation of the canonical resolution.
|
||||
pub fn native_resolve(input: &ResolutionInput) -> ResolutionResult {
|
||||
let contexts = if input.contexts.is_empty() {
|
||||
world_model::standard_executors(input.world.seed, 3)
|
||||
} else {
|
||||
input.contexts.clone()
|
||||
};
|
||||
|
||||
let world0 = input.world.clone();
|
||||
|
||||
let mut finals: Vec<WorldSnapshot> = Vec::with_capacity(contexts.len());
|
||||
let mut primary: Option<Vm> = None;
|
||||
for (idx, ctx) in contexts.iter().enumerate() {
|
||||
let mut vm = Vm::new(&world0, ctx);
|
||||
vm.run(&input.program);
|
||||
finals.push(vm.w.clone());
|
||||
if idx == 0 {
|
||||
primary = Some(vm);
|
||||
}
|
||||
}
|
||||
let vm = primary.expect("at least one executor");
|
||||
|
||||
let delta = WorldDelta::between(&world0, &vm.w);
|
||||
let divergence = compute_divergence(&finals);
|
||||
let fhash = future_hash(&vm.w);
|
||||
let behavior = behavior_fingerprint(
|
||||
&delta,
|
||||
&vm.causal_graph,
|
||||
&vm.read_graph,
|
||||
&vm.write_graph,
|
||||
&vm.info_flow,
|
||||
&vm.temporal,
|
||||
&divergence,
|
||||
fhash,
|
||||
);
|
||||
|
||||
let trace = ExecutionTrace {
|
||||
read_graph: vm.read_graph,
|
||||
write_graph: vm.write_graph,
|
||||
causal_graph: vm.causal_graph,
|
||||
information_flow: vm.info_flow,
|
||||
executor_divergence: divergence,
|
||||
temporal_graph: vm.temporal,
|
||||
perturbation_response: PerturbationResponse::default(),
|
||||
behavior_fingerprint: behavior,
|
||||
};
|
||||
let trace_hash = trace.canonical_hash();
|
||||
let delta_hash = delta.hash();
|
||||
let replay = ReplayRecord {
|
||||
world_seed: input.world.seed,
|
||||
program_seed: input.program.seed,
|
||||
contract_seed: input.contract_seed,
|
||||
perturbation_seed: input.perturbation_seed,
|
||||
trace_hash,
|
||||
delta_hash,
|
||||
future_hash: fhash,
|
||||
};
|
||||
|
||||
ResolutionResult {
|
||||
delta,
|
||||
trace,
|
||||
faults: vm.faults,
|
||||
replay,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
//! `semantic_mutation` — structurally generate mutated runtimes and prove the
|
||||
//! test suite kills every one. A mutant is an [`EngineConfig`] (the runtime
|
||||
//! artifact) with exactly one behavior-affecting knob changed. Every mutant
|
||||
//! must fail at least one named acceptance gate; a survivor means the tests are
|
||||
//! invalid and blocks merge.
|
||||
//! test suite kills every one **by the named acceptance gate it targets**.
|
||||
//!
|
||||
//! A mutant is an [`EngineConfig`] (the runtime artifact) with exactly one
|
||||
//! behavior-affecting knob changed. The spec requires that every mutant fail at
|
||||
//! least one *named* acceptance gate. An earlier version of this crate only
|
||||
//! checked that a mutant's canonical output *differed* from the reference — a
|
||||
//! weaker, wrong condition that a mutant could satisfy without tripping the gate
|
||||
//! it is supposed to expose. This version runs the actual named gate against
|
||||
//! each mutant and requires that specific gate to fail. A mutant that does not
|
||||
//! trip its named gate is a survivor and blocks merge.
|
||||
|
||||
use reference_runtime::{canonical, execute, Canonical, EngineConfig, ResolutionInput};
|
||||
use world_model::NUM_DOMAINS;
|
||||
@@ -10,6 +16,13 @@ use world_model::NUM_DOMAINS;
|
||||
/// A mutant runtime artifact.
|
||||
pub type RuntimeArtifact = EngineConfig;
|
||||
|
||||
// --- Gate thresholds, mirrored from the CI gate definitions. ----------------
|
||||
const CAUSAL_EDGES_MIN: f64 = 24.0;
|
||||
const CAUSAL_RANK_P95_MIN: f64 = 6.0;
|
||||
const DOMAIN_APPEARS_MIN: f64 = 0.35;
|
||||
const DOMAIN_MUTATED_MIN: f64 = 0.20;
|
||||
const FUTURE_ALT_MIN: f64 = 0.50;
|
||||
|
||||
/// The named acceptance gate a mutant is expected to fail.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum DetectionClass {
|
||||
@@ -23,8 +36,8 @@ impl DetectionClass {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
DetectionClass::RuntimeEquivalence => "runtime_equivalence",
|
||||
DetectionClass::CausalGate => "causal_gate",
|
||||
DetectionClass::TemporalGate => "temporal_gate",
|
||||
DetectionClass::CausalGate => "causal_rank/trace",
|
||||
DetectionClass::TemporalGate => "metamorphic_response/temporal",
|
||||
DetectionClass::DomainParticipation => "domain_participation",
|
||||
}
|
||||
}
|
||||
@@ -36,9 +49,6 @@ pub trait SemanticMutator {
|
||||
fn expected_detection_reason(&self) -> DetectionClass;
|
||||
}
|
||||
|
||||
/// Each generated mutant is also a [`SemanticMutator`]: applying it to any base
|
||||
/// artifact reproduces its single-knob change, and it names the gate it must
|
||||
/// fail. This ties the structural generator to the spec's trait surface.
|
||||
impl SemanticMutator for Mutant {
|
||||
fn mutate(&self, _base: &RuntimeArtifact) -> RuntimeArtifact {
|
||||
self.config.clone()
|
||||
@@ -58,7 +68,8 @@ pub struct Mutant {
|
||||
}
|
||||
|
||||
/// Build the `i`-th mutant deterministically from the reference artifact.
|
||||
/// Every mutant differs from the reference in exactly one behavioral knob.
|
||||
/// Every mutant differs from the reference in exactly one behavioral knob, and
|
||||
/// is tagged with the named gate that change must trip.
|
||||
pub fn mutant_for(i: usize) -> Mutant {
|
||||
let base = EngineConfig::reference();
|
||||
let mut cfg = base.clone();
|
||||
@@ -145,7 +156,6 @@ pub fn mutant_for(i: usize) -> Mutant {
|
||||
}
|
||||
};
|
||||
|
||||
// Safety net: guarantee the mutant is not accidentally identical.
|
||||
if cfg == base {
|
||||
cfg.use_hidden = !cfg.use_hidden;
|
||||
}
|
||||
@@ -169,20 +179,153 @@ pub fn reference_canon(inputs: &[ResolutionInput]) -> Vec<Canonical> {
|
||||
inputs.iter().map(|inp| canonical(&execute(&cfg, inp))).collect()
|
||||
}
|
||||
|
||||
/// Returns `Some(case_index)` of the first execution where the mutant diverges
|
||||
/// from the reference (i.e. the mutant is killed), or `None` if it survives.
|
||||
pub fn kill_index(
|
||||
mutant: &EngineConfig,
|
||||
inputs: &[ResolutionInput],
|
||||
reference: &[Canonical],
|
||||
) -> Option<usize> {
|
||||
for (i, inp) in inputs.iter().enumerate() {
|
||||
let c = canonical(&execute(mutant, inp));
|
||||
if c != reference[i] {
|
||||
return Some(i);
|
||||
// --- Named-gate evaluators. -------------------------------------------------
|
||||
//
|
||||
// Each evaluator computes, for a given engine config over the input corpus, the
|
||||
// metric a named CI gate checks, and returns whether that gate FAILS. The
|
||||
// reference config must pass all of them (asserted in tests); each mutant must
|
||||
// fail the one it targets.
|
||||
|
||||
fn percentile(mut v: Vec<f64>, p: f64) -> f64 {
|
||||
if v.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let idx = (((v.len() - 1) as f64) * p).round() as usize;
|
||||
v[idx.min(v.len() - 1)]
|
||||
}
|
||||
|
||||
fn median(v: Vec<f64>) -> f64 {
|
||||
if v.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut s = v;
|
||||
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
s[s.len() / 2]
|
||||
}
|
||||
|
||||
/// True if the causal/trace gate fails under `cfg`.
|
||||
fn causal_gate_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
|
||||
let edges: Vec<f64> = inputs
|
||||
.iter()
|
||||
.map(|inp| execute(cfg, inp).trace.causal_edge_count() as f64)
|
||||
.collect();
|
||||
let ranks: Vec<f64> = inputs
|
||||
.iter()
|
||||
.map(|inp| execute(cfg, inp).trace.causal_rank() as f64)
|
||||
.collect();
|
||||
median(edges) < CAUSAL_EDGES_MIN || percentile(ranks, 0.05) < CAUSAL_RANK_P95_MIN
|
||||
}
|
||||
|
||||
/// True if the domain-participation gate fails under `cfg`.
|
||||
fn domain_gate_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
|
||||
let n = inputs.len().max(1) as f64;
|
||||
let mut appears = [0u32; NUM_DOMAINS];
|
||||
let mut mutated = [0u32; NUM_DOMAINS];
|
||||
for inp in inputs {
|
||||
let r = execute(cfg, inp);
|
||||
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;
|
||||
}
|
||||
}
|
||||
for dd in &r.delta.domain_deltas {
|
||||
if !dd.is_zero() {
|
||||
mutated[dd.domain.0 as usize] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
(0..NUM_DOMAINS).any(|d| {
|
||||
(appears[d] as f64 / n) < DOMAIN_APPEARS_MIN
|
||||
|| (mutated[d] as f64 / n) < DOMAIN_MUTATED_MIN
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a fixed structural perturbation (bump domain 0, observed lane 0).
|
||||
fn perturbed(input: &ResolutionInput) -> ResolutionInput {
|
||||
let mut p = input.clone();
|
||||
p.world.domains[0].observed[0] = p.world.domains[0].observed[0].wrapping_add(101);
|
||||
p.world.mark_perturbed();
|
||||
p
|
||||
}
|
||||
|
||||
/// True if the temporal gate fails under `cfg`. The temporal gate asserts the
|
||||
/// runtime carries genuine 3-turn future dynamics: it must (a) record temporal
|
||||
/// edges, (b) have a future sensitive to perturbation, and (c) reproduce the
|
||||
/// reference's 3-turn future. Any of these failing fails the gate.
|
||||
fn temporal_gate_fails(
|
||||
cfg: &EngineConfig,
|
||||
inputs: &[ResolutionInput],
|
||||
reference: &[Canonical],
|
||||
) -> bool {
|
||||
// (a) temporal edges present.
|
||||
let tedges: Vec<f64> = inputs
|
||||
.iter()
|
||||
.map(|inp| execute(cfg, inp).trace.temporal_graph.edge_count() as f64)
|
||||
.collect();
|
||||
if median(tedges) < 1.0 {
|
||||
return true;
|
||||
}
|
||||
// (b) future sensitive to perturbation.
|
||||
let mut altered = 0usize;
|
||||
for inp in inputs {
|
||||
let base_future = execute(cfg, inp).replay.future_hash;
|
||||
let pert_future = execute(cfg, &perturbed(inp)).replay.future_hash;
|
||||
if base_future != pert_future {
|
||||
altered += 1;
|
||||
}
|
||||
}
|
||||
let alt_rate = altered as f64 / inputs.len().max(1) as f64;
|
||||
if alt_rate < FUTURE_ALT_MIN {
|
||||
return true;
|
||||
}
|
||||
// (c) future matches the reference's 3-turn future on every input.
|
||||
for (inp, ref_c) in inputs.iter().zip(reference) {
|
||||
if execute(cfg, inp).replay.future_hash != ref_c.future_hash {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True if the runtime-equivalence gate fails under `cfg` (i.e. the mutant
|
||||
/// diverges from the reference canonical view on at least one input).
|
||||
fn equivalence_gate_fails(
|
||||
cfg: &EngineConfig,
|
||||
inputs: &[ResolutionInput],
|
||||
reference: &[Canonical],
|
||||
) -> bool {
|
||||
inputs
|
||||
.iter()
|
||||
.zip(reference)
|
||||
.any(|(inp, ref_c)| canonical(&execute(cfg, inp)) != *ref_c)
|
||||
}
|
||||
|
||||
/// Evaluate whether a mutant is killed by its **named** gate. Returns `None` if
|
||||
/// killed (the named gate fails), or `Some(reason)` describing the survival.
|
||||
pub fn survival_reason(
|
||||
mutant: &Mutant,
|
||||
inputs: &[ResolutionInput],
|
||||
reference: &[Canonical],
|
||||
) -> Option<String> {
|
||||
let killed = match mutant.expected {
|
||||
DetectionClass::RuntimeEquivalence => {
|
||||
equivalence_gate_fails(&mutant.config, inputs, reference)
|
||||
}
|
||||
DetectionClass::CausalGate => causal_gate_fails(&mutant.config, inputs),
|
||||
DetectionClass::TemporalGate => temporal_gate_fails(&mutant.config, inputs, reference),
|
||||
DetectionClass::DomainParticipation => domain_gate_fails(&mutant.config, inputs),
|
||||
};
|
||||
if killed {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"mutant {} ({}) did not fail its named gate {}",
|
||||
mutant.id,
|
||||
mutant.name,
|
||||
mutant.expected.name()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of running the full mutation suite.
|
||||
@@ -199,17 +342,16 @@ impl MutationOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run all mutants against the input corpus.
|
||||
/// Run all mutants against the input corpus, killing each by its named gate.
|
||||
pub fn run_suite(count: usize, inputs: &[ResolutionInput]) -> MutationOutcome {
|
||||
let reference = reference_canon(inputs);
|
||||
let mutants = generate_mutants(count);
|
||||
let mut killed = 0;
|
||||
let mut survivors = Vec::new();
|
||||
for m in &mutants {
|
||||
if kill_index(&m.config, inputs, &reference).is_some() {
|
||||
killed += 1;
|
||||
} else {
|
||||
survivors.push((m.id, m.name.clone()));
|
||||
match survival_reason(m, inputs, &reference) {
|
||||
None => killed += 1,
|
||||
Some(reason) => survivors.push((m.id, reason)),
|
||||
}
|
||||
}
|
||||
MutationOutcome {
|
||||
@@ -223,13 +365,13 @@ pub fn run_suite(count: usize, inputs: &[ResolutionInput]) -> MutationOutcome {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rune_ir::{RuneProgram, RuneToken, ALL_OPS};
|
||||
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, NUM_DOMAINS};
|
||||
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, LANES, NUM_DOMAINS};
|
||||
|
||||
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..world_model::LANES {
|
||||
for l in 0..LANES {
|
||||
d.observed[l] = rng.range_i64(-5000, 5000);
|
||||
}
|
||||
for l in 0..world_model::HIDDEN_LANES {
|
||||
@@ -241,7 +383,6 @@ mod tests {
|
||||
w.causal_state.coupling[j][i] = rng.range_i64(-17, 17);
|
||||
}
|
||||
}
|
||||
// cover every op and every domain
|
||||
let tokens: Vec<RuneToken> = (0..40)
|
||||
.map(|i| RuneToken {
|
||||
op: ALL_OPS[i % ALL_OPS.len()],
|
||||
@@ -260,6 +401,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn corpus() -> Vec<ResolutionInput> {
|
||||
(0..16).map(|s| rich_input(s + 1)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_passes_every_named_gate() {
|
||||
let inputs = corpus();
|
||||
let reference = reference_canon(&inputs);
|
||||
let cfg = EngineConfig::reference();
|
||||
assert!(!causal_gate_fails(&cfg, &inputs), "reference fails causal gate");
|
||||
assert!(!domain_gate_fails(&cfg, &inputs), "reference fails domain gate");
|
||||
assert!(
|
||||
!temporal_gate_fails(&cfg, &inputs, &reference),
|
||||
"reference fails temporal gate"
|
||||
);
|
||||
assert!(
|
||||
!equivalence_gate_fails(&cfg, &inputs, &reference),
|
||||
"reference fails equivalence gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mutant_survives_its_named_gate() {
|
||||
let inputs = corpus();
|
||||
let outcome = run_suite(520, &inputs);
|
||||
assert!(outcome.passed(), "survivors: {:?}", outcome.survivors);
|
||||
assert_eq!(outcome.killed, outcome.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mutant_differs_from_reference() {
|
||||
let base = EngineConfig::reference();
|
||||
@@ -267,12 +437,4 @@ mod tests {
|
||||
assert_ne!(mutant_for(i).config, base, "mutant {i} equals reference");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mutant_survives() {
|
||||
let inputs: Vec<ResolutionInput> = (0..12).map(|s| rich_input(s + 1)).collect();
|
||||
let outcome = run_suite(520, &inputs);
|
||||
assert!(outcome.passed(), "survivors: {:?}", outcome.survivors);
|
||||
assert_eq!(outcome.killed, outcome.total);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user