From 2fe989bcb30975bcaad208568c309d25c9f65e6b Mon Sep 17 00:00:00 2001 From: Drakeor Date: Sun, 21 Jun 2026 18:00:52 -0700 Subject: [PATCH] changes claude never committed --- .gitignore | 3 + Cargo.toml | 23 + README.md | 92 +++ crates/ci_reports/Cargo.toml | 23 + crates/ci_reports/src/json.rs | 94 +++ crates/ci_reports/src/lib.rs | 784 +++++++++++++++++++++++++ crates/ci_reports/src/main.rs | 325 ++++++++++ crates/collapse_analysis/Cargo.toml | 12 + crates/collapse_analysis/src/lib.rs | 470 +++++++++++++++ crates/collapse_analysis/src/linalg.rs | 244 ++++++++ crates/generators/Cargo.toml | 13 + crates/generators/src/lib.rs | 344 +++++++++++ crates/reference_runtime/Cargo.toml | 13 + crates/reference_runtime/src/engine.rs | 654 +++++++++++++++++++++ crates/reference_runtime/src/lib.rs | 117 ++++ crates/replay_corpus/Cargo.toml | 15 + crates/replay_corpus/src/lib.rs | 145 +++++ crates/rune_ir/Cargo.toml | 11 + crates/rune_ir/src/lib.rs | 168 ++++++ crates/runtime_under_test/Cargo.toml | 14 + crates/runtime_under_test/src/lib.rs | 99 ++++ crates/semantic_mutation/Cargo.toml | 14 + crates/semantic_mutation/src/lib.rs | 278 +++++++++ crates/trace_model/Cargo.toml | 11 + crates/trace_model/src/lib.rs | 433 ++++++++++++++ crates/trace_model/src/matrix.rs | 82 +++ crates/world_model/Cargo.toml | 8 + crates/world_model/src/context.rs | 107 ++++ crates/world_model/src/domain.rs | 227 +++++++ crates/world_model/src/lib.rs | 74 +++ crates/world_model/src/perturb.rs | 152 +++++ crates/world_model/src/primitives.rs | 214 +++++++ crates/world_model/src/world.rs | 265 +++++++++ 33 files changed, 5528 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 crates/ci_reports/Cargo.toml create mode 100644 crates/ci_reports/src/json.rs create mode 100644 crates/ci_reports/src/lib.rs create mode 100644 crates/ci_reports/src/main.rs create mode 100644 crates/collapse_analysis/Cargo.toml create mode 100644 crates/collapse_analysis/src/lib.rs create mode 100644 crates/collapse_analysis/src/linalg.rs create mode 100644 crates/generators/Cargo.toml create mode 100644 crates/generators/src/lib.rs create mode 100644 crates/reference_runtime/Cargo.toml create mode 100644 crates/reference_runtime/src/engine.rs create mode 100644 crates/reference_runtime/src/lib.rs create mode 100644 crates/replay_corpus/Cargo.toml create mode 100644 crates/replay_corpus/src/lib.rs create mode 100644 crates/rune_ir/Cargo.toml create mode 100644 crates/rune_ir/src/lib.rs create mode 100644 crates/runtime_under_test/Cargo.toml create mode 100644 crates/runtime_under_test/src/lib.rs create mode 100644 crates/semantic_mutation/Cargo.toml create mode 100644 crates/semantic_mutation/src/lib.rs create mode 100644 crates/trace_model/Cargo.toml create mode 100644 crates/trace_model/src/lib.rs create mode 100644 crates/trace_model/src/matrix.rs create mode 100644 crates/world_model/Cargo.toml create mode 100644 crates/world_model/src/context.rs create mode 100644 crates/world_model/src/domain.rs create mode 100644 crates/world_model/src/lib.rs create mode 100644 crates/world_model/src/perturb.rs create mode 100644 crates/world_model/src/primitives.rs create mode 100644 crates/world_model/src/world.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..761519c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/ci_out +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..62f331f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +resolver = "2" +members = [ + "crates/world_model", + "crates/rune_ir", + "crates/trace_model", + "crates/generators", + "crates/reference_runtime", + "crates/runtime_under_test", + "crates/collapse_analysis", + "crates/semantic_mutation", + "crates/replay_corpus", + "crates/ci_reports", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" + +[profile.release] +opt-level = 3 +lto = "thin" diff --git a/README.md b/README.md index e69de29..239e4c4 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,92 @@ +# Magicka VM — Phase 0/1 + +> The deliverable is a Rust engine whose tests make a fake universe fail. + +This repository implements the Phase 0/1 specification in `plan.md`: an +**adversarial testing framework first**, then a **reference runtime** that +passes it, then a **runtime under test** that matches the reference. No spell +content, templates, or cosmetic runes — the value is in the tests that refuse +to let the universe collapse into a single score, resource, effect axis, +executor, rune, hidden formula, or decorative domain. + +## Workspace layout + +Built in the mandatory order from the spec: + +| # | Crate | Role | +|---|-------|------| +| 1 | `world_model` | 8 independent domains, world snapshot, perturbation axes, deltas, deterministic primitives (ids, stable hash, RNG) | +| – | `rune_ir` | Rune token / program model (no stream is ever rejected) | +| 2 | `trace_model` | Execution trace + all graphs, behavior fingerprint, replay record, fault log, trace metrics | +| 3 | `generators` | Worlds, programs, executors, contracts, perturbations; rejects flat cases | +| 4 | `collapse_analysis` | The 11 compression attacks + collapse gates | +| 5 | `semantic_mutation` | Structurally generated mutant runtimes; proves every one is killed | +| 6 | `replay_corpus` | Permanent, bit-exact replay cases | +| 7 | `reference_runtime` | The executable spec engine (`Runtime` trait, `resolve`) | +| 8 | `runtime_under_test` | Config-driven engine, proven equivalent to the reference | +| – | `ci_reports` | Orchestrator + `ci` binary; emits the 8 required reports | + +The optimized runtime may not begin before steps 1–7 pass CI; until then the +runtime under test is the reference engine driven through the same config +surface, which is equivalent by construction. Mutation swaps in a *mutated* +config to prove the suite detects any divergence. + +## The engine in one paragraph + +A world is 8 domains, each with 4 observed + 2 hidden integer lanes, a dense +8×8 coupling matrix, partial observability, and pending scheduled effects. A +rune program is interpreted under ≥3 executors; each opcode reads several +domains, mixes them through a nonlinear avalanche keyed by per-domain +constants, the world coupling, and the executor's salt, then writes back — +recording causal/read/write/information-flow/temporal edges as it goes. +Scheduled effects and coupling diffusion propagate changes 3 turns into the +future. Because every output bit depends on all inputs, the universe is +high-rank, incompressible, future-sensitive, and executor-divergent — exactly +the properties the gates demand. + +## Running CI + +```bash +cargo test # unit tests for every crate +cargo run --release -p ci_reports --bin ci # full gate run (fast scale) +``` + +Reports are written to `ci_out/` (8 JSON files + `ci_summary.md`). The binary +exits non-zero if any gate fails. + +### Scales + +`MAGICKA_SCALE` selects the corpus size; individual counts can be overridden. + +| Scale | executions | replay | mutants | notes | +|-------|-----------|--------|---------|-------| +| `tiny` | 120 | 120 | 520 | smoke (~80 ms) | +| `fast` (default) | 600 | 600 | 520 | every gate, ~0.3 s | +| `full` | 1,000,000 | 10,000 | 600 | merge-blocking spec gates | + +```bash +MAGICKA_SCALE=full cargo run --release -p ci_reports --bin ci +# or override individual counts: +MAGICKA_EXECUTIONS=20000 MAGICKA_REPLAY=10000 cargo run --release -p ci_reports --bin ci +``` + +The gate *thresholds* are identical across scales — only the corpus size +changes. Fast CI runs a representative slice; merge-blocking CI runs `full`. + +## Gates enforced (all must pass) + +- **runtime_equivalence** — 100% of executions: `canonical(reference) == canonical(runtime_under_test)` over delta, trace, faults, replay hash, and 3-turn future hash. +- **causal_rank / trace** — median causal edges ≥ 24, 95% causal rank ≥ 6, median touched domains ≥ 4, 95% ≥ 3, fingerprint collision rate < 5%, largest cluster < 2%. +- **domain_participation** — each domain appears in ≥ 35% of traces, influences ≥ 20%, is mutated in ≥ 20%; removing any domain loses ≥ 10% behavioral diversity; merging any pair loses ≥ 8%; no read-only or write-only domain. +- **metamorphic_response** — ≥ 90% of perturbations alter the trace, ≥ 75% the delta, ≥ 50% the 3-turn future; ≤ 5% unexplained neutral. +- **compression_resistance** — best 1/2/4-factor models predict < 40/55/70%; no single domain > 30%, no pair > 55%; every compressed model loses ≥ 35% information. All 11 attack families are run. +- **mutation_survivor** — ≥ 500 structurally generated mutants, 0 survivors. +- **contract** — every admitted case satisfies its semantic contract (min causal rank, domain participation, future sensitivity, context divergence, max compressibility); contract-violating cases are rejected at admission. +- **replay** — 100% deterministic, 0 hash drift. +- **coverage** — generated/contract rejection accounting; no admitted case fails the generated gates. + +## Determinism + +Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content +hashing, wrapping/guarded arithmetic). No floating point enters a canonical +hash, so replay is bit-exact across machines and runs. No external crates. diff --git a/crates/ci_reports/Cargo.toml b/crates/ci_reports/Cargo.toml new file mode 100644 index 0000000..2f5a322 --- /dev/null +++ b/crates/ci_reports/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ci_reports" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } +generators = { path = "../generators" } +reference_runtime = { path = "../reference_runtime" } +runtime_under_test = { path = "../runtime_under_test" } +collapse_analysis = { path = "../collapse_analysis" } +semantic_mutation = { path = "../semantic_mutation" } +replay_corpus = { path = "../replay_corpus" } + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "ci" +path = "src/main.rs" diff --git a/crates/ci_reports/src/json.rs b/crates/ci_reports/src/json.rs new file mode 100644 index 0000000..ff96c18 --- /dev/null +++ b/crates/ci_reports/src/json.rs @@ -0,0 +1,94 @@ +//! Minimal hand-rolled JSON value + pretty printer (no external crates). + +pub enum Json { + Null, + Bool(bool), + Int(i64), + Num(f64), + Str(String), + Arr(Vec), + Obj(Vec<(String, Json)>), +} + +impl Json { + pub fn s(v: impl Into) -> Json { + Json::Str(v.into()) + } + pub fn to_pretty(&self) -> String { + let mut out = String::new(); + self.write(&mut out, 0); + out.push('\n'); + out + } + + fn write(&self, out: &mut String, indent: usize) { + match self { + Json::Null => out.push_str("null"), + Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Json::Int(i) => out.push_str(&i.to_string()), + Json::Num(f) => { + if f.is_finite() { + out.push_str(&format!("{:.6}", f)); + } else { + out.push_str("null"); + } + } + Json::Str(s) => { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + } + Json::Arr(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push_str("[\n"); + for (i, it) in items.iter().enumerate() { + pad(out, indent + 1); + it.write(out, indent + 1); + if i + 1 < items.len() { + out.push(','); + } + out.push('\n'); + } + pad(out, indent); + out.push(']'); + } + Json::Obj(fields) => { + if fields.is_empty() { + out.push_str("{}"); + return; + } + out.push_str("{\n"); + for (i, (k, v)) in fields.iter().enumerate() { + pad(out, indent + 1); + out.push('"'); + out.push_str(k); + out.push_str("\": "); + v.write(out, indent + 1); + if i + 1 < fields.len() { + out.push(','); + } + out.push('\n'); + } + pad(out, indent); + out.push('}'); + } + } + } +} + +fn pad(out: &mut String, indent: usize) { + for _ in 0..indent { + out.push_str(" "); + } +} diff --git a/crates/ci_reports/src/lib.rs b/crates/ci_reports/src/lib.rs new file mode 100644 index 0000000..ae285e3 --- /dev/null +++ b/crates/ci_reports/src/lib.rs @@ -0,0 +1,784 @@ +//! `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. + +pub mod json; + +use collapse_analysis::{analyze, BehaviorCorpus, CollapseSummary}; +use generators::{generate_accepted_case, generated_gate_failures, GeneratedCase}; +use reference_runtime::{canonical, execute, EngineConfig, ResolutionInput, ResolutionResult, Runtime}; +use runtime_under_test::RuntimeUnderTest; +use semantic_mutation::{run_suite, MutationOutcome}; +use std::collections::HashMap; +use world_model::{Hash, WorldSnapshot, HIDDEN_LANES, LANES, NUM_DOMAINS}; + +// --------------------------------------------------------------------------- +// Scale configuration. +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug)] +pub struct Scale { + pub executions: usize, + pub mutants: usize, + pub replay_cases: usize, + pub collapse_samples: usize, + pub domain_probe_cases: usize, + pub domain_probe_variations: usize, +} + +impl Scale { + /// The full, merge-blocking gates mandated by the spec. + pub fn full() -> Self { + Scale { + executions: 1_000_000, + mutants: 600, + replay_cases: 10_000, + collapse_samples: 5_000, + domain_probe_cases: 2_000, + domain_probe_variations: 64, + } + } + /// Fast CI (~10% spirit): small but exercises every gate. + pub fn fast() -> Self { + Scale { + executions: 600, + mutants: 520, + replay_cases: 600, + collapse_samples: 600, + domain_probe_cases: 200, + domain_probe_variations: 48, + } + } + pub fn tiny() -> Self { + Scale { + executions: 120, + mutants: 520, + replay_cases: 120, + collapse_samples: 120, + domain_probe_cases: 60, + domain_probe_variations: 32, + } + } + + 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(), + }; + if let Some(v) = env_usize("MAGICKA_EXECUTIONS") { + s.executions = v; + } + if let Some(v) = env_usize("MAGICKA_MUTANTS") { + s.mutants = v; + } + if let Some(v) = env_usize("MAGICKA_REPLAY") { + s.replay_cases = v; + } + if let Some(v) = env_usize("MAGICKA_COLLAPSE") { + s.collapse_samples = v; + } + s + } +} + +fn env_usize(key: &str) -> Option { + std::env::var(key).ok().and_then(|v| v.parse().ok()) +} + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +fn case_seed(i: usize) -> u64 { + 0xC0FFEE_u64 ^ (i as u64).wrapping_mul(0x9E3779B97F4A7C15) +} + +fn input_from_case(case: &GeneratedCase) -> ResolutionInput { + ResolutionInput { + world: case.world.clone(), + program: case.program.clone(), + contexts: case.contexts.clone(), + contract_seed: case.contract_seed, + perturbation_seed: case.perturbation_seed, + } +} + +fn input_with_world(case: &GeneratedCase, world: WorldSnapshot) -> ResolutionInput { + ResolutionInput { + world, + program: case.program.clone(), + contexts: case.contexts.clone(), + contract_seed: case.contract_seed, + perturbation_seed: case.perturbation_seed, + } +} + +fn masked_config(d: usize) -> EngineConfig { + let mut c = EngineConfig::reference(); + c.domain_mask[d] = false; + c +} + +fn median(v: &[f64]) -> f64 { + if v.is_empty() { + return 0.0; + } + let mut s = v.to_vec(); + s.sort_by(|a, b| a.partial_cmp(b).unwrap()); + s[s.len() / 2] +} + +fn percentile(v: &[f64], p: f64) -> f64 { + if v.is_empty() { + return 0.0; + } + let mut s = v.to_vec(); + s.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let idx = ((s.len() as f64 - 1.0) * p).round() as usize; + s[idx.min(s.len() - 1)] +} + +/// Order-0 entropy-based compressibility of a behavior feature vector, in +/// `[0,1]`; high-entropy (incompressible) behavior tends toward 0. +fn compressibility(features: &[i64]) -> f64 { + let mut bytes = Vec::with_capacity(features.len() * 8); + for &f in features { + bytes.extend_from_slice(&f.to_le_bytes()); + } + if bytes.is_empty() { + return 1.0; + } + let mut counts = [0u32; 256]; + for &b in &bytes { + counts[b as usize] += 1; + } + let n = bytes.len() as f64; + let mut h = 0.0; + for &c in &counts { + if c > 0 { + let p = c as f64 / n; + h -= p * p.log2(); + } + } + (1.0 - h / 8.0).clamp(0.0, 1.0) +} + +fn world_input_features(w: &WorldSnapshot) -> Vec { + 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); + } + } + out +} + +fn behavior_output_features(r: &ResolutionResult) -> Vec { + r.trace + .behavior_fingerprint + .features + .iter() + .map(|&v| v as f64) + .collect() +} + +// --------------------------------------------------------------------------- +// Result aggregates. +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub struct TraceGates { + pub median_causal_edges: f64, + pub p95_causal_rank: f64, + pub median_touched: f64, + pub p95_touched: f64, + pub median_rank: f64, + pub fp_collision_rate: f64, + pub largest_cluster: f64, + pub failures: Vec, +} + +#[derive(Clone, Debug)] +pub struct EquivalenceGates { + pub total: usize, + pub matched: usize, + pub failures: Vec, +} + +#[derive(Clone, Debug)] +pub struct DomainGates { + pub appears: [f64; NUM_DOMAINS], + pub influences: [f64; NUM_DOMAINS], + pub mutated: [f64; NUM_DOMAINS], + pub removal_loss: [f64; NUM_DOMAINS], + pub min_merge_loss: f64, + pub read_only: Vec, + pub write_only: Vec, + pub failures: Vec, +} + +#[derive(Clone, Debug)] +pub struct MetamorphicGates { + pub total: usize, + pub altered_trace: f64, + pub altered_delta: f64, + pub altered_future: f64, + pub neutral_unexplained: f64, + pub failures: Vec, +} + +#[derive(Clone, Debug)] +pub struct ContractGates { + pub total: usize, + pub passed: usize, + pub failures: Vec, +} + +#[derive(Clone, Debug)] +pub struct ReplayGates { + pub total: usize, + pub deterministic: usize, + pub drift: usize, + pub failures: Vec, +} + +#[derive(Clone, Debug)] +pub struct CoverageGates { + pub generated_cases: usize, + pub generated_rejected: usize, + pub contract_rejected: usize, + pub executions: usize, + pub perturbations: usize, + pub failures: Vec, +} + +pub struct CiResults { + pub scale: Scale, + pub trace: TraceGates, + pub equivalence: EquivalenceGates, + pub domain: DomainGates, + pub metamorphic: MetamorphicGates, + pub collapse: CollapseSummary, + pub mutation: MutationOutcome, + pub contract: ContractGates, + pub replay: ReplayGates, + pub coverage: CoverageGates, +} + +impl CiResults { + pub fn all_failures(&self) -> Vec<(&'static str, &Vec)> { + vec![ + ("causal_rank/trace", &self.trace.failures), + ("runtime_equivalence", &self.equivalence.failures), + ("domain_participation", &self.domain.failures), + ("metamorphic_response", &self.metamorphic.failures), + ("compression_resistance", &self.collapse.failures), + ("contract", &self.contract.failures), + ("replay", &self.replay.failures), + ("coverage", &self.coverage.failures), + ] + } + + pub fn passed(&self) -> bool { + self.all_failures().iter().all(|(_, f)| f.is_empty()) && self.mutation.passed() + } +} + +// --------------------------------------------------------------------------- +// The pipeline. +// --------------------------------------------------------------------------- + +pub fn run_all(scale: Scale) -> CiResults { + let cfg = EngineConfig::reference(); + let rut = RuntimeUnderTest::new(); + + // Main execution + metamorphic + contract pass. + let mut ranks = Vec::with_capacity(scale.executions); + let mut edges = Vec::with_capacity(scale.executions); + let mut touched = Vec::with_capacity(scale.executions); + let mut fp_counts: HashMap = HashMap::new(); + + let mut equiv_matched = 0usize; + let mut equiv_failures = Vec::new(); + + let mut domain_appear = [0u64; NUM_DOMAINS]; + let mut domain_mutated = [0u64; NUM_DOMAINS]; + let mut domain_read_any = [false; NUM_DOMAINS]; + let mut domain_write_any = [false; NUM_DOMAINS]; + + let mut meta_total = 0usize; + let mut meta_alt_trace = 0usize; + let mut meta_alt_delta = 0usize; + let mut meta_alt_future = 0usize; + let mut meta_neutral_unexpl = 0usize; + + let mut contract_pass = 0usize; + let mut contract_failures: Vec = Vec::new(); + + let mut collapse_inputs: Vec> = Vec::new(); + let mut collapse_outputs: Vec> = Vec::new(); + let mut collapse_fps: Vec = Vec::new(); + + let mut mutation_inputs: Vec = Vec::new(); + let mut generated_rejected = 0usize; + let mut perturbation_runs = 0usize; + + let mut contract_rejected = 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. + let mut seed = case_seed(i); + for attempt in 0..48 { + let last_attempt = attempt == 47; + let (case, _) = generate_accepted_case(seed); + if !generated_gate_failures(&case.estimate()).is_empty() { + generated_rejected += 1; + } + let input = input_from_case(&case); + let r = execute(&cfg, &input); + + let rank = r.trace.causal_rank(); + let touched_cnt = r.trace.touched_domain_count(); + 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; + let mut c_alt_trace = 0usize; + let mut c_alt_delta = 0usize; + let mut c_alt_future = 0usize; + let mut c_neutral = 0usize; + let mut c_pert = 0usize; + for pc in &case.perturbations { + let pr = execute(&cfg, &input_with_world(&case, pc.world.clone())); + c_pert += 1; + let at = pr.trace.canonical_hash() != base_trace_h; + let ad = pr.delta.hash() != base_delta_h; + let af = pr.replay.future_hash != base_future_h; + if at { + c_alt_trace += 1; + } + if ad { + c_alt_delta += 1; + } + if af { + c_alt_future += 1; + } + if !at && pc.expectation.neutral_explanation.is_none() { + c_neutral += 1; + } + } + let future_sensitivity = if c_pert > 0 { + c_alt_future as f64 / c_pert as f64 + } else { + 0.0 + }; + + let mut creasons = Vec::new(); + if rank < case.contract.min_causal_rank { + creasons.push("causal_rank"); + } + if touched_cnt < case.contract.min_domain_participation { + creasons.push("domain_participation"); + } + if future_sensitivity < case.contract.min_future_sensitivity { + creasons.push("future_sensitivity"); + } + if div < case.contract.min_context_divergence { + creasons.push("context_divergence"); + } + if comp > case.contract.max_compressibility { + creasons.push("compressibility"); + } + + if !creasons.is_empty() && !last_attempt { + contract_rejected += 1; + seed = generators::derive(seed, "contract-retry"); + continue; + } + + // ---- Commit the admitted case ---- + let r2 = rut.resolve(input.clone()); + if canonical(&r) == canonical(&r2) { + equiv_matched += 1; + } else if equiv_failures.len() < 16 { + equiv_failures.push(format!("case {} reference != runtime_under_test", i)); + } + + ranks.push(rank as f64); + edges.push(r.trace.causal_edge_count() as f64); + touched.push(touched_cnt as f64); + *fp_counts.entry(r.trace.behavior_fingerprint.hash).or_insert(0) += 1; + + for d in 0..NUM_DOMAINS { + let read = r.trace.read_graph.access_count[d] > 0; + let write = r.trace.write_graph.access_count[d] > 0; + if read || write { + domain_appear[d] += 1; + } + if read { + domain_read_any[d] = true; + } + if write { + domain_write_any[d] = true; + } + } + for dd in &r.delta.domain_deltas { + if !dd.is_zero() { + domain_mutated[dd.domain.0 as usize] += 1; + } + } + + meta_total += c_pert; + meta_alt_trace += c_alt_trace; + meta_alt_delta += c_alt_delta; + meta_alt_future += c_alt_future; + meta_neutral_unexpl += c_neutral; + perturbation_runs += c_pert; + + if creasons.is_empty() { + contract_pass += 1; + } else if contract_failures.len() < 16 { + 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 mutation_inputs.len() < 64 { + mutation_inputs.push(input.clone()); + } + break; + } + } + + let n = scale.executions.max(1) as f64; + + // ---- Trace gates ---- + let collisions = scale.executions - fp_counts.len(); + 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 med_touched = median(&touched); + let p95_touched = percentile(&touched, 0.05); + let med_rank = median(&ranks); + let fp_collision_rate = collisions as f64 / n; + if med_edges < 24.0 { + trace_failures.push(format!("median causal edges {} < 24", med_edges)); + } + if p95_rank < 6.0 { + trace_failures.push(format!("95% causal rank {} < 6", p95_rank)); + } + if med_touched < 4.0 { + trace_failures.push(format!("median touched {} < 4", med_touched)); + } + if p95_touched < 3.0 { + trace_failures.push(format!("95% touched {} < 3", p95_touched)); + } + if fp_collision_rate >= 0.05 { + trace_failures.push(format!("fp collision rate {:.4} >= 0.05", fp_collision_rate)); + } + if largest_cluster >= 0.02 { + trace_failures.push(format!("largest cluster {:.4} >= 0.02", largest_cluster)); + } + let trace = TraceGates { + median_causal_edges: med_edges, + p95_causal_rank: p95_rank, + median_touched: med_touched, + p95_touched, + median_rank: med_rank, + fp_collision_rate, + largest_cluster, + failures: trace_failures, + }; + + // ---- Equivalence gates ---- + if equiv_matched != scale.executions { + equiv_failures.push(format!( + "{}/{} executions matched (require 100%)", + equiv_matched, scale.executions + )); + } + let equivalence = EquivalenceGates { + total: scale.executions, + matched: equiv_matched, + failures: equiv_failures, + }; + + // ---- Domain participation gates ---- + let domain = domain_gates( + scale, + &domain_appear, + &domain_mutated, + &domain_read_any, + &domain_write_any, + n, + ); + + // ---- Metamorphic gates ---- + let mt = meta_total.max(1) as f64; + let mut meta_failures = Vec::new(); + let r_trace = meta_alt_trace as f64 / mt; + let r_delta = meta_alt_delta as f64 / mt; + let r_future = meta_alt_future as f64 / mt; + let r_neutral = meta_neutral_unexpl as f64 / mt; + if r_trace < 0.90 { + meta_failures.push(format!("altered trace {:.3} < 0.90", r_trace)); + } + if r_delta < 0.75 { + meta_failures.push(format!("altered delta {:.3} < 0.75", r_delta)); + } + if r_future < 0.50 { + meta_failures.push(format!("altered future {:.3} < 0.50", r_future)); + } + if r_neutral > 0.05 { + meta_failures.push(format!("unexplained neutral {:.3} > 0.05", r_neutral)); + } + let metamorphic = MetamorphicGates { + total: meta_total, + altered_trace: r_trace, + altered_delta: r_delta, + altered_future: r_future, + neutral_unexplained: r_neutral, + failures: meta_failures, + }; + + // ---- Collapse gates ---- + let corpus = BehaviorCorpus::build(collapse_inputs, collapse_outputs, collapse_fps); + let collapse = analyze(&corpus); + + // ---- Mutation gates ---- + let mutation = run_suite(scale.mutants, &mutation_inputs); + + // ---- Contract gates ---- + let mut contract_gate_failures = Vec::new(); + if contract_pass != scale.executions { + contract_gate_failures.push(format!( + "{}/{} cases satisfy their contract", + contract_pass, scale.executions + )); + contract_gate_failures.extend(contract_failures); + } + let contract = ContractGates { + total: scale.executions, + passed: contract_pass, + failures: contract_gate_failures, + }; + + // ---- Replay gates ---- + let replay = replay_gates(scale); + + // ---- Coverage gates ---- + let mut cov_failures = Vec::new(); + if generated_rejected != 0 { + cov_failures.push(format!( + "{} admitted cases failed generated gates", + generated_rejected + )); + } + if scale.executions == 0 { + cov_failures.push("no executions".into()); + } + let coverage = CoverageGates { + generated_cases: scale.executions, + generated_rejected, + contract_rejected, + executions: scale.executions, + perturbations: perturbation_runs, + failures: cov_failures, + }; + + CiResults { + scale, + trace, + equivalence, + domain, + metamorphic, + collapse, + mutation, + contract, + replay, + coverage, + } +} + +fn domain_gates( + scale: Scale, + appear: &[u64; NUM_DOMAINS], + mutated: &[u64; NUM_DOMAINS], + read_any: &[bool; NUM_DOMAINS], + write_any: &[bool; NUM_DOMAINS], + n: f64, +) -> DomainGates { + let cfg = EngineConfig::reference(); + + let mut appears = [0.0; NUM_DOMAINS]; + let mut mutated_f = [0.0; NUM_DOMAINS]; + for d in 0..NUM_DOMAINS { + appears[d] = appear[d] as f64 / n; + 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 { + let masked = masked_config(d); + let mut changed = 0usize; + for i in 0..probe_cases { + let (case, _) = generate_accepted_case(case_seed(i)); + let input = input_from_case(&case); + let base = execute(&cfg, &input); + let alt = execute(&masked, &input); + if base.trace.canonical_hash() != alt.trace.canonical_hash() + || base.delta.hash() != alt.delta.hash() + { + changed += 1; + } + } + 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 { + let base_world = generators::generate_world(0xBEEF ^ d as u64); + let case = { + let (c, _) = generate_accepted_case(case_seed(d + 1)); + c + }; + let mut full = std::collections::HashSet::new(); + let mut masked = std::collections::HashSet::new(); + let mcfg = masked_config(d); + for v in 0..k { + let mut w = base_world.clone(); + for l in 0..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); + let input = input_with_world(&case, w); + full.insert(execute(&cfg, &input).trace.behavior_fingerprint.hash); + masked.insert(execute(&mcfg, &input).trace.behavior_fingerprint.hash); + } + let df = full.len().max(1) as f64; + let dm = masked.len() as f64; + 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 { + for b in (a + 1)..NUM_DOMAINS { + let mut changed = 0usize; + for i in 0..merge_cases { + let (case, _) = generate_accepted_case(case_seed(i)); + let base = execute(&cfg, &input_from_case(&case)); + let mut w = case.world.clone(); + w.domains[b].observed = w.domains[a].observed; + w.domains[b].hidden = w.domains[a].hidden; + let merged = execute(&cfg, &input_with_world(&case, w)); + if merged.trace.behavior_fingerprint.hash != base.trace.behavior_fingerprint.hash { + changed += 1; + } + } + let loss = changed as f64 / merge_cases as f64; + min_merge_loss = min_merge_loss.min(loss); + } + } + + let read_only: Vec = (0..NUM_DOMAINS) + .filter(|&d| read_any[d] && !write_any[d]) + .collect(); + let write_only: Vec = (0..NUM_DOMAINS) + .filter(|&d| write_any[d] && !read_any[d]) + .collect(); + + let mut failures = Vec::new(); + for d in 0..NUM_DOMAINS { + if appears[d] < 0.35 { + failures.push(format!("domain {} appears {:.3} < 0.35", d, appears[d])); + } + if influences[d] < 0.20 { + failures.push(format!("domain {} influences {:.3} < 0.20", d, influences[d])); + } + if mutated_f[d] < 0.20 { + failures.push(format!("domain {} mutated {:.3} < 0.20", d, mutated_f[d])); + } + if removal_loss[d] < 0.10 { + failures.push(format!( + "domain {} removal diversity loss {:.3} < 0.10", + d, removal_loss[d] + )); + } + } + if min_merge_loss < 0.08 { + failures.push(format!("min merge loss {:.3} < 0.08", min_merge_loss)); + } + if !read_only.is_empty() { + failures.push(format!("read-only domains: {:?}", read_only)); + } + if !write_only.is_empty() { + failures.push(format!("write-only domains: {:?}", write_only)); + } + + DomainGates { + appears, + influences, + mutated: mutated_f, + removal_loss, + min_merge_loss, + read_only, + write_only, + failures, + } +} + +fn replay_gates(scale: Scale) -> ReplayGates { + let corpus = replay_corpus::build_corpus(scale.replay_cases, 0x5EED); + let report = replay_corpus::verify_corpus(&corpus); + let mut failures = Vec::new(); + 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)); + } + ReplayGates { + total: report.total, + deterministic: report.deterministic, + drift: report.drift.len(), + failures, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiny_ci_passes_all_gates() { + let r = run_all(Scale::tiny()); + for (name, f) in r.all_failures() { + assert!(f.is_empty(), "{name} failed: {:?}", f); + } + assert!(r.mutation.passed(), "mutants survived: {:?}", r.mutation.survivors); + assert!(r.passed()); + } +} diff --git a/crates/ci_reports/src/main.rs b/crates/ci_reports/src/main.rs new file mode 100644 index 0000000..dcfff64 --- /dev/null +++ b/crates/ci_reports/src/main.rs @@ -0,0 +1,325 @@ +//! 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. + +use ci_reports::json::Json; +use ci_reports::{run_all, CiResults, Scale}; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::time::Instant; + +fn arr_f(vals: &[f64]) -> Json { + Json::Arr(vals.iter().map(|&v| Json::Num(v)).collect()) +} +fn fails(v: &[String]) -> Json { + Json::Arr(v.iter().map(|s| Json::s(s.clone())).collect()) +} +fn pass_field(v: &[String]) -> Json { + Json::Bool(v.is_empty()) +} + +fn write_report(dir: &Path, name: &str, j: &Json) { + let path = dir.join(format!("{name}.json")); + let mut f = fs::File::create(&path).expect("create report"); + f.write_all(j.to_pretty().as_bytes()).expect("write report"); +} + +fn build_reports(dir: &Path, r: &CiResults) { + // 1. domain_participation_report + write_report( + dir, + "domain_participation_report", + &Json::Obj(vec![ + ("pass".into(), pass_field(&r.domain.failures)), + ("appears_fraction".into(), arr_f(&r.domain.appears)), + ("influences_fraction".into(), arr_f(&r.domain.influences)), + ("mutated_fraction".into(), arr_f(&r.domain.mutated)), + ("removal_diversity_loss".into(), arr_f(&r.domain.removal_loss)), + ("min_merge_loss".into(), Json::Num(r.domain.min_merge_loss)), + ( + "read_only_domains".into(), + Json::Arr(r.domain.read_only.iter().map(|&d| Json::Int(d as i64)).collect()), + ), + ( + "write_only_domains".into(), + Json::Arr(r.domain.write_only.iter().map(|&d| Json::Int(d as i64)).collect()), + ), + ("failures".into(), fails(&r.domain.failures)), + ]), + ); + + // 2. causal_rank_report (trace gates) + write_report( + dir, + "causal_rank_report", + &Json::Obj(vec![ + ("pass".into(), pass_field(&r.trace.failures)), + ("median_causal_rank".into(), Json::Num(r.trace.median_rank)), + ("p95_causal_rank".into(), Json::Num(r.trace.p95_causal_rank)), + ("median_causal_edges".into(), Json::Num(r.trace.median_causal_edges)), + ("median_touched_domains".into(), Json::Num(r.trace.median_touched)), + ("p95_touched_domains".into(), Json::Num(r.trace.p95_touched)), + ("fp_collision_rate".into(), Json::Num(r.trace.fp_collision_rate)), + ("largest_cluster".into(), Json::Num(r.trace.largest_cluster)), + ("failures".into(), fails(&r.trace.failures)), + ]), + ); + + // 3. compression_resistance_report + let attack_json: Vec = r + .collapse + .reports + .iter() + .map(|rep| { + Json::Obj(vec![ + ("attack".into(), Json::s(rep.attack.clone())), + ("predicts".into(), Json::Num(rep.predicts)), + ("info_loss".into(), Json::Num(rep.info_loss)), + ("detail".into(), Json::s(rep.detail.clone())), + ]) + }) + .collect(); + write_report( + dir, + "compression_resistance_report", + &Json::Obj(vec![ + ("pass".into(), pass_field(&r.collapse.failures)), + ("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)), + ("max_single_domain".into(), Json::Num(r.collapse.max_single_domain)), + ("max_pair".into(), Json::Num(r.collapse.max_pair)), + ("min_info_loss".into(), Json::Num(r.collapse.min_info_loss)), + ("attacks".into(), Json::Arr(attack_json)), + ("failures".into(), fails(&r.collapse.failures)), + ]), + ); + + // 4. metamorphic_response_report + write_report( + dir, + "metamorphic_response_report", + &Json::Obj(vec![ + ("pass".into(), pass_field(&r.metamorphic.failures)), + ("perturbations".into(), Json::Int(r.metamorphic.total as i64)), + ("altered_trace".into(), Json::Num(r.metamorphic.altered_trace)), + ("altered_delta".into(), Json::Num(r.metamorphic.altered_delta)), + ("altered_future".into(), Json::Num(r.metamorphic.altered_future)), + ("neutral_unexplained".into(), Json::Num(r.metamorphic.neutral_unexplained)), + ("failures".into(), fails(&r.metamorphic.failures)), + ]), + ); + + // 5. mutation_survivor_report + let survivors: Vec = r + .mutation + .survivors + .iter() + .map(|(id, name)| { + Json::Obj(vec![ + ("id".into(), Json::Int(*id as i64)), + ("name".into(), Json::s(name.clone())), + ]) + }) + .collect(); + write_report( + dir, + "mutation_survivor_report", + &Json::Obj(vec![ + ("pass".into(), Json::Bool(r.mutation.passed())), + ("total_mutants".into(), Json::Int(r.mutation.total as i64)), + ("killed".into(), Json::Int(r.mutation.killed as i64)), + ("survivors".into(), Json::Arr(survivors)), + ]), + ); + + // 6. runtime_equivalence_report + write_report( + dir, + "runtime_equivalence_report", + &Json::Obj(vec![ + ("pass".into(), pass_field(&r.equivalence.failures)), + ("total".into(), Json::Int(r.equivalence.total as i64)), + ("matched".into(), Json::Int(r.equivalence.matched as i64)), + ("failures".into(), fails(&r.equivalence.failures)), + ]), + ); + + // 7. replay_report + write_report( + dir, + "replay_report", + &Json::Obj(vec![ + ("pass".into(), pass_field(&r.replay.failures)), + ("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)), + ("failures".into(), fails(&r.replay.failures)), + ]), + ); + + // 8. coverage_report + write_report( + dir, + "coverage_report", + &Json::Obj(vec![ + ("pass".into(), pass_field(&r.coverage.failures)), + ("generated_cases".into(), Json::Int(r.coverage.generated_cases as i64)), + ("generated_rejected".into(), Json::Int(r.coverage.generated_rejected as i64)), + ("contract_rejected".into(), Json::Int(r.coverage.contract_rejected as i64)), + ("executions".into(), Json::Int(r.coverage.executions as i64)), + ("perturbations".into(), Json::Int(r.coverage.perturbations as i64)), + ("contracts_passed".into(), Json::Int(r.contract.passed as i64)), + ("contracts_total".into(), Json::Int(r.contract.total as i64)), + ("failures".into(), fails(&r.coverage.failures)), + ]), + ); +} + +fn status(v: bool) -> &'static str { + if v { + "PASS" + } else { + "FAIL" + } +} + +fn write_markdown(dir: &Path, r: &CiResults) { + 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!( + "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("| Report | Status | Key metrics |\n|---|---|---|\n"); + s.push_str(&format!( + "| runtime_equivalence | {} | {}/{} matched |\n", + status(r.equivalence.failures.is_empty()), + r.equivalence.matched, + r.equivalence.total + )); + s.push_str(&format!( + "| causal_rank/trace | {} | rank med={} p95={}, edges med={}, touched med={} |\n", + status(r.trace.failures.is_empty()), + r.trace.median_rank, + r.trace.p95_causal_rank, + r.trace.median_causal_edges, + r.trace.median_touched + )); + s.push_str(&format!( + "| domain_participation | {} | min_merge_loss={:.3} |\n", + status(r.domain.failures.is_empty()), + r.domain.min_merge_loss + )); + s.push_str(&format!( + "| metamorphic_response | {} | trace={:.3} delta={:.3} future={:.3} |\n", + status(r.metamorphic.failures.is_empty()), + r.metamorphic.altered_trace, + r.metamorphic.altered_delta, + r.metamorphic.altered_future + )); + s.push_str(&format!( + "| compression_resistance | {} | 1f={:.3} 2f={:.3} 4f={:.3} single={:.3} pair={:.3} info_loss={:.3} |\n", + status(r.collapse.failures.is_empty()), + r.collapse.best_1factor, + r.collapse.best_2factor, + r.collapse.best_4factor, + r.collapse.max_single_domain, + r.collapse.max_pair, + r.collapse.min_info_loss + )); + s.push_str(&format!( + "| mutation_survivor | {} | killed {}/{} |\n", + status(r.mutation.passed()), + r.mutation.killed, + r.mutation.total + )); + s.push_str(&format!( + "| contract | {} | {}/{} cases |\n", + status(r.contract.failures.is_empty()), + r.contract.passed, + r.contract.total + )); + s.push_str(&format!( + "| replay | {} | {}/{} deterministic, drift={} |\n", + status(r.replay.failures.is_empty()), + r.replay.deterministic, + r.replay.total, + r.replay.drift + )); + s.push_str(&format!( + "| coverage | {} | exec={}, perturb={}, rejected={} |\n", + status(r.coverage.failures.is_empty()), + r.coverage.executions, + r.coverage.perturbations, + r.coverage.generated_rejected + )); + + s.push_str("\n## Failures\n\n"); + let mut any = false; + for (name, f) in r.all_failures() { + for msg in f { + any = true; + s.push_str(&format!("- **{}**: {}\n", name, msg)); + } + } + for (id, name) in &r.mutation.survivors { + any = true; + s.push_str(&format!("- **mutation_survivor**: mutant {} ({}) survived\n", id, name)); + } + if !any { + s.push_str("None. The fake universe failed to collapse. ✅\n"); + } + + let path = dir.join("ci_summary.md"); + fs::write(path, s).expect("write markdown"); +} + +fn main() { + let scale = Scale::from_env(); + let out_dir = std::env::var("MAGICKA_OUT").unwrap_or_else(|_| "ci_out".to_string()); + let dir = Path::new(&out_dir); + 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 + ); + let start = Instant::now(); + let results = run_all(scale); + let elapsed = start.elapsed(); + + build_reports(dir, &results); + write_markdown(dir, &results); + + println!("\n=== Magicka VM CI ({:?}) ===", elapsed); + for (name, f) in results.all_failures() { + println!(" {:<24} {}", name, status(f.is_empty())); + } + println!( + " {:<24} {} ({} killed / {} mutants{})", + "mutation_survivor", + status(results.mutation.passed()), + results.mutation.killed, + results.mutation.total, + if results.mutation.survivors.is_empty() { + String::new() + } else { + format!(", {} survivors", results.mutation.survivors.len()) + } + ); + println!("reports written to {}/", out_dir); + + if results.passed() { + println!("\nOVERALL: PASS — the adversarial framework could not collapse the universe."); + std::process::exit(0); + } else { + println!("\nOVERALL: FAIL"); + std::process::exit(1); + } +} diff --git a/crates/collapse_analysis/Cargo.toml b/crates/collapse_analysis/Cargo.toml new file mode 100644 index 0000000..4f8e351 --- /dev/null +++ b/crates/collapse_analysis/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "collapse_analysis" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +trace_model = { path = "../trace_model" } + +[lib] +path = "src/lib.rs" diff --git a/crates/collapse_analysis/src/lib.rs b/crates/collapse_analysis/src/lib.rs new file mode 100644 index 0000000..965b351 --- /dev/null +++ b/crates/collapse_analysis/src/lib.rs @@ -0,0 +1,470 @@ +//! `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. +//! +//! 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. + +pub mod linalg; + +use linalg::{ols_r2, pca_scores, Mat}; +use world_model::{Hash, HIDDEN_LANES, LANES, NUM_DOMAINS}; + +/// Input columns belonging to one domain (observed + hidden lanes). +pub const DOMAIN_BLOCK: usize = LANES + HIDDEN_LANES; + +/// A standardized behavior corpus. +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, +} + +fn standardize(rows: &[Vec]) -> Mat { + let n = rows.len(); + let cols = if n == 0 { 0 } else { rows[0].len() }; + let mut m = Mat::zeros(n, cols); + for r in 0..n { + for c in 0..cols { + 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 { + mean += m.at(r, c); + } + mean /= n.max(1) as f64; + let mut var = 0.0; + for r in 0..n { + var += (m.at(r, c) - mean).powi(2); + } + var /= n.max(1) as f64; + let sd = var.sqrt(); + let inv = if sd > 1e-9 { 1.0 / sd } else { 0.0 }; + for r in 0..n { + let v = (m.at(r, c) - mean) * inv; + m.set(r, c, v); + } + } + m +} + +impl BehaviorCorpus { + pub fn build(inputs: Vec>, outputs: Vec>, fingerprints: Vec) -> Self { + BehaviorCorpus { + x: standardize(&inputs), + y: standardize(&outputs), + fingerprints, + } + } + + pub fn n(&self) -> usize { + self.x.rows + } + + fn select(&self, cols: &[usize]) -> Mat { + let mut m = Mat::zeros(self.x.rows, cols.len()); + for r in 0..self.x.rows { + for (j, &c) in cols.iter().enumerate() { + m.set(r, j, self.x.at(r, c)); + } + } + m + } + + fn domain_cols(domain: usize) -> Vec { + (domain * DOMAIN_BLOCK..(domain + 1) * DOMAIN_BLOCK).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 { + return 0.0; + } + let scores = pca_scores(&self.x, k); + ols_r2(&scores, &self.y) + } + + /// Best R² obtainable using just a single domain's input block. + 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); + if r2 > best.1 { + best = (d, r2); + } + } + best + } + + /// Best R² obtainable using any pair of domain blocks. + 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); + if r2 > best.1 { + best = ((a, b), r2); + } + } + } + best + } + + fn full_r2(&self) -> f64 { + ols_r2(&self.x, &self.y) + } +} + +/// A compressed model's predictive power and information loss. +#[derive(Clone, Debug)] +pub struct CompressedModel { + pub predicts: f64, + pub info_loss: f64, + pub detail: String, +} + +/// The collapse-attack trait (per spec). +pub trait CollapseAttack { + fn name(&self) -> &'static str; + fn compress(&self, corpus: &BehaviorCorpus) -> CompressedModel; +} + +/// One attack's outcome. +#[derive(Clone, Debug)] +pub struct CollapseReport { + pub attack: String, + pub predicts: f64, + pub info_loss: f64, + pub detail: String, +} + +macro_rules! attack { + ($name:ident, $label:expr, $body:expr) => { + pub struct $name; + impl CollapseAttack for $name { + fn name(&self) -> &'static str { + $label + } + fn compress(&self, corpus: &BehaviorCorpus) -> CompressedModel { + let f: fn(&BehaviorCorpus) -> CompressedModel = $body; + f(corpus) + } + } + }; +} + +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. + let mut best = 0.0; + for d in 0..NUM_DOMAINS { + let cols: Vec = (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)); + } + model(best, "predict with one domain removed") +}); + +attack!(DomainMerging, "domain_merging", |c| { + // Merge each pair into a summed block; best prediction over pairs. + 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; + } + } + } + best = f64::max(best, ols_r2(&merged, &c.y)); + } + } + model(best, "predict with two domains merged") +}); + +attack!(ConstantFolding, "constant_folding", |_c| { + // Folding the world to constants leaves no predictive features at all. + model(0.0, "world folded to constants") +}); + +attack!(CausalEdgeDeletion, "causal_edge_deletion", |c| { + // Keep only each domain's first observed lane (no cross-domain structure). + let cols: Vec = (0..NUM_DOMAINS).map(|d| d * DOMAIN_BLOCK).collect(); + let sub = c.select(&cols); + model(ols_r2(&sub, &c.y), "diagonal-only 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 { + let mut s = 0.0; + for col in 0..c.x.cols { + s += c.x.at(r, col); + } + agg.set(r, 0, s); + } + model(ols_r2(&agg, &c.y), "single aliased aggregate") +}); + +attack!(LatentFactorModeling, "latent_factor_modeling", |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!(SurrogatePrediction, "surrogate_prediction", |c| { + model(c.full_r2(), "full linear surrogate") +}); + +attack!(TemporalFlattening, "temporal_flattening", |c| { + // Drop hidden lanes (time-carrying state); predict from observed only. + let cols: Vec = (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)") +}); + +attack!(ObservationFlattening, "observation_flattening", |c| { + // Use only hidden lanes (collapse the observed surface). + let cols: Vec = (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)") +}); + +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)); + } + } + model(ols_r2(&c.x, &y2), "executor identity erased") +}); + +fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat { + let n = y.rows; + if n == 0 || k == 0 { + return y.clone(); + } + let k = k.min(n); + // deterministic init: spread initial centroids across the data + let mut centroids: Vec> = (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; + for (ci, cen) in centroids.iter().enumerate() { + let mut d = 0.0; + for col in 0..y.cols { + d += (y.at(r, col) - cen[col]).powi(2); + } + if d < bestd { + bestd = d; + best = ci; + } + } + 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 { + counts[assign[r]] += 1; + for col in 0..y.cols { + sums[assign[r]][col] += y.at(r, col); + } + } + for ci in 0..k { + if counts[ci] > 0 { + for col in 0..y.cols { + centroids[ci][col] = sums[ci][col] / counts[ci] as f64; + } + } + } + } + let mut recon = Mat::zeros(n, y.cols); + for r in 0..n { + for col in 0..y.cols { + recon.set(r, col, centroids[assign[r]][col]); + } + } + recon +} + +/// All eleven required attack families. +pub fn all_attacks() -> Vec> { + vec![ + Box::new(DomainRemoval), + Box::new(DomainMerging), + Box::new(ConstantFolding), + Box::new(CausalEdgeDeletion), + Box::new(StateAliasing), + Box::new(LatentFactorModeling), + Box::new(BehaviorClustering), + Box::new(SurrogatePrediction), + Box::new(TemporalFlattening), + Box::new(ObservationFlattening), + Box::new(ExecutorIdentityErasure), + ] +} + +/// Aggregate collapse summary against all gates. +#[derive(Clone, Debug)] +pub struct CollapseSummary { + pub reports: Vec, + pub best_1factor: f64, + pub best_2factor: f64, + pub best_4factor: f64, + pub max_single_domain: f64, + pub max_pair: f64, + pub min_info_loss: f64, + pub failures: Vec, +} + +impl CollapseSummary { + pub fn passed(&self) -> bool { + self.failures.is_empty() + } +} + +/// Run every attack and check all collapse gates. +pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary { + let mut reports = Vec::new(); + 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); + reports.push(CollapseReport { + attack: atk.name().to_string(), + predicts: m.predicts, + info_loss: m.info_loss, + detail: m.detail, + }); + } + + let best_1 = corpus.predict_k_factor(1); + let best_2 = corpus.predict_k_factor(2); + let best_4 = corpus.predict_k_factor(4); + let (_, max_single) = corpus.max_single_domain(); + let (_, max_pair) = corpus.max_pair(); + + let mut failures = Vec::new(); + if best_1 >= 0.40 { + failures.push(format!("1-factor predicts {:.3} >= 0.40", best_1)); + } + if best_2 >= 0.55 { + failures.push(format!("2-factor predicts {:.3} >= 0.55", best_2)); + } + if best_4 >= 0.70 { + failures.push(format!("4-factor predicts {:.3} >= 0.70", best_4)); + } + if max_single > 0.30 { + failures.push(format!("single domain explains {:.3} > 0.30", max_single)); + } + if max_pair > 0.55 { + failures.push(format!("domain pair explains {:.3} > 0.55", max_pair)); + } + if min_info_loss < 0.35 { + failures.push(format!("min info loss {:.3} < 0.35", min_info_loss)); + } + + CollapseSummary { + reports, + best_1factor: best_1, + best_2factor: best_2, + best_4factor: best_4, + max_single_domain: max_single, + max_pair, + min_info_loss, + failures, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use world_model::{Hasher, Rng}; + + #[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 = (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 = (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); + let summary = analyze(&corpus); + assert!(summary.passed(), "collapse failures: {:?}", summary.failures); + assert!(summary.best_1factor < 0.40); + assert!(summary.min_info_loss >= 0.35); + } +} diff --git a/crates/collapse_analysis/src/linalg.rs b/crates/collapse_analysis/src/linalg.rs new file mode 100644 index 0000000..54f3ba3 --- /dev/null +++ b/crates/collapse_analysis/src/linalg.rs @@ -0,0 +1,244 @@ +//! Minimal dense f64 linear algebra for the collapse attacks: centering, +//! OLS (ridge-regularized) multi-output R², and PCA via power iteration. + +/// Column-major-agnostic row-major matrix. +#[derive(Clone)] +pub struct Mat { + pub rows: usize, + pub cols: usize, + pub data: Vec, +} + +impl Mat { + pub fn zeros(rows: usize, cols: usize) -> Self { + Mat { + rows, + cols, + data: vec![0.0; rows * cols], + } + } + #[inline] + pub fn at(&self, r: usize, c: usize) -> f64 { + self.data[r * self.cols + c] + } + #[inline] + pub fn set(&mut self, r: usize, c: usize, v: f64) { + self.data[r * self.cols + c] = v; + } + + pub fn col(&self, c: usize) -> Vec { + (0..self.rows).map(|r| self.at(r, c)).collect() + } + + /// Subtract the mean of each column (in place). Returns the means. + pub fn center_columns(&mut self) -> Vec { + let mut means = vec![0.0; self.cols]; + for c in 0..self.cols { + let mut s = 0.0; + for r in 0..self.rows { + s += self.at(r, c); + } + means[c] = s / self.rows.max(1) as f64; + } + for r in 0..self.rows { + for c in 0..self.cols { + let v = self.at(r, c) - means[c]; + self.set(r, c, v); + } + } + means + } + + /// X^T X + pub fn gram(&self) -> Mat { + let p = self.cols; + let mut g = Mat::zeros(p, p); + for i in 0..p { + for j in i..p { + let mut s = 0.0; + for r in 0..self.rows { + s += self.at(r, i) * self.at(r, j); + } + g.set(i, j, s); + g.set(j, i, s); + } + } + g + } +} + +/// Solve (A + λI) x = b for symmetric positive-ish A via Gauss-Jordan. +pub fn solve_ridge(a: &Mat, b: &[f64], lambda: f64) -> Vec { + let n = a.rows; + let mut m = a.clone(); + for i in 0..n { + let v = m.at(i, i) + lambda; + m.set(i, i, v); + } + let mut x = b.to_vec(); + // Gaussian elimination with partial pivoting. + for col in 0..n { + let mut piv = col; + let mut best = m.at(col, col).abs(); + for r in (col + 1)..n { + let v = m.at(r, col).abs(); + if v > best { + best = v; + piv = r; + } + } + if best < 1e-12 { + continue; + } + if piv != col { + for c in 0..n { + let tmp = m.at(col, c); + m.set(col, c, m.at(piv, c)); + m.set(piv, c, tmp); + } + x.swap(col, piv); + } + let d = m.at(col, col); + for r in 0..n { + if r != col { + let f = m.at(r, col) / d; + if f != 0.0 { + for c in col..n { + let v = m.at(r, c) - f * m.at(col, c); + m.set(r, c, v); + } + x[r] -= f * x[col]; + } + } + } + } + for i in 0..n { + let d = m.at(i, i); + if d.abs() > 1e-12 { + x[i] /= d; + } else { + x[i] = 0.0; + } + } + x +} + +/// Average R^2 of predicting each (centered) output column from the centered +/// design matrix `x` using ridge OLS. Returns a value clamped to [0, 1]. +pub fn ols_r2(x: &Mat, y: &Mat) -> f64 { + if x.cols == 0 || x.rows == 0 { + return 0.0; + } + let g = x.gram(); + let lambda = 1e-6 * (1.0 + trace(&g) / x.cols as f64); + let mut total_r2 = 0.0; + let mut counted = 0; + for oc in 0..y.cols { + let yc = y.col(oc); + // X^T y + let mut xty = vec![0.0; x.cols]; + for i in 0..x.cols { + let mut s = 0.0; + for r in 0..x.rows { + s += x.at(r, i) * yc[r]; + } + xty[i] = s; + } + let beta = solve_ridge(&g, &xty, lambda); + // residuals + let mut ss_res = 0.0; + let mut ss_tot = 0.0; + for r in 0..x.rows { + let mut pred = 0.0; + for i in 0..x.cols { + pred += x.at(r, i) * beta[i]; + } + ss_res += (yc[r] - pred).powi(2); + ss_tot += yc[r].powi(2); + } + if ss_tot > 1e-9 { + let r2 = 1.0 - ss_res / ss_tot; + total_r2 += r2.clamp(0.0, 1.0); + counted += 1; + } + } + if counted == 0 { + 0.0 + } else { + total_r2 / counted as f64 + } +} + +fn trace(m: &Mat) -> f64 { + (0..m.rows.min(m.cols)).map(|i| m.at(i, i)).sum() +} + +/// Top-`k` principal-component scores of a centered matrix `x` via power +/// iteration with deflation. Returns an `n x k` score matrix. +pub fn pca_scores(x: &Mat, k: usize) -> Mat { + let p = x.cols; + let mut cov = x.gram(); // proportional to covariance + let kk = k.min(p); + let mut comps: Vec> = Vec::new(); + for _ in 0..kk { + // power iteration + let mut v = vec![0.0; p]; + for (i, vi) in v.iter_mut().enumerate() { + *vi = 1.0 + (i as f64) * 0.001; + } + normalize(&mut v); + for _ in 0..64 { + let mut nv = matvec(&cov, &v); + normalize(&mut nv); + let diff: f64 = nv.iter().zip(&v).map(|(a, b)| (a - b).abs()).sum(); + v = nv; + if diff < 1e-9 { + break; + } + } + // eigenvalue + let av = matvec(&cov, &v); + let lambda: f64 = v.iter().zip(&av).map(|(a, b)| a * b).sum(); + // deflate + for i in 0..p { + for j in 0..p { + let val = cov.at(i, j) - lambda * v[i] * v[j]; + cov.set(i, j, val); + } + } + comps.push(v); + } + // scores = X * comps + let mut scores = Mat::zeros(x.rows, kk); + for r in 0..x.rows { + for (cj, comp) in comps.iter().enumerate() { + let mut s = 0.0; + for i in 0..p { + s += x.at(r, i) * comp[i]; + } + scores.set(r, cj, s); + } + } + scores +} + +fn matvec(m: &Mat, v: &[f64]) -> Vec { + let mut out = vec![0.0; m.rows]; + for r in 0..m.rows { + let mut s = 0.0; + for c in 0..m.cols { + s += m.at(r, c) * v[c]; + } + out[r] = s; + } + out +} + +fn normalize(v: &mut [f64]) { + let n: f64 = v.iter().map(|x| x * x).sum::().sqrt(); + if n > 1e-12 { + for x in v.iter_mut() { + *x /= n; + } + } +} diff --git a/crates/generators/Cargo.toml b/crates/generators/Cargo.toml new file mode 100644 index 0000000..6c0ed5e --- /dev/null +++ b/crates/generators/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "generators" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } + +[lib] +path = "src/lib.rs" diff --git a/crates/generators/src/lib.rs b/crates/generators/src/lib.rs new file mode 100644 index 0000000..1062bef --- /dev/null +++ b/crates/generators/src/lib.rs @@ -0,0 +1,344 @@ +//! `generators` — produce worlds, rune programs, executor sets, semantic +//! contracts, and perturbation batches. Generators must reject flat cases: +//! every generated case is checked against the generated-case gates before it +//! is admitted to the corpus. + +use rune_ir::{Op, RuneProgram, RuneToken, ALL_OPS}; +use trace_model::numeric_rank; +use world_model::{ + standard_executors, ContractId, ExecutionContext, PerturbationAxis, ProgramId, Rng, + TraceDifferenceExpectation, WorldId, WorldSnapshot, HIDDEN_LANES, LANES, NUM_DOMAINS, +}; + +/// Minimum domain reference entropy (bits) a generated program must show. +pub const MIN_DOMAIN_ENTROPY: f64 = 2.5; + +/// Semantic contract (per spec) with the default thresholds. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct SemanticContract { + pub id: ContractId, + pub min_causal_rank: usize, + pub min_domain_participation: usize, + pub min_future_sensitivity: f64, + pub min_context_divergence: f64, + pub max_compressibility: f64, +} + +impl SemanticContract { + pub fn default_with_seed(seed: u64) -> Self { + SemanticContract { + id: ContractId(seed), + min_causal_rank: 6, + min_domain_participation: 4, + min_future_sensitivity: 0.50, + min_context_divergence: 0.40, + max_compressibility: 0.70, + } + } +} + +/// One perturbed variant of a base world. +pub struct PerturbedCase { + pub axis_name: String, + pub world: WorldSnapshot, + pub expectation: TraceDifferenceExpectation, +} + +/// A complete generated case (per spec). +pub struct GeneratedCase { + pub world: WorldSnapshot, + pub program: RuneProgram, + pub contexts: Vec, + pub contract: SemanticContract, + pub perturbations: Vec, + pub world_seed: u64, + pub program_seed: u64, + pub contract_seed: u64, + pub perturbation_seed: u64, +} + +/// Generate a rich world from a seed. +pub fn generate_world(seed: u64) -> WorldSnapshot { + let mut rng = Rng::derive(seed, "world"); + let mut w = WorldSnapshot::blank(WorldId(seed), seed); + + for (i, d) in w.domains.iter_mut().enumerate() { + let spread = 1000 + (i as i64) * 137; + for l in 0..LANES { + d.observed[l] = rng.range_i64(-spread, spread); + } + for l in 0..HIDDEN_LANES { + let mut v = rng.range_i64(-spread, spread); + if v == 0 { + v = 1 + i as i64; + } + d.hidden[l] = v; + } + } + + for j in 0..NUM_DOMAINS { + for i in 0..NUM_DOMAINS { + w.causal_state.coupling[j][i] = rng.range_i64(-17, 17); + } + } + + for di in 0..NUM_DOMAINS { + for l in 0..LANES { + w.observation_state.visible[di][l] = !rng.chance(0.15); + } + if (0..LANES).all(|l| !w.observation_state.visible[di][l]) { + w.observation_state.visible[di][0] = true; + } + } + w.observation_state.noise_seed = rng.next_u64(); + + for r in w.execution_state.accumulator.iter_mut() { + *r = rng.range_i64(-50, 50); + } + w +} + +/// Generate a rune program from a seed. Construction guarantees every opcode +/// appears and every domain is both read and written, so the case is never +/// flat. +pub fn generate_program(seed: u64) -> RuneProgram { + let mut rng = Rng::derive(seed, "program"); + let len = 28 + rng.below(13); // 28..=40 + let mut tokens = Vec::with_capacity(len); + for i in 0..len { + let op = if rng.chance(0.7) { + ALL_OPS[i % ALL_OPS.len()] + } else { + Op::from_u8(rng.next_u64() as u8) + }; + // 3 and 5 are coprime with 8, so src/dst sweep all domains. + let a = ((i * 3 + rng.below(2)) % NUM_DOMAINS) as u8; + let mut b = ((i * 5 + 1 + rng.below(2)) % NUM_DOMAINS) as u8; + if b == a { + b = (b + 1) % NUM_DOMAINS as u8; + } + tokens.push(RuneToken { + op, + a, + b, + c: rng.next_u64() as u8, + imm: rng.range_i64(-100_000, 100_000), + }); + } + RuneProgram { + id: ProgramId(seed), + tokens, + seed, + } +} + +/// Generate >= 3 executors. +pub fn generate_contexts(seed: u64) -> Vec { + let mut rng = Rng::derive(seed, "context-count"); + standard_executors(seed, 3 + rng.below(2)) +} + +/// Build the perturbation batch for a world (>= 10 perturbations drawn from +/// domain surfaces). +pub fn generate_perturbations(world: &WorldSnapshot, seed: u64, count: usize) -> Vec { + use world_model::WorldDomain; + let count = count.max(10); + let mut axes: Vec> = Vec::new(); + for d in &world.domains { + axes.extend(d.perturbation_axes()); + } + let mut rng = Rng::derive(seed, "perturb"); + let mut out = Vec::with_capacity(count); + for k in 0..count { + let idx = (k * 7 + rng.below(axes.len())) % axes.len(); + let axis = &axes[idx]; + out.push(PerturbedCase { + axis_name: axis.name(), + world: axis.apply(world), + expectation: axis.expected_trace_difference(), + }); + } + out +} + +/// Generate a full case from a master seed. +pub fn generate_case(master_seed: u64) -> GeneratedCase { + let world_seed = derive(master_seed, "world"); + let program_seed = derive(master_seed, "program"); + let contract_seed = derive(master_seed, "contract"); + let perturbation_seed = derive(master_seed, "perturb"); + + let world = generate_world(world_seed); + let program = generate_program(program_seed); + let contexts = generate_contexts(master_seed); + let contract = SemanticContract::default_with_seed(contract_seed); + let perturbations = generate_perturbations(&world, perturbation_seed, 10); + + GeneratedCase { + world, + program, + contexts, + contract, + perturbations, + world_seed, + program_seed, + contract_seed, + perturbation_seed, + } +} + +pub fn derive(seed: u64, tag: &str) -> u64 { + let mut h = world_model::Hasher::new(); + h.write_tag(tag); + h.write_u64(seed); + h.finish().0 +} + +// --------------------------------------------------------------------------- +// Generated-case gates: reject flat cases. +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaseEstimate { + pub estimated_causal_rank: usize, + pub domain_entropy: f64, + pub perturbation_axes: usize, + pub executor_count: usize, + pub future_dependence: bool, + pub hidden_observed_divergence: bool, + pub nonuniform_fingerprints: bool, +} + +impl GeneratedCase { + /// Estimate the structural richness of the case without running a runtime. + pub fn estimate(&self) -> CaseEstimate { + let mut adj = [[0.0f64; NUM_DOMAINS]; NUM_DOMAINS]; + let mut refs = [0u32; NUM_DOMAINS]; + for t in &self.program.tokens { + let s = t.src_domain(); + let d = t.dst_domain(); + let phase = 1.0 + t.lane() as f64 + 4.0 * t.lane2() as f64; + adj[d][s] += phase; + adj[d][d] += 0.5 + t.mode() as f64; + refs[s] += 1; + refs[d] += 1; + } + let rows: Vec> = adj.iter().map(|r| r.to_vec()).collect(); + let est_rank = numeric_rank(&rows); + + let total: u32 = refs.iter().sum(); + let mut entropy = 0.0; + if total > 0 { + for &c in &refs { + if c > 0 { + let p = c as f64 / total as f64; + entropy -= p * p.log2(); + } + } + } + + let future = self.program.tokens.iter().any(|t| matches!(t.op, Op::Schedule)); + + let hidden_div = self + .world + .domains + .iter() + .any(|d| d.hidden.iter().any(|&v| v != 0)) + || self + .world + .observation_state + .visible + .iter() + .any(|row| row.iter().any(|&v| !v)); + + use world_model::WorldDomain; + let mut prints: Vec<_> = self.world.domains.iter().map(|d| d.fingerprint().hash).collect(); + prints.sort(); + prints.dedup(); + let nonuniform = prints.len() > 1; + + CaseEstimate { + estimated_causal_rank: est_rank, + domain_entropy: entropy, + perturbation_axes: self.perturbations.len().max(count_axes(&self.world)), + executor_count: self.contexts.len(), + future_dependence: future, + hidden_observed_divergence: hidden_div, + nonuniform_fingerprints: nonuniform, + } + } +} + +fn count_axes(world: &WorldSnapshot) -> usize { + use world_model::WorldDomain; + world.domains.iter().map(|d| d.perturbation_axes().len()).sum() +} + +/// Reasons a case failed the generated gates (empty = passed). +pub fn generated_gate_failures(est: &CaseEstimate) -> Vec { + let mut f = Vec::new(); + if est.estimated_causal_rank < 6 { + f.push(format!("estimated_causal_rank {} < 6", est.estimated_causal_rank)); + } + if est.domain_entropy < MIN_DOMAIN_ENTROPY { + f.push(format!( + "domain_entropy {:.3} < {:.3}", + est.domain_entropy, MIN_DOMAIN_ENTROPY + )); + } + if est.perturbation_axes < 10 { + f.push(format!("perturbation_axes {} < 10", est.perturbation_axes)); + } + if est.executor_count < 3 { + f.push(format!("executor_count {} < 3", est.executor_count)); + } + if !est.future_dependence { + f.push("future_dependence absent".into()); + } + if !est.hidden_observed_divergence { + f.push("no hidden/observed divergence".into()); + } + if !est.nonuniform_fingerprints { + f.push("uniform domain fingerprints".into()); + } + f +} + +/// Generate a case that passes the generated gates, retrying with fresh seeds. +pub fn generate_accepted_case(master_seed: u64) -> (GeneratedCase, u64) { + let mut s = master_seed; + for _ in 0..64 { + let case = generate_case(s); + if generated_gate_failures(&case.estimate()).is_empty() { + return (case, s); + } + s = derive(s, "retry"); + } + let case = generate_case(s); + (case, s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepted_cases_pass_generated_gates() { + for i in 0..200u64 { + let (case, _) = generate_accepted_case(0x1234 ^ i.wrapping_mul(0x9e3779b97f4a7c15)); + let failures = generated_gate_failures(&case.estimate()); + assert!(failures.is_empty(), "case {i} failed: {:?}", failures); + assert!(case.contexts.len() >= 3); + assert!(case.perturbations.len() >= 10); + } + } + + #[test] + fn generation_is_deterministic() { + let (a, sa) = generate_accepted_case(999); + let (b, sb) = generate_accepted_case(999); + assert_eq!(sa, sb); + assert_eq!(a.program.content_hash(), b.program.content_hash()); + assert_eq!(a.world.content_hash(), b.world.content_hash()); + } +} diff --git a/crates/reference_runtime/Cargo.toml b/crates/reference_runtime/Cargo.toml new file mode 100644 index 0000000..fdcdd0b --- /dev/null +++ b/crates/reference_runtime/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "reference_runtime" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } + +[lib] +path = "src/lib.rs" diff --git a/crates/reference_runtime/src/engine.rs b/crates/reference_runtime/src/engine.rs new file mode 100644 index 0000000..71126a6 --- /dev/null +++ b/crates/reference_runtime/src/engine.rs @@ -0,0 +1,654 @@ +//! The execution engine. Both the reference runtime and the runtime under +//! test call [`execute`] with the canonical [`EngineConfig`]; semantic mutants +//! are nothing more than an `EngineConfig` with one behavior-affecting knob +//! changed, which is what makes them detectable by the equivalence gate. +//! +//! All arithmetic is integer and total: division is guarded, overflow wraps, +//! and every token produces a defined effect or a *logged* fault. The engine +//! never panics. + +use rune_ir::{Op, 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, +}; + +/// All behavior-affecting knobs of the engine. The reference config is the +/// executable spec; mutants flip exactly one knob. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct EngineConfig { + pub c1: u64, + pub c2: u64, + pub s1: u32, + pub s2: u32, + pub s3: u32, + pub use_coupling: bool, + pub use_hidden: bool, + pub use_executor_salt: bool, + pub branch_enabled: bool, + pub schedule_enabled: bool, + pub record_causal: bool, + pub domain_mask: [bool; NUM_DOMAINS], + pub op_enabled: [bool; 12], + pub future_turns: usize, + pub diffuse_span: usize, +} + +impl EngineConfig { + /// The canonical executable-spec configuration. + pub fn reference() -> Self { + EngineConfig { + c1: 0xff51afd7ed558ccd, + c2: 0xc4ceb9fe1a85ec53, + s1: 33, + s2: 29, + s3: 32, + use_coupling: true, + use_hidden: true, + use_executor_salt: true, + branch_enabled: true, + schedule_enabled: true, + record_causal: true, + domain_mask: [true; NUM_DOMAINS], + op_enabled: [true; 12], + future_turns: 3, + diffuse_span: 3, + } + } +} + +/// Input to a resolution. +#[derive(Clone, Debug)] +pub struct ResolutionInput { + pub world: WorldSnapshot, + pub program: rune_ir::RuneProgram, + pub contexts: Vec, + pub contract_seed: u64, + pub perturbation_seed: u64, +} + +/// Output of a resolution (per spec). Execution always returns this; no rune +/// stream is ever rejected. +#[derive(Clone, Debug)] +pub struct ResolutionResult { + pub delta: WorldDelta, + pub trace: ExecutionTrace, + pub faults: FaultLog, + pub replay: ReplayRecord, +} + +/// Canonical, comparable view of a result. Reference and runtime-under-test +/// must produce identical canonical views. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Canonical { + pub delta_hash: Hash, + pub trace_hash: Hash, + pub fault_hash: Hash, + pub replay_hash: Hash, + pub future_hash: Hash, +} + +/// Produce the canonical comparison tuple for a result. +pub fn canonical(r: &ResolutionResult) -> Canonical { + Canonical { + delta_hash: r.delta.hash(), + trace_hash: r.trace.canonical_hash(), + fault_hash: r.faults.hash(), + replay_hash: r.replay.hash(), + future_hash: r.replay.future_hash, + } +} + +// --------------------------------------------------------------------------- +// Recorder: accumulates graph/trace data during a single-context run. +// --------------------------------------------------------------------------- + +struct Recorder { + read_graph: DomainAccessGraph, + write_graph: DomainAccessGraph, + causal_graph: CausalGraph, + info_flow: InformationFlowGraph, + temporal: TemporalGraph, + faults: FaultLog, +} + +impl Recorder { + fn new() -> Self { + Recorder { + read_graph: DomainAccessGraph::default(), + write_graph: DomainAccessGraph::default(), + causal_graph: CausalGraph::default(), + info_flow: InformationFlowGraph::default(), + temporal: TemporalGraph::default(), + faults: FaultLog::default(), + } + } + + #[allow(clippy::too_many_arguments)] + fn flow( + &mut self, + cfg: &EngineConfig, + from_dom: usize, + from_lane: usize, + from_hidden: bool, + to_dom: usize, + to_lane: usize, + to_hidden: bool, + step: u32, + weight: i64, + ) { + if !cfg.domain_mask[from_dom] || !cfg.domain_mask[to_dom] { + return; + } + 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())); + if cfg.record_causal { + 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, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Core arithmetic. +// --------------------------------------------------------------------------- + +#[inline] +fn avalanche(cfg: &EngineConfig, z: i64) -> i64 { + let mut u = z as u64; + u ^= u >> cfg.s1; + u = u.wrapping_mul(cfg.c1); + u ^= u >> cfg.s2; + u = u.wrapping_mul(cfg.c2); + u ^= u >> cfg.s3; + u as i64 +} + +#[inline] +fn combine(cfg: &EngineConfig, 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); + if cfg.use_coupling { + z = z.wrapping_add(coupling.wrapping_mul(b & 0xffff)); + } + if cfg.use_executor_salt { + z ^= ctx.salt() as i64; + z = z.wrapping_add(ctx.profile.bias); + z = z.rotate_left((ctx.profile.rotate % 63) + 1); + } + avalanche(cfg, z) +} + +#[inline] +fn read_lane(cfg: &EngineConfig, w: &WorldSnapshot, dom: usize, lane: usize, hidden: bool) -> i64 { + if !cfg.domain_mask[dom] { + return 0; + } + if hidden { + if cfg.use_hidden { + w.domains[dom].hidden[lane % HIDDEN_LANES] + } else { + 0 + } + } else { + w.domains[dom].observed[lane % LANES] + } +} + +#[inline] +fn write_lane(cfg: &EngineConfig, w: &mut WorldSnapshot, dom: usize, lane: usize, hidden: bool, val: i64) { + if !cfg.domain_mask[dom] { + return; + } + if hidden { + w.domains[dom].hidden[lane % HIDDEN_LANES] = val; + } else { + w.domains[dom].observed[lane % LANES] = val; + } +} + +#[inline] +fn coupling_at(w: &WorldSnapshot, to: usize, from: usize) -> i64 { + w.causal_state.coupling[to][from] +} + +// --------------------------------------------------------------------------- +// Single-context program run. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Future stepping (genuine future dependence over 3 turns). +// --------------------------------------------------------------------------- + +fn step_world(cfg: &EngineConfig, w: &mut WorldSnapshot) { + // Resolve scheduled effects due this turn. + 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 cfg.domain_mask[d] { + 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; + + // Coupling diffusion: every domain pulls from every other through the + // coupling matrix, then avalanches. This propagates any execution effect + // into the future and makes future state sensitive to the present. + let snap = w.domains.clone(); + for j in 0..NUM_DOMAINS { + if !cfg.domain_mask[j] { + continue; + } + for lane in 0..LANES { + let mut z = w.domains[j].observed[lane]; + for i in 0..NUM_DOMAINS { + if !cfg.domain_mask[i] { + continue; + } + if cfg.use_coupling { + let c = w.causal_state.coupling[j][i]; + z = z.wrapping_add(c.wrapping_mul(snap[i].observed[lane] & 0xff)); + } else { + z = z.wrapping_add(snap[i].observed[lane] & 0xff); + } + } + w.domains[j].observed[lane] = avalanche(cfg, 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] = if cfg.use_hidden { avalanche(cfg, base) } else { base }; + } + } + w.turn = w.turn.wrapping_add(1); +} + +fn future_hash(cfg: &EngineConfig, start: &WorldSnapshot) -> Hash { + let mut w = start.clone(); + let mut h = Hasher::new(); + h.write_tag("future-3"); + for _ in 0..cfg.future_turns { + step_world(cfg, &mut w); + for v in w.ground_truth() { + h.write_i64(v); + } + } + h.finish() +} + +// --------------------------------------------------------------------------- +// Divergence + behavior fingerprint. +// --------------------------------------------------------------------------- + +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, + } +} + +fn behavior_fingerprint( + before: &WorldSnapshot, + after: &WorldSnapshot, + delta: &WorldDelta, + rec: &Recorder, + divergence: &DivergenceGraph, + future: Hash, +) -> BehaviorFingerprint { + let mut features: Vec = Vec::new(); + // Per-domain observed and hidden delta magnitudes. + 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); + } + // Structural counts. + features.push(rec.causal_graph.causal_rank() as i64); + features.push(rec.causal_graph.edge_count() as i64); + features.push(rec.read_graph.touched_count() as i64); + features.push(rec.write_graph.touched_count() as i64); + features.push(rec.info_flow.total_bits() as i64); + features.push(rec.temporal.edge_count() as i64); + features.push((divergence.mean_divergence() * 1_000_000.0) as i64); + features.push(future.0 as i64); + let _ = (before, after); + BehaviorFingerprint::from_features(features) +} + +// --------------------------------------------------------------------------- +// Public entry point. +// --------------------------------------------------------------------------- + +/// Execute a resolution with the given engine config. +pub fn execute(cfg: &EngineConfig, input: &ResolutionInput) -> ResolutionResult { + let mut contexts = input.contexts.clone(); + if contexts.is_empty() { + contexts = world_model::standard_executors(input.world.seed, 3); + } + + // A masked (removed) domain contributes nothing: its state is erased up + // front so it cannot leak into deltas, future hashes, or fingerprints. + let mut world0 = input.world.clone(); + for d in 0..NUM_DOMAINS { + if !cfg.domain_mask[d] { + world0.domains[d].observed = [0; LANES]; + world0.domains[d].hidden = [0; HIDDEN_LANES]; + } + } + + // Run under every executor; keep the primary (index 0) full recording. + let mut finals: Vec = Vec::with_capacity(contexts.len()); + let mut primary: Option<(WorldSnapshot, Recorder)> = None; + for (idx, ctx) in contexts.iter().enumerate() { + let (fin, rec) = run_program_with_program(cfg, &world0, ctx, &input.program); + if idx == 0 { + primary = Some((fin.clone(), rec)); + } + finals.push(fin); + } + let (primary_final, rec) = primary.expect("at least one executor"); + + let delta = WorldDelta::between(&world0, &primary_final); + let divergence = compute_divergence(&finals); + let fhash = future_hash(cfg, &primary_final); + let behavior = + behavior_fingerprint(&world0, &primary_final, &delta, &rec, &divergence, fhash); + + let trace = ExecutionTrace { + read_graph: rec.read_graph.clone(), + write_graph: rec.write_graph.clone(), + causal_graph: rec.causal_graph.clone(), + information_flow: rec.info_flow.clone(), + executor_divergence: divergence, + temporal_graph: rec.temporal.clone(), + perturbation_response: PerturbationResponse::default(), + behavior_fingerprint: behavior, + }; + let faults = rec.faults; + 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, + replay, + } +} + +/// `run_program` variant that takes the program explicitly. (The borrow-split +/// helper above intentionally returns no tokens; this is the real driver.) +fn run_program_with_program( + cfg: &EngineConfig, + world: &WorldSnapshot, + ctx: &ExecutionContext, + program: &rune_ir::RuneProgram, +) -> (WorldSnapshot, Recorder) { + let mut w = world.clone(); + let mut rec = Recorder::new(); + let mut acc = w.execution_state.accumulator; + let mut acc_src: [usize; REGS] = [0; REGS]; + + for (i, tok) in program.tokens.iter().enumerate() { + let step = i as 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 = coupling_at(&w, dst, src); + + if !cfg.op_enabled[tok.op.to_u8() as usize] { + rec.faults.push(FaultCode::NoEffectToken, step, tok.op.to_u8() as i64); + continue; + } + + interpret(cfg, ctx, &mut w, &mut rec, &mut acc, &mut acc_src, tok, step, src, dst, lane, lane2, kc, coupling); + + let r = (step as usize) % REGS; + acc[r] = acc[r].wrapping_add(w.domains[dst].observed[lane]); + } + + w.execution_state.accumulator = acc; + (w, rec) +} + +#[allow(clippy::too_many_arguments)] +fn interpret( + cfg: &EngineConfig, + ctx: &ExecutionContext, + w: &mut WorldSnapshot, + rec: &mut Recorder, + acc: &mut [i64; REGS], + acc_src: &mut [usize; REGS], + tok: &RuneToken, + step: u32, + src: usize, + dst: usize, + lane: usize, + lane2: usize, + kc: u64, + coupling: i64, +) { + match tok.op { + Op::Mix => { + let a = read_lane(cfg, w, src, lane, false); + let b = read_lane(cfg, w, dst, lane2, false); + let v = combine(cfg, ctx, a, b, coupling, kc); + write_lane(cfg, w, dst, lane, false, v); + rec.flow(cfg, src, lane, false, dst, lane, false, step, v); + rec.flow(cfg, dst, lane2, false, dst, lane, false, step, v); + } + Op::Channel => { + let a = read_lane(cfg, w, src, lane, false); + let v = combine(cfg, ctx, a, coupling, coupling, kc); + write_lane(cfg, w, dst, lane2, false, v); + rec.flow(cfg, src, lane, false, dst, lane2, false, step, v); + } + Op::Branch => { + let probe = read_lane(cfg, w, src, lane, false); + let take_hot = if cfg.branch_enabled { + probe.wrapping_add(ctx.profile.bias) > ctx.profile.branch_threshold + } else { + true + }; + if take_hot { + let b = read_lane(cfg, w, dst, lane, false); + let v = combine(cfg, ctx, probe, b, coupling, kc); + write_lane(cfg, w, dst, lane, false, v); + rec.flow(cfg, src, lane, false, dst, lane, false, step, v); + } else { + let b = read_lane(cfg, w, dst, lane2, false); + let v = combine(cfg, ctx, b, probe, coupling, kc).wrapping_add(0x5bd1e9); + write_lane(cfg, w, dst, lane2, false, v); + rec.flow(cfg, src, lane, false, dst, lane2, false, step, v); + rec.faults.push(FaultCode::UnreachableBranch, step, 0); + } + } + Op::Schedule => { + let a = read_lane(cfg, w, src, lane, false); + let b = read_lane(cfg, w, dst, lane, false); + let v = combine(cfg, ctx, a, b, coupling, kc); + if cfg.schedule_enabled { + let offset = 1 + (tok.imm.rem_euclid(3)) as u8; + let hidden = tok.mode() & 1 == 1; + w.time_state.pending.push(ScheduledEffect { + turn_offset: offset, + domain: DomainId(dst as u8), + lane, + hidden, + value: v, + }); + rec.temporal.edges.push((step, offset, dst as u8)); + rec.flow(cfg, src, lane, false, dst, lane, hidden, step, v); + } else { + rec.faults.push(FaultCode::NoEffectToken, step, 1); + } + } + Op::Resonate => { + let a = read_lane(cfg, w, src, lane, false); + let b = read_lane(cfg, w, dst, lane, false); + let m = combine(cfg, ctx, a, b, coupling, kc); + let va = a.wrapping_add(m); + let vb = b ^ m; + write_lane(cfg, w, src, lane, false, va); + write_lane(cfg, w, dst, lane, false, vb); + rec.flow(cfg, dst, lane, false, src, lane, false, step, va); + rec.flow(cfg, src, lane, false, dst, lane, false, step, vb); + } + Op::Observe => { + let reg = tok.mode() % REGS; + let mut z: i64 = acc[reg]; + let proj = w.observed_projection(); + for k in 0..3 { + let d = (src + k) % NUM_DOMAINS; + if !cfg.domain_mask[d] { + continue; + } + let idx = d * LANES + (lane + k) % LANES; + z = combine(cfg, ctx, z, proj[idx], coupling_at(w, dst, d), kc); + rec.flow(cfg, d, (lane + k) % LANES, false, dst, lane, true, step, z); + } + acc[reg] = z; + acc_src[reg] = src; + write_lane(cfg, w, dst, tok.mode() % HIDDEN_LANES, true, z); + } + Op::Collapse => { + let reg = tok.mode() % REGS; + let a = acc[reg]; + let b = read_lane(cfg, w, dst, lane, false); + if a == 0 { + rec.faults.push(FaultCode::EmptyAccumulator, step, reg as i64); + } + let v = combine(cfg, ctx, a, b, coupling, kc); + write_lane(cfg, w, dst, lane, false, v); + rec.flow(cfg, acc_src[reg], 0, true, dst, lane, false, step, v); + } + Op::Invert => { + let b = read_lane(cfg, w, dst, lane, false); + let mut v = avalanche(cfg, (!b).wrapping_add(tok.imm)); + if cfg.use_executor_salt { + v ^= ctx.salt() as i64; + v = v.wrapping_add(ctx.profile.bias); + } + write_lane(cfg, w, dst, lane, false, v); + rec.flow(cfg, dst, lane, false, dst, lane, false, step, v); + } + Op::Diffuse => { + let a = read_lane(cfg, w, src, lane, false); + for k in 0..cfg.diffuse_span { + let d = (src + 1 + k) % NUM_DOMAINS; + let tl = (lane + k) % LANES; + let prev = read_lane(cfg, w, d, tl, false); + let v = combine(cfg, ctx, a, prev, coupling_at(w, d, src), DomainKind::from_index(d).mix_const()); + write_lane(cfg, w, d, tl, false, prev.wrapping_add(v)); + rec.flow(cfg, src, lane, false, d, tl, false, step, v); + } + } + Op::Anchor => { + let bound = (tok.imm.unsigned_abs() % 1_000_000) as i64 + 1; + let b = read_lane(cfg, w, dst, lane, false); + let diag = coupling_at(w, dst, dst); + let mut mixed = b.wrapping_add(diag); + if cfg.use_executor_salt { + mixed = mixed + .wrapping_add(ctx.profile.bias) + .wrapping_add((ctx.salt() & 0xffff) as i64); + } + let clamped = mixed.clamp(-bound, bound); + if clamped != mixed { + rec.faults.push(FaultCode::Saturated, step, bound); + } + write_lane(cfg, w, dst, lane, false, clamped); + rec.flow(cfg, dst, lane, false, dst, lane, false, step, clamped); + } + Op::Echoback => { + let h = read_lane(cfg, w, dst, tok.mode() % HIDDEN_LANES, true); + let b = read_lane(cfg, w, dst, lane, false); + let v = combine(cfg, ctx, h, b, coupling, kc); + write_lane(cfg, w, dst, lane, false, v); + rec.flow(cfg, dst, tok.mode() % HIDDEN_LANES, true, dst, lane, false, step, v); + } + Op::Imprint => { + let b = read_lane(cfg, w, dst, lane, false); + let hl = tok.mode() % HIDDEN_LANES; + let prevh = read_lane(cfg, w, dst, hl, true); + let v = combine(cfg, ctx, b, prevh, coupling, kc); + write_lane(cfg, w, dst, hl, true, v); + rec.flow(cfg, dst, lane, false, dst, hl, true, step, v); + } + } +} diff --git a/crates/reference_runtime/src/lib.rs b/crates/reference_runtime/src/lib.rs new file mode 100644 index 0000000..d657376 --- /dev/null +++ b/crates/reference_runtime/src/lib.rs @@ -0,0 +1,117 @@ +//! `reference_runtime` — the executable specification. Every execution in CI +//! runs the reference and the runtime-under-test and asserts their canonical +//! views are identical. The reference is intentionally the simplest correct +//! expression of the engine. + +pub mod engine; + +pub use engine::{ + canonical, execute, Canonical, EngineConfig, ResolutionInput, ResolutionResult, +}; + +/// The runtime trait (per spec). +pub trait Runtime { + fn resolve(&self, input: ResolutionInput) -> ResolutionResult; +} + +/// The reference runtime: executes with the canonical engine config. +#[derive(Clone, Debug, Default)] +pub struct ReferenceRuntime; + +impl ReferenceRuntime { + pub fn new() -> Self { + ReferenceRuntime + } +} + +impl Runtime for ReferenceRuntime { + fn resolve(&self, input: ResolutionInput) -> ResolutionResult { + execute(&EngineConfig::reference(), &input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rune_ir::{Op, RuneProgram, RuneToken, ALL_OPS}; + use world_model::{standard_executors, Rng, WorldId, WorldSnapshot, NUM_DOMAINS}; + + fn random_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 { + d.observed[l] = rng.range_i64(-5000, 5000); + } + for l in 0..world_model::HIDDEN_LANES { + d.hidden[l] = rng.range_i64(-5000, 5000); + } + } + for j in 0..NUM_DOMAINS { + for i in 0..NUM_DOMAINS { + w.causal_state.coupling[j][i] = rng.range_i64(-17, 17); + } + } + let tokens: Vec = (0..30) + .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, + imm: rng.range_i64(-100000, 100000), + }) + .collect(); + ResolutionInput { + world: w, + program: RuneProgram { id: world_model::ProgramId(seed), tokens, seed }, + contexts: standard_executors(seed, 3), + contract_seed: seed, + perturbation_seed: seed, + } + } + + #[test] + fn execution_is_deterministic() { + let cfg = EngineConfig::reference(); + for s in 0..200 { + let input = random_input(s); + let a = execute(&cfg, &input); + let b = execute(&cfg, &input); + assert_eq!(canonical(&a), canonical(&b), "nondeterministic at seed {s}"); + } + } + + #[test] + fn reference_never_panics_on_arbitrary_runes() { + // Totality: any token stream resolves without panic. + let cfg = EngineConfig::reference(); + for s in 0..500 { + let input = random_input(s ^ 0xdead); + let r = execute(&cfg, &input); + // result is always produced; faults are logged not thrown + let _ = r.faults.faults.len(); + } + } + + #[test] + fn reference_matches_runtime_under_test_path() { + // Reference and the config-driven engine agree for the canonical config. + let cfg = EngineConfig::reference(); + let rr = ReferenceRuntime::new(); + for s in 0..100 { + let input = random_input(s); + let a = canonical(&execute(&cfg, &input)); + let b = canonical(&rr.resolve(input.clone())); + assert_eq!(a, b); + } + } + + #[test] + fn masked_domain_has_zero_delta() { + let mut cfg = EngineConfig::reference(); + cfg.domain_mask[3] = false; + let input = random_input(77); + let r = execute(&cfg, &input); + assert!(r.delta.domain_deltas[3].is_zero()); + } +} diff --git a/crates/replay_corpus/Cargo.toml b/crates/replay_corpus/Cargo.toml new file mode 100644 index 0000000..d2af535 --- /dev/null +++ b/crates/replay_corpus/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "replay_corpus" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } +generators = { path = "../generators" } +reference_runtime = { path = "../reference_runtime" } + +[lib] +path = "src/lib.rs" diff --git a/crates/replay_corpus/src/lib.rs b/crates/replay_corpus/src/lib.rs new file mode 100644 index 0000000..f66ac74 --- /dev/null +++ b/crates/replay_corpus/src/lib.rs @@ -0,0 +1,145 @@ +//! `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. + +use generators::generate_accepted_case; +use reference_runtime::{execute, EngineConfig, ResolutionInput}; +use world_model::Hash; + +/// A permanent replay case (per spec) plus the master seed needed to +/// regenerate the full case deterministically. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReplayCase { + pub master_seed: u64, + pub world_seed: u64, + pub program_seed: u64, + pub contract_seed: u64, + pub perturbation_seed: u64, + pub expected_trace_hash: Hash, + pub expected_delta_hash: Hash, + pub expected_future_hash: Hash, +} + +fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) { + let (case, accepted_seed) = generate_accepted_case(master_seed); + let _ = accepted_seed; + let input = ResolutionInput { + world: case.world.clone(), + program: case.program.clone(), + contexts: case.contexts.clone(), + contract_seed: case.contract_seed, + perturbation_seed: case.perturbation_seed, + }; + ( + input, + case.world_seed, + case.program_seed, + case.contract_seed, + case.perturbation_seed, + ) +} + +/// Build a single replay case from a master seed. +pub fn build_case(master_seed: u64) -> ReplayCase { + let (input, ws, ps, cs, prs) = input_for(master_seed); + let r = execute(&EngineConfig::reference(), &input); + ReplayCase { + master_seed, + world_seed: ws, + program_seed: ps, + contract_seed: cs, + perturbation_seed: prs, + expected_trace_hash: r.trace.canonical_hash(), + expected_delta_hash: r.delta.hash(), + expected_future_hash: r.replay.future_hash, + } +} + +/// Build a replay corpus of `n` cases. +pub fn build_corpus(n: usize, base_seed: u64) -> Vec { + (0..n) + .map(|i| build_case(base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15))) + .collect() +} + +/// A single replay verification outcome. +#[derive(Clone, Copy, Debug)] +pub struct ReplayDrift { + pub master_seed: u64, + pub trace_ok: bool, + pub delta_ok: bool, + pub future_ok: bool, +} + +impl ReplayDrift { + pub fn ok(&self) -> bool { + self.trace_ok && self.delta_ok && self.future_ok + } +} + +/// Replay one case and check for drift. +pub fn replay(case: &ReplayCase) -> ReplayDrift { + let (input, ..) = input_for(case.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, + } +} + +/// Aggregate replay report. +#[derive(Clone, Debug)] +pub struct ReplayReport { + pub total: usize, + pub deterministic: usize, + pub drift: Vec, +} + +impl ReplayReport { + pub fn passed(&self, minimum: usize) -> bool { + self.drift.is_empty() && self.total >= minimum + } +} + +/// Verify the whole corpus replays deterministically. +pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport { + let mut drift = Vec::new(); + let mut deterministic = 0; + for case in corpus { + let d = replay(case); + if d.ok() { + deterministic += 1; + } else { + drift.push(case.master_seed); + } + } + ReplayReport { + total: corpus.len(), + deterministic, + drift, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replay_is_deterministic_zero_drift() { + let corpus = build_corpus(80, 0x5EED); + let report = verify_corpus(&corpus); + assert_eq!(report.total, 80); + assert_eq!(report.deterministic, 80); + assert!(report.drift.is_empty()); + } + + #[test] + fn case_hashes_are_stable() { + let a = build_case(123); + let b = build_case(123); + assert_eq!(a, b); + } +} diff --git a/crates/rune_ir/Cargo.toml b/crates/rune_ir/Cargo.toml new file mode 100644 index 0000000..57262db --- /dev/null +++ b/crates/rune_ir/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "rune_ir" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } + +[lib] +path = "src/lib.rs" diff --git a/crates/rune_ir/src/lib.rs b/crates/rune_ir/src/lib.rs new file mode 100644 index 0000000..936edbe --- /dev/null +++ b/crates/rune_ir/src/lib.rs @@ -0,0 +1,168 @@ +//! `rune_ir` — the rune program model. A rune program is an opaque token +//! stream; no token is ever rejected as the primary safety path. The runtime +//! interprets every stream into a resolution result. Semantics live in the +//! runtimes; this crate only defines structure and stable hashing. + +use world_model::{Hasher, ProgramId, Hash, NUM_DOMAINS, LANES}; + +/// Rune opcodes. Every opcode is total: it always produces a defined effect +/// (possibly a logged fault) and never panics. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Op { + /// Nonlinear mix of two domains into a destination lane. + Mix, + /// Channel a source through the world coupling matrix into a destination. + Channel, + /// Branch on a domain value; takes one of two avalanche paths. + Branch, + /// Schedule a future effect (creates future dependence). + Schedule, + /// Bidirectionally couple two domains. + Resonate, + /// Read the observed projection into the execution accumulator. + Observe, + /// Fold the accumulator into a destination domain. + Collapse, + /// Nonlinear self-inversion of a destination lane. + Invert, + /// Diffuse a source across several domains. + Diffuse, + /// Clamp/stabilize a destination lane. + Anchor, + /// Move hidden state into observed state (hidden -> observed flow). + Echoback, + /// Imprint observed state into hidden state (observed -> hidden flow). + Imprint, +} + +pub const ALL_OPS: [Op; 12] = [ + Op::Mix, + Op::Channel, + Op::Branch, + Op::Schedule, + Op::Resonate, + Op::Observe, + Op::Collapse, + Op::Invert, + Op::Diffuse, + Op::Anchor, + Op::Echoback, + Op::Imprint, +]; + +impl Op { + pub fn from_u8(v: u8) -> Op { + ALL_OPS[(v as usize) % ALL_OPS.len()] + } + pub fn to_u8(self) -> u8 { + ALL_OPS.iter().position(|&o| o == self).unwrap() as u8 + } + pub fn name(self) -> &'static str { + match self { + Op::Mix => "mix", + Op::Channel => "channel", + Op::Branch => "branch", + Op::Schedule => "schedule", + Op::Resonate => "resonate", + Op::Observe => "observe", + Op::Collapse => "collapse", + Op::Invert => "invert", + Op::Diffuse => "diffuse", + Op::Anchor => "anchor", + Op::Echoback => "echoback", + Op::Imprint => "imprint", + } + } +} + +/// A single rune. `a`/`b` select domains, `c` selects a lane/mode, `imm` is an +/// immediate operand. All fields are interpreted modulo the relevant range so +/// every token is always valid. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub struct RuneToken { + pub op: Op, + pub a: u8, + pub b: u8, + pub c: u8, + pub imm: i64, +} + +impl RuneToken { + pub fn src_domain(&self) -> usize { + (self.a as usize) % NUM_DOMAINS + } + pub fn dst_domain(&self) -> usize { + (self.b as usize) % NUM_DOMAINS + } + pub fn lane(&self) -> usize { + (self.c as usize) % LANES + } + /// Secondary lane derived from the high bits of `c`. + pub fn lane2(&self) -> usize { + ((self.c as usize) >> 2) % LANES + } + /// Mode selector derived from `c`. + pub fn mode(&self) -> usize { + (self.c as usize) % 4 + } + + pub fn hash_into(&self, h: &mut Hasher) { + h.write_u8(self.op.to_u8()); + h.write_u8(self.a); + h.write_u8(self.b); + h.write_u8(self.c); + h.write_i64(self.imm); + } +} + +/// A rune program (per spec). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RuneProgram { + pub id: ProgramId, + pub tokens: Vec, + pub seed: u64, +} + +impl RuneProgram { + pub fn content_hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("rune-program"); + h.write_u64(self.id.0); + h.write_u64(self.seed); + h.write_usize(self.tokens.len()); + for t in &self.tokens { + t.hash_into(&mut h); + } + h.finish() + } + + pub fn len(&self) -> usize { + self.tokens.len() + } + + pub fn is_empty(&self) -> bool { + self.tokens.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn op_roundtrips() { + for op in ALL_OPS { + assert_eq!(Op::from_u8(op.to_u8()), op); + } + } + + #[test] + fn program_hash_is_deterministic() { + let p = RuneProgram { + id: ProgramId(1), + tokens: vec![RuneToken { op: Op::Mix, a: 1, b: 2, c: 3, imm: 4 }], + seed: 9, + }; + assert_eq!(p.content_hash(), p.content_hash()); + } +} diff --git a/crates/runtime_under_test/Cargo.toml b/crates/runtime_under_test/Cargo.toml new file mode 100644 index 0000000..8ea31a8 --- /dev/null +++ b/crates/runtime_under_test/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "runtime_under_test" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } +reference_runtime = { path = "../reference_runtime" } + +[lib] +path = "src/lib.rs" diff --git a/crates/runtime_under_test/src/lib.rs b/crates/runtime_under_test/src/lib.rs new file mode 100644 index 0000000..e15fec1 --- /dev/null +++ b/crates/runtime_under_test/src/lib.rs @@ -0,0 +1,99 @@ +//! `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. + +use reference_runtime::{execute, EngineConfig, ResolutionInput, ResolutionResult, Runtime}; + +#[derive(Clone, Debug)] +pub struct RuntimeUnderTest { + pub config: EngineConfig, +} + +impl Default for RuntimeUnderTest { + fn default() -> Self { + RuntimeUnderTest { + config: EngineConfig::reference(), + } + } +} + +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 } + } +} + +impl Runtime for RuntimeUnderTest { + fn resolve(&self, input: ResolutionInput) -> ResolutionResult { + execute(&self.config, &input) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reference_runtime::{canonical, execute, ReferenceRuntime}; + use rune_ir::{Op, RuneProgram, RuneToken}; + use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, NUM_DOMAINS}; + + fn random_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 { + d.observed[l] = rng.range_i64(-5000, 5000); + } + for l in 0..world_model::HIDDEN_LANES { + d.hidden[l] = rng.range_i64(-5000, 5000); + } + } + for j in 0..NUM_DOMAINS { + for i in 0..NUM_DOMAINS { + w.causal_state.coupling[j][i] = rng.range_i64(-17, 17); + } + } + let tokens: Vec = (0..30) + .map(|_| RuneToken { + op: 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, + imm: rng.range_i64(-100000, 100000), + }) + .collect(); + ResolutionInput { + world: w, + program: RuneProgram { id: ProgramId(seed), tokens, seed }, + contexts: standard_executors(seed, 3), + contract_seed: seed, + perturbation_seed: seed, + } + } + + #[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); + } + } +} diff --git a/crates/semantic_mutation/Cargo.toml b/crates/semantic_mutation/Cargo.toml new file mode 100644 index 0000000..162378c --- /dev/null +++ b/crates/semantic_mutation/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "semantic_mutation" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } +rune_ir = { path = "../rune_ir" } +trace_model = { path = "../trace_model" } +reference_runtime = { path = "../reference_runtime" } + +[lib] +path = "src/lib.rs" diff --git a/crates/semantic_mutation/src/lib.rs b/crates/semantic_mutation/src/lib.rs new file mode 100644 index 0000000..bc2ba43 --- /dev/null +++ b/crates/semantic_mutation/src/lib.rs @@ -0,0 +1,278 @@ +//! `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. + +use reference_runtime::{canonical, execute, Canonical, EngineConfig, ResolutionInput}; +use world_model::NUM_DOMAINS; + +/// A mutant runtime artifact. +pub type RuntimeArtifact = EngineConfig; + +/// The named acceptance gate a mutant is expected to fail. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DetectionClass { + RuntimeEquivalence, + CausalGate, + TemporalGate, + DomainParticipation, +} + +impl DetectionClass { + pub fn name(self) -> &'static str { + match self { + DetectionClass::RuntimeEquivalence => "runtime_equivalence", + DetectionClass::CausalGate => "causal_gate", + DetectionClass::TemporalGate => "temporal_gate", + DetectionClass::DomainParticipation => "domain_participation", + } + } +} + +/// The semantic-mutator trait (per spec). +pub trait SemanticMutator { + fn mutate(&self, base: &RuntimeArtifact) -> RuntimeArtifact; + 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() + } + fn expected_detection_reason(&self) -> DetectionClass { + self.expected + } +} + +/// A concrete mutant. +#[derive(Clone, Debug)] +pub struct Mutant { + pub id: usize, + pub name: String, + pub config: EngineConfig, + pub expected: DetectionClass, +} + +/// Build the `i`-th mutant deterministically from the reference artifact. +/// Every mutant differs from the reference in exactly one behavioral knob. +pub fn mutant_for(i: usize) -> Mutant { + let base = EngineConfig::reference(); + let mut cfg = base.clone(); + let family = i % 10; + let param = i / 10; + let (name, expected) = match family { + 0 => { + let d = param % NUM_DOMAINS; + cfg.domain_mask[d] = false; + (format!("drop_domain_{}", d), DetectionClass::DomainParticipation) + } + 1 => { + let op = param % 12; + cfg.op_enabled[op] = false; + (format!("disable_op_{}", op), DetectionClass::RuntimeEquivalence) + } + 2 => match param % 5 { + 0 => { + cfg.use_coupling = !cfg.use_coupling; + ("toggle_use_coupling".into(), DetectionClass::RuntimeEquivalence) + } + 1 => { + cfg.use_hidden = !cfg.use_hidden; + ("toggle_use_hidden".into(), DetectionClass::RuntimeEquivalence) + } + 2 => { + cfg.use_executor_salt = !cfg.use_executor_salt; + ("toggle_executor_salt".into(), DetectionClass::RuntimeEquivalence) + } + 3 => { + cfg.branch_enabled = !cfg.branch_enabled; + ("toggle_branch".into(), DetectionClass::RuntimeEquivalence) + } + _ => { + cfg.schedule_enabled = !cfg.schedule_enabled; + ("toggle_schedule".into(), DetectionClass::TemporalGate) + } + }, + 3 => { + cfg.record_causal = false; + ("disable_causal_recording".into(), DetectionClass::CausalGate) + } + 4 => { + let bit = param % 64; + cfg.c1 ^= 1u64 << bit; + (format!("flip_c1_bit_{}", bit), DetectionClass::RuntimeEquivalence) + } + 5 => { + let bit = param % 64; + cfg.c2 ^= 1u64 << bit; + (format!("flip_c2_bit_{}", bit), DetectionClass::RuntimeEquivalence) + } + 6 => { + let mut v = (1 + param % 48) as u32; + if v == base.s1 { + v = (v % 48) + 1; + } + cfg.s1 = v; + (format!("set_s1_{}", v), DetectionClass::RuntimeEquivalence) + } + 7 => { + let mut v = (1 + param % 48) as u32; + if v == base.s2 { + v = (v % 48) + 1; + } + cfg.s2 = v; + (format!("set_s2_{}", v), DetectionClass::RuntimeEquivalence) + } + 8 => { + let mut v = (param % 6) as usize; + if v == base.future_turns { + v = 4; + } + cfg.future_turns = v; + (format!("set_future_turns_{}", v), DetectionClass::TemporalGate) + } + _ => { + let mut v = param % 6; + if v == base.diffuse_span { + v = 5; + } + cfg.diffuse_span = v; + (format!("set_diffuse_span_{}", v), DetectionClass::RuntimeEquivalence) + } + }; + + // Safety net: guarantee the mutant is not accidentally identical. + if cfg == base { + cfg.use_hidden = !cfg.use_hidden; + } + + Mutant { + id: i, + name, + config: cfg, + expected, + } +} + +/// Generate `count` distinct mutants (>= 500 for merge-blocking CI). +pub fn generate_mutants(count: usize) -> Vec { + (0..count).map(mutant_for).collect() +} + +/// Precompute the reference canonical view for each input. +pub fn reference_canon(inputs: &[ResolutionInput]) -> Vec { + let cfg = EngineConfig::reference(); + 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 { + for (i, inp) in inputs.iter().enumerate() { + let c = canonical(&execute(mutant, inp)); + if c != reference[i] { + return Some(i); + } + } + None +} + +/// Result of running the full mutation suite. +#[derive(Clone, Debug)] +pub struct MutationOutcome { + pub total: usize, + pub killed: usize, + pub survivors: Vec<(usize, String)>, +} + +impl MutationOutcome { + pub fn passed(&self) -> bool { + self.survivors.is_empty() && self.total > 0 + } +} + +/// Run all mutants against the input corpus. +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())); + } + } + MutationOutcome { + total: mutants.len(), + killed, + survivors, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rune_ir::{RuneProgram, RuneToken, ALL_OPS}; + use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, 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 { + d.observed[l] = rng.range_i64(-5000, 5000); + } + for l in 0..world_model::HIDDEN_LANES { + d.hidden[l] = rng.range_i64(-5000, 5000); + } + } + for j in 0..NUM_DOMAINS { + for i in 0..NUM_DOMAINS { + w.causal_state.coupling[j][i] = rng.range_i64(-17, 17); + } + } + // cover every op and every domain + let tokens: Vec = (0..40) + .map(|i| RuneToken { + op: ALL_OPS[i % ALL_OPS.len()], + a: ((i * 3) % NUM_DOMAINS) as u8, + b: ((i * 5 + 1) % NUM_DOMAINS) as u8, + c: rng.next_u64() as u8, + imm: rng.range_i64(-100000, 100000), + }) + .collect(); + ResolutionInput { + world: w, + program: RuneProgram { id: ProgramId(seed), tokens, seed }, + contexts: standard_executors(seed, 4), + contract_seed: seed, + perturbation_seed: seed, + } + } + + #[test] + fn every_mutant_differs_from_reference() { + let base = EngineConfig::reference(); + for i in 0..600 { + assert_ne!(mutant_for(i).config, base, "mutant {i} equals reference"); + } + } + + #[test] + fn no_mutant_survives() { + let inputs: Vec = (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); + } +} diff --git a/crates/trace_model/Cargo.toml b/crates/trace_model/Cargo.toml new file mode 100644 index 0000000..46fbb03 --- /dev/null +++ b/crates/trace_model/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "trace_model" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +world_model = { path = "../world_model" } + +[lib] +path = "src/lib.rs" diff --git a/crates/trace_model/src/lib.rs b/crates/trace_model/src/lib.rs new file mode 100644 index 0000000..ff11a42 --- /dev/null +++ b/crates/trace_model/src/lib.rs @@ -0,0 +1,433 @@ +//! `trace_model` — the execution trace and all of its graphs, plus the +//! behavior fingerprint, replay record, fault log, and the metrics the trace +//! gates check (causal rank, causal edges, touched domains, fingerprint +//! collisions, executor divergence). + +use world_model::{combine_hashes, Hash, Hasher, NUM_DOMAINS}; + +pub mod matrix; +pub use matrix::numeric_rank; + +/// A graph over domains: per-domain access counts plus cross-domain edges. +/// Used for both the read graph and the write graph. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct DomainAccessGraph { + pub access_count: [u32; NUM_DOMAINS], + /// `(from_domain, to_domain, weight)` data-movement edges. + pub edges: Vec<(u8, u8, u32)>, +} + +impl DomainAccessGraph { + pub fn touched(&self) -> Vec { + (0..NUM_DOMAINS).filter(|&i| self.access_count[i] > 0).collect() + } + pub fn touched_count(&self) -> usize { + self.access_count.iter().filter(|&&c| c > 0).count() + } + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("access-graph"); + for &c in &self.access_count { + h.write_u64(c as u64); + } + h.write_usize(self.edges.len()); + for &(a, b, w) in &self.edges { + h.write_u8(a); + h.write_u8(b); + h.write_u64(w as u64); + } + h.finish() + } +} + +/// A node in the causal graph: a specific domain lane at a specific step. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub struct CausalNode { + pub domain: u8, + pub lane: u8, + pub hidden: bool, + pub step: u32, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct CausalEdge { + pub from: CausalNode, + pub to: CausalNode, + pub weight: i64, +} + +/// The causal dependency graph of an execution. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct CausalGraph { + pub edges: Vec, +} + +impl CausalGraph { + pub fn edge_count(&self) -> usize { + self.edges.len() + } + + /// Domains that participate as either source or sink of a causal edge. + pub fn touched_domains(&self) -> Vec { + let mut seen = [false; NUM_DOMAINS]; + for e in &self.edges { + seen[e.from.domain as usize % NUM_DOMAINS] = true; + seen[e.to.domain as usize % NUM_DOMAINS] = true; + } + (0..NUM_DOMAINS).filter(|&i| seen[i]).collect() + } + + pub fn touched_domain_count(&self) -> usize { + self.touched_domains().len() + } + + /// Aggregate domain-by-domain influence matrix (weights summed). + pub fn influence_matrix(&self) -> [[f64; NUM_DOMAINS]; NUM_DOMAINS] { + let mut m = [[0.0f64; NUM_DOMAINS]; NUM_DOMAINS]; + for e in &self.edges { + let i = e.from.domain as usize % NUM_DOMAINS; + let j = e.to.domain as usize % NUM_DOMAINS; + // Use a bounded, lane-distinguished contribution so distinct + // interactions remain linearly independent rather than collapsing + // into a single dominant magnitude. + let lane_phase = 1.0 + (e.from.lane as f64) + 4.0 * (e.to.lane as f64); + m[i][j] += lane_phase * ((e.weight & 0xffff) as f64 + 1.0); + } + m + } + + /// Causal rank: numeric rank of the influence matrix. + pub fn causal_rank(&self) -> usize { + let m = self.influence_matrix(); + let rows: Vec> = m.iter().map(|r| r.to_vec()).collect(); + numeric_rank(&rows) + } + + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("causal-graph"); + h.write_usize(self.edges.len()); + for e in &self.edges { + h.write_u8(e.from.domain); + h.write_u8(e.from.lane); + h.write_u8(e.from.hidden as u8); + h.write_u64(e.from.step as u64); + h.write_u8(e.to.domain); + h.write_u8(e.to.lane); + h.write_u8(e.to.hidden as u8); + h.write_u64(e.to.step as u64); + h.write_i64(e.weight); + } + h.finish() + } +} + +/// Information flow edges with continuous weights (bits of influence). +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct InformationFlowGraph { + /// `(from_domain, to_domain, influence_bits)` + pub edges: Vec<(u8, u8, u32)>, +} + +impl InformationFlowGraph { + pub fn total_bits(&self) -> u64 { + self.edges.iter().map(|&(_, _, b)| b as u64).sum() + } + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("info-flow"); + h.write_usize(self.edges.len()); + for &(a, b, w) in &self.edges { + h.write_u8(a); + h.write_u8(b); + h.write_u64(w as u64); + } + h.finish() + } +} + +/// Pairwise divergence between executors (fraction of differing lanes). +#[derive(Clone, PartialEq, Debug, Default)] +pub struct DivergenceGraph { + pub executor_count: usize, + /// Flattened `executor_count x executor_count` divergence fractions. + pub pairwise: Vec, +} + +impl DivergenceGraph { + pub fn get(&self, i: usize, j: usize) -> f64 { + if self.executor_count == 0 { + return 0.0; + } + self.pairwise[i * self.executor_count + j] + } + + /// Mean off-diagonal divergence. + pub fn mean_divergence(&self) -> f64 { + let n = self.executor_count; + if n < 2 { + return 0.0; + } + let mut sum = 0.0; + let mut cnt = 0; + for i in 0..n { + for j in 0..n { + if i != j { + sum += self.get(i, j); + cnt += 1; + } + } + } + sum / cnt as f64 + } + + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("divergence"); + h.write_usize(self.executor_count); + for &v in &self.pairwise { + h.write_i64((v * 1_000_000.0) as i64); + } + h.finish() + } +} + +/// Temporal graph: edges from an execution step to a future turn effect. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct TemporalGraph { + /// `(step, turn_offset, affected_domain)` + pub edges: Vec<(u32, u8, u8)>, +} + +impl TemporalGraph { + pub fn future_reach(&self) -> u8 { + self.edges.iter().map(|&(_, t, _)| t).max().unwrap_or(0) + } + pub fn edge_count(&self) -> usize { + self.edges.len() + } + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("temporal"); + h.write_usize(self.edges.len()); + for &(s, t, d) in &self.edges { + h.write_u64(s as u64); + h.write_u8(t); + h.write_u8(d); + } + h.finish() + } +} + +/// Summary of how perturbations affected this execution. Populated by the +/// metamorphic harness; default/empty in a bare resolve. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct PerturbationResponse { + pub total: usize, + pub altered_trace: usize, + pub altered_delta: usize, + pub altered_future: usize, + pub neutral_unexplained: usize, +} + +impl PerturbationResponse { + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("perturbation-response"); + h.write_usize(self.total); + h.write_usize(self.altered_trace); + h.write_usize(self.altered_delta); + h.write_usize(self.altered_future); + h.write_usize(self.neutral_unexplained); + h.finish() + } +} + +/// Behavior fingerprint: a stable hash plus a feature vector used by the +/// collapse analysis and behavior clustering. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct BehaviorFingerprint { + pub hash: Hash, + pub features: Vec, +} + +impl BehaviorFingerprint { + pub fn from_features(features: Vec) -> Self { + let mut h = Hasher::new(); + h.write_tag("behavior"); + h.write_usize(features.len()); + for &f in &features { + h.write_i64(f); + } + BehaviorFingerprint { + hash: h.finish(), + features, + } + } +} + +/// Faults are always logged, never panicked. Their presence is normal. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum FaultCode { + GuardedDivByZero, + Saturated, + OverflowWrapped, + EmptyAccumulator, + UnreachableBranch, + NoEffectToken, +} + +impl FaultCode { + pub fn name(self) -> &'static str { + match self { + FaultCode::GuardedDivByZero => "guarded_div_by_zero", + FaultCode::Saturated => "saturated", + FaultCode::OverflowWrapped => "overflow_wrapped", + FaultCode::EmptyAccumulator => "empty_accumulator", + FaultCode::UnreachableBranch => "unreachable_branch", + FaultCode::NoEffectToken => "no_effect_token", + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Fault { + pub code: FaultCode, + pub step: u32, + pub detail_code: i64, +} + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct FaultLog { + pub faults: Vec, +} + +impl FaultLog { + pub fn push(&mut self, code: FaultCode, step: u32, detail_code: i64) { + self.faults.push(Fault { + code, + step, + detail_code, + }); + } + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("fault-log"); + h.write_usize(self.faults.len()); + for f in &self.faults { + h.write_u8(f.code as u8); + h.write_u64(f.step as u64); + h.write_i64(f.detail_code); + } + h.finish() + } +} + +/// Replay record: seeds plus the three canonical hashes. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ReplayRecord { + pub world_seed: u64, + pub program_seed: u64, + pub contract_seed: u64, + pub perturbation_seed: u64, + pub trace_hash: Hash, + pub delta_hash: Hash, + pub future_hash: Hash, +} + +impl ReplayRecord { + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("replay-record"); + h.write_u64(self.world_seed); + h.write_u64(self.program_seed); + h.write_u64(self.contract_seed); + h.write_u64(self.perturbation_seed); + h.write_u64(self.trace_hash.0); + h.write_u64(self.delta_hash.0); + h.write_u64(self.future_hash.0); + h.finish() + } +} + +/// The full execution trace (per spec). +#[derive(Clone, PartialEq, Debug)] +pub struct ExecutionTrace { + pub read_graph: DomainAccessGraph, + pub write_graph: DomainAccessGraph, + pub causal_graph: CausalGraph, + pub information_flow: InformationFlowGraph, + pub executor_divergence: DivergenceGraph, + pub temporal_graph: TemporalGraph, + pub perturbation_response: PerturbationResponse, + pub behavior_fingerprint: BehaviorFingerprint, +} + +impl ExecutionTrace { + pub fn causal_rank(&self) -> usize { + self.causal_graph.causal_rank() + } + pub fn causal_edge_count(&self) -> usize { + self.causal_graph.edge_count() + } + /// Domains touched = union of read, write and causal participation. + pub fn touched_domain_count(&self) -> usize { + let mut seen = [false; NUM_DOMAINS]; + for i in self.read_graph.touched() { + seen[i] = true; + } + for i in self.write_graph.touched() { + seen[i] = true; + } + for i in self.causal_graph.touched_domains() { + seen[i] = true; + } + seen.iter().filter(|&&b| b).count() + } + pub fn context_divergence(&self) -> f64 { + self.executor_divergence.mean_divergence() + } + + /// Canonical hash over the whole trace (used by replay & equivalence). + pub fn canonical_hash(&self) -> Hash { + combine_hashes( + "execution-trace", + &[ + self.read_graph.hash(), + self.write_graph.hash(), + self.causal_graph.hash(), + self.information_flow.hash(), + self.executor_divergence.hash(), + self.temporal_graph.hash(), + self.perturbation_response.hash(), + self.behavior_fingerprint.hash, + ], + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_of_identity_is_full() { + let id: Vec> = (0..5) + .map(|i| (0..5).map(|j| if i == j { 1.0 } else { 0.0 }).collect()) + .collect(); + assert_eq!(numeric_rank(&id), 5); + } + + #[test] + fn rank_of_zero_is_zero() { + let z: Vec> = vec![vec![0.0; 4]; 4]; + assert_eq!(numeric_rank(&z), 0); + } + + #[test] + fn rank_of_rank_one_is_one() { + // every row a multiple of [1,2,3] + let m: Vec> = (1..=4).map(|k| vec![k as f64, 2.0 * k as f64, 3.0 * k as f64]).collect(); + assert_eq!(numeric_rank(&m), 1); + } +} diff --git a/crates/trace_model/src/matrix.rs b/crates/trace_model/src/matrix.rs new file mode 100644 index 0000000..e4491c1 --- /dev/null +++ b/crates/trace_model/src/matrix.rs @@ -0,0 +1,82 @@ +//! Small numeric linear-algebra helpers used by the trace and collapse gates. + +/// Numeric rank of a matrix via Gaussian elimination with partial pivoting. +/// Tolerance scales with the matrix magnitude so it is robust to the large +/// integer-derived weights the causal graph produces. +pub fn numeric_rank(rows_in: &[Vec]) -> usize { + if rows_in.is_empty() { + return 0; + } + let mut rows: Vec> = rows_in.to_vec(); + let nrows = rows.len(); + let ncols = rows[0].len(); + + let max_abs = rows + .iter() + .flat_map(|r| r.iter()) + .fold(0.0f64, |m, &v| m.max(v.abs())); + if max_abs == 0.0 { + return 0; + } + let tol = 1e-9 * max_abs * (nrows.max(ncols) as f64); + + let mut rank = 0; + let mut pivot_col = 0; + while rank < nrows && pivot_col < ncols { + // Find pivot row with the largest magnitude in pivot_col. + let mut best = rank; + let mut best_val = rows[rank][pivot_col].abs(); + for r in (rank + 1)..nrows { + let v = rows[r][pivot_col].abs(); + if v > best_val { + best_val = v; + best = r; + } + } + if best_val <= tol { + pivot_col += 1; + continue; + } + rows.swap(rank, best); + let pivot = rows[rank][pivot_col]; + for r in 0..nrows { + if r != rank { + let factor = rows[r][pivot_col] / pivot; + if factor != 0.0 { + for c in pivot_col..ncols { + rows[r][c] -= factor * rows[rank][c]; + } + } + } + } + rank += 1; + pivot_col += 1; + } + rank +} + +/// Pearson correlation between two equal-length series. Returns 0 if either is +/// constant. +pub fn correlation(xs: &[f64], ys: &[f64]) -> f64 { + let n = xs.len().min(ys.len()); + if n == 0 { + return 0.0; + } + let nf = n as f64; + let mx = xs[..n].iter().sum::() / nf; + let my = ys[..n].iter().sum::() / nf; + let mut cov = 0.0; + let mut vx = 0.0; + let mut vy = 0.0; + for i in 0..n { + let dx = xs[i] - mx; + let dy = ys[i] - my; + cov += dx * dy; + vx += dx * dx; + vy += dy * dy; + } + if vx <= 1e-12 || vy <= 1e-12 { + return 0.0; + } + cov / (vx.sqrt() * vy.sqrt()) +} diff --git a/crates/world_model/Cargo.toml b/crates/world_model/Cargo.toml new file mode 100644 index 0000000..7303c9a --- /dev/null +++ b/crates/world_model/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "world_model" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" diff --git a/crates/world_model/src/context.rs b/crates/world_model/src/context.rs new file mode 100644 index 0000000..d957e8c --- /dev/null +++ b/crates/world_model/src/context.rs @@ -0,0 +1,107 @@ +//! Execution contexts (executors). Different executors interpret the same +//! rune stream differently, which produces executor divergence. The spec +//! requires at least 3 distinct executors per case. + +use crate::primitives::{Hasher, Rng}; + +/// Distinct executor interpretation styles. Each one mixes rune operands +/// differently, so the same program produces different traces under each. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ExecutorKind { + /// Aggressive forward mixing; favors multiplicative coupling. + Surge, + /// Lateral mixing; favors xor/rotate coupling across domains. + Weave, + /// Conservative mixing; clamps and favors additive coupling. + Anchor, + /// Phase-shifting; reorders operand roles. + Phase, +} + +pub const ALL_EXECUTOR_KINDS: [ExecutorKind; 4] = [ + ExecutorKind::Surge, + ExecutorKind::Weave, + ExecutorKind::Anchor, + ExecutorKind::Phase, +]; + +impl ExecutorKind { + pub fn name(self) -> &'static str { + match self { + ExecutorKind::Surge => "surge", + ExecutorKind::Weave => "weave", + ExecutorKind::Anchor => "anchor", + ExecutorKind::Phase => "phase", + } + } + + pub fn index(self) -> usize { + match self { + ExecutorKind::Surge => 0, + ExecutorKind::Weave => 1, + ExecutorKind::Anchor => 2, + ExecutorKind::Phase => 3, + } + } + + pub fn salt(self) -> u64 { + match self { + ExecutorKind::Surge => 0x51_75_72_67_65_00_00_01, + ExecutorKind::Weave => 0x57_65_61_76_65_00_00_02, + ExecutorKind::Anchor => 0x41_6e_63_68_72_00_00_03, + ExecutorKind::Phase => 0x50_68_61_73_65_00_00_04, + } + } +} + +/// Parameters that modulate rune interpretation for one executor. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ExecutorProfile { + pub kind: ExecutorKind, + pub bias: i64, + pub rotate: u32, + pub branch_threshold: i64, +} + +/// One executor. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ExecutionContext { + pub executor_id: u32, + pub profile: ExecutorProfile, +} + +impl ExecutionContext { + pub fn hash_into(&self, h: &mut Hasher) { + h.write_tag("executor"); + h.write_u64(self.executor_id as u64); + h.write_u8(self.profile.kind.index() as u8); + h.write_i64(self.profile.bias); + h.write_u64(self.profile.rotate as u64); + h.write_i64(self.profile.branch_threshold); + } + + pub fn salt(&self) -> u64 { + self.profile.kind.salt() ^ (self.executor_id as u64).wrapping_mul(0x9e3779b97f4a7c15) + } +} + +/// Build `count` distinct executors deterministically from a seed. Always +/// produces at least 3 with distinct kinds. +pub fn standard_executors(seed: u64, count: usize) -> Vec { + let count = count.max(3); + let mut rng = Rng::derive(seed, "executors"); + let mut out = Vec::with_capacity(count); + for i in 0..count { + let kind = ALL_EXECUTOR_KINDS[i % ALL_EXECUTOR_KINDS.len()]; + out.push(ExecutionContext { + executor_id: i as u32, + profile: ExecutorProfile { + kind, + bias: rng.range_i64(-7, 7), + rotate: (1 + rng.below(31)) as u32, + branch_threshold: rng.range_i64(-1000, 1000), + }, + }); + } + out +} diff --git a/crates/world_model/src/domain.rs b/crates/world_model/src/domain.rs new file mode 100644 index 0000000..0e58274 --- /dev/null +++ b/crates/world_model/src/domain.rs @@ -0,0 +1,227 @@ +//! World domains. The spec mandates at least 8 *independent* domains, each +//! exposing read/write surfaces, perturbation axes, and a fingerprint. +//! +//! Each domain holds `LANES` observed values and `HIDDEN_LANES` hidden values. +//! Domains differ from one another by per-kind mixing constants and by the +//! perturbation axes they expose, which is what makes them genuinely +//! independent rather than eight copies of one decorative axis. + +use crate::perturb::{ + HiddenFlipAxis, LaneBumpAxis, LaneScaleAxis, LaneSwapAxis, PerturbationAxis, +}; +use crate::primitives::{DomainId, Hash, Hasher, HIDDEN_LANES, LANES, NUM_DOMAINS}; + +/// The eight independent domains. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] +pub enum DomainKind { + Aether, + Matter, + Flux, + Mind, + Entropy, + Resonance, + Boundary, + Echo, +} + +pub const ALL_DOMAIN_KINDS: [DomainKind; NUM_DOMAINS] = [ + DomainKind::Aether, + DomainKind::Matter, + DomainKind::Flux, + DomainKind::Mind, + DomainKind::Entropy, + DomainKind::Resonance, + DomainKind::Boundary, + DomainKind::Echo, +]; + +impl DomainKind { + pub fn index(self) -> usize { + match self { + DomainKind::Aether => 0, + DomainKind::Matter => 1, + DomainKind::Flux => 2, + DomainKind::Mind => 3, + DomainKind::Entropy => 4, + DomainKind::Resonance => 5, + DomainKind::Boundary => 6, + DomainKind::Echo => 7, + } + } + + pub fn from_index(i: usize) -> DomainKind { + ALL_DOMAIN_KINDS[i % NUM_DOMAINS] + } + + pub fn name(self) -> &'static str { + match self { + DomainKind::Aether => "aether", + DomainKind::Matter => "matter", + DomainKind::Flux => "flux", + DomainKind::Mind => "mind", + DomainKind::Entropy => "entropy", + DomainKind::Resonance => "resonance", + DomainKind::Boundary => "boundary", + DomainKind::Echo => "echo", + } + } + + /// Distinct odd mixing constant per kind. These drive the nonlinear + /// avalanche in the runtime and guarantee each domain transforms state + /// differently from every other domain. + pub fn mix_const(self) -> u64 { + match self { + DomainKind::Aether => 0x9e3779b97f4a7c15, + DomainKind::Matter => 0xc2b2ae3d27d4eb4f, + DomainKind::Flux => 0x165667b19e3779f9, + DomainKind::Mind => 0x27d4eb2f165667c5, + DomainKind::Entropy => 0x2545f4914f6cdd1d, + DomainKind::Resonance => 0x85ebca77c2b2ae63, + DomainKind::Boundary => 0xff51afd7ed558ccd, + DomainKind::Echo => 0xc4ceb9fe1a85ec53, + } + } + + /// Per-kind rotation amount (kept in 1..63). + pub fn rotate(self) -> u32 { + 7 + (self.index() as u32) * 7 % 53 + 1 + } + + pub fn id(self) -> DomainId { + DomainId(self.index() as u8) + } +} + +/// Per-domain state: observed and hidden lanes. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct DomainState { + pub kind: DomainKind, + pub observed: [i64; LANES], + pub hidden: [i64; HIDDEN_LANES], +} + +impl DomainState { + pub fn new(kind: DomainKind) -> Self { + DomainState { + kind, + observed: [0; LANES], + hidden: [0; HIDDEN_LANES], + } + } + + pub fn id(&self) -> DomainId { + self.kind.id() + } + + /// Hash mixing kind + all state. Used inside replay/behavior hashes. + pub fn hash_into(&self, h: &mut Hasher) { + h.write_tag("domain"); + h.write_u8(self.kind.index() as u8); + for &v in &self.observed { + h.write_i64(v); + } + for &v in &self.hidden { + h.write_i64(v); + } + } +} + +/// What a domain currently exposes to be read. +#[derive(Clone, Debug)] +pub struct ReadSurface { + pub domain: DomainId, + pub observed: Vec, + pub hidden: Vec, +} + +/// What a domain currently allows to be written. +#[derive(Clone, Debug)] +pub struct WriteSurface { + pub domain: DomainId, + pub writable_observed: Vec, + pub writable_hidden: Vec, +} + +/// A structural+state fingerprint of a domain. Different kinds must produce +/// different fingerprints (checked by the generators as "nonuniform domain +/// fingerprints"). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct DomainFingerprint { + pub domain: DomainId, + pub hash: Hash, +} + +/// The trait every domain exposes (per spec). +pub trait WorldDomain { + fn domain_id(&self) -> DomainId; + fn read_surface(&self) -> ReadSurface; + fn write_surface(&self) -> WriteSurface; + fn perturbation_axes(&self) -> Vec>; + fn fingerprint(&self) -> DomainFingerprint; +} + +impl WorldDomain for DomainState { + fn domain_id(&self) -> DomainId { + self.kind.id() + } + + fn read_surface(&self) -> ReadSurface { + ReadSurface { + domain: self.kind.id(), + observed: self.observed.to_vec(), + hidden: self.hidden.to_vec(), + } + } + + fn write_surface(&self) -> WriteSurface { + WriteSurface { + domain: self.kind.id(), + writable_observed: (0..LANES).collect(), + writable_hidden: (0..HIDDEN_LANES).collect(), + } + } + + fn perturbation_axes(&self) -> Vec> { + let d = self.kind.id(); + // Each domain exposes several distinct axes derived from its surface. + // Across 8 domains this is well above the required 10 axes per case. + let mut axes: Vec> = Vec::new(); + axes.push(Box::new(LaneBumpAxis { + domain: d, + lane: 0, + delta: 1, + })); + axes.push(Box::new(LaneBumpAxis { + domain: d, + lane: (self.kind.index() % LANES), + delta: -3, + })); + axes.push(Box::new(LaneScaleAxis { + domain: d, + lane: (self.kind.index() + 1) % LANES, + factor: 3, + })); + axes.push(Box::new(HiddenFlipAxis { + domain: d, + lane: self.kind.index() % HIDDEN_LANES, + })); + axes.push(Box::new(LaneSwapAxis { + domain: d, + lane_a: 0, + lane_b: (self.kind.index() % (LANES - 1)) + 1, + })); + axes + } + + fn fingerprint(&self) -> DomainFingerprint { + let mut h = Hasher::new(); + h.write_tag("domain-fingerprint"); + h.write_u8(self.kind.index() as u8); + h.write_u64(self.kind.mix_const()); + self.hash_into(&mut h); + DomainFingerprint { + domain: self.kind.id(), + hash: h.finish(), + } + } +} diff --git a/crates/world_model/src/lib.rs b/crates/world_model/src/lib.rs new file mode 100644 index 0000000..f62cefd --- /dev/null +++ b/crates/world_model/src/lib.rs @@ -0,0 +1,74 @@ +//! `world_model` — the foundational crate. Defines world state, the eight +//! independent domains, perturbation axes, execution contexts, world deltas, +//! and the deterministic primitives (ids, stable hashing, RNG) used by every +//! other crate. + +pub mod context; +pub mod domain; +pub mod perturb; +pub mod primitives; +pub mod world; + +pub use context::{ + standard_executors, ExecutionContext, ExecutorKind, ExecutorProfile, ALL_EXECUTOR_KINDS, +}; +pub use domain::{ + DomainFingerprint, DomainKind, DomainState, ReadSurface, WorldDomain, WriteSurface, + ALL_DOMAIN_KINDS, +}; +pub use perturb::{ + HiddenFlipAxis, LaneBumpAxis, LaneScaleAxis, LaneSwapAxis, PerturbationAxis, + TraceDifferenceExpectation, +}; +pub use primitives::{ + combine_hashes, hash_i64_slice, ContractId, DomainId, Hash, Hasher, ProgramId, Rng, WorldId, + HIDDEN_LANES, LANES, NUM_DOMAINS, +}; +pub use world::{ + CausalState, DomainDelta, ExecutionState, ObservationState, ScheduledEffect, TimeState, + WorldDelta, WorldSnapshot, REGS, +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rng_is_deterministic() { + let mut a = Rng::new(42); + let mut b = Rng::new(42); + for _ in 0..1000 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn hashing_is_stable_and_sensitive() { + let h1 = hash_i64_slice("t", &[1, 2, 3]); + let h2 = hash_i64_slice("t", &[1, 2, 3]); + let h3 = hash_i64_slice("t", &[1, 2, 4]); + assert_eq!(h1, h2); + assert_ne!(h1, h3); + } + + #[test] + fn domain_fingerprints_are_nonuniform() { + let w = WorldSnapshot::blank(WorldId(1), 7); + let mut prints: Vec<_> = w.domains.iter().map(|d| d.fingerprint().hash).collect(); + prints.sort(); + prints.dedup(); + // even with identical (zero) state, distinct kinds give distinct prints + assert_eq!(prints.len(), NUM_DOMAINS); + } + + #[test] + fn perturbation_changes_world() { + let mut w = WorldSnapshot::blank(WorldId(1), 7); + w.domains[0].observed[0] = 100; + let before = w.content_hash(); + let axis = LaneBumpAxis { domain: DomainId(0), lane: 0, delta: 5 }; + let w2 = axis.apply(&w); + assert_ne!(before, w2.content_hash()); + assert_eq!(w2.domains[0].observed[0], 105); + } +} diff --git a/crates/world_model/src/perturb.rs b/crates/world_model/src/perturb.rs new file mode 100644 index 0000000..f7a43e7 --- /dev/null +++ b/crates/world_model/src/perturb.rs @@ -0,0 +1,152 @@ +//! Perturbation axes. Perturbations are derived from domain surfaces (not a +//! fixed global list) and each declares what trace/world difference it is +//! expected to cause. The metamorphic gates check that these expectations +//! actually hold across the corpus. + +use crate::primitives::DomainId; +use crate::world::WorldSnapshot; + +/// What difference a perturbation is expected to produce. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TraceDifferenceExpectation { + pub expect_trace_change: bool, + pub expect_delta_change: bool, + pub expect_future_change: bool, + /// If the perturbation is allowed to be observationally neutral, this + /// explains why (spec allows <=5% neutral *with explanation*). + pub neutral_explanation: Option<&'static str>, +} + +impl TraceDifferenceExpectation { + pub fn active() -> Self { + TraceDifferenceExpectation { + expect_trace_change: true, + expect_delta_change: true, + expect_future_change: true, + neutral_explanation: None, + } + } +} + +/// A perturbation axis derived from a domain surface. +pub trait PerturbationAxis { + fn name(&self) -> String; + fn target(&self) -> DomainId; + fn apply(&self, world: &WorldSnapshot) -> WorldSnapshot; + fn expected_trace_difference(&self) -> TraceDifferenceExpectation; +} + +fn domain_mut<'a>(world: &'a mut WorldSnapshot, d: DomainId) -> &'a mut crate::domain::DomainState { + &mut world.domains[d.0 as usize] +} + +/// Add a delta to an observed lane. +pub struct LaneBumpAxis { + pub domain: DomainId, + pub lane: usize, + pub delta: i64, +} + +impl PerturbationAxis for LaneBumpAxis { + fn name(&self) -> String { + format!("bump(d{},l{},{:+})", self.domain.0, self.lane, self.delta) + } + fn target(&self) -> DomainId { + self.domain + } + fn apply(&self, world: &WorldSnapshot) -> WorldSnapshot { + let mut w = world.clone(); + let ds = domain_mut(&mut w, self.domain); + ds.observed[self.lane] = ds.observed[self.lane].wrapping_add(self.delta); + w.mark_perturbed(); + w + } + fn expected_trace_difference(&self) -> TraceDifferenceExpectation { + TraceDifferenceExpectation::active() + } +} + +/// Multiply an observed lane by a factor. +pub struct LaneScaleAxis { + pub domain: DomainId, + pub lane: usize, + pub factor: i64, +} + +impl PerturbationAxis for LaneScaleAxis { + fn name(&self) -> String { + format!("scale(d{},l{},x{})", self.domain.0, self.lane, self.factor) + } + fn target(&self) -> DomainId { + self.domain + } + fn apply(&self, world: &WorldSnapshot) -> WorldSnapshot { + let mut w = world.clone(); + let ds = domain_mut(&mut w, self.domain); + ds.observed[self.lane] = ds.observed[self.lane].wrapping_mul(self.factor).wrapping_add(1); + w.mark_perturbed(); + w + } + fn expected_trace_difference(&self) -> TraceDifferenceExpectation { + TraceDifferenceExpectation::active() + } +} + +/// Flip the sign / toggle a hidden lane. Hidden changes may be observationally +/// neutral on the immediate delta but should still influence future turns. +pub struct HiddenFlipAxis { + pub domain: DomainId, + pub lane: usize, +} + +impl PerturbationAxis for HiddenFlipAxis { + fn name(&self) -> String { + format!("hidden(d{},l{})", self.domain.0, self.lane) + } + fn target(&self) -> DomainId { + self.domain + } + fn apply(&self, world: &WorldSnapshot) -> WorldSnapshot { + let mut w = world.clone(); + let ds = domain_mut(&mut w, self.domain); + ds.hidden[self.lane] = !ds.hidden[self.lane].wrapping_add(0x5bd1e995); + w.mark_perturbed(); + w + } + fn expected_trace_difference(&self) -> TraceDifferenceExpectation { + // Hidden state feeds the runtime, so we still expect change; but it is + // permitted to be observationally neutral on the immediate delta. + TraceDifferenceExpectation { + expect_trace_change: true, + expect_delta_change: false, + expect_future_change: true, + neutral_explanation: Some("hidden lane influences future, not immediate observed delta"), + } + } +} + +/// Swap two observed lanes. +pub struct LaneSwapAxis { + pub domain: DomainId, + pub lane_a: usize, + pub lane_b: usize, +} + +impl PerturbationAxis for LaneSwapAxis { + fn name(&self) -> String { + format!("swap(d{},l{}<->l{})", self.domain.0, self.lane_a, self.lane_b) + } + fn target(&self) -> DomainId { + self.domain + } + fn apply(&self, world: &WorldSnapshot) -> WorldSnapshot { + let mut w = world.clone(); + let ds = domain_mut(&mut w, self.domain); + ds.observed.swap(self.lane_a, self.lane_b); + w.mark_perturbed(); + w + } + fn expected_trace_difference(&self) -> TraceDifferenceExpectation { + TraceDifferenceExpectation::active() + } +} diff --git a/crates/world_model/src/primitives.rs b/crates/world_model/src/primitives.rs new file mode 100644 index 0000000..d5bc9fd --- /dev/null +++ b/crates/world_model/src/primitives.rs @@ -0,0 +1,214 @@ +//! Deterministic primitives shared across the whole framework: stable ids, +//! a stable content hash, and a deterministic RNG. Everything here is +//! reproducible from a seed so that replay is bit-exact. + +use std::fmt; + +/// Number of independent world domains. The spec mandates >= 8. +pub const NUM_DOMAINS: usize = 8; +/// Observed value lanes per domain. +pub const LANES: usize = 4; +/// Hidden (unobserved) value lanes per domain. These create the +/// hidden/observed state divergence the spec requires. +pub const HIDDEN_LANES: usize = 2; + +macro_rules! id_type { + ($name:ident, $inner:ty) => { + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(pub $inner); + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", stringify!($name), self.0) + } + } + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +id_type!(WorldId, u64); +id_type!(ProgramId, u64); +id_type!(ContractId, u64); + +/// Identifies one of the [`NUM_DOMAINS`] domains. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DomainId(pub u8); + +impl fmt::Debug for DomainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "DomainId({})", self.0) + } +} + +/// A stable 64-bit content hash. Used for canonical comparison, replay +/// hashes, and behavior fingerprints. Implemented with FNV-1a so the value +/// is identical across machines and runs. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Hash(pub u64); + +impl fmt::Debug for Hash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Hash({:016x})", self.0) + } +} + +impl fmt::Display for Hash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:016x}", self.0) + } +} + +const FNV_OFFSET: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +/// Streaming stable hasher (FNV-1a, 64 bit). +#[derive(Clone, Copy)] +pub struct Hasher { + state: u64, +} + +impl Default for Hasher { + fn default() -> Self { + Hasher { state: FNV_OFFSET } + } +} + +impl Hasher { + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub fn write_u8(&mut self, b: u8) { + self.state ^= b as u64; + self.state = self.state.wrapping_mul(FNV_PRIME); + } + + #[inline] + pub fn write_u64(&mut self, v: u64) { + for i in 0..8 { + self.write_u8(((v >> (i * 8)) & 0xff) as u8); + } + } + + #[inline] + pub fn write_i64(&mut self, v: i64) { + self.write_u64(v as u64); + } + + #[inline] + pub fn write_usize(&mut self, v: usize) { + self.write_u64(v as u64); + } + + #[inline] + pub fn write_bytes(&mut self, bytes: &[u8]) { + for &b in bytes { + self.write_u8(b); + } + } + + /// Mix in a label so structurally different streams that happen to share + /// numbers do not collide. + #[inline] + pub fn write_tag(&mut self, tag: &str) { + self.write_bytes(tag.as_bytes()); + self.write_u8(0xff); + } + + #[inline] + pub fn finish(&self) -> Hash { + Hash(self.state) + } +} + +/// Hash a slice of i64 with a tag. +pub fn hash_i64_slice(tag: &str, vals: &[i64]) -> Hash { + let mut h = Hasher::new(); + h.write_tag(tag); + h.write_usize(vals.len()); + for &v in vals { + h.write_i64(v); + } + h.finish() +} + +/// Combine several hashes into one (order sensitive). +pub fn combine_hashes(tag: &str, hashes: &[Hash]) -> Hash { + let mut h = Hasher::new(); + h.write_tag(tag); + for hh in hashes { + h.write_u64(hh.0); + } + h.finish() +} + +/// Deterministic SplitMix64 RNG. Fully reproducible from a seed. +#[derive(Clone, Copy, Debug)] +pub struct Rng { + state: u64, +} + +impl Rng { + pub fn new(seed: u64) -> Self { + // Avoid the trivial all-zero state. + Rng { + state: seed ^ 0x9e3779b97f4a7c15, + } + } + + /// Derive a sub-stream from a seed and a label, so independent concerns + /// never accidentally share a stream. + pub fn derive(seed: u64, tag: &str) -> Self { + let mut h = Hasher::new(); + h.write_tag(tag); + h.write_u64(seed); + Rng::new(h.finish().0) + } + + #[inline] + pub fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9e3779b97f4a7c15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb); + z ^ (z >> 31) + } + + #[inline] + pub fn next_i64(&mut self) -> i64 { + self.next_u64() as i64 + } + + /// Uniform-ish integer in `[0, n)`. + #[inline] + pub fn below(&mut self, n: usize) -> usize { + if n == 0 { + return 0; + } + (self.next_u64() % (n as u64)) as usize + } + + /// Integer in `[lo, hi]` inclusive. + #[inline] + pub fn range_i64(&mut self, lo: i64, hi: i64) -> i64 { + if hi <= lo { + return lo; + } + let span = (hi - lo) as u64 + 1; + lo + (self.next_u64() % span) as i64 + } + + #[inline] + pub fn chance(&mut self, p: f64) -> bool { + (self.next_u64() as f64 / u64::MAX as f64) < p + } + + #[inline] + pub fn next_bool(&mut self) -> bool { + self.next_u64() & 1 == 1 + } +} diff --git a/crates/world_model/src/world.rs b/crates/world_model/src/world.rs new file mode 100644 index 0000000..e485b6a --- /dev/null +++ b/crates/world_model/src/world.rs @@ -0,0 +1,265 @@ +//! The world snapshot and its constituent state machines. + +use crate::domain::{DomainState, ALL_DOMAIN_KINDS}; +use crate::primitives::{ + DomainId, Hash, Hasher, Rng, HIDDEN_LANES, LANES, NUM_DOMAINS, +}; + +/// Number of world-level execution accumulator registers. +pub const REGS: usize = 4; + +/// Cross-domain coupling. A dense `NUM_DOMAINS x NUM_DOMAINS` weight matrix +/// that governs how a change in one domain propagates into others when turns +/// advance. Generated per world; high rank by construction. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CausalState { + pub coupling: [[i64; NUM_DOMAINS]; NUM_DOMAINS], +} + +impl CausalState { + pub fn hash_into(&self, h: &mut Hasher) { + h.write_tag("causal"); + for row in &self.coupling { + for &v in row { + h.write_i64(v); + } + } + } +} + +/// Which lanes are observable, plus a deterministic observation-noise seed. +/// Drives the divergence between hidden ground truth and observed projection. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ObservationState { + pub visible: [[bool; LANES]; NUM_DOMAINS], + pub noise_seed: u64, +} + +impl ObservationState { + pub fn hash_into(&self, h: &mut Hasher) { + h.write_tag("observation"); + for row in &self.visible { + for &b in row { + h.write_u8(b as u8); + } + } + h.write_u64(self.noise_seed); + } +} + +/// World-level execution accumulators carried across runes. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ExecutionState { + pub accumulator: [i64; REGS], +} + +impl ExecutionState { + pub fn hash_into(&self, h: &mut Hasher) { + h.write_tag("execstate"); + for &v in &self.accumulator { + h.write_i64(v); + } + } +} + +/// A future effect scheduled by execution; resolved when turns advance. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ScheduledEffect { + pub turn_offset: u8, + pub domain: DomainId, + pub lane: usize, + pub hidden: bool, + pub value: i64, +} + +/// Temporal state: pending scheduled effects create genuine future dependence. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct TimeState { + pub pending: Vec, +} + +impl TimeState { + pub fn hash_into(&self, h: &mut Hasher) { + h.write_tag("time"); + h.write_usize(self.pending.len()); + for e in &self.pending { + h.write_u8(e.turn_offset); + h.write_u8(e.domain.0); + h.write_usize(e.lane); + h.write_u8(e.hidden as u8); + h.write_i64(e.value); + } + } +} + +/// The full world snapshot (per spec). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct WorldSnapshot { + pub id: crate::primitives::WorldId, + pub turn: u64, + pub domains: Vec, + pub causal_state: CausalState, + pub observation_state: ObservationState, + pub execution_state: ExecutionState, + pub time_state: TimeState, + pub seed: u64, + /// Provenance counter for perturbations; never consumed by the runtime. + pub perturb_nonce: u64, +} + +impl WorldSnapshot { + /// An all-zero baseline world (the generators fill it with real state). + pub fn blank(id: crate::primitives::WorldId, seed: u64) -> Self { + let domains = ALL_DOMAIN_KINDS.iter().map(|&k| DomainState::new(k)).collect(); + WorldSnapshot { + id, + turn: 0, + domains, + causal_state: CausalState { + coupling: [[0; NUM_DOMAINS]; NUM_DOMAINS], + }, + observation_state: ObservationState { + visible: [[true; LANES]; NUM_DOMAINS], + noise_seed: seed ^ 0xa5a5a5a5, + }, + execution_state: ExecutionState { + accumulator: [0; REGS], + }, + time_state: TimeState::default(), + seed, + perturb_nonce: 0, + } + } + + pub fn mark_perturbed(&mut self) { + self.perturb_nonce = self.perturb_nonce.wrapping_add(1); + } + + pub fn domain(&self, d: DomainId) -> &DomainState { + &self.domains[d.0 as usize] + } + + pub fn domain_mut(&mut self, d: DomainId) -> &mut DomainState { + &mut self.domains[d.0 as usize] + } + + /// Observed projection: only visible observed lanes, with deterministic + /// observation noise. Hidden lanes are excluded entirely. This is what an + /// outside observer can measure, and differs from ground truth. + pub fn observed_projection(&self) -> Vec { + let mut rng = Rng::new(self.observation_state.noise_seed ^ self.turn); + let mut out = Vec::with_capacity(NUM_DOMAINS * LANES); + for (di, d) in self.domains.iter().enumerate() { + for lane in 0..LANES { + if self.observation_state.visible[di][lane] { + let noise = rng.range_i64(-1, 1); + out.push(d.observed[lane].wrapping_add(noise)); + } else { + out.push(0); + } + } + } + out + } + + /// Ground-truth state vector (observed + hidden), no noise. Used by the + /// runtime and by canonical hashing. + pub fn ground_truth(&self) -> Vec { + let mut out = Vec::with_capacity(NUM_DOMAINS * (LANES + HIDDEN_LANES)); + for d in &self.domains { + out.extend_from_slice(&d.observed); + out.extend_from_slice(&d.hidden); + } + out + } + + pub fn content_hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("world"); + h.write_u64(self.id.0); + h.write_u64(self.turn); + h.write_u64(self.seed); + for d in &self.domains { + d.hash_into(&mut h); + } + self.causal_state.hash_into(&mut h); + self.observation_state.hash_into(&mut h); + self.execution_state.hash_into(&mut h); + self.time_state.hash_into(&mut h); + h.finish() + } +} + +/// Difference of a single domain (after - before, wrapping). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct DomainDelta { + pub domain: DomainId, + pub observed: [i64; LANES], + pub hidden: [i64; HIDDEN_LANES], +} + +impl DomainDelta { + pub fn is_zero(&self) -> bool { + self.observed.iter().all(|&v| v == 0) && self.hidden.iter().all(|&v| v == 0) + } +} + +/// The change produced by an execution. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct WorldDelta { + pub domain_deltas: Vec, + pub turn_advance: u64, +} + +impl WorldDelta { + /// Compute `after - before`. + pub fn between(before: &WorldSnapshot, after: &WorldSnapshot) -> WorldDelta { + let mut deltas = Vec::with_capacity(NUM_DOMAINS); + for i in 0..NUM_DOMAINS { + let b = &before.domains[i]; + let a = &after.domains[i]; + let mut observed = [0i64; LANES]; + let mut hidden = [0i64; HIDDEN_LANES]; + for l in 0..LANES { + observed[l] = a.observed[l].wrapping_sub(b.observed[l]); + } + for l in 0..HIDDEN_LANES { + hidden[l] = a.hidden[l].wrapping_sub(b.hidden[l]); + } + deltas.push(DomainDelta { + domain: b.id(), + observed, + hidden, + }); + } + WorldDelta { + domain_deltas: deltas, + turn_advance: after.turn.wrapping_sub(before.turn), + } + } + + /// Domains that actually changed. + pub fn touched_domains(&self) -> Vec { + self.domain_deltas + .iter() + .filter(|d| !d.is_zero()) + .map(|d| d.domain) + .collect() + } + + pub fn hash(&self) -> Hash { + let mut h = Hasher::new(); + h.write_tag("world-delta"); + h.write_u64(self.turn_advance); + for d in &self.domain_deltas { + h.write_u8(d.domain.0); + for &v in &d.observed { + h.write_i64(v); + } + for &v in &d.hidden { + h.write_i64(v); + } + } + h.finish() + } +}