update
This commit is contained in:
@@ -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