Compare commits

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:39:50 -07:00
linus-dandClaude Opus 4.8 11162ae448 Stream full-trace evidence at 100% coverage; verified at merge scale (1M)
run_all_to streams every base execution's full trace+delta to disk (no sampling);
main writes evidence/traces.tsv covering 100% of base executions. Verified
end-to-end at the real merge profile:

  merge run: 1,000,000 executions / 11,000,000 comparisons, all gates PASS, 288s
  evidence: leaves.tsv = 11,000,000 leaves; traces.tsv = 1,000,000 full traces (3.9G)
  independent attest: recomputed root a2b27c027f87788f over 11,000,000 leaves ==
    claimed; 1,000,000/1,000,000 full traces reconstructed and re-derived to
    retained leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:00:59 -07:00
linus-dandClaude Opus 4.8 bea076df43 Finding 4: full-trace evidence — reconstructed and re-derived, not summarized
Removes the deferral. Each sampled execution's FULL trace (every graph edge,
count, weight) and delta are persisted as evidence; the independent attestor
reconstructs them, recomputes the canonical trace hash and delta hash, re-derives
the replay-record leaf, and confirms it is among the retained Merkle leaves the
root is built from. The behavior-fingerprint hash is re-derived from features
(not trusted), and f64 divergence is stored bit-exact for an identical hash.

- trace_model: ExecutionTrace::serialize/deserialize (round-trips canonical_hash;
  total on garbage). Test: full_trace_serialize_roundtrips_canonical_hash.
- world_model: WorldDelta::serialize/deserialize (round-trips hash).
- ci_reports: retains TRACE_EVIDENCE_SAMPLE full traces; writes
  evidence/traces.tsv.
- attestation: depends on trace_model/world_model; reconstructs each trace,
  recomputes the leaf, requires it to match the claimed leaf AND be a retained
  leaf. Negative control: tampered_trace_breaks_attestation (corrupting the full
  trace, leaving the claimed leaf, fails attestation).
- merge-gates: requires evidence/traces.tsv; the separate attest step verifies it.

End-to-end (fast profile): 256/256 full traces reconstructed and re-derived to
retained leaves; recomputed root matches the claim over 6600 leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:22:55 -07:00
linus-dandClaude Opus 4.8 1e50c80627 Compliance hardening: enforce measured behavior, real gates, retained evidence
Addresses the attached findings as blocking compliance failures. Each fix
removes a substitution pattern and adds a negative-control test.

Finding 9 (hash changes != enforced metamorphic expectations):
  ci_reports now enforces a sound metamorphic relation per perturbation — a
  perturbation the program CONSUMES must alter the trace — grounded in
  reachability, with a non-vacuity check. Test:
  consumed_perturbation_must_alter_trace.

Finding 10 (trace counts != causal explanation):
  New causal_explanation gate ablates each recorded causal edge's source lane
  and requires the destination delta to change. Measured: true-source 0.77 vs
  scrambled-source 0.03. Threshold 0.50. New required report
  causal_explanation_report. Test:
  causal_edges_are_intervention_confirmed_not_counted.

Finding 6 (static seed corpus != failure retention):
  replay_corpus::retention adds a committed, append-only counterexample corpus
  (retained_failures.tsv) re-verified every run against the independent runtime.
  Negative control: reintroduced_bug_is_caught_by_retention.

Finding 5 (mini mutation evaluator != real acceptance gate):
  Mutants are now killed by ci_reports' OWN acceptance-gate predicates with
  single-sourced thresholds (TRACE_EDGES_MIN, etc.); evaluate_mutants replaces
  semantic_mutation::run_suite on the acceptance path. Test:
  mutants_killed_by_real_acceptance_gates.

Findings 2 & 3 (generated report != independent attestation; merkle root !=
provenance without leaves):
  ci_reports persists every Merkle leaf (evidence/leaves.tsv) + claims. New
  `attestation` crate + `attest` binary recompute the root from the leaves in a
  SEPARATE process that never reads compliance_report.json; wired as a distinct
  merge-gates step. Negative control: tampered_leaf_breaks_attestation.

Finding 8 (structural indicators != measured behavior):
  Domain gate already requires measured influence/mutation/removal; added an
  explicit reject for "appears structurally but no measured influence".

Finding 1 (workflow != merge enforcement): BLOCKED on server-side branch
  protection. Added .github/rulesets/main-required-checks.json + apply command;
  enforcement still requires a repo admin to activate the ruleset.

Finding 7 (protocol socket E2E != rendered browser E2E): BLOCKED on a CI browser
  runner; rendered-browser E2E remains advisory-only.

Finding 4 (trace summary != full trace evidence): PARTIAL. Each retained leaf
  binds the full trace via canonical_hash over all graph edges, and the root is
  independently recomputed from the leaves; per-execution raw-trace round-trip
  reconstruction by the attestor is not yet implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:13:48 -07:00
linus-d 93c78d9c76 update 2026-06-21 20:43:11 -07:00
linus-dandClaude Opus 4.8 9d9d5ce41c Add web game (plan2.md) on the independent runtime, merge-blocking gates
Builds the browser game around the existing Rust runtime: a window into the
universe, not a second simulation. Pure std, no external crates.

New crates:
- protocol: versioned, hashable client/server messages + hand-rolled JSON value
  and total parser (malformed packet -> Err, never panic).
- game_runtime: authoritative match state. Resolves turns through the
  INDEPENDENT interpreter (runtime_under_test::native_resolve), not the
  reference engine; filters visibility/knowledge; records and regenerates
  replays. A match is a pure function of (seed, roster, ordered inputs).
- web_assets/web_client: embedded browser client (arena, rune editor,
  knowledge panels, replay viewer) + static HTTP delivery.
- server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing),
  turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock.
- web_tests: dependency-free WebSocket test client + Phase H gates.

Trust hardening per review:
- game_runtime no longer delegates to reference_runtime::execute; it runs the
  independent interpreter that the runtime-equivalence gate proves correct.
- Protocol/socket/replay/visibility/resilience gates are merge-blocking
  (added to the merge_group-required job in merge-gates.yml): 1k matches/0
  drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks.
- Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only
  (continue-on-error, artifacts) until CI infrastructure with a browser exists;
  it is treated as unsatisfied, not green. The headless 100-match gate is
  labeled protocol-level coverage, not rendered-browser coverage.
- README documents the hand-rolled crypto/parser audit risk explicitly.

Fixes an integer-overflow panic in observed-volatility inference (i64 sum /
abs near i64::MIN) that could poison the server mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:20:46 -07:00
linus-d 659544f0b2 update
magicka-merge-gates / advisory-fast (push) Has been skipped
magicka-merge-gates / merge-gates (push) Failing after 38s
2026-06-21 19:21:48 -07:00
linus-d 2fe989bcb3 changes claude never committed 2026-06-21 18:00:52 -07:00
linus-d 39386a81c9 added plan 2026-06-21 15:29:01 -07:00
74 changed files with 24583 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Merge enforcement (finding 1 — external dependency)
A workflow file in `.github/workflows/` **defines** jobs; it does **not** enforce
that they pass before merge. Enforcement is a server-side GitHub setting
(branch protection / repository ruleset) that marks the jobs as **required
status checks** on `main` and the merge queue. That setting lives in the GitHub
repository configuration, not in this repository's tree, and applying it
requires repository-admin privileges and an authenticated `gh`/API token.
This is therefore BLOCKED on external infrastructure. To close the gap, a repo
admin applies the ruleset in `main-required-checks.json`:
```bash
# Requires: gh auth login as a repo admin
gh api -X POST repos/<owner>/<repo>/rulesets \
--input .github/rulesets/main-required-checks.json
# Verify the required checks are active:
gh api repos/<owner>/<repo>/rulesets --jq '.[].name'
gh api repos/<owner>/<repo>/branches/main/protection 2>/dev/null \
|| echo "no classic protection (rulesets in use)"
```
Until that ruleset is active, the `merge-gates` and `web-rust-gates` jobs are
*advisory CI*, not merge enforcement. Do not treat their presence in the tree as
satisfying the merge-blocking requirement.
@@ -0,0 +1,27 @@
{
"name": "main-merge-gates",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/heads/main"], "exclude": [] } },
"rules": [
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false
}
},
{ "type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "merge-gates" },
{ "context": "web-rust-gates" }
]
}
},
{ "type": "non_fast_forward" }
]
}
+93
View File
@@ -0,0 +1,93 @@
name: magicka-merge-gates
# The merge-blocking enforcement point for the Phase 0/1 acceptance gates.
# Per the spec's compliance model, acceptance may NOT be satisfied by a locally
# runnable binary, a default (fast) profile, or a documentation claim. This
# workflow is the enforcement: the `merge-gates` job below runs the full merge
# profile (1,000,000 executions, 10 perturbations each, 100% reference/runtime
# comparison, 10,000 persisted replay cases, >=500 mutants) and must be a
# REQUIRED status check on the protected branch / merge queue. If it fails or is
# absent, merge is blocked.
on:
pull_request:
branches: [main]
merge_group: # GitHub merge queue — the merge-blocking entry point
push:
branches: [main]
jobs:
# Advisory PR feedback only. The spec permits a 10% fast slice for PR signal,
# but this job is explicitly NOT acceptance and never substitutes for the
# merge profile.
advisory-fast:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Unit tests (incl. negative controls)
run: cargo test --release --workspace
- name: Fast advisory gate run
env:
MAGICKA_PROFILE: fast
run: cargo run --release -p ci_reports --bin ci
# The acceptance gate. Required on merge_group / protected main.
merge-gates:
if: github.event_name == 'merge_group' || github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 1440
steps:
- uses: actions/checkout@v4
- name: Verify committed replay corpus has >= 10,000 cases
run: |
lines=$(grep -vcE '^(#|master)' crates/replay_corpus/corpus/replay_corpus.tsv)
echo "replay corpus cases: $lines"
test "$lines" -ge 10000
- name: Unit tests (incl. negative controls)
run: cargo test --release --workspace
# plan2.md Phase H: the protocol + socket + replay/visibility gates are
# merge-blocking. These run the full HTTP/WebSocket/protocol/runtime path
# headlessly over real sockets (1k matches, 10k fuzz, 100 e2e, leak +
# resilience). The rendered-browser layer is NOT gated here — see
# web-gates.yml (advisory, blocked on CI infrastructure).
- name: Web protocol + socket gates (merge-blocking)
run: cargo test --release -p protocol -p game_runtime -p server -p web_assets -p web_client -p web_tests
- name: Full merge-blocking acceptance gates
env:
MAGICKA_PROFILE: merge
MAGICKA_OUT: ci_out
run: cargo run --release -p ci_reports --bin ci
- name: Enforce required artifacts exist
run: |
for r in domain_participation_report causal_rank_report \
causal_explanation_report \
compression_resistance_report metamorphic_response_report \
mutation_survivor_report runtime_equivalence_report \
replay_report coverage_report provenance_report \
compliance_report; do
test -s "ci_out/${r}.json" || { echo "MISSING ARTIFACT: ${r}.json"; exit 1; }
done
for e in leaves.tsv traces.tsv causal_evidence.tsv collapse_feature_rows.tsv MANIFEST.tsv claims.tsv; do
test -s "ci_out/evidence/${e}" || { echo "MISSING EVIDENCE: ${e}"; exit 1; }
done
# Independent attestation (findings 2, 3): a SEPARATE process recomputes
# the Merkle root from the retained leaves and checks it against the
# producer's claim. It never reads compliance_report.json. A producer that
# reported a root inconsistent with its own leaves fails here.
- name: Independent attestation of evidence
run: cargo run --release -p attestation --bin attest -- ci_out
- name: Upload acceptance evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: magicka-acceptance-evidence
path: ci_out/
+72
View File
@@ -0,0 +1,72 @@
name: magicka-web-gates
# Phase H of plan2.md — the web CI gates.
#
# MERGE-BLOCKING gates live in merge-gates.yml (the merge_group-required job),
# which runs the protocol + socket + replay/visibility/resilience suite. This
# workflow provides the same Rust gates as fast PR/push feedback, plus the
# rendered-browser layer.
#
# The Rust gates (web-rust-gates) enforce, deterministically and without a
# browser:
# * 1,000 simulated matches, 0 replay hash mismatches (determinism.rs)
# * 10,000 protocol fuzz cases, 0 server panics (fuzz.rs)
# * 100 end-to-end matches over real sockets (e2e.rs, protocol-level)
# * 0 hidden-state leaks (visibility.rs)
# * disconnect/reconnect + timer edges (resilience.rs)
#
# The rendered-browser layer (rendered-browser-e2e) is EXTERNAL-BLOCKED: it
# cannot be merge-blocking until CI infrastructure with a real browser exists.
# Until then it runs advisory-only (continue-on-error) and uploads Playwright
# artifacts. It is NOT counted as satisfied coverage.
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
jobs:
web-rust-gates:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Protocol / server / game-runtime unit tests
run: cargo test --release -p protocol -p game_runtime -p server -p web_assets -p web_client
- name: Web CI gates (1k matches, 10k fuzz, 100 e2e, leak + resilience)
run: cargo test --release -p web_tests
# EXTERNAL-BLOCKED: rendered-browser end-to-end. A real browser is not
# available in this CI, so this job is advisory only and never blocks merge.
# It produces Playwright artifacts as evidence; it does not satisfy the
# "rendered browser" coverage claim until CI infrastructure exists.
rendered-browser-e2e:
name: rendered-browser-e2e (ADVISORY — blocked on CI infra)
runs-on: ubuntu-latest
timeout-minutes: 30
continue-on-error: true
needs: web-rust-gates
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Playwright (Chromium)
working-directory: crates/web_tests/e2e
continue-on-error: true
run: |
npm install
npx playwright install --with-deps chromium
- name: Rendered-browser E2E (advisory)
working-directory: crates/web_tests/e2e
continue-on-error: true
run: npm test
- name: Upload advisory Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-advisory
path: crates/web_tests/e2e/playwright-report
if-no-files-found: ignore
+9
View File
@@ -0,0 +1,9 @@
/target
/ci_out
/ci_out_merge
Cargo.lock
# Playwright / Node artifacts for the rendered-browser E2E
crates/web_tests/e2e/node_modules
crates/web_tests/e2e/package-lock.json
crates/web_tests/e2e/test-results
crates/web_tests/e2e/playwright-report
+30
View File
@@ -0,0 +1,30 @@
[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",
"crates/attestation",
"crates/protocol",
"crates/game_runtime",
"crates/web_assets",
"crates/web_client",
"crates/server",
"crates/web_tests",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
[profile.release]
opt-level = 3
lto = "thin"
+226
View File
@@ -0,0 +1,226 @@
# Magicka VM — Phase 0/1
> The deliverable is a Rust engine whose tests make a fake universe fail.
This repository implements the Phase 0/1 specification in `plan.md`: an
**adversarial testing framework first**, then a **reference runtime** that
passes it, then a **runtime under test** that matches the reference. No spell
content, templates, or cosmetic runes — the value is in the tests that refuse
to let the universe collapse into a single score, resource, effect axis,
executor, rune, hidden formula, or decorative domain.
## Compliance model
No gate may pass from configuration, naming, shared implementation, smoke-scale
runs, regenerated expectations, proxy metrics, a default profile, or a
locally-runnable binary. A gate passes only from persisted, independently
reproducible, full-scale adversarial evidence enforced at merge. Every
acceptance obligation has all four of: a **measured artifact**, a **provenance
chain** to the run that produced it, a **merge-blocking enforcement point**, and
a **failure condition if the artifact or provenance is absent**.
- The merge-blocking enforcement point is `.github/workflows/merge-gates.yml`,
whose `merge-gates` job runs `MAGICKA_PROFILE=merge` (full gates) and must be a
**required status check** on the protected branch / merge queue. It is not a
local binary, and the fast profile is advisory only — it can never stand in for
acceptance.
- `compliance_report.json` enumerates every obligation, its artifact, its floor,
the actual measured value, and whether the artifact is present. A missing
required report fails acceptance.
- The merge floors (50k worlds, 250k programs, 1,000,000 executions, 10
perturbations/exec, 100% reference/runtime comparison over base **and**
perturbations, 500 mutants, 10,000 replay cases) cannot be lowered by
environment overrides: a lowering override is recorded as a provenance failure
and the floor is kept.
Every gate is built to be *able to fail*, and a negative-control test proves it does:
| Gate | How it is made unbypassable | Negative control proving it can fail |
|------|-----------------------------|--------------------------------------|
| runtime_equivalence | Compares two **independent implementations** (the reference engine vs. `runtime_under_test::native`, which never calls the reference engine) | `buggy_runtime_is_rejected` — a runtime with one dropped causal edge is caught |
| compression_resistance | Attacks operate on the **real serialized trace** (causal influence, info-flow, access, temporal, deltas), not a hash proxy; info loss is genuine unexplained variance | `single_factor_corpus_is_rejected` — a rank-1 universe is rejected |
| mutation_survivor | Each mutant must fail the **named gate** it targets, not merely differ from the reference | `reference_passes_every_named_gate` + `no_mutant_survives_its_named_gate` |
| replay | Expectations are **loaded from a committed file**, not regenerated in the same run | `corrupted_expectation_is_detected` |
| domain_participation | Decorative/redundant domains are flagged directly | `decorative_domain_is_rejected` |
| merge scale floor | Env overrides may only **raise** merge counts; a lowering attempt is recorded and the floor kept; executions actually performed are counted | `merge_floor_cannot_be_lowered_by_override`, `merge_profile_at_smoke_scale_is_rejected` |
| 100% comparison | Reference vs. runtime-under-test compared for **every** execution — base and all perturbations, never base only | `runtime_equivalence` gate fails unless `equiv_total == base + perturbations` |
| provenance | A Merkle root over per-execution records, plus independent engine identities, binds reported numbers to executed work | `merkle_root_binds_to_leaves` |
## Workspace layout
Built in the mandatory order from the spec:
| # | Crate | Role |
|---|-------|------|
| 1 | `world_model` | 8 independent domains, world snapshot, perturbation axes, deltas, deterministic primitives (ids, stable hash, RNG) |
| | `rune_ir` | Rune token / program model (no stream is ever rejected) |
| 2 | `trace_model` | Execution trace + all graphs, behavior fingerprint, replay record, fault log, trace metrics |
| 3 | `generators` | Worlds, programs, executors, contracts, perturbations; rejects flat cases |
| 4 | `collapse_analysis` | The 11 compression attacks over real trace structure + collapse gates |
| 5 | `semantic_mutation` | Structurally generated mutant runtimes; proves every one fails its named gate |
| 6 | `replay_corpus` | Permanent, bit-exact replay cases persisted to `corpus/replay_corpus.tsv` |
| 7 | `reference_runtime` | The executable spec engine (`Runtime` trait, `resolve`) |
| 8 | `runtime_under_test` | An **independent** interpreter (`native`) proven equivalent to the reference |
| | `ci_reports` | Orchestrator + `ci` binary; emits 8 gate reports + a provenance report |
The runtime under test does not call the reference engine. It re-derives the
canonical behavior from the spec in a different code organization, so 100%
agreement is *evidence* the spec is implemented correctly rather than a
tautology. (`native_matches_reference_bit_for_bit` checks this over a 2000-seed
sweep.)
## The engine in one paragraph
A world is 8 domains, each with 4 observed + 2 hidden integer lanes, a dense
8×8 coupling matrix, partial observability, and pending scheduled effects. A
rune program is interpreted under ≥3 executors; each opcode reads several
domains, mixes them through a nonlinear avalanche keyed by per-domain
constants, the world coupling, and the executor's salt, then writes back —
recording causal/read/write/information-flow/temporal edges as it goes.
Scheduled effects and coupling diffusion propagate changes 3 turns into the
future.
## Running CI
```bash
cargo test # unit tests + negative controls
MAGICKA_PROFILE=fast cargo run --release -p ci_reports --bin ci # advisory PR slice
MAGICKA_PROFILE=merge cargo run --release -p ci_reports --bin ci # acceptance (full gates)
```
Reports are written to the output dir (8 gate reports + `provenance_report.json`
+ `compliance_report.json` + `ci_summary.md`). The binary exits non-zero if any
gate fails or any required artifact is absent.
### Profiles
`MAGICKA_PROFILE` (or `MAGICKA_SCALE`) selects the run profile.
| Profile | executions | replay | mutants | role |
|---------|-----------|--------|---------|------|
| `fast` (default) | 600 | 10,000 (committed) | 520 | **advisory only — never acceptance** |
| `tiny` | 120 | 10,000 | 520 | smoke |
| `merge` (`MAGICKA_SCALE=full`) | 1,000,000 | 10,000 | 600 | **acceptance — hard floors** |
The fast/tiny profiles print `ADVISORY … NOT a merge-blocking acceptance run`
and are labelled non-acceptance in `compliance_report.json`. Acceptance comes
only from the merge profile, run by the merge-gates workflow. The merge floors
cannot be lowered by environment overrides (a lowering override is recorded as a
provenance failure and the floor kept).
### Merge-blocking enforcement (required check)
`.github/workflows/merge-gates.yml` defines the enforcement point. Configure
branch protection / the merge queue to **require** the `merge-gates` job. That
job runs the full merge profile, verifies the committed corpus has ≥10,000
cases, and fails if any required artifact is missing. The full run executes
~1M base executions × (1 base + 10 perturbations) with 100% reference/runtime
comparison; it completes in minutes on a CI runner.
### Replay corpus
The replay corpus is committed at
`crates/replay_corpus/corpus/replay_corpus.tsv` (10,000 cases). Replay loads
those expectations and re-executes the reference, so any engine change that
alters a hash makes the committed file and the fresh run disagree and CI fails.
Regenerate it only as a deliberate, reviewed migration:
```bash
cargo run --release -p replay_corpus --bin freeze -- 10000
```
## Determinism
Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content
hashing, wrapping/guarded arithmetic). No floating point enters a canonical
hash, so replay is bit-exact across machines and runs. No external crates.
## The web game (plan2.md)
A browser game is built **around** the existing runtime — it is a playable
window into the Rust universe, never a second simulation. The browser sends only
*intent*; the server is the sole authority; every rune program executes through
the **independent** interpreter (`runtime_under_test::native_resolve`) against
the shared world. The game deliberately does **not** call the reference engine —
the interpreter it uses is the one the runtime-equivalence gate proves correct
(with a negative control proving that gate can fail). Same constraints as the
rest of the repo: pure `std`, no external crates (the WebSocket server
hand-rolls SHA-1, base64, and RFC 6455 framing; JSON is hand-rolled with a total
parser).
> Audit note: the hand-rolled SHA-1 / base64 / RFC-6455 framing and JSON parser
> are checked against published test vectors (RFC 6455 §1.3 accept key, SHA-1
> "abc", base64 length cases) and a fuzz gate, but they are bespoke
> cryptographic/parsing code and carry audit risk relative to a reviewed
> library. They exist to honor the repo's no-external-crates rule; a future
> hardening pass could swap in vetted implementations behind the same interface.
```
Rust runtime → game_runtime (authority) → protocol (WS messages) → server → browser
```
| Crate | Role |
|-------|------|
| `protocol` | Versioned, hashable, **total-decode** client/server messages + JSON value/parser. A malformed packet yields `Err`, never a panic. |
| `game_runtime` | Authoritative match state. Resolves turns through the **independent interpreter** (`runtime_under_test`, not the reference engine), filters visibility/knowledge, records + regenerates replays. A match is a pure function of `(seed, roster, ordered inputs)`. |
| `web_assets` | The embedded browser client (HTML/CSS/JS): arena, rune editor, domain/knowledge panels, replay viewer. |
| `web_client` | Static-asset HTTP delivery (keeps raw assets separate from framing). |
| `server` | `std::net` HTTP + WebSocket server: turn timer, action collection, disconnect handling, panic-proof dispatch. |
| `web_tests` | A dependency-free WebSocket test client + the Phase H gates. |
### Running it
```bash
cargo run --release -p server --bin magicka-server # serve on 127.0.0.1:8080
# then open http://127.0.0.1:8080 in a browser
MAGICKA_ADDR=0.0.0.0:9000 MAGICKA_TURN_MS=8000 cargo run --release -p server --bin magicka-server
```
Join is immediate (1 player + a training dummy). A duel shares a match by id:
two browsers that `JoinMatch` the same `match_id` take slots 1 and 2.
### Web CI gates (Phase H)
These gates are **merge-blocking**: they run inside the merge-required job in
`.github/workflows/merge-gates.yml` (and as fast PR feedback in
`web-gates.yml`). They are the Rust suite in `crates/web_tests`, run with
`cargo test -p web_tests`:
| Gate | Test | Minimum | Status |
|------|------|---------|--------|
| Replay determinism | `determinism.rs` | 1,000 simulated matches, **0 hash mismatches** | merge-blocking |
| Protocol fuzz | `fuzz.rs` | 10,000 fuzz cases, **0 panics** (+ a live server survives a malformed-packet burst) | merge-blocking |
| End-to-end matches | `e2e.rs` | **100** full matches over real sockets; recorded replay reproduces every live per-turn hash | merge-blocking |
| Hidden-state leaks | `visibility.rs` | **0 leaks** — no client-bound frame carries a hidden key; redaction counts every withheld value | merge-blocking |
| Disconnect / timer edges | `resilience.rs` | mid-match disconnect does not corrupt the match; wrong-turn / late submits are rejected deterministically | merge-blocking |
| Rendered-browser E2E | `e2e/specs/play.spec.js` | a real browser joins, casts, and replays a match | **external-blocked (advisory only)** |
Scope honesty — two distinct things, not conflated:
- The "100 E2E matches" merge-blocking gate drives the full
HTTP→WebSocket→protocol→runtime path **headlessly over real sockets**. This is
protocol-level coverage. It is **not** rendered-browser coverage and is not
claimed as such.
- Rendered-browser coverage is **blocked on CI infrastructure**: this CI has no
real browser, so the Playwright suite under `crates/web_tests/e2e/` cannot be
merge-blocking yet. It runs **advisory-only** (`continue-on-error`) in the
`rendered-browser-e2e` job and uploads its report as an artifact. Until a CI
runner with a browser exists, rendered-browser E2E is treated as
**unsatisfied**, not green. Run it locally with:
```bash
cd crates/web_tests/e2e && npm install && npx playwright install chromium && npm test
```
### Acceptance criteria mapping (plan2.md)
| Criterion | Where it holds |
|-----------|----------------|
| A player can join a browser match | `server` join + `web_assets` client; `e2e.rs::single_match_full_playthrough` |
| A turn timer runs | `server` timer thread; client header countdown |
| Inspect / move / attack / cast | `Action` in `protocol`; `game_runtime::apply_action` |
| Rune programs execute only on the server | `game_runtime` is the only caller of the interpreter (`runtime_under_test::native_resolve`); client never imports `EngineConfig` (asserted in `web_assets`) |
| Results return as filtered observations | `VisibleWorldSnapshot`; `visibility.rs` |
| Replay can reproduce the match | `game_runtime::replay`; `determinism.rs`, `e2e.rs` |
| Browser cannot alter hidden truth | intent-only protocol; `visibility.rs` leak gate |
| CI proves protocol, replay, visibility, authority | merge-blocking gates in `merge-gates.yml` (+ `web-gates.yml`); rendered-browser E2E remains external-blocked |
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "attestation"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "attest"
path = "src/main.rs"
[dependencies]
# NOT ci_reports. The root, the full-trace hashes, and the leaves are recomputed
# by this crate's own code path — an independent check, not a re-export of the
# producer's claim. trace_model/world_model provide the shared trace/delta types
# and hashing the verifier reconstructs from raw evidence.
world_model = { path = "../world_model" }
trace_model = { path = "../trace_model" }
# For independent RE-EXECUTION of retained per-edge causal interventions.
generators = { path = "../generators" }
reference_runtime = { path = "../reference_runtime" }
# For recomputing collapse feature rows from full traces (proving derivation).
collapse_analysis = { path = "../collapse_analysis" }
+596
View File
@@ -0,0 +1,596 @@
//! `attestation` — an **independent** verifier of a CI run's evidence
//! (findings 2 and 3).
//!
//! The substitution it removes: a `compliance_report.json` written by the same
//! binary that ran the gates is self-certification, and a Merkle *root* reported
//! without its *leaves* cannot be checked by anyone. This crate is a separate
//! process with its own code path that:
//! * reads the retained per-execution leaves (`evidence/leaves.tsv`),
//! * recomputes the Merkle root from them with its own implementation,
//! * reads the producer's *claims* (`evidence/claims.tsv`) and checks the
//! recomputed root and leaf count match what was claimed,
//! * NEVER reads `compliance_report.json` — it does not trust the producer's
//! own pass/fail verdict.
//!
//! It depends only on `world_model` for the shared hash primitive. What it does
//! NOT do (and cannot, in-repo) is prove the leaves correspond to real
//! executions performed by a trusted third party — that requires external
//! re-execution / signing infrastructure (see the BLOCKED note in the report).
use generators::generate_accepted_case;
use reference_runtime::{execute, EngineConfig, ResolutionInput};
use std::collections::HashSet;
use std::path::Path;
use trace_model::{ExecutionTrace, ReplayRecord};
use world_model::{Hash, Hasher, WorldDelta, HIDDEN_LANES, LANES, NUM_DOMAINS};
/// Re-execute one retained causal record from its seed and recompute the
/// destination delta before and after perturbing the recorded source lane.
/// Returns `(base_dv, alt_dv)`; the caller compares against the retained values.
pub fn recompute_causal_record(
seed: u64,
from_domain: usize,
from_lane: usize,
from_hidden: bool,
to_domain: usize,
to_lane: usize,
to_hidden: bool,
) -> (i64, i64) {
let (case, _) = generate_accepted_case(seed);
let cfg = EngineConfig::reference();
let input = ResolutionInput {
world: case.world.clone(),
program: case.program.clone(),
contexts: case.contexts.clone(),
contract_seed: case.contract_seed,
perturbation_seed: case.perturbation_seed,
};
let base = execute(&cfg, &input);
let mut w = input.world.clone();
let fd = from_domain % NUM_DOMAINS;
if from_hidden {
let l = from_lane % HIDDEN_LANES;
w.domains[fd].hidden[l] = w.domains[fd].hidden[l].wrapping_add(0x9_27c1);
} else {
let l = from_lane % LANES;
w.domains[fd].observed[l] = w.domains[fd].observed[l].wrapping_add(0x9_27c1);
}
let mut alt_input = input.clone();
alt_input.world = w;
let alt = execute(&cfg, &alt_input);
let dd = to_domain % NUM_DOMAINS;
let read = |d: &world_model::DomainDelta| -> i64 {
if to_hidden {
d.hidden[to_lane % HIDDEN_LANES]
} else {
d.observed[to_lane % LANES]
}
};
(read(&base.delta.domain_deltas[dd]), read(&alt.delta.domain_deltas[dd]))
}
/// Recompute the Merkle root over `leaves` (binary tree, FNV-combined). This is
/// an independent re-implementation of the producer's algorithm; agreement is
/// the check.
pub fn merkle_root(leaves: &[u64]) -> u64 {
if leaves.is_empty() {
return 0;
}
let mut level = leaves.to_vec();
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
for pair in level.chunks(2) {
let mut h = Hasher::new();
h.write_tag("merkle");
h.write_u64(pair[0]);
h.write_u64(if pair.len() > 1 { pair[1] } else { pair[0] });
next.push(h.finish().0);
}
level = next;
}
level[0]
}
/// Parse a leaves file: one hex u64 per data line, `#`/`leaf` headers skipped.
pub fn parse_leaves(text: &str) -> Vec<u64> {
text.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("leaf") && !l.trim().is_empty())
.filter_map(|l| u64::from_str_radix(l.trim(), 16).ok())
.collect()
}
/// Parse a `key<TAB>value` claims file into `(key, value)` pairs.
pub fn parse_claims(text: &str) -> Vec<(String, String)> {
text.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.filter_map(|l| l.split_once('\t').map(|(k, v)| (k.trim().to_string(), v.trim().to_string())))
.collect()
}
fn claim<'a>(claims: &'a [(String, String)], key: &str) -> Option<&'a str> {
claims.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
/// Reconstruct the leaf of one full-trace evidence record from its raw trace +
/// delta + seeds, independently of any reported digest. Returns the recomputed
/// leaf, or `None` if the record is malformed.
///
/// `line` is `kind ws ps cs prs future leaf <TAB> <trace> <TAB> <delta>` where
/// `kind` is `b` (base) or `p` (perturbation). Returns `(recomputed_leaf,
/// claimed_leaf, is_base)`.
pub fn recompute_trace_leaf(line: &str) -> Option<(u64, u64, bool)> {
let mut parts = line.splitn(3, '\t');
let header = parts.next()?;
let trace_s = parts.next()?;
let delta_s = parts.next()?;
let mut h = header.split_whitespace();
let kind = h.next()?;
let is_base = kind == "b";
let hx = |s: Option<&str>| -> Option<u64> { u64::from_str_radix(s?.trim(), 16).ok() };
let world_seed = hx(h.next())?;
let program_seed = hx(h.next())?;
let contract_seed = hx(h.next())?;
let perturbation_seed = hx(h.next())?;
let future = hx(h.next())?;
let claimed_leaf = hx(h.next())?;
let trace = ExecutionTrace::deserialize(trace_s)?;
let delta = WorldDelta::deserialize(delta_s)?;
// Recompute the canonical trace hash and delta hash from the FULL structure.
let rr = ReplayRecord {
world_seed,
program_seed,
contract_seed,
perturbation_seed,
trace_hash: trace.canonical_hash(),
delta_hash: delta.hash(),
future_hash: Hash(future),
};
Some((rr.hash().0, claimed_leaf, is_base))
}
/// Outcome of an attestation.
#[derive(Clone, Debug)]
pub struct Attestation {
pub ok: bool,
pub leaf_count: usize,
pub recomputed_root: u64,
pub claimed_root: Option<u64>,
pub traces_verified: usize,
pub traces_total: usize,
pub causal_total: usize,
pub causal_recomputed: usize,
pub causal_confirmed: usize,
pub collapse_total: usize,
pub collapse_derived: usize,
pub checks: Vec<(String, bool)>,
}
/// Verify the evidence directory `dir` (which contains `evidence/`).
pub fn verify_dir(dir: &Path) -> Result<Attestation, String> {
let leaves_path = dir.join("evidence/leaves.tsv");
let claims_path = dir.join("evidence/claims.tsv");
let leaves_txt = std::fs::read_to_string(&leaves_path)
.map_err(|e| format!("cannot read {}: {e}", leaves_path.display()))?;
let claims_txt = std::fs::read_to_string(&claims_path)
.map_err(|e| format!("cannot read {}: {e}", claims_path.display()))?;
let leaves = parse_leaves(&leaves_txt);
let claims = parse_claims(&claims_txt);
let recomputed = merkle_root(&leaves);
let claimed_root = claim(&claims, "root").and_then(|v| u64::from_str_radix(v, 16).ok());
let claimed_count = claim(&claims, "leaf_count").and_then(|v| v.parse::<usize>().ok());
let claimed_comparisons =
claim(&claims, "total_comparisons").and_then(|v| v.parse::<usize>().ok());
let mut checks = Vec::new();
let root_ok = claimed_root == Some(recomputed);
checks.push(("recomputed_root == claimed_root".into(), root_ok));
let count_ok = claimed_count == Some(leaves.len());
checks.push(("leaf_count == claimed_leaf_count".into(), count_ok));
let cmp_ok = claimed_comparisons.map(|c| c == leaves.len()).unwrap_or(false);
checks.push(("leaf_count == claimed_total_comparisons".into(), cmp_ok));
let nonempty = !leaves.is_empty();
checks.push(("leaves are present (root has leaves)".into(), nonempty));
// Full-trace evidence (finding 4): reconstruct each sampled trace + delta,
// recompute its leaf independently, and confirm it both matches the record's
// claimed leaf AND is one of the retained leaves the root is built from.
let leaf_set: HashSet<u64> = leaves.iter().copied().collect();
let traces_path = dir.join("evidence/traces.tsv");
let traces_txt = std::fs::read_to_string(&traces_path)
.map_err(|e| format!("cannot read {}: {e}", traces_path.display()))?;
let mut traces_total = 0usize;
let mut traces_verified = 0usize;
let mut covered: HashSet<u64> = HashSet::new();
for line in traces_txt.lines() {
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
traces_total += 1;
if let Some((recomputed_leaf, claimed_leaf, _is_base)) = recompute_trace_leaf(line) {
if recomputed_leaf == claimed_leaf && leaf_set.contains(&recomputed_leaf) {
traces_verified += 1;
covered.insert(recomputed_leaf);
}
}
}
let traces_present = traces_total > 0;
checks.push(("full-trace evidence present".into(), traces_present));
checks.push((
"every full trace recomputes to a retained leaf".into(),
traces_present && traces_verified == traces_total,
));
// Completeness (finding 3): every retained leaf must be covered by a full
// trace record, and there must be exactly one record per leaf — a bijection,
// not just a consistent count.
checks.push((
"every leaf has full retained evidence (bijection)".into(),
traces_present
&& traces_total == leaves.len()
&& covered.len() == leaf_set.len(),
));
// Causal intervention evidence (finding 7): RE-EXECUTE each retained record
// from its seed and confirm the retained base_dv/alt_dv reproduce, then
// require the confirmed fraction to clear the threshold.
let causal_path = dir.join("evidence/causal_evidence.tsv");
let causal_txt = std::fs::read_to_string(&causal_path)
.map_err(|e| format!("cannot read {}: {e}", causal_path.display()))?;
let mut causal_total = 0usize;
let mut causal_recomputed = 0usize;
let mut causal_confirmed = 0usize;
for line in causal_txt.lines() {
if line.starts_with("seed") || line.starts_with('#') || line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 9 {
continue;
}
let seed = match u64::from_str_radix(f[0].trim(), 16) {
Ok(v) => v,
Err(_) => continue,
};
let p = |i: usize| f[i].trim().parse::<i64>().ok();
let (Some(fd), Some(fl), Some(fh), Some(td), Some(tl), Some(th), Some(bdv), Some(adv)) =
(p(1), p(2), p(3), p(4), p(5), p(6), p(7), p(8))
else {
continue;
};
causal_total += 1;
let (rb, ra) = recompute_causal_record(
seed, fd as usize, fl as usize, fh != 0, td as usize, tl as usize, th != 0,
);
if rb == bdv && ra == adv {
causal_recomputed += 1;
}
if rb != ra {
causal_confirmed += 1;
}
}
// Collapse derivation (finding 4): recompute each collapse feature row from
// the corresponding retained FULL trace + delta and require bit-exact match,
// proving the summary the collapse gate consumed derives from the full trace.
let crows_path = dir.join("evidence/collapse_feature_rows.tsv");
let crows_txt = std::fs::read_to_string(&crows_path)
.map_err(|e| format!("cannot read {}: {e}", crows_path.display()))?;
let claimed_rows: Vec<Vec<u64>> = crows_txt
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.split_whitespace().filter_map(|t| t.parse::<u64>().ok()).collect())
.collect();
// Re-read the trace records in order to recompute their feature rows.
let mut collapse_total = 0usize;
let mut collapse_derived = 0usize;
{
let mut idx = 0usize;
for line in traces_txt.lines() {
// Collapse rows correspond to BASE executions in order.
if !line.starts_with("b ") {
continue;
}
if idx >= claimed_rows.len() {
break;
}
let mut parts = line.splitn(3, '\t');
let _hdr = parts.next();
let trace_s = parts.next();
let delta_s = parts.next();
if let (Some(ts), Some(ds)) = (trace_s, delta_s) {
if let (Some(trace), Some(delta)) =
(ExecutionTrace::deserialize(ts), WorldDelta::deserialize(ds))
{
let recomputed = collapse_analysis::trace_feature_row(&trace, &delta);
let recomputed_bits: Vec<u64> = recomputed.iter().map(|v| v.to_bits()).collect();
collapse_total += 1;
if recomputed_bits == claimed_rows[idx] {
collapse_derived += 1;
}
}
}
idx += 1;
}
}
let collapse_present = collapse_total > 0;
checks.push(("collapse feature rows present".into(), collapse_present));
checks.push((
"every collapse summary derives from a full trace".into(),
collapse_present && collapse_derived == collapse_total,
));
let causal_present = causal_total > 0;
let causal_frac = if causal_total > 0 { causal_confirmed as f64 / causal_total as f64 } else { 0.0 };
checks.push(("causal evidence present".into(), causal_present));
checks.push((
"every causal record recomputes (base_dv/alt_dv reproduce)".into(),
causal_present && causal_recomputed == causal_total,
));
checks.push((
"recomputed causal confirmation >= 0.50".into(),
causal_present && causal_frac >= 0.50,
));
// Artifact bundle (finding 1): the merge-scale claim requires a complete,
// content-hashed bundle. Verify the manifest lists the required files and
// each file's recomputed hash + length match.
let manifest_path = dir.join("evidence/MANIFEST.tsv");
let manifest_txt = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("cannot read {}: {e}", manifest_path.display()))?;
let required = [
"leaves.tsv",
"claims.tsv",
"causal_evidence.tsv",
"collapse_feature_rows.tsv",
"traces.tsv",
];
let mut listed: HashSet<String> = HashSet::new();
let mut bundle_intact = true;
for line in manifest_txt.lines() {
if line.starts_with("file") || line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 3 {
bundle_intact = false;
continue;
}
let name = f[0].trim();
let claimed_hash = u64::from_str_radix(f[1].trim(), 16).ok();
let claimed_len = f[2].trim().parse::<usize>().ok();
let bytes = std::fs::read(dir.join("evidence").join(name)).unwrap_or_default();
let mut h = Hasher::new();
h.write_tag("evidence-file");
h.write_bytes(&bytes);
if claimed_hash != Some(h.finish().0) || claimed_len != Some(bytes.len()) {
bundle_intact = false;
}
listed.insert(name.to_string());
}
let bundle_complete = required.iter().all(|r| listed.contains(*r));
checks.push(("artifact bundle manifest complete".into(), bundle_complete));
checks.push(("artifact bundle files intact (hash + length)".into(), bundle_intact && bundle_complete));
let ok = checks.iter().all(|(_, b)| *b);
Ok(Attestation {
ok,
leaf_count: leaves.len(),
recomputed_root: recomputed,
claimed_root,
traces_verified,
traces_total,
causal_total,
causal_recomputed,
causal_confirmed,
collapse_total,
collapse_derived,
checks,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_matches_known_vector() {
// Mirrors ci_reports::merkle_root over the same leaves.
let a = merkle_root(&[1, 2, 3]);
let b = merkle_root(&[1, 2, 3]);
let c = merkle_root(&[1, 2, 4]);
assert_eq!(a, b);
assert_ne!(a, c);
}
use trace_model::{
BehaviorFingerprint, CausalEdge, CausalGraph, CausalNode, DivergenceGraph,
DomainAccessGraph, ExecutionTrace, InformationFlowGraph, PerturbationResponse, ReplayRecord,
TemporalGraph,
};
use world_model::{DomainDelta, DomainId, Hash, WorldDelta, HIDDEN_LANES, LANES};
fn sample_trace(seed: u64) -> ExecutionTrace {
let mut read_graph = DomainAccessGraph::default();
read_graph.access_count[(seed % 8) as usize] = 3;
read_graph.edges.push((0, 2, (seed % 7) as u32 + 1));
let mut write_graph = DomainAccessGraph::default();
write_graph.access_count[2] = 4;
let causal_graph = CausalGraph {
edges: vec![CausalEdge {
from: CausalNode { domain: 0, lane: 1, hidden: false, step: 2 },
to: CausalNode { domain: 2, lane: 0, hidden: true, step: 2 },
weight: seed as i64 - 100,
}],
};
ExecutionTrace {
read_graph,
write_graph,
causal_graph,
information_flow: InformationFlowGraph { edges: vec![(0, 2, 9)] },
executor_divergence: DivergenceGraph { executor_count: 2, pairwise: vec![0.0, 0.5, 0.5, 0.0] },
temporal_graph: TemporalGraph { edges: vec![(1, 2, 3)] },
perturbation_response: PerturbationResponse::default(),
behavior_fingerprint: BehaviorFingerprint::from_features(vec![seed as i64, -2, 3]),
}
}
fn sample_delta(seed: u64) -> WorldDelta {
let domain_deltas = (0..NUM_DOMAINS)
.map(|d| {
let mut observed = [0i64; LANES];
observed[0] = seed as i64 + d as i64;
DomainDelta { domain: DomainId(d as u8), observed, hidden: [0i64; HIDDEN_LANES] }
})
.collect();
WorldDelta { domain_deltas, turn_advance: 0 }
}
fn record(seed: u64) -> (ReplayRecord, ExecutionTrace, WorldDelta) {
let trace = sample_trace(seed);
let delta = sample_delta(seed);
let rr = ReplayRecord {
world_seed: seed,
program_seed: seed ^ 1,
contract_seed: seed ^ 2,
perturbation_seed: seed ^ 3,
trace_hash: trace.canonical_hash(),
delta_hash: delta.hash(),
future_hash: Hash(seed.wrapping_mul(0x9e3779b97f4a7c15)),
};
(rr, trace, delta)
}
/// Write a full evidence dir from real records. If `tamper_trace` is set,
/// that record's serialized trace is corrupted after its leaf was claimed.
fn write_full_evidence(dir: &Path, seeds: &[u64], tamper_trace: Option<usize>) {
let ev = dir.join("evidence");
std::fs::create_dir_all(&ev).unwrap();
let records: Vec<_> = seeds.iter().map(|&s| record(s)).collect();
let leaves: Vec<u64> = records.iter().map(|(rr, _, _)| rr.hash().0).collect();
let mut lt = String::from("leaf\n");
for l in &leaves {
lt.push_str(&format!("{:016x}\n", l));
}
std::fs::write(ev.join("leaves.tsv"), lt).unwrap();
let claims = format!(
"# claims\nroot\t{:016x}\nleaf_count\t{}\ntotal_comparisons\t{}\n",
merkle_root(&leaves),
leaves.len(),
leaves.len()
);
std::fs::write(ev.join("claims.tsv"), claims).unwrap();
let mut traces = String::from("# trace evidence\n");
for (i, (rr, trace, delta)) in records.iter().enumerate() {
let mut trace_s = trace.serialize();
if tamper_trace == Some(i) {
// Corrupt the full trace without changing the claimed leaf.
trace_s = trace_s.replacen("trace-v1 ", "trace-v1 999 ", 1);
}
traces.push_str(&format!(
"b {:016x} {:016x} {:016x} {:016x} {:016x} {:016x}\t{}\t{}\n",
rr.world_seed, rr.program_seed, rr.contract_seed, rr.perturbation_seed,
rr.future_hash.0, rr.hash().0, trace_s, delta.serialize(),
));
}
std::fs::write(ev.join("traces.tsv"), traces).unwrap();
// Real, recomputable causal evidence from actual reference executions.
let mut causal = String::from(
"seed\tfrom_domain\tfrom_lane\tfrom_hidden\tto_domain\tto_lane\tto_hidden\tbase_dv\talt_dv\n",
);
let cfg = EngineConfig::reference();
for &cseed in &[0xC0FFEEu64, 0xBEEF, 0x1234, 0x5EED, 0xABCD] {
let (case, _) = generate_accepted_case(cseed);
let input = ResolutionInput {
world: case.world.clone(),
program: case.program.clone(),
contexts: case.contexts.clone(),
contract_seed: case.contract_seed,
perturbation_seed: case.perturbation_seed,
};
let base = execute(&cfg, &input);
let edges = base.trace.causal_graph.edges.clone();
let stride = (edges.len() / 12).max(1);
for e in edges.iter().step_by(stride).take(12) {
let fd = e.from.domain as usize % NUM_DOMAINS;
let fl = e.from.lane as usize;
let fh = e.from.hidden;
let td = e.to.domain as usize % NUM_DOMAINS;
let tl = e.to.lane as usize;
let th = e.to.hidden;
let (b, a) = recompute_causal_record(cseed, fd, fl, fh, td, tl, th);
causal.push_str(&format!(
"{:016x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
cseed, fd, fl, fh as u8, td, tl, th as u8, b, a
));
}
}
std::fs::write(ev.join("causal_evidence.tsv"), causal).unwrap();
// Collapse feature rows derived from the same synthetic traces, in order.
let mut crows = String::new();
for (_, trace, delta) in &records {
let row = collapse_analysis::trace_feature_row(trace, delta);
let cells: Vec<String> = row.iter().map(|v| v.to_bits().to_string()).collect();
crows.push_str(&cells.join(" "));
crows.push('\n');
}
std::fs::write(ev.join("collapse_feature_rows.tsv"), crows).unwrap();
// Bundle manifest over the written files.
let mut manifest = String::from("file\thash\tbytes\n");
for name in ["leaves.tsv", "claims.tsv", "causal_evidence.tsv", "collapse_feature_rows.tsv", "traces.tsv"] {
let bytes = std::fs::read(ev.join(name)).unwrap_or_default();
let mut h = Hasher::new();
h.write_tag("evidence-file");
h.write_bytes(&bytes);
manifest.push_str(&format!("{}\t{:016x}\t{}\n", name, h.finish().0, bytes.len()));
}
std::fs::write(ev.join("MANIFEST.tsv"), manifest).unwrap();
}
#[test]
fn honest_evidence_attests() {
let dir = std::env::temp_dir().join("magicka_attest_ok");
write_full_evidence(&dir, &[10, 20, 30, 40, 50], None);
let att = verify_dir(&dir).unwrap();
assert!(att.ok, "honest evidence should attest: {:?}", att.checks);
assert_eq!(att.traces_verified, att.traces_total);
assert!(att.traces_total > 0);
}
/// Negative control: a tampered leaf changes the recomputed root, so the
/// claimed root no longer matches and attestation FAILS.
#[test]
fn tampered_leaf_breaks_attestation() {
let dir = std::env::temp_dir().join("magicka_attest_bad_leaf");
write_full_evidence(&dir, &[10, 20, 30, 40, 50], None);
// Flip a leaf in the file after the root was claimed.
let lp = dir.join("evidence/leaves.tsv");
let txt = std::fs::read_to_string(&lp).unwrap();
let mut lines: Vec<String> = txt.lines().map(|s| s.to_string()).collect();
lines[2] = format!("{:016x}", 0xdead_beefu64);
std::fs::write(&lp, lines.join("\n")).unwrap();
let att = verify_dir(&dir).unwrap();
assert!(!att.ok, "tampered leaf must fail attestation");
assert!(att.checks.iter().any(|(n, ok)| n.contains("root") && !ok));
}
/// Negative control for finding 4: corrupting the FULL TRACE (without
/// touching the claimed leaf) makes the recomputed leaf disagree, so the
/// trace no longer attests. Proves the evidence is the full trace, not a
/// trusted digest.
#[test]
fn tampered_trace_breaks_attestation() {
let dir = std::env::temp_dir().join("magicka_attest_bad_trace");
write_full_evidence(&dir, &[10, 20, 30, 40, 50], Some(2));
let att = verify_dir(&dir).unwrap();
assert!(!att.ok, "tampered full trace must fail attestation");
assert!(att.traces_verified < att.traces_total);
assert!(att.checks.iter().any(|(n, ok)| n.contains("full trace") && !ok));
}
}
+48
View File
@@ -0,0 +1,48 @@
//! `attest` — independently verify a CI run's evidence directory.
//!
//! Usage: `attest <dir>` (default `ci_out`). Exits non-zero if the recomputed
//! Merkle root does not match the claimed root, the leaf count is inconsistent,
//! or the leaves are missing. It deliberately ignores `compliance_report.json`.
use std::path::PathBuf;
fn main() {
let dir = std::env::args().nth(1).unwrap_or_else(|| "ci_out".to_string());
let path = PathBuf::from(&dir);
match attestation::verify_dir(&path) {
Ok(att) => {
eprintln!("=== independent attestation of {dir} ===");
eprintln!(" leaves: {}", att.leaf_count);
eprintln!(" recomputed root: {:016x}", att.recomputed_root);
match att.claimed_root {
Some(r) => eprintln!(" claimed root: {:016x}", r),
None => eprintln!(" claimed root: <missing>"),
}
eprintln!(
" full traces verified: {}/{} (reconstructed + leaf re-derived)",
att.traces_verified, att.traces_total
);
eprintln!(
" causal records recomputed: {}/{} ({} confirmed)",
att.causal_recomputed, att.causal_total, att.causal_confirmed
);
eprintln!(
" collapse rows derived from full traces: {}/{}",
att.collapse_derived, att.collapse_total
);
for (name, ok) in &att.checks {
eprintln!(" [{}] {}", if *ok { "PASS" } else { "FAIL" }, name);
}
if att.ok {
eprintln!("ATTESTATION: PASS — leaves recompute to the claimed root.");
} else {
eprintln!("ATTESTATION: FAIL — evidence is inconsistent.");
std::process::exit(1);
}
}
Err(e) => {
eprintln!("ATTESTATION: ERROR — {e}");
std::process::exit(2);
}
}
}
+23
View File
@@ -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"
+94
View File
@@ -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<Json>),
Obj(Vec<(String, Json)>),
}
impl Json {
pub fn s(v: impl Into<String>) -> 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(" ");
}
}
File diff suppressed because it is too large Load Diff
+738
View File
@@ -0,0 +1,738 @@
//! The `ci` binary: runs the full adversarial framework against the reference
//! and the independently-implemented runtime under test, writes the required
//! reports (JSON + markdown) including a provenance report binding the numbers
//! to executed work, and exits nonzero if any gate fails.
use ci_reports::json::Json;
use ci_reports::{run_all_to, CiResults, Profile, 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 hex(h: world_model::Hash) -> Json {
Json::s(format!("{:016x}", h.0))
}
fn write_report(dir: &Path, name: &str, j: &Json) {
let path = dir.join(format!("{name}.json"));
let mut f = fs::File::create(&path).expect("create report");
f.write_all(j.to_pretty().as_bytes()).expect("write report");
}
/// Write the raw evidence the independent `attest` binary verifies (findings
/// 2, 3): every Merkle leaf, plus the producer's claims. The attestor recomputes
/// the root from these leaves and checks it against the claimed root, in a
/// separate process that never reads the compliance report.
fn write_evidence(dir: &Path, r: &CiResults) {
let ev = dir.join("evidence");
fs::create_dir_all(&ev).expect("create evidence dir");
let mut leaves = String::from("leaf\n");
for h in &r.merkle_leaves {
leaves.push_str(&format!("{:016x}\n", h.0));
}
fs::write(ev.join("leaves.tsv"), leaves).expect("write leaves");
let claims = format!(
"# evidence claims for independent attestation\n\
root\t{:016x}\n\
leaf_count\t{}\n\
total_comparisons\t{}\n\
reference_engine_id\t{:016x}\n\
rut_engine_id\t{:016x}\n\
engines_agree\t{}\n",
r.provenance.execution_merkle_root.0,
r.provenance.merkle_leaf_count,
r.provenance.total_comparisons,
r.provenance.reference_engine_id.0,
r.provenance.rut_engine_id.0,
r.provenance.engines_agree,
);
fs::write(ev.join("claims.tsv"), claims).expect("write claims");
// Per-edge causal intervention evidence (finding 7): one recomputable record
// per tested edge.
let mut causal = String::from(
"seed\tfrom_domain\tfrom_lane\tfrom_hidden\tto_domain\tto_lane\tto_hidden\tbase_dv\talt_dv\n",
);
for rec in &r.causal_explanation.evidence {
causal.push_str(&format!(
"{:016x}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
rec.case_seed,
rec.from_domain,
rec.from_lane,
rec.from_hidden as u8,
rec.to_domain,
rec.to_lane,
rec.to_hidden as u8,
rec.base_dv,
rec.alt_dv,
));
}
fs::write(ev.join("causal_evidence.tsv"), causal).expect("write causal evidence");
// Collapse feature rows (finding 4): the summaries the collapse gate consumes,
// in the same order as the first base trace records. Stored as f64 bits so an
// attestor can recompute each row from the full trace and compare bit-exact.
let mut crows = String::new();
for row in &r.collapse_feature_rows {
let cells: Vec<String> = row.iter().map(|v| v.to_bits().to_string()).collect();
crows.push_str(&cells.join(" "));
crows.push('\n');
}
fs::write(ev.join("collapse_feature_rows.tsv"), crows).expect("write collapse rows");
// Artifact bundle manifest (finding 1): the merge-scale claim is only valid
// if this complete, content-hashed bundle is retained. The attestor verifies
// every listed file exists and its hash + length match.
let bundle = [
"leaves.tsv",
"claims.tsv",
"causal_evidence.tsv",
"collapse_feature_rows.tsv",
"traces.tsv",
];
let mut manifest = String::from("file\thash\tbytes\n");
for name in bundle {
let bytes = fs::read(ev.join(name)).unwrap_or_default();
let mut h = world_model::Hasher::new();
h.write_tag("evidence-file");
h.write_bytes(&bytes);
manifest.push_str(&format!("{}\t{:016x}\t{}\n", name, h.finish().0, bytes.len()));
}
fs::write(ev.join("MANIFEST.tsv"), manifest).expect("write manifest");
// Note: evidence/traces.tsv (the FULL per-execution trace corpus) is streamed
// during the run in main(), covering 100% of base executions — not written
// here from a capped sample.
}
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<Json> = r
.collapse
.reports
.iter()
.map(|rep| {
Json::Obj(vec![
("attack".into(), Json::s(rep.attack.clone())),
("reconstructs".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)),
("measures".into(), Json::s("real serialized trace structure")),
("best_1factor".into(), Json::Num(r.collapse.best_1factor)),
("best_2factor".into(), Json::Num(r.collapse.best_2factor)),
("best_4factor".into(), Json::Num(r.collapse.best_4factor)),
("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)),
("consumed_perturbation_trace_violations".into(), Json::Num(r.metamorphic.expectation_violations)),
("consumed_delta_change_rate".into(), Json::Num(r.metamorphic.consumed_delta_rate)),
("consumed_future_change_rate".into(), Json::Num(r.metamorphic.consumed_future_rate)),
("legitimately_neutral".into(), Json::Num(r.metamorphic.explained_neutral)),
("consumed_perturbations".into(), Json::Int(r.metamorphic.consumed as i64)),
("failures".into(), fails(&r.metamorphic.failures)),
]),
);
// 4b. causal_explanation_report (finding 10: intervention-confirmed edges)
write_report(
dir,
"causal_explanation_report",
&Json::Obj(vec![
("pass".into(), pass_field(&r.causal_explanation.failures)),
("edges_tested".into(), Json::Int(r.causal_explanation.edges_tested as i64)),
("edges_confirmed".into(), Json::Int(r.causal_explanation.edges_confirmed as i64)),
("confirmed_fraction".into(), Json::Num(r.causal_explanation.confirmed_fraction)),
("method".into(), Json::s("ablate recorded causal source lane; require destination delta to change")),
("failures".into(), fails(&r.causal_explanation.failures)),
]),
);
// 5. mutation_survivor_report
let survivors: Vec<Json> = r
.mutation
.survivors
.iter()
.map(|(id, reason)| {
Json::Obj(vec![
("id".into(), Json::Int(*id as i64)),
("reason".into(), Json::s(reason.clone())),
])
})
.collect();
write_report(
dir,
"mutation_survivor_report",
&Json::Obj(vec![
("pass".into(), Json::Bool(r.mutation.passed())),
("killed_by".into(), Json::s("named acceptance gate")),
("total_mutants".into(), Json::Int(r.mutation.total as i64)),
("killed".into(), Json::Int(r.mutation.killed as i64)),
("survivors".into(), Json::Arr(survivors)),
]),
);
// 6. runtime_equivalence_report
write_report(
dir,
"runtime_equivalence_report",
&Json::Obj(vec![
("pass".into(), pass_field(&r.equivalence.failures)),
("independent_implementations".into(), Json::Bool(r.equivalence.independent)),
("reference_engine_id".into(), hex(r.provenance.reference_engine_id)),
("rut_engine_id".into(), hex(r.provenance.rut_engine_id)),
("engines_agree".into(), Json::Bool(r.provenance.engines_agree)),
("total".into(), Json::Int(r.equivalence.total as i64)),
("matched".into(), Json::Int(r.equivalence.matched as i64)),
("failures".into(), fails(&r.equivalence.failures)),
]),
);
// 7. replay_report
write_report(
dir,
"replay_report",
&Json::Obj(vec![
("pass".into(), pass_field(&r.replay.failures)),
("loaded_from_committed_corpus".into(), Json::Bool(r.replay.loaded_from_disk)),
("total".into(), Json::Int(r.replay.total as i64)),
("deterministic".into(), Json::Int(r.replay.deterministic as i64)),
("drift".into(), Json::Int(r.replay.drift as i64)),
("retained_failures_present".into(), Json::Bool(r.replay.retained_present)),
("retained_failures_total".into(), Json::Int(r.replay.retained_total as i64)),
("retained_failures_regressions".into(), Json::Int(r.replay.retained_regressions 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)),
]),
);
// 9. provenance_report — binds the run to executed work.
let p = &r.provenance;
write_report(
dir,
"provenance_report",
&Json::Obj(vec![
("pass".into(), pass_field(&p.failures)),
("profile".into(), Json::s(p.profile.name())),
(
"merge_floor".into(),
Json::Obj(vec![
("worlds".into(), Json::Int(p.floor.worlds as i64)),
("programs".into(), Json::Int(p.floor.programs as i64)),
("executions".into(), Json::Int(p.floor.executions as i64)),
("perturbations_per_exec".into(), Json::Int(p.floor.perturbations_per_exec as i64)),
("mutants".into(), Json::Int(p.floor.mutants as i64)),
("replay_cases".into(), Json::Int(p.floor.replay_cases as i64)),
]),
),
("worlds_generated".into(), Json::Int(p.worlds_generated as i64)),
("programs_generated".into(), Json::Int(p.programs_generated as i64)),
("actual_executions".into(), Json::Int(p.actual_executions as i64)),
("min_perturbations_per_exec".into(), Json::Int(p.min_perturbations_per_exec as i64)),
("total_comparisons".into(), Json::Int(p.total_comparisons as i64)),
("actual_mutants".into(), Json::Int(p.actual_mutants as i64)),
("replay_total".into(), Json::Int(p.replay_total as i64)),
("reference_engine_id".into(), hex(p.reference_engine_id)),
("rut_engine_id".into(), hex(p.rut_engine_id)),
("engines_agree".into(), Json::Bool(p.engines_agree)),
("execution_merkle_root".into(), hex(p.execution_merkle_root)),
("merkle_leaf_count".into(), Json::Int(p.merkle_leaf_count as i64)),
("collapse_feature_width".into(), Json::Int(p.collapse_feature_width as i64)),
("failures".into(), fails(&p.failures)),
]),
);
}
/// The reports the spec requires every CI run to produce. A missing or empty
/// artifact is itself an acceptance failure (compliance rule 4).
const REQUIRED_REPORTS: [&str; 10] = [
"domain_participation_report",
"causal_rank_report",
"causal_explanation_report",
"compression_resistance_report",
"metamorphic_response_report",
"mutation_survivor_report",
"runtime_equivalence_report",
"replay_report",
"coverage_report",
"provenance_report",
];
fn report_present(dir: &Path, name: &str) -> bool {
fs::metadata(dir.join(format!("{name}.json")))
.map(|m| m.len() > 0)
.unwrap_or(false)
}
/// One acceptance obligation: a measured artifact, a provenance chain, a
/// merge-blocking enforcement point, and a failure if the artifact/provenance is
/// absent. This is the compliance model made machine-checkable.
struct Obligation {
requirement: &'static str,
artifact: &'static str,
floor: i64,
actual: i64,
gate_pass: bool,
}
fn obligations(r: &CiResults) -> Vec<Obligation> {
let p = &r.provenance;
let merge = p.profile == Profile::Merge;
let f = |v: usize| v as i64;
let floor = |v: usize| if merge { v as i64 } else { 0 };
vec![
Obligation {
requirement: "generated worlds >= 50,000",
artifact: "provenance_report",
floor: floor(p.floor.worlds),
actual: f(p.worlds_generated),
gate_pass: !merge || p.worlds_generated >= p.floor.worlds,
},
Obligation {
requirement: "generated programs >= 250,000",
artifact: "provenance_report",
floor: floor(p.floor.programs),
actual: f(p.programs_generated),
gate_pass: !merge || p.programs_generated >= p.floor.programs,
},
Obligation {
requirement: "executions >= 1,000,000",
artifact: "provenance_report",
floor: floor(p.floor.executions),
actual: f(p.actual_executions),
gate_pass: !merge || p.actual_executions >= p.floor.executions,
},
Obligation {
requirement: "perturbations per execution >= 10",
artifact: "metamorphic_response_report",
floor: floor(p.floor.perturbations_per_exec),
actual: f(p.min_perturbations_per_exec),
gate_pass: !merge || p.min_perturbations_per_exec >= p.floor.perturbations_per_exec,
},
Obligation {
requirement: "reference/runtime comparison = 100% of executions",
artifact: "runtime_equivalence_report",
floor: floor(p.floor.executions * (1 + p.floor.perturbations_per_exec)),
actual: f(p.total_comparisons),
gate_pass: r.equivalence.failures.is_empty(),
},
Obligation {
requirement: "semantic mutants >= 500, 0 survivors, killed by named gate",
artifact: "mutation_survivor_report",
floor: floor(p.floor.mutants),
actual: f(r.mutation.total),
gate_pass: r.mutation.passed() && (!merge || r.mutation.total >= p.floor.mutants),
},
Obligation {
requirement: "replay corpus >= 10,000, persisted, 0 drift",
artifact: "replay_report",
floor: floor(p.floor.replay_cases),
actual: f(r.replay.total),
gate_pass: r.replay.failures.is_empty() && r.replay.loaded_from_disk,
},
Obligation {
requirement: "collapse attacks fail to simplify (real trace info)",
artifact: "compression_resistance_report",
floor: 0,
actual: 0,
gate_pass: r.collapse.failures.is_empty(),
},
Obligation {
requirement: "trace gates (causal edges/rank/touched/fingerprints)",
artifact: "causal_rank_report",
floor: 0,
actual: 0,
gate_pass: r.trace.failures.is_empty(),
},
Obligation {
requirement: "domain participation (no decorative/read-only/write-only)",
artifact: "domain_participation_report",
floor: 0,
actual: 0,
gate_pass: r.domain.failures.is_empty(),
},
Obligation {
requirement: "metamorphic response thresholds",
artifact: "metamorphic_response_report",
floor: 0,
actual: 0,
gate_pass: r.metamorphic.failures.is_empty(),
},
Obligation {
requirement: "recorded causal edges are intervention-confirmed (not counted)",
artifact: "causal_explanation_report",
floor: 0,
actual: f(r.causal_explanation.edges_confirmed),
gate_pass: r.causal_explanation.failures.is_empty(),
},
Obligation {
requirement: "every admitted case satisfies its contract",
artifact: "coverage_report",
floor: 0,
actual: 0,
gate_pass: r.contract.failures.is_empty() && r.coverage.failures.is_empty(),
},
Obligation {
requirement: "provenance binds reports to executed work",
artifact: "provenance_report",
floor: 0,
actual: 0,
gate_pass: r.provenance.failures.is_empty(),
},
]
}
/// Write the compliance report and return whether the compliance model holds:
/// every obligation's artifact is present and every obligation passes.
fn build_compliance_report(dir: &Path, r: &CiResults) -> bool {
let items = obligations(r);
let mut all_ok = true;
let mut json_items = Vec::new();
for ob in &items {
let present = report_present(dir, ob.artifact);
let pass = present && ob.gate_pass;
if !pass {
all_ok = false;
}
json_items.push(Json::Obj(vec![
("requirement".into(), Json::s(ob.requirement)),
("measured_artifact".into(), Json::s(format!("{}.json", ob.artifact))),
("artifact_present".into(), Json::Bool(present)),
("provenance".into(), Json::s("provenance_report.json (merkle root + engine ids)")),
("merge_blocking".into(), Json::Bool(true)),
("floor".into(), Json::Int(ob.floor)),
("actual".into(), Json::Int(ob.actual)),
("pass".into(), Json::Bool(pass)),
]));
}
// Every required report must exist and be non-empty.
let mut missing = Vec::new();
for rep in REQUIRED_REPORTS {
if !report_present(dir, rep) {
missing.push(rep.to_string());
all_ok = false;
}
}
write_report(
dir,
"compliance_report",
&Json::Obj(vec![
("pass".into(), Json::Bool(all_ok)),
("profile".into(), Json::s(r.provenance.profile.name())),
(
"merge_blocking_run".into(),
Json::Bool(r.provenance.profile == Profile::Merge),
),
(
"note".into(),
Json::s(if r.provenance.profile == Profile::Merge {
"merge profile: floors enforced, all obligations acceptance-blocking"
} else {
"advisory profile: NOT acceptance; floors not enforced (fast/tiny)"
}),
),
("required_reports".into(), Json::Arr(REQUIRED_REPORTS.iter().map(|s| Json::s(*s)).collect())),
("missing_reports".into(), Json::Arr(missing.iter().map(|s| Json::s(s.clone())).collect())),
("obligations".into(), Json::Arr(json_items)),
]),
);
all_ok
}
fn status(v: bool) -> &'static str {
if v {
"PASS"
} else {
"FAIL"
}
}
fn write_markdown(dir: &Path, r: &CiResults) {
let p = &r.provenance;
let mut s = String::new();
s.push_str("# Magicka VM — Phase 0/1 CI Report\n\n");
s.push_str(&format!("Overall: **{}**\n\n", status(r.passed())));
s.push_str(&format!("Profile: **{}**", p.profile.name()));
if p.profile == Profile::Fast {
s.push_str(" (representative slice — NOT merge-blocking)");
}
s.push_str("\n\n");
s.push_str(&format!(
"Scale: executions={}, mutants={}, replay={}, collapse_samples={}\n\n",
r.scale.executions, r.scale.mutants, r.scale.replay_cases, r.scale.collapse_samples
));
s.push_str("## Provenance\n\n");
s.push_str(&format!("- Execution Merkle root: `{:016x}` over {} leaves\n", p.execution_merkle_root.0, p.merkle_leaf_count));
s.push_str(&format!(
"- Reference engine id: `{:016x}`; runtime-under-test engine id: `{:016x}`; agree: **{}**\n",
p.reference_engine_id.0, p.rut_engine_id.0, p.engines_agree
));
s.push_str(&format!(
"- Actual executions: {} (merge floor {}), min perturbations/exec: {} (floor {})\n",
p.actual_executions, p.floor.executions, p.min_perturbations_per_exec, p.floor.perturbations_per_exec
));
s.push_str(&format!("- Collapse measures real trace structure ({} features/trace)\n\n", p.collapse_feature_width));
s.push_str("| Report | Status | Key metrics |\n|---|---|---|\n");
s.push_str(&format!(
"| runtime_equivalence | {} | {}/{} matched, independent impls, engines agree={} |\n",
status(r.equivalence.failures.is_empty()),
r.equivalence.matched,
r.equivalence.total,
p.engines_agree
));
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 {}/{} by named gate |\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 (committed corpus), 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(&format!(
"| provenance | {} | merkle over {} leaves, floor enforced |\n",
status(r.provenance.failures.is_empty()),
r.provenance.merkle_leaf_count
));
s.push_str("\n## Failures\n\n");
let mut any = false;
for (name, f) in r.all_failures() {
for msg in f {
any = true;
s.push_str(&format!("- **{}**: {}\n", name, msg));
}
}
for (id, reason) in &r.mutation.survivors {
any = true;
s.push_str(&format!("- **mutation_survivor**: mutant {} survived ({})\n", id, reason));
}
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: profile={} executions={} mutants={} replay={} collapse_samples={}",
scale.profile.name(), scale.executions, scale.mutants, scale.replay_cases, scale.collapse_samples
);
for v in &scale.override_violations {
eprintln!(" override rejected: {v}");
}
let merge = scale.profile == Profile::Merge;
let start = Instant::now();
// Stream full-trace evidence for 100% of base executions straight to disk.
let ev_dir = dir.join("evidence");
fs::create_dir_all(&ev_dir).expect("create evidence dir");
let traces_path = ev_dir.join("traces.tsv");
let mut traces_w = std::io::BufWriter::new(fs::File::create(&traces_path).expect("create traces"));
traces_w
.write_all(b"# full-trace evidence (100% of base executions): header<TAB>trace<TAB>delta\n")
.expect("write traces header");
let results = run_all_to(scale, Some(&mut traces_w));
traces_w.flush().expect("flush traces");
drop(traces_w);
let elapsed = start.elapsed();
build_reports(dir, &results);
write_evidence(dir, &results);
write_markdown(dir, &results);
let compliance_ok = build_compliance_report(dir, &results);
println!("\n=== Magicka VM CI ({:?}) ===", elapsed);
for (name, f) in results.all_failures() {
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!(" {:<24} {}", "compliance", status(compliance_ok));
println!(
" provenance: merkle={:016x} over {} leaves, engines_agree={}, comparisons={}",
results.provenance.execution_merkle_root.0,
results.provenance.merkle_leaf_count,
results.provenance.engines_agree,
results.provenance.total_comparisons,
);
println!("reports written to {}/", out_dir);
let gates_pass = results.passed() && compliance_ok;
if !merge {
// The fast/tiny profiles are advisory only — they may never stand in for
// the merge-blocking acceptance run (compliance: no default-profile
// substitution). Report status but make clear this is not acceptance.
println!(
"\nADVISORY ({} profile): {} — NOT a merge-blocking acceptance run. \
Acceptance requires MAGICKA_PROFILE=merge (full gates).",
results.provenance.profile.name(),
status(gates_pass)
);
// A failing advisory run still fails the PR check; a passing one is green
// but explicitly non-acceptance.
std::process::exit(if gates_pass { 0 } else { 1 });
}
if gates_pass {
println!("\nACCEPTANCE: PASS — full merge gates satisfied with persisted, independently reproducible evidence.");
std::process::exit(0);
} else {
println!("\nACCEPTANCE: FAIL");
std::process::exit(1);
}
}
+12
View File
@@ -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"
+570
View File
@@ -0,0 +1,570 @@
//! `collapse_analysis` — the framework's compression attacks.
//!
//! Each attack tries to reconstruct the *actual serialized trace* from a
//! simpler (compressed) representation of it. If a small model reconstructs the
//! trace above the configured thresholds, the universe has collapsed and CI must
//! fail.
//!
//! The earlier version of this crate operated on a 22-element behavior
//! *fingerprint* — a hash-derived proxy the avalanche engine guarantees is
//! near-random — and defined information loss circularly as `1 - predicts`. That
//! made every attack pass for free: the metric never touched real trace content.
//!
//! This version feeds the genuine trace structure (per-domain causal influence,
//! information flow, access counts, temporal reach, and state deltas, plus the
//! global rank/edge/divergence summaries) into the attacks, and defines
//! information loss as the real unexplained-variance fraction of reconstructing
//! the trace. The negative-control test builds a deliberately collapsible
//! (single-factor) corpus and verifies the gate rejects it, demonstrating the
//! gate discriminates a rich universe from a degenerate one.
pub mod linalg;
use linalg::{ols_r2, pca_scores, Mat};
use trace_model::ExecutionTrace;
use world_model::{WorldDelta, NUM_DOMAINS};
/// The single definition of a trace feature row. The collapse corpus is built
/// from these rows; an attestor recomputes the same row from the retained FULL
/// trace and delta and checks equality, which is how the summary is proven to
/// derive from the full trace. Layout per domain block (`BLOCK_W`):
/// `[infl_out, infl_in, flow_out, flow_in, read, write, temporal, obs_delta,
/// hid_delta]`, then globals `[causal_rank, edge_count, touched, divergence]`.
pub fn trace_feature_row(trace: &ExecutionTrace, delta: &WorldDelta) -> Vec<f64> {
let infl = trace.causal_graph.influence_matrix();
let mut flow = [[0.0f64; NUM_DOMAINS]; NUM_DOMAINS];
for &(a, b, bits) in &trace.information_flow.edges {
flow[a as usize % NUM_DOMAINS][b as usize % NUM_DOMAINS] += bits as f64;
}
let mut temporal = [0.0f64; NUM_DOMAINS];
for &(_s, _off, d) in &trace.temporal_graph.edges {
temporal[d as usize % NUM_DOMAINS] += 1.0;
}
let mut row = Vec::with_capacity(FEATURE_W);
for d in 0..NUM_DOMAINS {
let infl_out: f64 = (0..NUM_DOMAINS).map(|j| infl[d][j]).sum();
let infl_in: f64 = (0..NUM_DOMAINS).map(|i| infl[i][d]).sum();
let flow_out: f64 = (0..NUM_DOMAINS).map(|j| flow[d][j]).sum();
let flow_in: f64 = (0..NUM_DOMAINS).map(|i| flow[i][d]).sum();
let read = trace.read_graph.access_count[d] as f64;
let write = trace.write_graph.access_count[d] as f64;
let temp = temporal[d];
let obs_delta: f64 = delta.domain_deltas[d].observed.iter().map(|&v| (v as f64).abs()).sum();
let hid_delta: f64 = delta.domain_deltas[d].hidden.iter().map(|&v| (v as f64).abs()).sum();
row.extend_from_slice(&[
infl_out, infl_in, flow_out, flow_in, read, write, temp, obs_delta, hid_delta,
]);
}
row.push(trace.causal_rank() as f64);
row.push(trace.causal_edge_count() as f64);
row.push(trace.touched_domain_count() as f64);
row.push(trace.context_divergence());
row
}
/// Width of one per-domain feature block in a trace feature row.
/// `[infl_out, infl_in, flow_out, flow_in, read, write, temporal, obs_delta, hid_delta]`
pub const BLOCK_W: usize = 9;
/// Number of trailing global (non-domain) feature columns.
/// `[causal_rank, edge_count, touched_count, divergence_mean]`
pub const GLOBALS: usize = 4;
/// Total trace feature width.
pub const FEATURE_W: usize = NUM_DOMAINS * BLOCK_W + GLOBALS;
/// A standardized corpus of real trace feature rows.
pub struct BehaviorCorpus {
/// `n x FEATURE_W` standardized trace features.
pub traces: Mat,
}
fn standardize(rows: &[Vec<f64>]) -> 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]);
}
}
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(rows: Vec<Vec<f64>>) -> Self {
BehaviorCorpus {
traces: standardize(&rows),
}
}
pub fn n(&self) -> usize {
self.traces.rows
}
fn cols(&self) -> usize {
self.traces.cols
}
fn block_cols(d: usize) -> Vec<usize> {
(d * BLOCK_W..(d + 1) * BLOCK_W).collect()
}
fn select(&self, cols: &[usize]) -> Mat {
let mut m = Mat::zeros(self.traces.rows, cols.len());
for r in 0..self.traces.rows {
for (j, &c) in cols.iter().enumerate() {
m.set(r, j, self.traces.at(r, c));
}
}
m
}
fn complement(&self, cols: &[usize]) -> Vec<usize> {
(0..self.cols()).filter(|c| !cols.contains(c)).collect()
}
/// R² of reconstructing the columns `target` from the columns `source`.
fn reconstruct(&self, source: &[usize], target: &[usize]) -> f64 {
if source.is_empty() || target.is_empty() {
return 0.0;
}
let x = self.select(source);
let y = self.select(target);
ols_r2(&x, &y)
}
/// Explained-variance fraction of the whole trace from its top-`k` PCA
/// factors (a genuine k-factor reconstruction quality).
pub fn predict_k_factor(&self, k: usize) -> f64 {
if self.n() == 0 || self.cols() == 0 {
return 0.0;
}
let scores = pca_scores(&self.traces, k);
ols_r2(&scores, &self.traces)
}
/// Best fraction of the *rest of the trace* explained by a single domain.
pub fn max_single_domain(&self) -> (usize, f64) {
let mut best = (0usize, 0.0);
for d in 0..NUM_DOMAINS {
let src = Self::block_cols(d);
let tgt = self.complement(&src);
let r2 = self.reconstruct(&src, &tgt);
if r2 > best.1 {
best = (d, r2);
}
}
best
}
/// Best fraction of the rest explained by any pair of domains.
pub fn max_pair(&self) -> ((usize, usize), f64) {
let mut best = ((0usize, 1usize), 0.0);
for a in 0..NUM_DOMAINS {
for b in (a + 1)..NUM_DOMAINS {
let mut src = Self::block_cols(a);
src.extend(Self::block_cols(b));
let tgt = self.complement(&src);
let r2 = self.reconstruct(&src, &tgt);
if r2 > best.1 {
best = ((a, b), r2);
}
}
}
best
}
}
/// A compressed model's reconstruction power and (genuine) 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;
/// Whether this attack produces a dimensionality-reduced representation of
/// the *entire* trace (a genuine compressed model), as opposed to an
/// ablation probe that reconstructs one facet from the rest. Only
/// whole-trace compressors define the "compressed model loses ≥ 35%
/// information" gate; ablation probes are reported for the record and feed
/// their own structural gates (domain participation, etc.).
fn whole_trace(&self) -> bool {
false
}
}
/// One attack's outcome.
#[derive(Clone, Debug)]
pub struct CollapseReport {
pub attack: String,
pub predicts: f64,
pub info_loss: f64,
pub detail: String,
}
/// Build a compressed-model result. `info_loss` is the genuine unexplained
/// fraction of the reconstructed trace variance.
fn model(predicts: f64, detail: &str) -> CompressedModel {
CompressedModel {
predicts,
info_loss: (1.0 - predicts).clamp(0.0, 1.0),
detail: detail.to_string(),
}
}
macro_rules! attack {
($name:ident, $label:expr, $body:expr) => {
attack!($name, $label, false, $body);
};
($name:ident, $label:expr, $whole:expr, $body:expr) => {
pub struct $name;
impl CollapseAttack for $name {
fn name(&self) -> &'static str {
$label
}
fn compress(&self, corpus: &BehaviorCorpus) -> CompressedModel {
let f: fn(&BehaviorCorpus) -> CompressedModel = $body;
f(corpus)
}
fn whole_trace(&self) -> bool {
$whole
}
}
};
}
attack!(DomainRemoval, "domain_removal", |c| {
// Can the rest of the trace reconstruct each removed domain's own block?
let mut best = 0.0;
for d in 0..NUM_DOMAINS {
let tgt = BehaviorCorpus::block_cols(d);
let src = c.complement(&tgt);
best = f64::max(best, c.reconstruct(&src, &tgt));
}
model(best, "reconstruct a removed domain from the rest")
});
attack!(DomainMerging, "domain_merging", |c| {
// Merge each pair (sum their blocks); can the merged sum reconstruct the two
// separate blocks? If so, distinguishing the domains is redundant.
let mut best = 0.0;
for a in 0..NUM_DOMAINS {
for b in (a + 1)..NUM_DOMAINS {
let mut merged = Mat::zeros(c.traces.rows, BLOCK_W);
for r in 0..c.traces.rows {
for l in 0..BLOCK_W {
let v = c.traces.at(r, a * BLOCK_W + l) + c.traces.at(r, b * BLOCK_W + l);
merged.set(r, l, v);
}
}
let mut tgt_cols = BehaviorCorpus::block_cols(a);
tgt_cols.extend(BehaviorCorpus::block_cols(b));
let tgt = c.select(&tgt_cols);
best = f64::max(best, ols_r2(&merged, &tgt));
}
}
model(best, "reconstruct two domains from their merged sum")
});
attack!(ConstantFolding, "constant_folding", true, |_c| {
// Folding the trace to constants reconstructs nothing.
model(0.0, "trace folded to constants")
});
attack!(CausalEdgeDeletion, "causal_edge_deletion", |c| {
// Drop all cross-domain causal/flow columns; can local features
// (counts/deltas) reconstruct the deleted causal structure?
let mut deleted = Vec::new();
for d in 0..NUM_DOMAINS {
for l in 0..4 {
// infl_out, infl_in, flow_out, flow_in
deleted.push(d * BLOCK_W + l);
}
}
let kept = c.complement(&deleted);
model(c.reconstruct(&kept, &deleted), "reconstruct deleted causal edges from local features")
});
attack!(StateAliasing, "state_aliasing", true, |c| {
// Alias the whole trace into one aggregate column; reconstruct the full trace.
let mut agg = Mat::zeros(c.traces.rows, 1);
for r in 0..c.traces.rows {
let mut s = 0.0;
for col in 0..c.traces.cols {
s += c.traces.at(r, col);
}
agg.set(r, 0, s);
}
model(ols_r2(&agg, &c.traces), "reconstruct trace from a single aliased aggregate")
});
attack!(LatentFactorModeling, "latent_factor_modeling", true, |c| {
model(c.predict_k_factor(4), "top-4 latent factors")
});
attack!(BehaviorClustering, "behavior_clustering", true, |c| {
let recon = kmeans_reconstruct(&c.traces, 4);
model(reconstruction_r2(&c.traces, &recon), "4-cluster behavior model")
});
attack!(SurrogatePrediction, "surrogate_prediction", true, |c| {
// A small (2-factor) linear surrogate of the whole trace.
model(c.predict_k_factor(2), "2-factor linear surrogate")
});
attack!(TemporalFlattening, "temporal_flattening", |c| {
// Drop temporal columns; can the rest reconstruct temporal reach?
let temporal: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * BLOCK_W + 6).collect();
let kept = c.complement(&temporal);
model(c.reconstruct(&kept, &temporal), "reconstruct temporal reach from non-temporal features")
});
attack!(ObservationFlattening, "observation_flattening", |c| {
// Drop hidden-state delta columns; reconstruct them from the observed side.
let hidden: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * BLOCK_W + 8).collect();
let kept = c.complement(&hidden);
model(c.reconstruct(&kept, &hidden), "reconstruct hidden deltas from observed features")
});
attack!(ExecutorIdentityErasure, "executor_identity_erasure", |c| {
// The divergence summary is the last global column; reconstruct it from the
// rest (erasing executor identity).
let div = vec![c.cols() - 1];
let kept = c.complement(&div);
model(c.reconstruct(&kept, &div), "reconstruct executor divergence from the rest")
});
fn reconstruction_r2(original: &Mat, recon: &Mat) -> f64 {
let mut ss_res = 0.0;
let mut ss_tot = 0.0;
for r in 0..original.rows {
for col in 0..original.cols {
let o = original.at(r, col);
ss_res += (o - recon.at(r, col)).powi(2);
ss_tot += o.powi(2);
}
}
if ss_tot <= 1e-9 {
return 0.0;
}
(1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
}
fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
let n = y.rows;
if n == 0 || k == 0 {
return y.clone();
}
let k = k.min(n);
let mut centroids: Vec<Vec<f64>> = (0..k)
.map(|i| y.data[(i * n / k) * y.cols..(i * n / k) * y.cols + y.cols].to_vec())
.collect();
let mut assign = vec![0usize; n];
for _ in 0..12 {
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;
}
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<Box<dyn CollapseAttack>> {
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<CollapseReport>,
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<String>,
}
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();
// `min_info_loss` is defined only over whole-trace compressors — the genuine
// "compressed model loses ≥ 35% information" gate. Ablation probes run and
// report, but reconstructing one facet from the rest is redundancy, not
// whole-trace compression, and is gated structurally elsewhere.
let mut min_info_loss: f64 = 1.0;
for atk in all_attacks() {
let m = atk.compress(corpus);
if atk.whole_trace() {
min_info_loss = min_info_loss.min(m.info_loss);
}
reports.push(CollapseReport {
attack: atk.name().to_string(),
predicts: m.predicts,
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 reconstructs {:.3} >= 0.40", best_1));
}
if best_2 >= 0.55 {
failures.push(format!("2-factor reconstructs {:.3} >= 0.55", best_2));
}
if best_4 >= 0.70 {
failures.push(format!("4-factor reconstructs {:.3} >= 0.70", best_4));
}
if max_single > 0.30 {
failures.push(format!("single domain explains {:.3} > 0.30", max_single));
}
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::Rng;
fn random_trace_rows(n: usize, seed: u64) -> Vec<Vec<f64>> {
let mut rng = Rng::new(seed);
(0..n)
.map(|_| {
(0..FEATURE_W)
.map(|_| rng.range_i64(-5000, 5000) as f64)
.collect()
})
.collect()
}
#[test]
fn high_entropy_trace_resists_collapse() {
let corpus = BehaviorCorpus::build(random_trace_rows(400, 1));
let summary = analyze(&corpus);
assert!(summary.passed(), "collapse failures: {:?}", summary.failures);
}
/// Negative control: a single-factor (rank-1) corpus is genuinely
/// collapsible. Every feature is a fixed loading times one latent value plus
/// tiny noise, so a 1-factor model reconstructs almost everything. The gate
/// MUST reject it — proving the collapse analysis discriminates.
#[test]
fn single_factor_corpus_is_rejected() {
let mut rng = Rng::new(7);
let loadings: Vec<f64> = (0..FEATURE_W).map(|i| 1.0 + (i % 5) as f64).collect();
let rows: Vec<Vec<f64>> = (0..400)
.map(|_| {
let latent = rng.range_i64(-1000, 1000) as f64;
loadings
.iter()
.map(|&load| load * latent + rng.range_i64(-2, 2) as f64)
.collect()
})
.collect();
let corpus = BehaviorCorpus::build(rows);
let summary = analyze(&corpus);
assert!(
!summary.passed(),
"collapse gate failed to reject a single-factor universe (1f={:.3}, info_loss={:.3})",
summary.best_1factor,
summary.min_info_loss
);
}
}
+244
View File
@@ -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<f64>,
}
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<f64> {
(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<f64> {
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<f64> {
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<f64>> = 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<f64> {
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::<f64>().sqrt();
if n > 1e-12 {
for x in v.iter_mut() {
*x /= n;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "game_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" }
# Used only for the shared engine I/O *contract* types (ResolutionInput /
# ResolutionResult) and the canonical view. Execution is driven through the
# independent interpreter in `runtime_under_test`, never the reference engine.
reference_runtime = { path = "../reference_runtime" }
runtime_under_test = { path = "../runtime_under_test" }
generators = { path = "../generators" }
protocol = { path = "../protocol" }
+757
View File
@@ -0,0 +1,757 @@
//! `game_runtime` — the authoritative match layer (Phase B of `plan2.md`).
//!
//! This crate is the *only* place game truth is decided. The browser sends
//! intent; this crate resolves it. Every rune program executes through the
//! **independent** interpreter [`runtime_under_test::native_resolve`] against
//! the shared [`WorldSnapshot`] — the web layer is a window into the Rust
//! universe and never a second simulation. The game does not call the reference
//! engine; correctness of the interpreter it does use is established separately
//! by the runtime-equivalence gate (which compares that interpreter against the
//! reference over a large sweep, with a negative control proving the gate can
//! fail).
//!
//! Two properties are essential and tested:
//! * **Determinism** — a match is a pure function of `(seed, roster, ordered
//! inputs)`. [`replay`] reconstructs any match and produces an identical
//! final hash. No wall clock, no ambient RNG; the turn *timer* lives in the
//! server, never here.
//! * **Authority + visibility** — players receive a [`VisibleWorldSnapshot`]
//! that redacts all hidden lanes and every non-observable observed lane. The
//! hidden ground truth is never placed in any client-bound structure.
use protocol::{
Action, Knowledge, MatchId, RuneDiagnostics, RuneTokenWire, VisibleDomain,
VisibleEntity, VisibleWorldSnapshot,
};
use reference_runtime::{canonical, ResolutionInput, ResolutionResult};
use runtime_under_test::native_resolve;
use rune_ir::{Op, RuneProgram, RuneToken};
use world_model::{
standard_executors, DomainKind, ExecutionContext, Hash, Hasher, ProgramId, Rng, WorldSnapshot,
HIDDEN_LANES, LANES, NUM_DOMAINS,
};
pub const ARENA_W: i32 = 8;
pub const ARENA_H: i32 = 8;
pub const MAX_HP: i32 = 30;
/// Basic stick attack damage.
pub const ATTACK_DAMAGE: i32 = 4;
/// Range (Manhattan) within which a cast's consequence reaches enemies.
pub const CAST_RANGE: i32 = 3;
/// One combatant on the arena. A "player" entity is driven by a connection; a
/// "dummy" is a deterministic stationary target for the 1-player slice.
#[derive(Clone, Debug)]
pub struct Entity {
pub id: u32,
pub name: String,
pub x: i32,
pub y: i32,
pub hp: i32,
pub is_dummy: bool,
/// The player's current editable rune program (Phase D editor state).
pub program: RuneProgram,
}
impl Entity {
pub fn alive(&self) -> bool {
self.hp > 0
}
}
/// A roster entry needed to reconstruct a match for replay.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RosterEntry {
pub id: u32,
pub name: String,
pub is_dummy: bool,
}
/// One resolved turn's authoritative input, sufficient to replay it exactly.
#[derive(Clone, Debug, PartialEq)]
pub struct TurnInput {
pub player: u32,
pub action: Action,
/// The exact program used, captured iff `action` is `Cast`. Recording the
/// program here (rather than replaying editor edits) makes replay a pure
/// function of this input stream.
pub program: Option<Vec<RuneTokenWire>>,
}
/// One recorded turn (Phase G).
#[derive(Clone, Debug)]
pub struct RecordedTurn {
pub turn: u64,
pub inputs: Vec<TurnInput>,
pub turn_hash: Hash,
pub events: Vec<String>,
}
/// The full replay log for a match.
#[derive(Clone, Debug)]
pub struct ReplayLog {
pub seed: u64,
pub roster: Vec<RosterEntry>,
pub turns: Vec<RecordedTurn>,
pub final_hash: Hash,
}
/// The authoritative match state.
#[derive(Clone, Debug)]
pub struct Match {
pub id: MatchId,
pub seed: u64,
pub turn: u64,
pub world: WorldSnapshot,
pub contexts: Vec<ExecutionContext>,
pub entities: Vec<Entity>,
pub history: Vec<String>,
pub replay: ReplayLog,
pub finished: bool,
/// The most recent per-domain observed change, used to tag freshly-observed
/// lanes without storing per-player memory (keeps resolution stateless).
last_observed_delta: [[i64; LANES]; NUM_DOMAINS],
}
/// A blank default program (a single benign token) so every entity always has
/// something to cast.
fn default_program(seed: u64) -> RuneProgram {
let mut rng = Rng::derive(seed, "default-program");
let tokens = (0..8)
.map(|i| RuneToken {
op: Op::from_u8((i as u8).wrapping_add(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(-1000, 1000),
})
.collect();
RuneProgram { id: ProgramId(seed), tokens, seed }
}
impl Match {
/// Create a match deterministically from a seed and a roster.
pub fn new(id: MatchId, seed: u64, roster: Vec<RosterEntry>) -> Match {
let world = generators::generate_world(seed);
let contexts = standard_executors(seed, 3);
let mut placer = Rng::derive(seed, "arena-placement");
let mut taken: Vec<(i32, i32)> = Vec::new();
let mut entities = Vec::with_capacity(roster.len());
for entry in &roster {
// Deterministic distinct placement.
let (x, y) = loop {
let x = placer.below(ARENA_W as usize) as i32;
let y = placer.below(ARENA_H as usize) as i32;
if !taken.contains(&(x, y)) {
break (x, y);
}
};
taken.push((x, y));
entities.push(Entity {
id: entry.id,
name: entry.name.clone(),
x,
y,
hp: MAX_HP,
is_dummy: entry.is_dummy,
program: default_program(seed ^ (entry.id as u64).wrapping_mul(0x9e3779b97f4a7c15)),
});
}
Match {
id,
seed,
turn: 0,
world,
contexts,
entities,
history: Vec::new(),
replay: ReplayLog { seed, roster, turns: Vec::new(), final_hash: Hash(0) },
finished: false,
last_observed_delta: [[0; LANES]; NUM_DOMAINS],
}
}
pub fn entity(&self, id: u32) -> Option<&Entity> {
self.entities.iter().find(|e| e.id == id)
}
pub fn entity_mut(&mut self, id: u32) -> Option<&mut Entity> {
self.entities.iter_mut().find(|e| e.id == id)
}
/// Replace a player's editable program (Phase D / `EditRuneProgram`). Length
/// is bounded by the protocol decoder; this just stores it.
pub fn set_program(&mut self, player: u32, tokens: Vec<RuneTokenWire>) {
let seed = self.seed;
if let Some(e) = self.entity_mut(player) {
e.program = RuneProgram {
id: ProgramId(player as u64),
seed: seed ^ player as u64,
tokens: tokens.iter().map(|t| t.into_token()).collect(),
};
}
}
fn resolution_input(&self, program: &RuneProgram) -> ResolutionInput {
ResolutionInput {
world: self.world.clone(),
program: program.clone(),
contexts: self.contexts.clone(),
contract_seed: self.seed,
perturbation_seed: self.seed ^ self.turn,
}
}
/// Resolve one turn from a set of `(player, action)` submissions. Missing
/// players default to `Wait`. Returns the per-turn event list. This is the
/// authoritative state transition and is fully deterministic.
pub fn resolve_turn(&mut self, submissions: &[(u32, Action)]) -> Vec<String> {
// Build a canonical, complete, sorted input set: one action per entity.
let mut inputs: Vec<TurnInput> = Vec::new();
let mut ids: Vec<u32> = self.entities.iter().map(|e| e.id).collect();
ids.sort_unstable();
for id in ids {
let action = submissions
.iter()
.find(|(pid, _)| *pid == id)
.map(|(_, a)| a.clone())
.unwrap_or(Action::Wait);
let program = if matches!(action, Action::Cast) {
self.entity(id)
.map(|e| e.program.tokens.iter().map(RuneTokenWire::from_token).collect())
} else {
None
};
inputs.push(TurnInput { player: id, action, program });
}
let before = self.world.clone();
let mut events = Vec::new();
for input in &inputs {
self.apply_action(input, &mut events);
}
// Knowledge bookkeeping: which observed lanes changed this turn.
for d in 0..NUM_DOMAINS {
for l in 0..LANES {
self.last_observed_delta[d][l] =
self.world.domains[d].observed[l].wrapping_sub(before.domains[d].observed[l]);
}
}
self.turn = self.turn.wrapping_add(1);
// Per-turn hash binds every effect: world state + entity state + inputs.
let turn_hash = self.turn_hash(&inputs);
for e in &events {
self.history.push(format!("turn {}: {}", self.turn, e));
}
// End condition: in a multi-player match, finish when at most one
// non-dummy combatant is still standing.
let players = self.entities.iter().filter(|e| !e.is_dummy).count();
let living_players = self.entities.iter().filter(|e| !e.is_dummy && e.alive()).count();
if players >= 2 && living_players <= 1 {
self.finished = true;
}
self.replay.turns.push(RecordedTurn {
turn: self.turn,
inputs,
turn_hash,
events: events.clone(),
});
self.recompute_final_hash();
events
}
fn apply_action(&mut self, input: &TurnInput, events: &mut Vec<String>) {
// Skip dead entities entirely.
let alive = self.entity(input.player).map(|e| e.alive()).unwrap_or(false);
if !alive {
return;
}
match &input.action {
Action::Wait => {}
Action::Move { dx, dy } => {
let (nx, ny) = {
let e = self.entity(input.player).unwrap();
(
(e.x + dx.clamp(&-1, &1)).clamp(0, ARENA_W - 1),
(e.y + dy.clamp(&-1, &1)).clamp(0, ARENA_H - 1),
)
};
let occupied = self
.entities
.iter()
.any(|o| o.id != input.player && o.alive() && o.x == nx && o.y == ny);
if !occupied {
let name = self.entity(input.player).unwrap().name.clone();
let e = self.entity_mut(input.player).unwrap();
e.x = nx;
e.y = ny;
events.push(format!("{name} moved to ({nx},{ny})"));
}
}
Action::Attack { target } => {
let attacker = self.entity(input.player).unwrap().clone();
if let Some(t) = self.entity(*target) {
let adjacent = (t.x - attacker.x).abs() <= 1 && (t.y - attacker.y).abs() <= 1;
if adjacent && t.alive() && *target != input.player {
let tname = t.name.clone();
let te = self.entity_mut(*target).unwrap();
te.hp = (te.hp - ATTACK_DAMAGE).max(0);
let hp = te.hp;
events.push(format!(
"{} struck {} for {ATTACK_DAMAGE} ({} hp left)",
attacker.name, tname, hp
));
if hp == 0 {
events.push(format!("{tname} fell"));
}
}
}
}
Action::Inspect { target } => {
if let Some(t) = self.entity(*target) {
events.push(format!(
"{} inspected {}",
self.entity(input.player).unwrap().name,
t.name
));
}
}
Action::Cast => {
let program = match &input.program {
Some(toks) => RuneProgram {
id: ProgramId(input.player as u64),
seed: self.seed ^ input.player as u64,
tokens: toks.iter().map(|t| t.into_token()).collect(),
},
None => self.entity(input.player).unwrap().program.clone(),
};
let res = native_resolve(&self.resolution_input(&program));
self.apply_resolution(&res);
let power = cast_power(&res);
let caster = self.entity(input.player).unwrap().clone();
events.push(format!("{} cast a rune program (power {power})", caster.name));
// Consequence: enemies within range take `power` damage.
let targets: Vec<u32> = self
.entities
.iter()
.filter(|o| {
o.id != input.player
&& o.alive()
&& (o.x - caster.x).abs() + (o.y - caster.y).abs() <= CAST_RANGE
})
.map(|o| o.id)
.collect();
for tid in targets {
let tname = self.entity(tid).unwrap().name.clone();
let te = self.entity_mut(tid).unwrap();
te.hp = (te.hp - power).max(0);
let hp = te.hp;
events.push(format!("{tname} took {power} from the working ({hp} hp left)"));
if hp == 0 {
events.push(format!("{tname} fell"));
}
}
}
}
}
/// Apply a resolution's world delta to the shared world (observed + hidden).
fn apply_resolution(&mut self, res: &ResolutionResult) {
for dd in &res.delta.domain_deltas {
let d = dd.domain.0 as usize;
if d >= NUM_DOMAINS {
continue;
}
for l in 0..LANES {
self.world.domains[d].observed[l] =
self.world.domains[d].observed[l].wrapping_add(dd.observed[l]);
}
for l in 0..HIDDEN_LANES {
self.world.domains[d].hidden[l] =
self.world.domains[d].hidden[l].wrapping_add(dd.hidden[l]);
}
}
}
fn turn_hash(&self, inputs: &[TurnInput]) -> Hash {
let mut h = Hasher::new();
h.write_tag("game-turn");
h.write_u64(self.turn);
h.write_u64(self.world.content_hash().0);
for e in &self.entities {
h.write_u64(e.id as u64);
h.write_i64(e.x as i64);
h.write_i64(e.y as i64);
h.write_i64(e.hp as i64);
}
for input in inputs {
h.write_u64(input.player as u64);
hash_action(&mut h, &input.action);
if let Some(prog) = &input.program {
h.write_usize(prog.len());
for t in prog {
h.write_u8(t.op);
h.write_u8(t.a);
h.write_u8(t.b);
h.write_u8(t.c);
h.write_i64(t.imm);
}
}
}
h.finish()
}
fn recompute_final_hash(&mut self) {
let mut h = Hasher::new();
h.write_tag("game-replay-final");
h.write_u64(self.seed);
for entry in &self.replay.roster {
h.write_u64(entry.id as u64);
h.write_bytes(entry.name.as_bytes());
h.write_u8(entry.is_dummy as u8);
}
for t in &self.replay.turns {
h.write_u64(t.turn_hash.0);
}
self.replay.final_hash = h.finish();
}
/// Hex string of the most recent turn's hash (Phase G `runtime_hash`).
pub fn last_turn_hash_hex(&self) -> String {
format!("{}", self.replay.turns.last().map(|t| t.turn_hash).unwrap_or(Hash(0)))
}
pub fn final_hash_hex(&self) -> String {
format!("{}", self.replay.final_hash)
}
// -- Visibility / knowledge (Phase F) -----------------------------------
/// Build the filtered snapshot for one player. Hidden lanes and non-visible
/// observed lanes are redacted; only their *count* is reported.
pub fn visible_for(&self, player: u32) -> VisibleWorldSnapshot {
let projection = self.world.observed_projection();
let mut observed_domains = Vec::with_capacity(NUM_DOMAINS);
let mut redactions: u32 = 0;
for d in 0..NUM_DOMAINS {
let mut observed = Vec::with_capacity(LANES);
let mut knowledge = Vec::with_capacity(LANES);
for l in 0..LANES {
if self.world.observation_state.visible[d][l] {
observed.push(Some(projection[d * LANES + l]));
knowledge.push(if self.last_observed_delta[d][l] != 0 {
Knowledge::NewlyObserved
} else {
Knowledge::Known
});
} else {
observed.push(None);
knowledge.push(Knowledge::Unknown);
redactions += 1;
}
}
observed_domains.push(VisibleDomain {
index: d as u8,
name: DomainKind::from_index(d).name().to_string(),
observed,
knowledge,
});
}
// All hidden lanes are always withheld.
redactions += (NUM_DOMAINS * HIDDEN_LANES) as u32;
let observed_entities = self
.entities
.iter()
.map(|e| VisibleEntity {
id: e.id,
name: e.name.clone(),
x: e.x,
y: e.y,
hp: e.hp,
is_self: e.id == player,
alive: e.alive(),
})
.collect();
// Inference from *observed* volatility only — never from hidden state.
// Use a presence test (any lane changed) rather than summing magnitudes,
// which avoids overflow on wrapping deltas near i64::MIN.
let mut inferred_markers = Vec::new();
for d in 0..NUM_DOMAINS {
let shifted = (0..LANES).any(|l| self.last_observed_delta[d][l] != 0);
if shifted && self.world.observation_state.visible[d].iter().any(|&v| v) {
inferred_markers.push(format!(
"{} shifted recently — likely volatile",
DomainKind::from_index(d).name()
));
}
}
let known_history: Vec<String> = self.history.iter().rev().take(8).rev().cloned().collect();
VisibleWorldSnapshot {
turn: self.turn,
arena_w: ARENA_W,
arena_h: ARENA_H,
observed_domains,
observed_entities,
observed_environment: vec![
format!("arena {ARENA_W}x{ARENA_H}"),
format!("turn {}", self.turn),
],
known_history,
inferred_markers,
hidden_state_redactions: redactions,
}
}
/// Player-facing diagnostics for a candidate program (Phase D). A *dry run*
/// against a clone of the world — it mutates nothing and never reports
/// hidden values, only domain names, counts, and observed fault risks.
pub fn diagnostics_for(&self, program: &RuneProgram) -> RuneDiagnostics {
let res = native_resolve(&self.resolution_input(program));
let visible_domain = |d: usize| self.world.observation_state.visible[d].iter().any(|&v| v);
let mut known_reads = Vec::new();
for d in res.trace.read_graph.touched() {
if visible_domain(d) {
known_reads.push(DomainKind::from_index(d).name().to_string());
}
}
known_reads.sort();
known_reads.dedup();
let mut known_writes = Vec::new();
let mut unknown_listeners = 0u32;
for d in res.trace.write_graph.touched() {
if visible_domain(d) {
known_writes.push(DomainKind::from_index(d).name().to_string());
} else {
unknown_listeners += 1;
}
}
known_writes.sort();
known_writes.dedup();
let mut observed_risks = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for f in &res.faults.faults {
if seen.insert(f.code.name()) {
observed_risks.push(format!("possible {}", f.code.name()));
}
}
let matching: Vec<String> = self
.history
.iter()
.filter(|h| h.contains("cast") || h.contains("working"))
.cloned()
.collect();
let start = matching.len().saturating_sub(4);
let previous_outcomes: Vec<String> = matching[start..].to_vec();
RuneDiagnostics {
known_reads,
known_writes,
observed_risks,
unknown_listeners,
previous_outcomes,
}
}
/// Diagnostics for a player's currently-stored program.
pub fn diagnostics_for_player(&self, player: u32) -> RuneDiagnostics {
match self.entity(player) {
Some(e) => self.diagnostics_for(&e.program),
None => RuneDiagnostics::default(),
}
}
}
/// Damage power derived from the runtime trace — the rune program's effect on
/// the game is a function of the structure the Rust engine actually produced.
fn cast_power(res: &ResolutionResult) -> i32 {
let rank = res.trace.causal_rank() as i32;
let touched = res.trace.touched_domain_count() as i32;
(1 + rank + touched / 2).clamp(1, 10)
}
fn hash_action(h: &mut Hasher, a: &Action) {
match a {
Action::Wait => h.write_u8(0),
Action::Move { dx, dy } => {
h.write_u8(1);
h.write_i64(*dx as i64);
h.write_i64(*dy as i64);
}
Action::Inspect { target } => {
h.write_u8(2);
h.write_u64(*target as u64);
}
Action::Cast => h.write_u8(3),
Action::Attack { target } => {
h.write_u8(4);
h.write_u64(*target as u64);
}
}
}
/// Build a standard 1-player + dummy roster.
pub fn solo_roster(player_name: &str) -> Vec<RosterEntry> {
vec![
RosterEntry { id: 1, name: player_name.to_string(), is_dummy: false },
RosterEntry { id: 2, name: "training dummy".to_string(), is_dummy: true },
]
}
/// Build a 2-player roster.
pub fn duel_roster(a: &str, b: &str) -> Vec<RosterEntry> {
vec![
RosterEntry { id: 1, name: a.to_string(), is_dummy: false },
RosterEntry { id: 2, name: b.to_string(), is_dummy: false },
]
}
/// Re-run a match from its seed, roster, and the exact recorded inputs, and
/// return the reconstructed log. Determinism gate: this must reproduce the
/// original `final_hash` bit-for-bit.
pub fn replay(seed: u64, roster: &[RosterEntry], recorded: &[RecordedTurn]) -> ReplayLog {
let mut m = Match::new(MatchId(0), seed, roster.to_vec());
for rt in recorded {
// Restore each casting player's program from the record, then apply the
// same actions in the same order.
for input in &rt.inputs {
if let (Action::Cast, Some(prog)) = (&input.action, &input.program) {
m.set_program(input.player, prog.clone());
}
}
let subs: Vec<(u32, Action)> =
rt.inputs.iter().map(|i| (i.player, i.action.clone())).collect();
m.resolve_turn(&subs);
}
m.replay
}
/// Convenience: run a scripted match end-to-end and return its log. Used by the
/// determinism tests and the headless E2E harness.
pub fn run_scripted(
seed: u64,
roster: &[RosterEntry],
scripts: &[Vec<(u32, Action)>],
) -> (Match, ReplayLog) {
let mut m = Match::new(MatchId(seed), seed, roster.to_vec());
for turn_subs in scripts {
m.resolve_turn(turn_subs);
}
let log = m.replay.clone();
(m, log)
}
/// Canonical fingerprint of a single resolution (used by integration tests to
/// assert the game layer truly drove the independent interpreter).
pub fn resolution_fingerprint(world: &WorldSnapshot, program: &RuneProgram, seed: u64) -> Hash {
let input = ResolutionInput {
world: world.clone(),
program: program.clone(),
contexts: standard_executors(seed, 3),
contract_seed: seed,
perturbation_seed: seed,
};
let c = canonical(&native_resolve(&input));
let mut h = Hasher::new();
h.write_tag("resolution-fp");
h.write_u64(c.delta_hash.0);
h.write_u64(c.trace_hash.0);
h.write_u64(c.replay_hash.0);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn rune(op: u8, a: u8, b: u8, c: u8, imm: i64) -> RuneTokenWire {
RuneTokenWire { op, a, b, c, imm }
}
fn scripted_match() -> Vec<Vec<(u32, Action)>> {
vec![
vec![(1, Action::Move { dx: 1, dy: 0 })],
vec![(1, Action::Cast)],
vec![(1, Action::Attack { target: 2 })],
vec![(1, Action::Wait), (2, Action::Wait)],
vec![(1, Action::Cast)],
]
}
#[test]
fn match_resolves_through_independent_interpreter() {
let mut m = Match::new(MatchId(1), 7, solo_roster("dev"));
m.set_program(1, vec![rune(0, 1, 2, 3, 4), rune(5, 2, 1, 0, -3)]);
let before = m.world.content_hash();
m.resolve_turn(&[(1, Action::Cast)]);
// A cast changed the shared world via the independent interpreter.
assert_ne!(before, m.world.content_hash());
assert_eq!(m.turn, 1);
}
#[test]
fn replay_reproduces_final_hash() {
let seed = 12345;
let roster = solo_roster("dev");
let mut m = Match::new(MatchId(seed), seed, roster.clone());
m.set_program(1, vec![rune(2, 3, 4, 5, 6), rune(8, 1, 1, 1, 1), rune(0, 7, 7, 7, 7)]);
for subs in scripted_match() {
m.resolve_turn(&subs);
}
let original = m.replay.final_hash;
// Replay from the recorded inputs alone.
let reconstructed = replay(seed, &roster, &m.replay.turns);
assert_eq!(original, reconstructed.final_hash, "replay drifted");
}
#[test]
fn many_matches_are_deterministic() {
for seed in 0..200u64 {
let roster = solo_roster("p");
let (m, log) = run_scripted(seed, &roster, &scripted_match());
let again = replay(seed, &roster, &log.turns);
assert_eq!(m.replay.final_hash, again.final_hash, "seed {seed} not deterministic");
}
}
#[test]
fn hidden_state_never_appears_in_visible_snapshot() {
let mut m = Match::new(MatchId(1), 999, solo_roster("dev"));
// Mask some observed lanes so redaction is non-trivial.
for d in 0..NUM_DOMAINS {
m.world.observation_state.visible[d][1] = false;
}
m.set_program(1, vec![rune(10, 1, 2, 3, 4)]);
m.resolve_turn(&[(1, Action::Cast)]);
let snap = m.visible_for(1);
for vd in &snap.observed_domains {
for (l, o) in vd.observed.iter().enumerate() {
if !m.world.observation_state.visible[vd.index as usize][l] {
assert!(o.is_none(), "masked lane leaked a value");
}
}
}
assert!(snap.hidden_state_redactions >= (NUM_DOMAINS * HIDDEN_LANES) as u32);
}
#[test]
fn diagnostics_are_names_and_counts_only() {
let m = Match::new(MatchId(1), 5, solo_roster("dev"));
let diag = m.diagnostics_for_player(1);
for s in diag.known_reads.iter().chain(diag.known_writes.iter()) {
assert!(s.chars().any(|c| c.is_alphabetic()), "diagnostic should be a domain name");
}
}
}
+13
View File
@@ -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"
+349
View File
@@ -0,0 +1,349 @@
//! `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,
/// The domain this axis perturbs. The metamorphic gate uses this to enforce
/// the sound relation "a perturbation the program *consumes* must alter the
/// trace" rather than merely "some hash changed".
pub target_domain: usize,
}
/// A complete generated case (per spec).
pub struct GeneratedCase {
pub world: WorldSnapshot,
pub program: RuneProgram,
pub contexts: Vec<ExecutionContext>,
pub contract: SemanticContract,
pub perturbations: Vec<PerturbedCase>,
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<ExecutionContext> {
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<PerturbedCase> {
use world_model::WorldDomain;
let count = count.max(10);
let mut axes: Vec<Box<dyn PerturbationAxis>> = 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(),
target_domain: axis.target().0 as usize,
});
}
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<Vec<f64>> = 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<String> {
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());
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "protocol"
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" }
+531
View File
@@ -0,0 +1,531 @@
//! A complete hand-rolled JSON value, serializer, and parser (no external
//! crates). The orchestrator's `ci_reports::json` is write-only; the protocol
//! needs to *parse* untrusted client packets too, and parsing must be **total**
//! — any byte sequence yields `Ok` or `Err`, never a panic. That totality is
//! what lets the server treat a malformed packet as a deterministic
//! `ValidationReport` rather than a crash.
use std::collections::BTreeMap;
use std::fmt;
/// A parsed JSON value. Objects use a `BTreeMap` so key order is canonical,
/// which keeps re-serialization stable and hashable.
#[derive(Clone, PartialEq, Debug)]
pub enum Json {
Null,
Bool(bool),
/// All numbers are carried as `f64`; integer accessors round-trip exact
/// values within the safe integer range, which is all the protocol uses.
Num(f64),
Str(String),
Arr(Vec<Json>),
Obj(BTreeMap<String, Json>),
}
impl Json {
pub fn s(v: impl Into<String>) -> Json {
Json::Str(v.into())
}
pub fn i(v: i64) -> Json {
Json::Num(v as f64)
}
pub fn u(v: u64) -> Json {
Json::Num(v as f64)
}
pub fn obj(fields: Vec<(&str, Json)>) -> Json {
let mut m = BTreeMap::new();
for (k, v) in fields {
m.insert(k.to_string(), v);
}
Json::Obj(m)
}
// ---- typed accessors (all fallible, none panic) ----
pub fn get(&self, key: &str) -> Option<&Json> {
match self {
Json::Obj(m) => m.get(key),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Json::Str(s) => Some(s),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
Json::Num(n) => Some(*n),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
Json::Num(n) if n.is_finite() => Some(*n as i64),
_ => None,
}
}
pub fn as_u64(&self) -> Option<u64> {
match self {
Json::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
_ => None,
}
}
pub fn as_u8(&self) -> Option<u8> {
self.as_u64().and_then(|v| u8::try_from(v).ok())
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Json::Bool(b) => Some(*b),
_ => None,
}
}
pub fn as_arr(&self) -> Option<&[Json]> {
match self {
Json::Arr(a) => Some(a),
_ => None,
}
}
/// Convenience: required field accessors that produce a descriptive error.
pub fn field<'a>(&'a self, key: &str) -> Result<&'a Json, JsonError> {
self.get(key).ok_or_else(|| JsonError::Field(key.to_string()))
}
pub fn str_field(&self, key: &str) -> Result<String, JsonError> {
self.field(key)?
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| JsonError::Type(key.to_string(), "string"))
}
pub fn u64_field(&self, key: &str) -> Result<u64, JsonError> {
self.field(key)?
.as_u64()
.ok_or_else(|| JsonError::Type(key.to_string(), "u64"))
}
pub fn i64_field(&self, key: &str) -> Result<i64, JsonError> {
self.field(key)?
.as_i64()
.ok_or_else(|| JsonError::Type(key.to_string(), "i64"))
}
pub fn arr_field<'a>(&'a self, key: &str) -> Result<&'a [Json], JsonError> {
self.field(key)?
.as_arr()
.ok_or_else(|| JsonError::Type(key.to_string(), "array"))
}
// ---- serialization ----
/// Compact canonical serialization (no whitespace). Deterministic because
/// object keys are stored sorted.
pub fn to_compact(&self) -> String {
let mut out = String::new();
self.write(&mut out);
out
}
fn write(&self, out: &mut String) {
match self {
Json::Null => out.push_str("null"),
Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Json::Num(n) => {
if !n.is_finite() {
out.push_str("null");
} else if *n == n.trunc() && n.abs() < 9_007_199_254_740_992.0 {
// Exact integer: print without a decimal point.
out.push_str(&(*n as i64).to_string());
} else {
out.push_str(&format!("{}", n));
}
}
Json::Str(s) => write_str(out, s),
Json::Arr(items) => {
out.push('[');
for (i, it) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
it.write(out);
}
out.push(']');
}
Json::Obj(m) => {
out.push('{');
for (i, (k, v)) in m.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_str(out, k);
out.push(':');
v.write(out);
}
out.push('}');
}
}
}
}
fn write_str(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
/// A JSON parse / shape error. Carrying a message keeps decode failures
/// diagnosable without ever unwinding.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum JsonError {
Parse(String),
Field(String),
Type(String, &'static str),
}
impl fmt::Display for JsonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
JsonError::Parse(m) => write!(f, "json parse error: {m}"),
JsonError::Field(k) => write!(f, "missing field: {k}"),
JsonError::Type(k, t) => write!(f, "field {k} is not a {t}"),
}
}
}
impl std::error::Error for JsonError {}
/// Parse a JSON document. Total: never panics on any input.
pub fn parse(input: &str) -> Result<Json, JsonError> {
let bytes = input.as_bytes();
let mut p = Parser { bytes, pos: 0, depth: 0 };
p.skip_ws();
let v = p.value()?;
p.skip_ws();
if p.pos != bytes.len() {
return Err(JsonError::Parse("trailing characters".into()));
}
Ok(v)
}
/// Maximum nesting depth. Bounds recursion so a deeply-nested adversarial
/// packet returns `Err` instead of overflowing the stack.
const MAX_DEPTH: usize = 64;
struct Parser<'a> {
bytes: &'a [u8],
pos: usize,
depth: usize,
}
impl<'a> Parser<'a> {
fn peek(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
fn skip_ws(&mut self) {
while let Some(b) = self.peek() {
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
self.pos += 1;
} else {
break;
}
}
}
fn value(&mut self) -> Result<Json, JsonError> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(JsonError::Parse("max depth exceeded".into()));
}
let r = match self.peek() {
Some(b'{') => self.object(),
Some(b'[') => self.array(),
Some(b'"') => Ok(Json::Str(self.string()?)),
Some(b't') | Some(b'f') => self.boolean(),
Some(b'n') => self.null(),
Some(b'-') | Some(b'0'..=b'9') => self.number(),
Some(c) => Err(JsonError::Parse(format!("unexpected byte '{}'", c as char))),
None => Err(JsonError::Parse("unexpected end".into())),
};
self.depth -= 1;
r
}
fn expect(&mut self, b: u8) -> Result<(), JsonError> {
if self.peek() == Some(b) {
self.pos += 1;
Ok(())
} else {
Err(JsonError::Parse(format!("expected '{}'", b as char)))
}
}
fn object(&mut self) -> Result<Json, JsonError> {
self.expect(b'{')?;
let mut m = BTreeMap::new();
self.skip_ws();
if self.peek() == Some(b'}') {
self.pos += 1;
return Ok(Json::Obj(m));
}
loop {
self.skip_ws();
let key = self.string()?;
self.skip_ws();
self.expect(b':')?;
self.skip_ws();
let val = self.value()?;
m.insert(key, val);
self.skip_ws();
match self.peek() {
Some(b',') => {
self.pos += 1;
continue;
}
Some(b'}') => {
self.pos += 1;
break;
}
_ => return Err(JsonError::Parse("expected ',' or '}'".into())),
}
}
Ok(Json::Obj(m))
}
fn array(&mut self) -> Result<Json, JsonError> {
self.expect(b'[')?;
let mut a = Vec::new();
self.skip_ws();
if self.peek() == Some(b']') {
self.pos += 1;
return Ok(Json::Arr(a));
}
loop {
self.skip_ws();
a.push(self.value()?);
self.skip_ws();
match self.peek() {
Some(b',') => {
self.pos += 1;
continue;
}
Some(b']') => {
self.pos += 1;
break;
}
_ => return Err(JsonError::Parse("expected ',' or ']'".into())),
}
}
Ok(Json::Arr(a))
}
fn string(&mut self) -> Result<String, JsonError> {
self.expect(b'"')?;
let mut s = String::new();
loop {
match self.peek() {
None => return Err(JsonError::Parse("unterminated string".into())),
Some(b'"') => {
self.pos += 1;
break;
}
Some(b'\\') => {
self.pos += 1;
match self.peek() {
Some(b'"') => s.push('"'),
Some(b'\\') => s.push('\\'),
Some(b'/') => s.push('/'),
Some(b'n') => s.push('\n'),
Some(b'r') => s.push('\r'),
Some(b't') => s.push('\t'),
Some(b'b') => s.push('\u{0008}'),
Some(b'f') => s.push('\u{000c}'),
Some(b'u') => {
let cp = self.hex4()?;
// Handle surrogate pairs.
if (0xD800..=0xDBFF).contains(&cp) {
if self.peek() == Some(b'\\') {
self.pos += 1;
if self.peek() == Some(b'u') {
let lo = self.hex4()?;
if (0xDC00..=0xDFFF).contains(&lo) {
let c = 0x10000
+ ((cp - 0xD800) << 10)
+ (lo - 0xDC00);
if let Some(ch) = char::from_u32(c) {
s.push(ch);
} else {
s.push('\u{FFFD}');
}
continue;
}
}
}
s.push('\u{FFFD}');
} else if let Some(ch) = char::from_u32(cp) {
s.push(ch);
} else {
s.push('\u{FFFD}');
}
continue;
}
_ => return Err(JsonError::Parse("bad escape".into())),
}
self.pos += 1;
}
Some(_) => {
// Copy one UTF-8 codepoint from the source.
let start = self.pos;
let len = utf8_len(self.bytes[start]);
if start + len > self.bytes.len() {
return Err(JsonError::Parse("bad utf8".into()));
}
match std::str::from_utf8(&self.bytes[start..start + len]) {
Ok(chunk) => s.push_str(chunk),
Err(_) => return Err(JsonError::Parse("bad utf8".into())),
}
self.pos += len;
}
}
}
Ok(s)
}
fn hex4(&mut self) -> Result<u32, JsonError> {
// assumes the 'u' has been consumed
self.pos += 1;
let mut v: u32 = 0;
for _ in 0..4 {
let d = self
.peek()
.and_then(|b| (b as char).to_digit(16))
.ok_or_else(|| JsonError::Parse("bad \\u".into()))?;
v = v * 16 + d;
self.pos += 1;
}
Ok(v)
}
fn boolean(&mut self) -> Result<Json, JsonError> {
if self.bytes[self.pos..].starts_with(b"true") {
self.pos += 4;
Ok(Json::Bool(true))
} else if self.bytes[self.pos..].starts_with(b"false") {
self.pos += 5;
Ok(Json::Bool(false))
} else {
Err(JsonError::Parse("bad literal".into()))
}
}
fn null(&mut self) -> Result<Json, JsonError> {
if self.bytes[self.pos..].starts_with(b"null") {
self.pos += 4;
Ok(Json::Null)
} else {
Err(JsonError::Parse("bad literal".into()))
}
}
fn number(&mut self) -> Result<Json, JsonError> {
let start = self.pos;
if self.peek() == Some(b'-') {
self.pos += 1;
}
while let Some(b'0'..=b'9') = self.peek() {
self.pos += 1;
}
if self.peek() == Some(b'.') {
self.pos += 1;
while let Some(b'0'..=b'9') = self.peek() {
self.pos += 1;
}
}
if let Some(b'e') | Some(b'E') = self.peek() {
self.pos += 1;
if let Some(b'+') | Some(b'-') = self.peek() {
self.pos += 1;
}
while let Some(b'0'..=b'9') = self.peek() {
self.pos += 1;
}
}
let slice = std::str::from_utf8(&self.bytes[start..self.pos])
.map_err(|_| JsonError::Parse("bad number".into()))?;
slice
.parse::<f64>()
.map(Json::Num)
.map_err(|_| JsonError::Parse("bad number".into()))
}
}
fn utf8_len(b: u8) -> usize {
if b < 0x80 {
1
} else if b >> 5 == 0b110 {
2
} else if b >> 4 == 0b1110 {
3
} else if b >> 3 == 0b11110 {
4
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_basic() {
let v = Json::obj(vec![
("a", Json::i(42)),
("b", Json::Arr(vec![Json::Bool(true), Json::Null, Json::s("x")])),
("c", Json::Num(1.5)),
]);
let s = v.to_compact();
let back = parse(&s).unwrap();
assert_eq!(v, back);
}
#[test]
fn parse_never_panics_on_garbage() {
let deep = "{".repeat(1000);
let cases = [
"", "{", "[", "\"", "nul", "{\"a\":}", "[1,2,", "tru", "12.3.4",
"{\"a\"1}", "\\", "\"\\u00\"", deep.as_str(),
];
for c in cases {
// Must return without panicking; value is irrelevant.
let _ = parse(c);
}
}
#[test]
fn deep_nesting_is_rejected_not_overflowed() {
let deep = "[".repeat(10_000);
assert!(parse(&deep).is_err());
}
#[test]
fn integers_roundtrip_exact() {
let v = Json::i(-1234567890123);
assert_eq!(parse(&v.to_compact()).unwrap().as_i64(), Some(-1234567890123));
}
#[test]
fn escapes_roundtrip() {
let v = Json::s("line\ntab\tquote\"slash\\end");
let s = v.to_compact();
assert_eq!(parse(&s).unwrap(), v);
}
}
+858
View File
@@ -0,0 +1,858 @@
//! `protocol` — the versioned, serializable, hashable client/server message
//! contract (Phase A of `plan2.md`). Defined **before** any UI.
//!
//! Invariants enforced here:
//! * Every message carries a protocol version (`PROTOCOL_VERSION`); a decoder
//! rejects mismatched versions deterministically.
//! * Decoding is **total**: any byte string yields `Ok(msg)` or `Err(..)`,
//! never a panic. The server relies on this to turn a malformed client
//! packet into a `ValidationReport`/`ErrorEvent` instead of crashing.
//! * Every server output is **hashable** ([`ServerMessage::content_hash`]) over
//! a canonical (sorted-key, whitespace-free) serialization, so replays and
//! the browser can verify byte-for-byte agreement with the server.
//! * No game truth lives client-side: client messages carry only *intent*
//! (movement choice, rune program, slot selection, inspection request).
pub mod json;
pub use json::{parse, Json, JsonError};
use world_model::{Hash, Hasher};
/// Protocol version. Bumped on any wire-incompatible change. Both peers check
/// it on every message.
pub const PROTOCOL_VERSION: u32 = 1;
/// Stable identifier for a connected player within a match.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct PlayerId(pub u32);
/// Stable identifier for a match.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct MatchId(pub u64);
// ---------------------------------------------------------------------------
// Rune token wire form (mirror of `rune_ir::RuneToken`, kept independent so the
// wire format does not silently change when the IR changes).
// ---------------------------------------------------------------------------
/// One rune token as it crosses the wire. `op` is the opcode index
/// (`rune_ir::Op::to_u8`); every field is interpreted modulo its range by the
/// runtime, so no token value is ever rejected.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct RuneTokenWire {
pub op: u8,
pub a: u8,
pub b: u8,
pub c: u8,
pub imm: i64,
}
impl RuneTokenWire {
pub fn to_json(self) -> Json {
Json::obj(vec![
("op", Json::u(self.op as u64)),
("a", Json::u(self.a as u64)),
("b", Json::u(self.b as u64)),
("c", Json::u(self.c as u64)),
("imm", Json::i(self.imm)),
])
}
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
Ok(RuneTokenWire {
op: j.field("op")?.as_u8().ok_or(JsonError::Type("op".into(), "u8"))?,
a: j.field("a")?.as_u8().ok_or(JsonError::Type("a".into(), "u8"))?,
b: j.field("b")?.as_u8().ok_or(JsonError::Type("b".into(), "u8"))?,
c: j.field("c")?.as_u8().ok_or(JsonError::Type("c".into(), "u8"))?,
imm: j.i64_field("imm")?,
})
}
pub fn into_token(self) -> rune_ir::RuneToken {
rune_ir::RuneToken {
op: rune_ir::Op::from_u8(self.op),
a: self.a,
b: self.b,
c: self.c,
imm: self.imm,
}
}
pub fn from_token(t: &rune_ir::RuneToken) -> Self {
RuneTokenWire { op: t.op.to_u8(), a: t.a, b: t.b, c: t.c, imm: t.imm }
}
}
// ---------------------------------------------------------------------------
// Player intent / actions (client -> server only).
// ---------------------------------------------------------------------------
/// A single per-turn action chosen by a player. The browser only ever sends
/// *intent*; the server is the sole authority on the outcome.
#[derive(Clone, PartialEq, Debug)]
pub enum Action {
/// Step one cell on the arena grid (`dx`,`dy` in {-1,0,1}).
Move { dx: i32, dy: i32 },
/// Inspect a target entity (request diagnostics about it).
Inspect { target: u32 },
/// Cast the player's currently-edited rune program.
Cast,
/// Basic stick/melee attack against a target entity.
Attack { target: u32 },
/// Pass the turn.
Wait,
}
impl Action {
pub fn to_json(&self) -> Json {
match self {
Action::Move { dx, dy } => Json::obj(vec![
("kind", Json::s("move")),
("dx", Json::i(*dx as i64)),
("dy", Json::i(*dy as i64)),
]),
Action::Inspect { target } => Json::obj(vec![
("kind", Json::s("inspect")),
("target", Json::u(*target as u64)),
]),
Action::Cast => Json::obj(vec![("kind", Json::s("cast"))]),
Action::Attack { target } => Json::obj(vec![
("kind", Json::s("attack")),
("target", Json::u(*target as u64)),
]),
Action::Wait => Json::obj(vec![("kind", Json::s("wait"))]),
}
}
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
match j.str_field("kind")?.as_str() {
"move" => {
let dx = j.i64_field("dx")? as i32;
let dy = j.i64_field("dy")? as i32;
// Clamp to legal step range so a hostile client cannot teleport.
Ok(Action::Move { dx: dx.clamp(-1, 1), dy: dy.clamp(-1, 1) })
}
"inspect" => Ok(Action::Inspect { target: j.u64_field("target")? as u32 }),
"cast" => Ok(Action::Cast),
"attack" => Ok(Action::Attack { target: j.u64_field("target")? as u32 }),
"wait" => Ok(Action::Wait),
other => Err(JsonError::Parse(format!("unknown action kind '{other}'"))),
}
}
}
// ---------------------------------------------------------------------------
// ClientMessage.
// ---------------------------------------------------------------------------
/// Everything a browser may send. Intent only — never game truth.
#[derive(Clone, PartialEq, Debug)]
pub enum ClientMessage {
/// Request to join (or create) a match. `name` is a dev/anonymous label.
JoinMatch { name: String, match_id: Option<MatchId> },
/// Submit this turn's action for the current turn number.
SubmitTurn { turn: u64, action: Action },
/// Replace the player's editable rune program (library/editor state).
EditRuneProgram { tokens: Vec<RuneTokenWire> },
/// Ask for diagnostics about a target entity.
InspectTarget { target: u32 },
/// Ask the server to stream the recorded replay for a match.
RequestReplay { match_id: MatchId },
/// Liveness ping.
Ping { nonce: u64 },
}
impl ClientMessage {
fn type_tag(&self) -> &'static str {
match self {
ClientMessage::JoinMatch { .. } => "JoinMatch",
ClientMessage::SubmitTurn { .. } => "SubmitTurn",
ClientMessage::EditRuneProgram { .. } => "EditRuneProgram",
ClientMessage::InspectTarget { .. } => "InspectTarget",
ClientMessage::RequestReplay { .. } => "RequestReplay",
ClientMessage::Ping { .. } => "Ping",
}
}
fn body(&self) -> Json {
match self {
ClientMessage::JoinMatch { name, match_id } => Json::obj(vec![
("name", Json::s(name.clone())),
(
"match_id",
match match_id {
Some(m) => Json::u(m.0),
None => Json::Null,
},
),
]),
ClientMessage::SubmitTurn { turn, action } => Json::obj(vec![
("turn", Json::u(*turn)),
("action", action.to_json()),
]),
ClientMessage::EditRuneProgram { tokens } => Json::obj(vec![(
"tokens",
Json::Arr(tokens.iter().map(|t| t.to_json()).collect()),
)]),
ClientMessage::InspectTarget { target } => {
Json::obj(vec![("target", Json::u(*target as u64))])
}
ClientMessage::RequestReplay { match_id } => {
Json::obj(vec![("match_id", Json::u(match_id.0))])
}
ClientMessage::Ping { nonce } => Json::obj(vec![("nonce", Json::u(*nonce))]),
}
}
/// Canonical envelope: `{v, type, body}`.
pub fn to_json(&self) -> Json {
Json::obj(vec![
("v", Json::u(PROTOCOL_VERSION as u64)),
("type", Json::s(self.type_tag())),
("body", self.body()),
])
}
pub fn encode(&self) -> String {
self.to_json().to_compact()
}
/// Decode a wire string. Total: never panics. Rejects version mismatch.
pub fn decode(raw: &str) -> Result<ClientMessage, JsonError> {
let j = parse(raw)?;
Self::from_json(&j)
}
pub fn from_json(j: &Json) -> Result<ClientMessage, JsonError> {
let v = j.u64_field("v")?;
if v != PROTOCOL_VERSION as u64 {
return Err(JsonError::Parse(format!(
"protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}"
)));
}
let ty = j.str_field("type")?;
let body = j.field("body")?;
match ty.as_str() {
"JoinMatch" => {
let name = body.str_field("name")?;
let match_id = match body.get("match_id") {
Some(Json::Null) | None => None,
Some(other) => other.as_u64().map(MatchId),
};
Ok(ClientMessage::JoinMatch { name, match_id })
}
"SubmitTurn" => {
let turn = body.u64_field("turn")?;
let action = Action::from_json(body.field("action")?)?;
Ok(ClientMessage::SubmitTurn { turn, action })
}
"EditRuneProgram" => {
let arr = body.arr_field("tokens")?;
// Bound the program length defensively.
if arr.len() > MAX_PROGRAM_TOKENS {
return Err(JsonError::Parse("program too long".into()));
}
let mut tokens = Vec::with_capacity(arr.len());
for t in arr {
tokens.push(RuneTokenWire::from_json(t)?);
}
Ok(ClientMessage::EditRuneProgram { tokens })
}
"InspectTarget" => Ok(ClientMessage::InspectTarget {
target: body.u64_field("target")? as u32,
}),
"RequestReplay" => Ok(ClientMessage::RequestReplay {
match_id: MatchId(body.u64_field("match_id")?),
}),
"Ping" => Ok(ClientMessage::Ping { nonce: body.u64_field("nonce")? }),
other => Err(JsonError::Parse(format!("unknown client message '{other}'"))),
}
}
}
/// Hard upper bound on a submitted rune program, enforced at decode.
pub const MAX_PROGRAM_TOKENS: usize = 256;
// ---------------------------------------------------------------------------
// Visibility / knowledge layer (Phase F). This is filtered *game state*, not UI
// notes: the client renders exactly what the server says is observable, and the
// hidden ground truth never crosses the wire.
// ---------------------------------------------------------------------------
/// How well a piece of state is known to the observing player.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Knowledge {
Known,
Unknown,
Suspected,
Contradicted,
NewlyObserved,
}
impl Knowledge {
pub fn name(self) -> &'static str {
match self {
Knowledge::Known => "known",
Knowledge::Unknown => "unknown",
Knowledge::Suspected => "suspected",
Knowledge::Contradicted => "contradicted",
Knowledge::NewlyObserved => "newly_observed",
}
}
pub fn from_str(s: &str) -> Option<Knowledge> {
Some(match s {
"known" => Knowledge::Known,
"unknown" => Knowledge::Unknown,
"suspected" => Knowledge::Suspected,
"contradicted" => Knowledge::Contradicted,
"newly_observed" => Knowledge::NewlyObserved,
_ => return None,
})
}
}
/// One domain as the player observes it. Only *visible observed* lanes carry a
/// value; non-visible observed lanes and **all hidden lanes** are redacted.
#[derive(Clone, PartialEq, Debug)]
pub struct VisibleDomain {
pub index: u8,
pub name: String,
/// `Some(v)` for a visible observed lane, `None` for a redacted lane.
pub observed: Vec<Option<i64>>,
/// Per-lane knowledge tag.
pub knowledge: Vec<Knowledge>,
}
impl VisibleDomain {
fn to_json(&self) -> Json {
Json::obj(vec![
("index", Json::u(self.index as u64)),
("name", Json::s(self.name.clone())),
(
"observed",
Json::Arr(
self.observed
.iter()
.map(|o| match o {
Some(v) => Json::i(*v),
None => Json::Null,
})
.collect(),
),
),
(
"knowledge",
Json::Arr(self.knowledge.iter().map(|k| Json::s(k.name())).collect()),
),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
let observed = j
.arr_field("observed")?
.iter()
.map(|v| match v {
Json::Null => None,
other => other.as_i64(),
})
.collect();
let knowledge = j
.arr_field("knowledge")?
.iter()
.filter_map(|v| v.as_str().and_then(Knowledge::from_str))
.collect();
Ok(VisibleDomain {
index: j.field("index")?.as_u8().ok_or(JsonError::Type("index".into(), "u8"))?,
name: j.str_field("name")?,
observed,
knowledge,
})
}
}
/// An entity (player/dummy) as seen on the arena.
#[derive(Clone, PartialEq, Debug)]
pub struct VisibleEntity {
pub id: u32,
pub name: String,
pub x: i32,
pub y: i32,
pub hp: i32,
pub is_self: bool,
pub alive: bool,
}
impl VisibleEntity {
fn to_json(&self) -> Json {
Json::obj(vec![
("id", Json::u(self.id as u64)),
("name", Json::s(self.name.clone())),
("x", Json::i(self.x as i64)),
("y", Json::i(self.y as i64)),
("hp", Json::i(self.hp as i64)),
("is_self", Json::Bool(self.is_self)),
("alive", Json::Bool(self.alive)),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
Ok(VisibleEntity {
id: j.u64_field("id")? as u32,
name: j.str_field("name")?,
x: j.i64_field("x")? as i32,
y: j.i64_field("y")? as i32,
hp: j.i64_field("hp")? as i32,
is_self: j.field("is_self")?.as_bool().unwrap_or(false),
alive: j.field("alive")?.as_bool().unwrap_or(true),
})
}
}
/// The server's filtered view of the world for one player (Phase F).
#[derive(Clone, PartialEq, Debug)]
pub struct VisibleWorldSnapshot {
pub turn: u64,
pub arena_w: i32,
pub arena_h: i32,
pub observed_domains: Vec<VisibleDomain>,
pub observed_entities: Vec<VisibleEntity>,
/// Short human-readable environment descriptors (arena conditions).
pub observed_environment: Vec<String>,
/// Prior-turn outcome summaries the player has already witnessed.
pub known_history: Vec<String>,
/// Inferred (suspected) markers, e.g. "domain 3 likely volatile".
pub inferred_markers: Vec<String>,
/// Count of state values deliberately withheld (hidden lanes + masked
/// observed lanes). Proof that hidden state exists and is *not* sent.
pub hidden_state_redactions: u32,
}
impl VisibleWorldSnapshot {
pub fn to_json(&self) -> Json {
Json::obj(vec![
("turn", Json::u(self.turn)),
("arena_w", Json::i(self.arena_w as i64)),
("arena_h", Json::i(self.arena_h as i64)),
(
"observed_domains",
Json::Arr(self.observed_domains.iter().map(|d| d.to_json()).collect()),
),
(
"observed_entities",
Json::Arr(self.observed_entities.iter().map(|e| e.to_json()).collect()),
),
(
"observed_environment",
Json::Arr(self.observed_environment.iter().map(|s| Json::s(s.clone())).collect()),
),
(
"known_history",
Json::Arr(self.known_history.iter().map(|s| Json::s(s.clone())).collect()),
),
(
"inferred_markers",
Json::Arr(self.inferred_markers.iter().map(|s| Json::s(s.clone())).collect()),
),
("hidden_state_redactions", Json::u(self.hidden_state_redactions as u64)),
])
}
pub fn from_json(j: &Json) -> Result<Self, JsonError> {
let observed_domains = j
.arr_field("observed_domains")?
.iter()
.map(VisibleDomain::from_json)
.collect::<Result<_, _>>()?;
let observed_entities = j
.arr_field("observed_entities")?
.iter()
.map(VisibleEntity::from_json)
.collect::<Result<_, _>>()?;
let strs = |key| -> Result<Vec<String>, JsonError> {
Ok(j.arr_field(key)?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect())
};
Ok(VisibleWorldSnapshot {
turn: j.u64_field("turn")?,
arena_w: j.i64_field("arena_w")? as i32,
arena_h: j.i64_field("arena_h")? as i32,
observed_domains,
observed_entities,
observed_environment: strs("observed_environment")?,
known_history: strs("known_history")?,
inferred_markers: strs("inferred_markers")?,
hidden_state_redactions: j.u64_field("hidden_state_redactions")? as u32,
})
}
}
/// Player-facing diagnostics for a rune program (Phase D). Strictly *observed*
/// claims — never "guaranteed damage" or full hidden state.
#[derive(Clone, PartialEq, Debug, Default)]
pub struct RuneDiagnostics {
pub known_reads: Vec<String>,
pub known_writes: Vec<String>,
pub observed_risks: Vec<String>,
pub unknown_listeners: u32,
pub previous_outcomes: Vec<String>,
}
impl RuneDiagnostics {
fn to_json(&self) -> Json {
Json::obj(vec![
("known_reads", Json::Arr(self.known_reads.iter().map(|s| Json::s(s.clone())).collect())),
("known_writes", Json::Arr(self.known_writes.iter().map(|s| Json::s(s.clone())).collect())),
("observed_risks", Json::Arr(self.observed_risks.iter().map(|s| Json::s(s.clone())).collect())),
("unknown_listeners", Json::u(self.unknown_listeners as u64)),
("previous_outcomes", Json::Arr(self.previous_outcomes.iter().map(|s| Json::s(s.clone())).collect())),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
let strs = |key| -> Vec<String> {
j.get(key)
.and_then(|v| v.as_arr())
.map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default()
};
Ok(RuneDiagnostics {
known_reads: strs("known_reads"),
known_writes: strs("known_writes"),
observed_risks: strs("observed_risks"),
unknown_listeners: j.get("unknown_listeners").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
previous_outcomes: strs("previous_outcomes"),
})
}
}
/// One recorded turn in a replay stream (Phase G).
#[derive(Clone, PartialEq, Debug)]
pub struct ReplayTurn {
pub turn: u64,
/// `(player_id, action)` pairs applied this turn, in canonical order.
pub inputs: Vec<(u32, Action)>,
/// The runtime canonical replay hash produced this turn.
pub runtime_hash: String,
}
impl ReplayTurn {
fn to_json(&self) -> Json {
Json::obj(vec![
("turn", Json::u(self.turn)),
(
"inputs",
Json::Arr(
self.inputs
.iter()
.map(|(pid, a)| {
Json::obj(vec![("player", Json::u(*pid as u64)), ("action", a.to_json())])
})
.collect(),
),
),
("runtime_hash", Json::s(self.runtime_hash.clone())),
])
}
fn from_json(j: &Json) -> Result<Self, JsonError> {
let inputs = j
.arr_field("inputs")?
.iter()
.map(|e| {
let pid = e.u64_field("player")? as u32;
let a = Action::from_json(e.field("action")?)?;
Ok((pid, a))
})
.collect::<Result<_, JsonError>>()?;
Ok(ReplayTurn {
turn: j.u64_field("turn")?,
inputs,
runtime_hash: j.str_field("runtime_hash")?,
})
}
}
// ---------------------------------------------------------------------------
// ServerMessage.
// ---------------------------------------------------------------------------
/// Everything the server may send. Every variant is hashable; the browser can
/// verify it matches a recorded replay.
#[derive(Clone, PartialEq, Debug)]
pub enum ServerMessage {
/// Assigned identity + match parameters on join.
MatchState {
match_id: MatchId,
player_id: PlayerId,
turn: u64,
snapshot: VisibleWorldSnapshot,
},
/// A new turn has begun; `deadline_ms` is the wall-clock budget.
TurnStarted { turn: u64, deadline_ms: u64 },
/// A turn resolved authoritatively. Carries the runtime replay hash so the
/// client can verify determinism.
TurnResolved {
turn: u64,
snapshot: VisibleWorldSnapshot,
runtime_hash: String,
events: Vec<String>,
},
/// Result of an inspection request (filtered observations of a target).
ObservationResult { target: u32, diagnostics: RuneDiagnostics },
/// Validation feedback for a client packet (accepted/rejected + why).
ValidationReport { accepted: bool, detail: String, diagnostics: RuneDiagnostics },
/// One chunk of a replay stream.
ReplayChunk {
match_id: MatchId,
seed: u64,
index: u32,
total: u32,
turns: Vec<ReplayTurn>,
final_hash: String,
},
/// A protocol/transport error that is not tied to a specific submission.
ErrorEvent { code: String, detail: String },
}
impl ServerMessage {
fn type_tag(&self) -> &'static str {
match self {
ServerMessage::MatchState { .. } => "MatchState",
ServerMessage::TurnStarted { .. } => "TurnStarted",
ServerMessage::TurnResolved { .. } => "TurnResolved",
ServerMessage::ObservationResult { .. } => "ObservationResult",
ServerMessage::ValidationReport { .. } => "ValidationReport",
ServerMessage::ReplayChunk { .. } => "ReplayChunk",
ServerMessage::ErrorEvent { .. } => "ErrorEvent",
}
}
fn body(&self) -> Json {
match self {
ServerMessage::MatchState { match_id, player_id, turn, snapshot } => Json::obj(vec![
("match_id", Json::u(match_id.0)),
("player_id", Json::u(player_id.0 as u64)),
("turn", Json::u(*turn)),
("snapshot", snapshot.to_json()),
]),
ServerMessage::TurnStarted { turn, deadline_ms } => Json::obj(vec![
("turn", Json::u(*turn)),
("deadline_ms", Json::u(*deadline_ms)),
]),
ServerMessage::TurnResolved { turn, snapshot, runtime_hash, events } => Json::obj(vec![
("turn", Json::u(*turn)),
("snapshot", snapshot.to_json()),
("runtime_hash", Json::s(runtime_hash.clone())),
("events", Json::Arr(events.iter().map(|s| Json::s(s.clone())).collect())),
]),
ServerMessage::ObservationResult { target, diagnostics } => Json::obj(vec![
("target", Json::u(*target as u64)),
("diagnostics", diagnostics.to_json()),
]),
ServerMessage::ValidationReport { accepted, detail, diagnostics } => Json::obj(vec![
("accepted", Json::Bool(*accepted)),
("detail", Json::s(detail.clone())),
("diagnostics", diagnostics.to_json()),
]),
ServerMessage::ReplayChunk { match_id, seed, index, total, turns, final_hash } => {
Json::obj(vec![
("match_id", Json::u(match_id.0)),
("seed", Json::u(*seed)),
("index", Json::u(*index as u64)),
("total", Json::u(*total as u64)),
("turns", Json::Arr(turns.iter().map(|t| t.to_json()).collect())),
("final_hash", Json::s(final_hash.clone())),
])
}
ServerMessage::ErrorEvent { code, detail } => Json::obj(vec![
("code", Json::s(code.clone())),
("detail", Json::s(detail.clone())),
]),
}
}
pub fn to_json(&self) -> Json {
Json::obj(vec![
("v", Json::u(PROTOCOL_VERSION as u64)),
("type", Json::s(self.type_tag())),
("body", self.body()),
])
}
pub fn encode(&self) -> String {
self.to_json().to_compact()
}
/// Stable content hash over the canonical serialization. Because object
/// keys are sorted and there is no whitespace, identical messages hash
/// identically across machines — this is how replays are verified.
pub fn content_hash(&self) -> Hash {
let mut h = Hasher::new();
h.write_tag("server-message");
h.write_bytes(self.encode().as_bytes());
h.finish()
}
pub fn decode(raw: &str) -> Result<ServerMessage, JsonError> {
let j = parse(raw)?;
Self::from_json(&j)
}
pub fn from_json(j: &Json) -> Result<ServerMessage, JsonError> {
let v = j.u64_field("v")?;
if v != PROTOCOL_VERSION as u64 {
return Err(JsonError::Parse(format!(
"protocol version mismatch: got {v}, expected {PROTOCOL_VERSION}"
)));
}
let ty = j.str_field("type")?;
let body = j.field("body")?;
match ty.as_str() {
"MatchState" => Ok(ServerMessage::MatchState {
match_id: MatchId(body.u64_field("match_id")?),
player_id: PlayerId(body.u64_field("player_id")? as u32),
turn: body.u64_field("turn")?,
snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?,
}),
"TurnStarted" => Ok(ServerMessage::TurnStarted {
turn: body.u64_field("turn")?,
deadline_ms: body.u64_field("deadline_ms")?,
}),
"TurnResolved" => Ok(ServerMessage::TurnResolved {
turn: body.u64_field("turn")?,
snapshot: VisibleWorldSnapshot::from_json(body.field("snapshot")?)?,
runtime_hash: body.str_field("runtime_hash")?,
events: body
.arr_field("events")?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
}),
"ObservationResult" => Ok(ServerMessage::ObservationResult {
target: body.u64_field("target")? as u32,
diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?,
}),
"ValidationReport" => Ok(ServerMessage::ValidationReport {
accepted: body.field("accepted")?.as_bool().unwrap_or(false),
detail: body.str_field("detail")?,
diagnostics: RuneDiagnostics::from_json(body.field("diagnostics")?)?,
}),
"ReplayChunk" => Ok(ServerMessage::ReplayChunk {
match_id: MatchId(body.u64_field("match_id")?),
seed: body.u64_field("seed")?,
index: body.u64_field("index")? as u32,
total: body.u64_field("total")? as u32,
turns: body
.arr_field("turns")?
.iter()
.map(ReplayTurn::from_json)
.collect::<Result<_, _>>()?,
final_hash: body.str_field("final_hash")?,
}),
"ErrorEvent" => Ok(ServerMessage::ErrorEvent {
code: body.str_field("code")?,
detail: body.str_field("detail")?,
}),
other => Err(JsonError::Parse(format!("unknown server message '{other}'"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_snapshot() -> VisibleWorldSnapshot {
VisibleWorldSnapshot {
turn: 3,
arena_w: 8,
arena_h: 8,
observed_domains: vec![VisibleDomain {
index: 0,
name: "aether".into(),
observed: vec![Some(1), None, Some(-4), None],
knowledge: vec![
Knowledge::Known,
Knowledge::Unknown,
Knowledge::NewlyObserved,
Knowledge::Unknown,
],
}],
observed_entities: vec![VisibleEntity {
id: 1,
name: "you".into(),
x: 2,
y: 3,
hp: 30,
is_self: true,
alive: true,
}],
observed_environment: vec!["calm".into()],
known_history: vec!["turn 2: you moved".into()],
inferred_markers: vec!["domain 4 likely volatile".into()],
hidden_state_redactions: 18,
}
}
#[test]
fn client_messages_roundtrip() {
let msgs = vec![
ClientMessage::JoinMatch { name: "dev".into(), match_id: None },
ClientMessage::JoinMatch { name: "dev".into(), match_id: Some(MatchId(9)) },
ClientMessage::SubmitTurn { turn: 4, action: Action::Move { dx: 1, dy: -1 } },
ClientMessage::SubmitTurn { turn: 4, action: Action::Cast },
ClientMessage::SubmitTurn { turn: 4, action: Action::Attack { target: 2 } },
ClientMessage::EditRuneProgram {
tokens: vec![RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: -7 }],
},
ClientMessage::InspectTarget { target: 5 },
ClientMessage::RequestReplay { match_id: MatchId(42) },
ClientMessage::Ping { nonce: 123 },
];
for m in msgs {
let s = m.encode();
assert_eq!(ClientMessage::decode(&s).unwrap(), m, "roundtrip failed for {m:?}");
}
}
#[test]
fn server_messages_roundtrip_and_hash_is_stable() {
let msgs = vec![
ServerMessage::MatchState {
match_id: MatchId(1),
player_id: PlayerId(1),
turn: 0,
snapshot: sample_snapshot(),
},
ServerMessage::TurnStarted { turn: 1, deadline_ms: 5000 },
ServerMessage::TurnResolved {
turn: 1,
snapshot: sample_snapshot(),
runtime_hash: "deadbeefcafef00d".into(),
events: vec!["you cast".into(), "dummy took 4".into()],
},
ServerMessage::ValidationReport {
accepted: false,
detail: "late".into(),
diagnostics: RuneDiagnostics::default(),
},
];
for m in msgs {
let s = m.encode();
let back = ServerMessage::decode(&s).unwrap();
assert_eq!(back, m);
// Hash is a pure function of the canonical bytes.
assert_eq!(m.content_hash(), back.content_hash());
}
}
#[test]
fn version_mismatch_is_rejected() {
let mut j = ClientMessage::Ping { nonce: 1 }.to_json();
if let Json::Obj(ref mut m) = j {
m.insert("v".into(), Json::u(999));
}
assert!(ClientMessage::from_json(&j).is_err());
}
#[test]
fn decode_is_total_on_garbage() {
for raw in ["", "{}", "null", "{\"v\":1}", "{\"v\":1,\"type\":\"Nope\",\"body\":{}}"] {
// Must be Err, never a panic.
assert!(ClientMessage::decode(raw).is_err());
}
}
}
+13
View File
@@ -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"
+654
View File
@@ -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<ExecutionContext>,
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<i64> = 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<WorldSnapshot> = 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);
}
}
}
+117
View File
@@ -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<RuneToken> = (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());
}
}
+20
View File
@@ -0,0 +1,20 @@
[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" }
runtime_under_test = { path = "../runtime_under_test" }
[dev-dependencies]
# Enables `buggy_resolve` for the retention negative-control test.
runtime_under_test = { path = "../runtime_under_test", features = ["negative_controls"] }
[lib]
path = "src/lib.rs"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
# magicka-retained-failures v1
seed gate note
0000000000000001 runtime_equivalence curated regression guard: independent-runtime equivalence on a causal-edge-bearing case (catches the dropped-edge bug class)
0000000000000007 runtime_equivalence curated regression guard: fault-heavy case (saturation/empty-accumulator paths)
000000000000002a runtime_equivalence curated regression guard: temporal/future-dependence case
00000000000000ff runtime_equivalence curated regression guard: high-coupling case
0000000000000539 runtime_equivalence curated regression guard: branch-divergence case
1 # magicka-retained-failures v1
2 seed gate note
3 0000000000000001 runtime_equivalence curated regression guard: independent-runtime equivalence on a causal-edge-bearing case (catches the dropped-edge bug class)
4 0000000000000007 runtime_equivalence curated regression guard: fault-heavy case (saturation/empty-accumulator paths)
5 000000000000002a runtime_equivalence curated regression guard: temporal/future-dependence case
6 00000000000000ff runtime_equivalence curated regression guard: high-coupling case
7 0000000000000539 runtime_equivalence curated regression guard: branch-divergence case
+27
View File
@@ -0,0 +1,27 @@
//! Freeze the committed replay corpus. Run this only as a deliberate, reviewed
//! migration when the engine semantics legitimately change.
//!
//! ```text
//! cargo run --release -p replay_corpus --bin freeze -- [count]
//! ```
fn main() {
let count: usize = std::env::args()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(600);
let base = replay_corpus::DEFAULT_BASE_SEED;
match replay_corpus::freeze_to_disk(count, base) {
Ok(n) => {
println!(
"froze {} replay cases to {}",
n,
replay_corpus::corpus_path().display()
);
}
Err(e) => {
eprintln!("failed to freeze corpus: {e}");
std::process::exit(1);
}
}
}
+277
View File
@@ -0,0 +1,277 @@
//! `replay_corpus` — every case is permanent and must replay bit-for-bit.
//!
//! The corpus is **persisted to a committed file** (`corpus/replay_corpus.tsv`).
//! Replay loads the expected hashes from that file — produced by an earlier
//! `freeze` run — regenerates the case deterministically from its master seed,
//! re-executes the reference runtime, and asserts zero drift against the stored
//! expectation. Because the expectation is read from disk rather than recomputed
//! and compared to itself in the same run, drift is genuinely possible: any
//! change to the engine that alters a hash makes the committed expectation and
//! the fresh execution disagree, and CI fails. (Proven by the negative-control
//! test, which corrupts a stored hash and checks the drift is detected.)
use generators::generate_accepted_case;
use reference_runtime::{execute, EngineConfig, ResolutionInput};
use std::path::PathBuf;
use world_model::Hash;
pub mod retention;
/// Format version of the persisted corpus file. Bump only with a deliberate,
/// reviewed migration of the committed corpus.
pub const CORPUS_VERSION: u32 = 1;
/// A permanent replay case (per spec) plus the master seed needed to
/// regenerate the full case deterministically.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
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,
}
pub(crate) fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
let (case, _accepted_seed) = generate_accepted_case(master_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,
)
}
/// Master seed for the `i`-th corpus case (stable, deterministic).
pub fn master_seed_for(base_seed: u64, i: usize) -> u64 {
base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15)
}
/// Build a single replay case from a master seed using the *current* reference.
/// Used when freezing the corpus to disk.
pub fn build_case(master_seed: u64) -> ReplayCase {
let (input, ws, ps, cs, prs) = input_for(master_seed);
let r = execute(&EngineConfig::reference(), &input);
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 an in-memory corpus of `n` cases (used by `freeze`).
pub fn build_corpus(n: usize, base_seed: u64) -> Vec<ReplayCase> {
(0..n).map(|i| build_case(master_seed_for(base_seed, i))).collect()
}
// --- Persistence. -----------------------------------------------------------
/// Path to the committed corpus file, anchored to this crate.
pub fn corpus_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("corpus/replay_corpus.tsv")
}
/// The default base seed the committed corpus is frozen with.
pub const DEFAULT_BASE_SEED: u64 = 0x5EED;
/// Serialize a corpus to the on-disk TSV format (with a provenance header).
pub fn serialize_corpus(corpus: &[ReplayCase]) -> String {
let mut s = String::new();
s.push_str(&format!("# magicka-replay-corpus v{} cases={}\n", CORPUS_VERSION, corpus.len()));
s.push_str("master\tworld\tprogram\tcontract\tperturb\ttrace\tdelta\tfuture\n");
for c in corpus {
s.push_str(&format!(
"{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\t{:016x}\n",
c.master_seed,
c.world_seed,
c.program_seed,
c.contract_seed,
c.perturbation_seed,
c.expected_trace_hash.0,
c.expected_delta_hash.0,
c.expected_future_hash.0,
));
}
s
}
fn parse_hex(s: &str) -> Option<u64> {
u64::from_str_radix(s.trim(), 16).ok()
}
/// Parse a corpus from the on-disk TSV format.
pub fn parse_corpus(text: &str) -> Vec<ReplayCase> {
let mut out = Vec::new();
for line in text.lines() {
if line.starts_with('#') || line.starts_with("master") || line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() != 8 {
continue;
}
let vals: Option<Vec<u64>> = f.iter().map(|x| parse_hex(x)).collect();
if let Some(v) = vals {
out.push(ReplayCase {
master_seed: v[0],
world_seed: v[1],
program_seed: v[2],
contract_seed: v[3],
perturbation_seed: v[4],
expected_trace_hash: Hash(v[5]),
expected_delta_hash: Hash(v[6]),
expected_future_hash: Hash(v[7]),
});
}
}
out
}
/// Load the committed corpus from disk.
pub fn load_persisted_corpus() -> std::io::Result<Vec<ReplayCase>> {
let text = std::fs::read_to_string(corpus_path())?;
Ok(parse_corpus(&text))
}
/// Freeze a fresh corpus of `n` cases to the committed file.
pub fn freeze_to_disk(n: usize, base_seed: u64) -> std::io::Result<usize> {
let corpus = build_corpus(n, base_seed);
let path = corpus_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serialize_corpus(&corpus))?;
Ok(corpus.len())
}
// --- Replay verification (against persisted expectations). ------------------
/// A single replay verification outcome.
#[derive(Clone, Copy, Debug)]
pub struct ReplayDrift {
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 *stored* case: regenerate it and compare a fresh reference run to
/// the expectation read from disk.
pub fn replay_against_stored(stored: &ReplayCase) -> ReplayDrift {
let (input, ..) = input_for(stored.master_seed);
let r = execute(&EngineConfig::reference(), &input);
ReplayDrift {
master_seed: stored.master_seed,
trace_ok: r.trace.canonical_hash() == stored.expected_trace_hash,
delta_ok: r.delta.hash() == stored.expected_delta_hash,
future_ok: r.replay.future_hash == stored.expected_future_hash,
}
}
/// Aggregate replay report.
#[derive(Clone, Debug)]
pub struct ReplayReport {
pub total: usize,
pub deterministic: usize,
pub drift: Vec<u64>,
pub loaded_from_disk: bool,
}
impl ReplayReport {
pub fn passed(&self, minimum: usize) -> bool {
self.loaded_from_disk && self.drift.is_empty() && self.total >= minimum
}
}
/// Verify a corpus (already loaded from disk) replays without drift.
pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
let mut drift = Vec::new();
let mut deterministic = 0;
for case in corpus {
let d = replay_against_stored(case);
if d.ok() {
deterministic += 1;
} else {
drift.push(case.master_seed);
}
}
ReplayReport {
total: corpus.len(),
deterministic,
drift,
loaded_from_disk: true,
}
}
/// Load the committed corpus and verify it. The single entry point CI uses.
pub fn verify_persisted_corpus() -> ReplayReport {
match load_persisted_corpus() {
Ok(corpus) => verify_corpus(&corpus),
Err(_) => ReplayReport {
total: 0,
deterministic: 0,
drift: Vec::new(),
loaded_from_disk: false,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn committed_corpus_loads_and_replays_without_drift() {
let corpus = load_persisted_corpus().expect("committed corpus must exist; run `freeze`");
assert!(!corpus.is_empty(), "committed corpus is empty");
let report = verify_corpus(&corpus);
assert!(report.drift.is_empty(), "drift in committed corpus: {:?}", report.drift);
assert_eq!(report.deterministic, report.total);
}
#[test]
fn serialize_roundtrips() {
let corpus = build_corpus(20, 0x1234);
let text = serialize_corpus(&corpus);
let parsed = parse_corpus(&text);
assert_eq!(corpus, parsed);
}
/// Negative control: a corrupted stored expectation is detected as drift.
/// Proves the replay gate is not vacuous.
#[test]
fn corrupted_expectation_is_detected() {
let mut corpus = build_corpus(10, 0x9999);
// Flip one stored hash — as if the committed corpus disagreed with the
// engine. Replay must flag it.
corpus[3].expected_trace_hash = Hash(corpus[3].expected_trace_hash.0 ^ 0xdead_beef);
let report = verify_corpus(&corpus);
assert!(
!report.drift.is_empty(),
"replay failed to detect a corrupted expectation"
);
assert!(report.drift.contains(&corpus[3].master_seed));
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Failure retention (finding 6). A static seed corpus proves the engine is
//! stable on a *fixed* sample; it does not retain *discovered* counterexamples.
//! This module is the regression memory: every counterexample CI ever finds
//! (e.g. a reference/runtime divergence) is curated into a committed,
//! append-only file and **re-verified on every run**, so a fixed bug can never
//! silently reappear.
//!
//! Two halves, both real:
//! * The committed `corpus/retained_failures.tsv` is loaded and each case is
//! re-executed under the reference and the independent runtime-under-test; any
//! case where they disagree is a *regression* and fails CI.
//! * When a run discovers a NEW divergence, it is serialized to the run's output
//! so it must be triaged and added to the committed set (the run also fails).
//! Discovered failures are therefore never lost.
use crate::input_for;
use reference_runtime::{canonical, execute, EngineConfig, ResolutionInput, ResolutionResult};
use runtime_under_test::native_resolve;
use std::path::PathBuf;
/// A retained counterexample: the seed needed to regenerate it, the gate it
/// originally tripped, and a human note.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RetainedFailure {
pub master_seed: u64,
pub gate: String,
pub note: String,
}
/// Path to the committed retained-failures file.
pub fn retained_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("corpus/retained_failures.tsv")
}
/// Serialize a retained set (with a provenance header).
pub fn serialize(failures: &[RetainedFailure]) -> String {
let mut s = String::from("# magicka-retained-failures v1\n");
s.push_str("seed\tgate\tnote\n");
for f in failures {
// Tabs/newlines are stripped from free text to keep the TSV well-formed.
let gate = f.gate.replace(['\t', '\n'], " ");
let note = f.note.replace(['\t', '\n'], " ");
s.push_str(&format!("{:016x}\t{}\t{}\n", f.master_seed, gate, note));
}
s
}
/// Parse a retained set.
pub fn parse(text: &str) -> Vec<RetainedFailure> {
let mut out = Vec::new();
for line in text.lines() {
if line.starts_with('#') || line.starts_with("seed") || line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.splitn(3, '\t').collect();
if f.len() < 1 {
continue;
}
if let Ok(seed) = u64::from_str_radix(f[0].trim(), 16) {
out.push(RetainedFailure {
master_seed: seed,
gate: f.get(1).unwrap_or(&"").to_string(),
note: f.get(2).unwrap_or(&"").to_string(),
});
}
}
out
}
/// Load the committed retained set. Missing file => empty set + `present=false`.
pub fn load() -> (Vec<RetainedFailure>, bool) {
match std::fs::read_to_string(retained_path()) {
Ok(t) => (parse(&t), true),
Err(_) => (Vec::new(), false),
}
}
/// Append a newly-discovered failure to the committed set (append-only).
pub fn append(failure: &RetainedFailure) -> std::io::Result<()> {
let (mut set, _present) = load();
if !set.iter().any(|f| f.master_seed == failure.master_seed) {
set.push(failure.clone());
}
let path = retained_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serialize(&set))
}
/// Does a retained case still agree between the reference and `resolve`? A
/// retained counterexample is "fixed" iff the two implementations now produce
/// identical canonical views on it.
pub fn agrees(
failure: &RetainedFailure,
resolve: impl Fn(&ResolutionInput) -> ResolutionResult,
) -> bool {
let (input, ..) = input_for(failure.master_seed);
canonical(&execute(&EngineConfig::reference(), &input)) == canonical(&resolve(&input))
}
/// Result of verifying the retained set.
#[derive(Clone, Debug)]
pub struct RetentionReport {
pub present: bool,
pub total: usize,
/// Seeds that regressed (reference and runtime-under-test disagree again).
pub regressions: Vec<u64>,
}
impl RetentionReport {
pub fn ok(&self) -> bool {
self.present && self.regressions.is_empty()
}
}
/// Verify the committed retained set against the independent runtime-under-test.
pub fn verify() -> RetentionReport {
let (set, present) = load();
let regressions = set
.iter()
.filter(|f| !agrees(f, |inp| native_resolve(inp)))
.map(|f| f.master_seed)
.collect();
RetentionReport { present, total: set.len(), regressions }
}
#[cfg(test)]
mod tests {
use super::*;
use runtime_under_test::buggy_resolve;
#[test]
fn serialize_roundtrips() {
let set = vec![
RetainedFailure { master_seed: 0xABC, gate: "runtime_equivalence".into(), note: "dropped edge".into() },
RetainedFailure { master_seed: 0x1, gate: "replay".into(), note: "drift".into() },
];
assert_eq!(parse(&serialize(&set)), set);
}
#[test]
fn committed_retained_set_exists_and_holds() {
// The committed file must be present (the retention mechanism is wired),
// and every retained counterexample must still be fixed.
let report = verify();
assert!(report.present, "committed retained_failures.tsv is missing");
assert!(report.regressions.is_empty(), "regressions: {:?}", report.regressions);
}
/// A retained counterexample re-checked against a *buggy* runtime must be
/// flagged as a regression. Proves the retention check is not vacuous: if a
/// fixed bug reappears, retention catches it.
#[test]
fn reintroduced_bug_is_caught_by_retention() {
let f = RetainedFailure { master_seed: 42, gate: "runtime_equivalence".into(), note: "synthetic".into() };
// Against the real runtime the case agrees (the bug is fixed)...
assert!(agrees(&f, |inp| native_resolve(inp)));
// ...but a runtime that reintroduces the bug is caught as a regression.
assert!(!agrees(&f, |inp| buggy_resolve(inp)));
}
}
+11
View File
@@ -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"
+168
View File
@@ -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<RuneToken>,
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());
}
}
+17
View File
@@ -0,0 +1,17 @@
[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" }
[features]
negative_controls = []
[lib]
path = "src/lib.rs"
+113
View File
@@ -0,0 +1,113 @@
//! `runtime_under_test` — the runtime that CI proves equivalent to the
//! reference. Unlike the reference, this crate does **not** call the reference
//! engine: it carries its own independent interpreter ([`native::native_resolve`])
//! re-derived from the spec. The runtime-equivalence gate therefore compares
//! two genuinely separate implementations, so 100% agreement is *evidence* that
//! the spec is implemented correctly rather than a tautology. A transcription
//! error in either implementation surfaces as an equivalence failure (proven by
//! the negative-control test below).
pub mod native;
use reference_runtime::{ResolutionInput, ResolutionResult, Runtime};
pub use native::native_resolve;
#[derive(Clone, Debug, Default)]
pub struct RuntimeUnderTest;
impl RuntimeUnderTest {
pub fn new() -> Self {
RuntimeUnderTest
}
}
impl Runtime for RuntimeUnderTest {
fn resolve(&self, input: ResolutionInput) -> ResolutionResult {
native_resolve(&input)
}
}
/// A deliberately broken runtime used as a negative control: it shares the
/// independent interpreter but corrupts one recorded value. The equivalence
/// gate **must** reject it. This proves the gate can fail.
#[cfg(any(test, feature = "negative_controls"))]
pub fn buggy_resolve(input: &ResolutionInput) -> ResolutionResult {
let mut r = native_resolve(input);
// Drop a single causal edge — a subtle bug an honest gate has to catch.
r.trace.causal_graph.edges.pop();
r
}
#[cfg(test)]
mod tests {
use super::*;
use reference_runtime::{canonical, execute, EngineConfig};
use rune_ir::{Op, 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);
}
}
let tokens: Vec<RuneToken> = (0..40)
.map(|i| RuneToken {
op: if i % 3 == 0 { ALL_OPS[i % 12] } else { Op::from_u8(rng.next_u64() as u8) },
a: rng.next_u64() as u8,
b: rng.next_u64() as u8,
c: rng.next_u64() as u8,
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,
}
}
/// The core honesty property: the independent interpreter reproduces the
/// reference engine bit-for-bit over a large seed sweep. This is what makes
/// the equivalence gate meaningful rather than vacuous.
#[test]
fn native_matches_reference_bit_for_bit() {
let cfg = EngineConfig::reference();
for s in 0..2000u64 {
let input = rich_input(s.wrapping_mul(0x9e3779b97f4a7c15) ^ 0xabc);
let a = canonical(&execute(&cfg, &input));
let b = canonical(&native_resolve(&input));
assert_eq!(a, b, "independent interpreter diverged at seed {s}");
}
}
/// Negative control: a runtime with a real bug is rejected by the canonical
/// comparison. Proves the equivalence gate is not vacuous.
#[test]
fn buggy_runtime_is_rejected() {
let cfg = EngineConfig::reference();
let mut caught = 0;
for s in 0..200u64 {
let input = rich_input(s + 1);
let a = canonical(&execute(&cfg, &input));
let b = canonical(&buggy_resolve(&input));
if a != b {
caught += 1;
}
}
assert!(caught > 0, "the equivalence gate failed to catch a buggy runtime");
}
}
+503
View File
@@ -0,0 +1,503 @@
//! An *independent* interpreter for the canonical engine behavior.
//!
//! This is the whole point of the runtime-equivalence gate: if the runtime
//! under test merely called `reference_runtime::execute`, agreement would be a
//! tautology and a bug in the shared interpreter would hide in both. This file
//! re-derives the executable spec's canonical behavior from scratch, in a
//! different code organization (a register-machine `Vm` rather than the
//! reference's free-function dispatch), depending only on the shared *data*
//! crates (`world_model`, `trace_model`, `rune_ir`) and never on the
//! reference's engine. When the two implementations agree it is evidence; when
//! a transcription error is introduced, the equivalence gate catches it (see
//! the negative-control tests).
//!
//! Because the runtime under test only ever needs to reproduce the *canonical*
//! reference configuration, the engine constants are inlined here as literals —
//! they are the spec, independently restated, not imported.
use reference_runtime::{ResolutionInput, ResolutionResult};
use rune_ir::{Op, RuneProgram, RuneToken};
use trace_model::{
BehaviorFingerprint, CausalEdge, CausalGraph, CausalNode, DivergenceGraph, DomainAccessGraph,
ExecutionTrace, FaultCode, FaultLog, InformationFlowGraph, PerturbationResponse, ReplayRecord,
TemporalGraph,
};
use world_model::{
DomainKind, ExecutionContext, Hash, Hasher, ScheduledEffect, WorldDelta, WorldSnapshot,
DomainId, HIDDEN_LANES, LANES, NUM_DOMAINS, REGS,
};
// --- The canonical engine constants, independently restated. ---------------
const C1: u64 = 0xff51afd7ed558ccd;
const C2: u64 = 0xc4ceb9fe1a85ec53;
const S1: u32 = 33;
const S2: u32 = 29;
const S3: u32 = 32;
const FUTURE_TURNS: usize = 3;
const DIFFUSE_SPAN: usize = 3;
#[inline]
fn avalanche(z: i64) -> i64 {
let mut u = z as u64;
u ^= u >> S1;
u = u.wrapping_mul(C1);
u ^= u >> S2;
u = u.wrapping_mul(C2);
u ^= u >> S3;
u as i64
}
#[inline]
fn combine(ctx: &ExecutionContext, a: i64, b: i64, coupling: i64, kc: u64) -> i64 {
let mut z = a.wrapping_mul(kc as i64);
z ^= b.rotate_left(((kc & 31) as u32) + 1);
z = z.wrapping_add(coupling.wrapping_mul(b & 0xffff));
z ^= ctx.salt() as i64;
z = z.wrapping_add(ctx.profile.bias);
z = z.rotate_left((ctx.profile.rotate % 63) + 1);
avalanche(z)
}
/// A register-machine view of one single-executor run. Holds the working world,
/// the trace graphs accumulated as the program executes, and the accumulator
/// file. Organised as a stateful object with `&mut self` methods, deliberately
/// unlike the reference's stateless free functions.
struct Vm<'a> {
w: WorldSnapshot,
ctx: &'a ExecutionContext,
read_graph: DomainAccessGraph,
write_graph: DomainAccessGraph,
causal_graph: CausalGraph,
info_flow: InformationFlowGraph,
temporal: TemporalGraph,
faults: FaultLog,
acc: [i64; REGS],
acc_src: [usize; REGS],
}
impl<'a> Vm<'a> {
fn new(world: &WorldSnapshot, ctx: &'a ExecutionContext) -> Self {
let acc = world.execution_state.accumulator;
Vm {
w: world.clone(),
ctx,
read_graph: DomainAccessGraph::default(),
write_graph: DomainAccessGraph::default(),
causal_graph: CausalGraph::default(),
info_flow: InformationFlowGraph::default(),
temporal: TemporalGraph::default(),
faults: FaultLog::default(),
acc,
acc_src: [0; REGS],
}
}
#[inline]
fn rd(&self, dom: usize, lane: usize, hidden: bool) -> i64 {
if hidden {
self.w.domains[dom].hidden[lane % HIDDEN_LANES]
} else {
self.w.domains[dom].observed[lane % LANES]
}
}
#[inline]
fn wr(&mut self, dom: usize, lane: usize, hidden: bool, val: i64) {
if hidden {
self.w.domains[dom].hidden[lane % HIDDEN_LANES] = val;
} else {
self.w.domains[dom].observed[lane % LANES] = val;
}
}
#[inline]
fn coupling(&self, to: usize, from: usize) -> i64 {
self.w.causal_state.coupling[to][from]
}
#[inline]
fn mix(&self, a: i64, b: i64, coupling: i64, kc: u64) -> i64 {
combine(self.ctx, a, b, coupling, kc)
}
/// Record one data-movement edge across every graph, in the canonical order.
#[allow(clippy::too_many_arguments)]
fn flow(
&mut self,
from_dom: usize,
from_lane: usize,
from_hidden: bool,
to_dom: usize,
to_lane: usize,
to_hidden: bool,
step: u32,
weight: i64,
) {
self.read_graph.access_count[from_dom] += 1;
self.write_graph.access_count[to_dom] += 1;
self.read_graph.edges.push((from_dom as u8, to_dom as u8, 1));
self.write_graph.edges.push((from_dom as u8, to_dom as u8, 1));
self.info_flow
.edges
.push((from_dom as u8, to_dom as u8, (weight as u64).count_ones()));
self.causal_graph.edges.push(CausalEdge {
from: CausalNode {
domain: from_dom as u8,
lane: from_lane as u8,
hidden: from_hidden,
step,
},
to: CausalNode {
domain: to_dom as u8,
lane: to_lane as u8,
hidden: to_hidden,
step,
},
weight,
});
}
fn run(&mut self, program: &RuneProgram) {
for (i, tok) in program.tokens.iter().enumerate() {
let step = i as u32;
self.step(tok, step);
let dst = tok.dst_domain();
let lane = tok.lane();
let r = (step as usize) % REGS;
self.acc[r] = self.acc[r].wrapping_add(self.w.domains[dst].observed[lane]);
}
self.w.execution_state.accumulator = self.acc;
}
fn step(&mut self, tok: &RuneToken, step: u32) {
let src = tok.src_domain();
let dst = tok.dst_domain();
let lane = tok.lane();
let lane2 = tok.lane2();
let kc = DomainKind::from_index(dst).mix_const();
let coupling = self.coupling(dst, src);
match tok.op {
Op::Mix => {
let a = self.rd(src, lane, false);
let b = self.rd(dst, lane2, false);
let v = self.mix(a, b, coupling, kc);
self.wr(dst, lane, false, v);
self.flow(src, lane, false, dst, lane, false, step, v);
self.flow(dst, lane2, false, dst, lane, false, step, v);
}
Op::Channel => {
let a = self.rd(src, lane, false);
let v = self.mix(a, coupling, coupling, kc);
self.wr(dst, lane2, false, v);
self.flow(src, lane, false, dst, lane2, false, step, v);
}
Op::Branch => {
let probe = self.rd(src, lane, false);
let take_hot =
probe.wrapping_add(self.ctx.profile.bias) > self.ctx.profile.branch_threshold;
if take_hot {
let b = self.rd(dst, lane, false);
let v = self.mix(probe, b, coupling, kc);
self.wr(dst, lane, false, v);
self.flow(src, lane, false, dst, lane, false, step, v);
} else {
let b = self.rd(dst, lane2, false);
let v = self.mix(b, probe, coupling, kc).wrapping_add(0x5bd1e9);
self.wr(dst, lane2, false, v);
self.flow(src, lane, false, dst, lane2, false, step, v);
self.faults.push(FaultCode::UnreachableBranch, step, 0);
}
}
Op::Schedule => {
let a = self.rd(src, lane, false);
let b = self.rd(dst, lane, false);
let v = self.mix(a, b, coupling, kc);
let offset = 1 + (tok.imm.rem_euclid(3)) as u8;
let hidden = tok.mode() & 1 == 1;
self.w.time_state.pending.push(ScheduledEffect {
turn_offset: offset,
domain: DomainId(dst as u8),
lane,
hidden,
value: v,
});
self.temporal.edges.push((step, offset, dst as u8));
self.flow(src, lane, false, dst, lane, hidden, step, v);
}
Op::Resonate => {
let a = self.rd(src, lane, false);
let b = self.rd(dst, lane, false);
let m = self.mix(a, b, coupling, kc);
let va = a.wrapping_add(m);
let vb = b ^ m;
self.wr(src, lane, false, va);
self.wr(dst, lane, false, vb);
self.flow(dst, lane, false, src, lane, false, step, va);
self.flow(src, lane, false, dst, lane, false, step, vb);
}
Op::Observe => {
let reg = tok.mode() % REGS;
let mut z: i64 = self.acc[reg];
let proj = self.w.observed_projection();
for k in 0..3 {
let d = (src + k) % NUM_DOMAINS;
let idx = d * LANES + (lane + k) % LANES;
let cpl = self.coupling(dst, d);
z = self.mix(z, proj[idx], cpl, kc);
self.flow(d, (lane + k) % LANES, false, dst, lane, true, step, z);
}
self.acc[reg] = z;
self.acc_src[reg] = src;
self.wr(dst, tok.mode() % HIDDEN_LANES, true, z);
}
Op::Collapse => {
let reg = tok.mode() % REGS;
let a = self.acc[reg];
let b = self.rd(dst, lane, false);
if a == 0 {
self.faults.push(FaultCode::EmptyAccumulator, step, reg as i64);
}
let v = self.mix(a, b, coupling, kc);
self.wr(dst, lane, false, v);
let asrc = self.acc_src[reg];
self.flow(asrc, 0, true, dst, lane, false, step, v);
}
Op::Invert => {
let b = self.rd(dst, lane, false);
let mut v = avalanche((!b).wrapping_add(tok.imm));
v ^= self.ctx.salt() as i64;
v = v.wrapping_add(self.ctx.profile.bias);
self.wr(dst, lane, false, v);
self.flow(dst, lane, false, dst, lane, false, step, v);
}
Op::Diffuse => {
let a = self.rd(src, lane, false);
for k in 0..DIFFUSE_SPAN {
let d = (src + 1 + k) % NUM_DOMAINS;
let tl = (lane + k) % LANES;
let prev = self.rd(d, tl, false);
let cpl = self.coupling(d, src);
let kc2 = DomainKind::from_index(d).mix_const();
let v = self.mix(a, prev, cpl, kc2);
self.wr(d, tl, false, prev.wrapping_add(v));
self.flow(src, lane, false, d, tl, false, step, v);
}
}
Op::Anchor => {
let bound = (tok.imm.unsigned_abs() % 1_000_000) as i64 + 1;
let b = self.rd(dst, lane, false);
let diag = self.coupling(dst, dst);
let mut mixed = b.wrapping_add(diag);
mixed = mixed
.wrapping_add(self.ctx.profile.bias)
.wrapping_add((self.ctx.salt() & 0xffff) as i64);
let clamped = mixed.clamp(-bound, bound);
if clamped != mixed {
self.faults.push(FaultCode::Saturated, step, bound);
}
self.wr(dst, lane, false, clamped);
self.flow(dst, lane, false, dst, lane, false, step, clamped);
}
Op::Echoback => {
let h = self.rd(dst, tok.mode() % HIDDEN_LANES, true);
let b = self.rd(dst, lane, false);
let v = self.mix(h, b, coupling, kc);
self.wr(dst, lane, false, v);
self.flow(dst, tok.mode() % HIDDEN_LANES, true, dst, lane, false, step, v);
}
Op::Imprint => {
let b = self.rd(dst, lane, false);
let hl = tok.mode() % HIDDEN_LANES;
let prevh = self.rd(dst, hl, true);
let v = self.mix(b, prevh, coupling, kc);
self.wr(dst, hl, true, v);
self.flow(dst, lane, false, dst, hl, true, step, v);
}
}
}
}
/// Advance the world one turn: resolve due scheduled effects, then run coupling
/// diffusion. Identical semantics to the reference's `step_world`, restated.
fn step_world(w: &mut WorldSnapshot) {
let pending = std::mem::take(&mut w.time_state.pending);
let mut still = Vec::new();
for e in pending {
if e.turn_offset <= 1 {
let d = e.domain.0 as usize;
if e.hidden {
let l = e.lane % HIDDEN_LANES;
w.domains[d].hidden[l] = w.domains[d].hidden[l].wrapping_add(e.value);
} else {
let l = e.lane % LANES;
w.domains[d].observed[l] = w.domains[d].observed[l].wrapping_add(e.value);
}
} else {
still.push(ScheduledEffect {
turn_offset: e.turn_offset - 1,
..e
});
}
}
w.time_state.pending = still;
let snap = w.domains.clone();
for j in 0..NUM_DOMAINS {
for lane in 0..LANES {
let mut z = w.domains[j].observed[lane];
for i in 0..NUM_DOMAINS {
let c = w.causal_state.coupling[j][i];
z = z.wrapping_add(c.wrapping_mul(snap[i].observed[lane] & 0xff));
}
w.domains[j].observed[lane] = avalanche(z);
}
for hl in 0..HIDDEN_LANES {
let base = w.domains[j].hidden[hl].wrapping_add(snap[j].observed[0]);
w.domains[j].hidden[hl] = avalanche(base);
}
}
w.turn = w.turn.wrapping_add(1);
}
fn future_hash(start: &WorldSnapshot) -> Hash {
let mut w = start.clone();
let mut h = Hasher::new();
h.write_tag("future-3");
for _ in 0..FUTURE_TURNS {
step_world(&mut w);
for v in w.ground_truth() {
h.write_i64(v);
}
}
h.finish()
}
fn compute_divergence(finals: &[WorldSnapshot]) -> DivergenceGraph {
let n = finals.len();
let mut pairwise = vec![0.0f64; n * n];
let total = (NUM_DOMAINS * LANES) as f64;
for i in 0..n {
for j in 0..n {
if i == j {
continue;
}
let mut diff = 0usize;
for d in 0..NUM_DOMAINS {
for l in 0..LANES {
if finals[i].domains[d].observed[l] != finals[j].domains[d].observed[l] {
diff += 1;
}
}
}
pairwise[i * n + j] = diff as f64 / total;
}
}
DivergenceGraph {
executor_count: n,
pairwise,
}
}
#[allow(clippy::too_many_arguments)]
fn behavior_fingerprint(
delta: &WorldDelta,
causal: &CausalGraph,
read_graph: &DomainAccessGraph,
write_graph: &DomainAccessGraph,
info_flow: &InformationFlowGraph,
temporal: &TemporalGraph,
divergence: &DivergenceGraph,
future: Hash,
) -> BehaviorFingerprint {
let mut features: Vec<i64> = Vec::new();
for dd in &delta.domain_deltas {
let mut s = 0i64;
for &v in &dd.observed {
s = s.wrapping_add(v);
}
features.push(s);
}
for dd in &delta.domain_deltas {
let mut s = 0i64;
for &v in &dd.hidden {
s = s.wrapping_add(v);
}
features.push(s);
}
features.push(causal.causal_rank() as i64);
features.push(causal.edge_count() as i64);
features.push(read_graph.touched_count() as i64);
features.push(write_graph.touched_count() as i64);
features.push(info_flow.total_bits() as i64);
features.push(temporal.edge_count() as i64);
features.push((divergence.mean_divergence() * 1_000_000.0) as i64);
features.push(future.0 as i64);
BehaviorFingerprint::from_features(features)
}
/// The independent implementation of the canonical resolution.
pub fn native_resolve(input: &ResolutionInput) -> ResolutionResult {
let contexts = if input.contexts.is_empty() {
world_model::standard_executors(input.world.seed, 3)
} else {
input.contexts.clone()
};
let world0 = input.world.clone();
let mut finals: Vec<WorldSnapshot> = Vec::with_capacity(contexts.len());
let mut primary: Option<Vm> = None;
for (idx, ctx) in contexts.iter().enumerate() {
let mut vm = Vm::new(&world0, ctx);
vm.run(&input.program);
finals.push(vm.w.clone());
if idx == 0 {
primary = Some(vm);
}
}
let vm = primary.expect("at least one executor");
let delta = WorldDelta::between(&world0, &vm.w);
let divergence = compute_divergence(&finals);
let fhash = future_hash(&vm.w);
let behavior = behavior_fingerprint(
&delta,
&vm.causal_graph,
&vm.read_graph,
&vm.write_graph,
&vm.info_flow,
&vm.temporal,
&divergence,
fhash,
);
let trace = ExecutionTrace {
read_graph: vm.read_graph,
write_graph: vm.write_graph,
causal_graph: vm.causal_graph,
information_flow: vm.info_flow,
executor_divergence: divergence,
temporal_graph: vm.temporal,
perturbation_response: PerturbationResponse::default(),
behavior_fingerprint: behavior,
};
let trace_hash = trace.canonical_hash();
let delta_hash = delta.hash();
let replay = ReplayRecord {
world_seed: input.world.seed,
program_seed: input.program.seed,
contract_seed: input.contract_seed,
perturbation_seed: input.perturbation_seed,
trace_hash,
delta_hash,
future_hash: fhash,
};
ResolutionResult {
delta,
trace,
faults: vm.faults,
replay,
}
}
+14
View File
@@ -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"
+440
View File
@@ -0,0 +1,440 @@
//! `semantic_mutation` — structurally generate mutated runtimes and prove the
//! test suite kills every one **by the named acceptance gate it targets**.
//!
//! A mutant is an [`EngineConfig`] (the runtime artifact) with exactly one
//! behavior-affecting knob changed. The spec requires that every mutant fail at
//! least one *named* acceptance gate. An earlier version of this crate only
//! checked that a mutant's canonical output *differed* from the reference — a
//! weaker, wrong condition that a mutant could satisfy without tripping the gate
//! it is supposed to expose. This version runs the actual named gate against
//! each mutant and requires that specific gate to fail. A mutant that does not
//! trip its named gate is a survivor and blocks merge.
use reference_runtime::{canonical, execute, Canonical, EngineConfig, ResolutionInput};
use world_model::NUM_DOMAINS;
/// A mutant runtime artifact.
pub type RuntimeArtifact = EngineConfig;
// --- Gate thresholds, mirrored from the CI gate definitions. ----------------
const CAUSAL_EDGES_MIN: f64 = 24.0;
const CAUSAL_RANK_P95_MIN: f64 = 6.0;
const DOMAIN_APPEARS_MIN: f64 = 0.35;
const DOMAIN_MUTATED_MIN: f64 = 0.20;
const FUTURE_ALT_MIN: f64 = 0.50;
/// The named acceptance gate a mutant is expected to fail.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DetectionClass {
RuntimeEquivalence,
CausalGate,
TemporalGate,
DomainParticipation,
}
impl DetectionClass {
pub fn name(self) -> &'static str {
match self {
DetectionClass::RuntimeEquivalence => "runtime_equivalence",
DetectionClass::CausalGate => "causal_rank/trace",
DetectionClass::TemporalGate => "metamorphic_response/temporal",
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;
}
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, and
/// is tagged with the named gate that change must trip.
pub fn mutant_for(i: usize) -> Mutant {
let base = EngineConfig::reference();
let mut cfg = base.clone();
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)
}
};
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<Mutant> {
(0..count).map(mutant_for).collect()
}
/// Precompute the reference canonical view for each input.
pub fn reference_canon(inputs: &[ResolutionInput]) -> Vec<Canonical> {
let cfg = EngineConfig::reference();
inputs.iter().map(|inp| canonical(&execute(&cfg, inp))).collect()
}
// --- Named-gate evaluators. -------------------------------------------------
//
// Each evaluator computes, for a given engine config over the input corpus, the
// metric a named CI gate checks, and returns whether that gate FAILS. The
// reference config must pass all of them (asserted in tests); each mutant must
// fail the one it targets.
fn percentile(mut v: Vec<f64>, p: f64) -> f64 {
if v.is_empty() {
return 0.0;
}
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let idx = (((v.len() - 1) as f64) * p).round() as usize;
v[idx.min(v.len() - 1)]
}
fn median(v: Vec<f64>) -> f64 {
if v.is_empty() {
return 0.0;
}
let mut s = v;
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
s[s.len() / 2]
}
/// True if the causal/trace gate fails under `cfg`.
fn causal_gate_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
let edges: Vec<f64> = inputs
.iter()
.map(|inp| execute(cfg, inp).trace.causal_edge_count() as f64)
.collect();
let ranks: Vec<f64> = inputs
.iter()
.map(|inp| execute(cfg, inp).trace.causal_rank() as f64)
.collect();
median(edges) < CAUSAL_EDGES_MIN || percentile(ranks, 0.05) < CAUSAL_RANK_P95_MIN
}
/// True if the domain-participation gate fails under `cfg`.
fn domain_gate_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
let n = inputs.len().max(1) as f64;
let mut appears = [0u32; NUM_DOMAINS];
let mut mutated = [0u32; NUM_DOMAINS];
for inp in inputs {
let r = execute(cfg, inp);
for d in 0..NUM_DOMAINS {
if r.trace.read_graph.access_count[d] > 0 || r.trace.write_graph.access_count[d] > 0 {
appears[d] += 1;
}
}
for dd in &r.delta.domain_deltas {
if !dd.is_zero() {
mutated[dd.domain.0 as usize] += 1;
}
}
}
(0..NUM_DOMAINS).any(|d| {
(appears[d] as f64 / n) < DOMAIN_APPEARS_MIN
|| (mutated[d] as f64 / n) < DOMAIN_MUTATED_MIN
})
}
/// Apply a fixed structural perturbation (bump domain 0, observed lane 0).
fn perturbed(input: &ResolutionInput) -> ResolutionInput {
let mut p = input.clone();
p.world.domains[0].observed[0] = p.world.domains[0].observed[0].wrapping_add(101);
p.world.mark_perturbed();
p
}
/// True if the temporal gate fails under `cfg`. The temporal gate asserts the
/// runtime carries genuine 3-turn future dynamics: it must (a) record temporal
/// edges, (b) have a future sensitive to perturbation, and (c) reproduce the
/// reference's 3-turn future. Any of these failing fails the gate.
fn temporal_gate_fails(
cfg: &EngineConfig,
inputs: &[ResolutionInput],
reference: &[Canonical],
) -> bool {
// (a) temporal edges present.
let tedges: Vec<f64> = inputs
.iter()
.map(|inp| execute(cfg, inp).trace.temporal_graph.edge_count() as f64)
.collect();
if median(tedges) < 1.0 {
return true;
}
// (b) future sensitive to perturbation.
let mut altered = 0usize;
for inp in inputs {
let base_future = execute(cfg, inp).replay.future_hash;
let pert_future = execute(cfg, &perturbed(inp)).replay.future_hash;
if base_future != pert_future {
altered += 1;
}
}
let alt_rate = altered as f64 / inputs.len().max(1) as f64;
if alt_rate < FUTURE_ALT_MIN {
return true;
}
// (c) future matches the reference's 3-turn future on every input.
for (inp, ref_c) in inputs.iter().zip(reference) {
if execute(cfg, inp).replay.future_hash != ref_c.future_hash {
return true;
}
}
false
}
/// True if the runtime-equivalence gate fails under `cfg` (i.e. the mutant
/// diverges from the reference canonical view on at least one input).
fn equivalence_gate_fails(
cfg: &EngineConfig,
inputs: &[ResolutionInput],
reference: &[Canonical],
) -> bool {
inputs
.iter()
.zip(reference)
.any(|(inp, ref_c)| canonical(&execute(cfg, inp)) != *ref_c)
}
/// Evaluate whether a mutant is killed by its **named** gate. Returns `None` if
/// killed (the named gate fails), or `Some(reason)` describing the survival.
pub fn survival_reason(
mutant: &Mutant,
inputs: &[ResolutionInput],
reference: &[Canonical],
) -> Option<String> {
let killed = match mutant.expected {
DetectionClass::RuntimeEquivalence => {
equivalence_gate_fails(&mutant.config, inputs, reference)
}
DetectionClass::CausalGate => causal_gate_fails(&mutant.config, inputs),
DetectionClass::TemporalGate => temporal_gate_fails(&mutant.config, inputs, reference),
DetectionClass::DomainParticipation => domain_gate_fails(&mutant.config, inputs),
};
if killed {
None
} else {
Some(format!(
"mutant {} ({}) did not fail its named gate {}",
mutant.id,
mutant.name,
mutant.expected.name()
))
}
}
/// Result of running the full mutation suite.
#[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, killing each by its named gate.
pub fn run_suite(count: usize, inputs: &[ResolutionInput]) -> MutationOutcome {
let reference = reference_canon(inputs);
let mutants = generate_mutants(count);
let mut killed = 0;
let mut survivors = Vec::new();
for m in &mutants {
match survival_reason(m, inputs, &reference) {
None => killed += 1,
Some(reason) => survivors.push((m.id, reason)),
}
}
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, LANES, NUM_DOMAINS};
fn rich_input(seed: u64) -> ResolutionInput {
let mut rng = Rng::new(seed);
let mut w = WorldSnapshot::blank(WorldId(seed), seed);
for d in &mut w.domains {
for l in 0..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<RuneToken> = (0..40)
.map(|i| RuneToken {
op: ALL_OPS[i % ALL_OPS.len()],
a: ((i * 3) % NUM_DOMAINS) as u8,
b: ((i * 5 + 1) % NUM_DOMAINS) as u8,
c: rng.next_u64() as u8,
imm: rng.range_i64(-100000, 100000),
})
.collect();
ResolutionInput {
world: w,
program: RuneProgram { id: ProgramId(seed), tokens, seed },
contexts: standard_executors(seed, 4),
contract_seed: seed,
perturbation_seed: seed,
}
}
fn corpus() -> Vec<ResolutionInput> {
(0..16).map(|s| rich_input(s + 1)).collect()
}
#[test]
fn reference_passes_every_named_gate() {
let inputs = corpus();
let reference = reference_canon(&inputs);
let cfg = EngineConfig::reference();
assert!(!causal_gate_fails(&cfg, &inputs), "reference fails causal gate");
assert!(!domain_gate_fails(&cfg, &inputs), "reference fails domain gate");
assert!(
!temporal_gate_fails(&cfg, &inputs, &reference),
"reference fails temporal gate"
);
assert!(
!equivalence_gate_fails(&cfg, &inputs, &reference),
"reference fails equivalence gate"
);
}
#[test]
fn no_mutant_survives_its_named_gate() {
let inputs = corpus();
let outcome = run_suite(520, &inputs);
assert!(outcome.passed(), "survivors: {:?}", outcome.survivors);
assert_eq!(outcome.killed, outcome.total);
}
#[test]
fn every_mutant_differs_from_reference() {
let base = EngineConfig::reference();
for i in 0..600 {
assert_ne!(mutant_for(i).config, base, "mutant {i} equals reference");
}
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "server"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "magicka-server"
path = "src/main.rs"
[dependencies]
protocol = { path = "../protocol" }
game_runtime = { path = "../game_runtime" }
world_model = { path = "../world_model" }
web_client = { path = "../web_client" }
+113
View File
@@ -0,0 +1,113 @@
//! Minimal HTTP/1.1 request parsing — only enough to tell a static GET from a
//! WebSocket upgrade and to read the upgrade key. Tolerant and total: a
//! malformed request yields `None`, never a panic.
use std::collections::BTreeMap;
use std::io::{self, BufRead};
/// A parsed request head.
pub struct Request {
pub method: String,
pub path: String,
pub headers: BTreeMap<String, String>,
}
impl Request {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(&name.to_ascii_lowercase()).map(|s| s.as_str())
}
/// True if this is a WebSocket upgrade request.
pub fn is_websocket_upgrade(&self) -> bool {
self.header("upgrade")
.map(|v| v.eq_ignore_ascii_case("websocket"))
.unwrap_or(false)
&& self
.header("connection")
.map(|v| v.to_ascii_lowercase().contains("upgrade"))
.unwrap_or(false)
}
pub fn websocket_key(&self) -> Option<&str> {
self.header("sec-websocket-key")
}
}
/// Read and parse the request head from a buffered reader. Returns `Ok(None)`
/// on a clean EOF before any bytes.
pub fn read_request<R: BufRead>(r: &mut R) -> io::Result<Option<Request>> {
let mut line = String::new();
let n = r.read_line(&mut line)?;
if n == 0 {
return Ok(None);
}
let mut parts = line.trim_end().split_whitespace();
let method = match parts.next() {
Some(m) => m.to_string(),
None => return Ok(None),
};
let path = parts.next().unwrap_or("/").to_string();
let mut headers = BTreeMap::new();
loop {
let mut h = String::new();
let hn = r.read_line(&mut h)?;
if hn == 0 {
break;
}
let trimmed = h.trim_end();
if trimmed.is_empty() {
break;
}
if let Some((k, v)) = trimmed.split_once(':') {
headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
}
// Bound header count defensively.
if headers.len() > 100 {
break;
}
}
Ok(Some(Request { method, path, headers }))
}
/// Build the 101 Switching Protocols handshake response.
pub fn handshake_response(accept: &str) -> String {
format!(
"HTTP/1.1 101 Switching Protocols\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Accept: {accept}\r\n\r\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufReader;
#[test]
fn parses_websocket_upgrade() {
let raw = "GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: abc\r\n\r\n";
let mut r = BufReader::new(raw.as_bytes());
let req = read_request(&mut r).unwrap().unwrap();
assert_eq!(req.method, "GET");
assert_eq!(req.path, "/ws");
assert!(req.is_websocket_upgrade());
assert_eq!(req.websocket_key(), Some("abc"));
}
#[test]
fn parses_plain_get() {
let raw = "GET /app.js HTTP/1.1\r\nHost: x\r\n\r\n";
let mut r = BufReader::new(raw.as_bytes());
let req = read_request(&mut r).unwrap().unwrap();
assert!(!req.is_websocket_upgrade());
assert_eq!(req.path, "/app.js");
}
#[test]
fn empty_input_is_none() {
let mut r = BufReader::new("".as_bytes());
assert!(read_request(&mut r).unwrap().is_none());
}
}
+606
View File
@@ -0,0 +1,606 @@
//! `server` — the authoritative match server (Phase B of `plan2.md`), built on
//! `std::net` with no external crates.
//!
//! Responsibilities the server owns: match state, the turn timer, collecting
//! submitted actions, driving resolution through [`game_runtime`], visibility
//! filtering, replay recording, and disconnect handling. The browser is served
//! the embedded client and then speaks the `protocol` over a WebSocket.
//!
//! Authority guarantees enforced here and covered by tests:
//! * **No panic on bad input** — every client packet is decoded with the total
//! `protocol` decoder; a failure becomes a `ValidationReport`, never a crash.
//! * **Late input rejected deterministically** — a `SubmitTurn` for any turn
//! other than the live one, or after the deadline, is rejected with a stable
//! reason.
//! * **Disconnect cannot corrupt a match** — a dropped connection simply stops
//! submitting; that player's turns default to `Wait` and the match continues.
//! * **Client cannot mutate hidden state** — only intent is accepted, and the
//! hidden ground truth is never serialized to a client.
pub mod http;
pub mod ws;
use std::collections::BTreeMap;
use std::io::{BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use game_runtime::{duel_roster, solo_roster, Match};
use protocol::{
Action, ClientMessage, MatchId, PlayerId, ReplayTurn, RuneDiagnostics, ServerMessage,
};
/// How long the timer thread sleeps between ticks.
const TICK: Duration = Duration::from_millis(40);
/// Replay turns per `ReplayChunk`.
const REPLAY_CHUNK: usize = 16;
/// An outbound item for a single connection's writer thread. Routing every
/// write through one thread keeps frames from interleaving.
enum Out {
Text(String),
Pong(Vec<u8>),
Close,
}
/// One live match plus its scheduling and connection state.
struct Session {
m: Match,
turn_len: Duration,
deadline: Instant,
pending: BTreeMap<u32, Action>,
conns: BTreeMap<u32, Sender<Out>>,
/// Entity ids that are human-controlled (vs. a dummy).
human_slots: Vec<u32>,
}
impl Session {
fn deadline_ms(&self, now: Instant) -> u64 {
self.deadline.saturating_duration_since(now).as_millis() as u64
}
fn snapshot_msg(&self, player: u32) -> ServerMessage {
ServerMessage::MatchState {
match_id: self.m.id,
player_id: PlayerId(player),
turn: self.m.turn,
snapshot: self.m.visible_for(player),
}
}
}
/// The shared manager: all matches, behind one mutex.
pub struct Manager {
sessions: BTreeMap<u64, Session>,
next_auto_id: u64,
turn_ms: u64,
}
/// Result of a successful join.
struct JoinOk {
match_id: MatchId,
player_id: u32,
initial: ServerMessage,
turn_started: ServerMessage,
}
impl Manager {
fn new(turn_ms: u64) -> Manager {
Manager { sessions: BTreeMap::new(), next_auto_id: 1, turn_ms }
}
fn turn_len(&self) -> Duration {
Duration::from_millis(self.turn_ms)
}
/// Join (or create) a match. `requested = None` creates a fresh solo match
/// (player + dummy). `requested = Some(id)` joins an existing duel by id, or
/// creates that duel and takes the first human slot.
fn join(
&mut self,
name: &str,
requested: Option<MatchId>,
tx: Sender<Out>,
) -> Result<JoinOk, String> {
let now = Instant::now();
let turn_len = self.turn_len();
let key = match requested {
Some(m) => m.0,
None => {
let id = self.next_auto_id;
self.next_auto_id += 1;
id
}
};
// Create the session if absent.
if !self.sessions.contains_key(&key) {
let (roster, human_slots) = if requested.is_some() {
(duel_roster(name, "opponent"), vec![1u32, 2])
} else {
(solo_roster(name), vec![1u32])
};
let m = Match::new(MatchId(key), key, roster);
self.sessions.insert(
key,
Session {
m,
turn_len,
deadline: now + turn_len,
pending: BTreeMap::new(),
conns: BTreeMap::new(),
human_slots,
},
);
}
let session = self.sessions.get_mut(&key).unwrap();
// Find the first human slot without a live connection.
let slot = session
.human_slots
.iter()
.copied()
.find(|s| !session.conns.contains_key(s))
.ok_or_else(|| "match is full".to_string())?;
// Adopt the player's chosen name on their entity.
if let Some(e) = session.m.entity_mut(slot) {
e.name = name.to_string();
}
session.conns.insert(slot, tx);
Ok(JoinOk {
match_id: MatchId(key),
player_id: slot,
initial: session.snapshot_msg(slot),
turn_started: ServerMessage::TurnStarted {
turn: session.m.turn,
deadline_ms: session.deadline_ms(now),
},
})
}
/// Queue a turn submission. Rejects late / wrong-turn submissions
/// deterministically.
fn submit(
&mut self,
match_key: u64,
player: u32,
turn: u64,
action: Action,
) -> Result<(), String> {
let now = Instant::now();
let session = self
.sessions
.get_mut(&match_key)
.ok_or_else(|| "no such match".to_string())?;
if session.m.finished {
return Err("match has ended".to_string());
}
if turn != session.m.turn {
return Err(format!(
"wrong turn: submitted {}, live turn is {}",
turn, session.m.turn
));
}
if now > session.deadline {
return Err("late: turn deadline has passed".to_string());
}
session.pending.insert(player, action);
Ok(())
}
fn set_program(
&mut self,
match_key: u64,
player: u32,
tokens: Vec<protocol::RuneTokenWire>,
) -> Result<RuneDiagnostics, String> {
let session = self
.sessions
.get_mut(&match_key)
.ok_or_else(|| "no such match".to_string())?;
session.m.set_program(player, tokens);
Ok(session.m.diagnostics_for_player(player))
}
fn inspect(&self, match_key: u64, target: u32) -> Result<ServerMessage, String> {
let session = self
.sessions
.get(&match_key)
.ok_or_else(|| "no such match".to_string())?;
let diagnostics = session.m.diagnostics_for_player(target);
Ok(ServerMessage::ObservationResult { target, diagnostics })
}
fn replay_chunks(&self, match_key: u64) -> Result<Vec<ServerMessage>, String> {
let session = self
.sessions
.get(&match_key)
.ok_or_else(|| "no such match".to_string())?;
let m = &session.m;
let turns: Vec<ReplayTurn> = m
.replay
.turns
.iter()
.map(|rt| ReplayTurn {
turn: rt.turn,
inputs: rt
.inputs
.iter()
.map(|i| (i.player, i.action.clone()))
.collect(),
runtime_hash: format!("{}", rt.turn_hash),
})
.collect();
let final_hash = m.final_hash_hex();
let chunks: Vec<&[ReplayTurn]> = if turns.is_empty() {
vec![&[]]
} else {
turns.chunks(REPLAY_CHUNK).collect()
};
let total = chunks.len() as u32;
Ok(chunks
.into_iter()
.enumerate()
.map(|(i, c)| ServerMessage::ReplayChunk {
match_id: m.id,
seed: m.seed,
index: i as u32,
total,
turns: c.to_vec(),
final_hash: final_hash.clone(),
})
.collect())
}
/// Advance any match whose deadline has elapsed. Runs under the lock; sends
/// are non-blocking on unbounded channels.
fn tick(&mut self, now: Instant) {
let mut empty: Vec<u64> = Vec::new();
for (key, session) in self.sessions.iter_mut() {
if session.conns.is_empty() {
empty.push(*key);
continue;
}
if session.m.finished || now < session.deadline {
continue;
}
// Resolve the turn from queued submissions.
let subs: Vec<(u32, Action)> =
session.pending.iter().map(|(p, a)| (*p, a.clone())).collect();
let events = session.m.resolve_turn(&subs);
session.pending.clear();
let runtime_hash = session.m.last_turn_hash_hex();
let turn = session.m.turn;
// Broadcast the resolved state, filtered per player.
for (pid, tx) in session.conns.iter() {
let msg = ServerMessage::TurnResolved {
turn,
snapshot: session.m.visible_for(*pid),
runtime_hash: runtime_hash.clone(),
events: events.clone(),
};
let _ = tx.send(Out::Text(msg.encode()));
}
// Open the next turn unless the match just ended.
if !session.m.finished {
session.deadline = now + session.turn_len;
let ts = ServerMessage::TurnStarted {
turn,
deadline_ms: session.turn_len.as_millis() as u64,
};
for tx in session.conns.values() {
let _ = tx.send(Out::Text(ts.encode()));
}
}
}
// Drop sessions nobody is connected to (replay no longer reachable).
for key in empty {
self.sessions.remove(&key);
}
}
fn disconnect(&mut self, match_key: u64, player: u32) {
if let Some(session) = self.sessions.get_mut(&match_key) {
session.conns.remove(&player);
if session.conns.is_empty() {
self.sessions.remove(&match_key);
}
}
}
}
/// Configuration.
pub struct Config {
pub turn_ms: u64,
}
impl Config {
pub fn from_env() -> Config {
let turn_ms = std::env::var("MAGICKA_TURN_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
Config { turn_ms }
}
}
/// Start the server on `addr`. Returns the bound address (useful when binding
/// to port 0 in tests). Spawns the accept loop and the turn-timer thread as
/// detached background threads.
pub fn serve(addr: &str, cfg: Config) -> std::io::Result<SocketAddr> {
let listener = TcpListener::bind(addr)?;
let local = listener.local_addr()?;
let manager = Arc::new(Mutex::new(Manager::new(cfg.turn_ms)));
// Turn timer.
{
let mgr = Arc::clone(&manager);
thread::spawn(move || loop {
thread::sleep(TICK);
let now = Instant::now();
lock(&mgr).tick(now);
});
}
// Accept loop.
{
let mgr = Arc::clone(&manager);
thread::spawn(move || {
for stream in listener.incoming() {
if let Ok(stream) = stream {
let mgr = Arc::clone(&mgr);
thread::spawn(move || {
let _ = handle_conn(stream, mgr);
});
}
}
});
}
Ok(local)
}
/// Blocking entry point for the binary.
pub fn run(addr: &str) -> std::io::Result<()> {
let local = serve(addr, Config::from_env())?;
eprintln!("magicka-server listening on http://{local} (open it in a browser)");
loop {
thread::sleep(Duration::from_secs(3600));
}
}
fn handle_conn(stream: TcpStream, mgr: Arc<Mutex<Manager>>) -> std::io::Result<()> {
stream.set_nodelay(true).ok();
let mut head_reader = BufReader::new(stream.try_clone()?);
let req = match http::read_request(&mut head_reader)? {
Some(r) => r,
None => return Ok(()),
};
if !req.is_websocket_upgrade() {
// Static asset.
let mut s = stream;
let resp = web_client::http_response(&req.path).unwrap_or_else(web_client::not_found);
s.write_all(&resp)?;
return Ok(());
}
// Complete the WebSocket handshake.
let key = match req.websocket_key() {
Some(k) => k,
None => return Ok(()),
};
let accept = ws::accept_key(key);
{
let mut s = stream.try_clone()?;
s.write_all(http::handshake_response(&accept).as_bytes())?;
s.flush()?;
}
// Writer thread: the only thing that ever writes to this socket.
let (tx, rx) = mpsc::channel::<Out>();
let mut write_stream = stream.try_clone()?;
let writer = thread::spawn(move || {
for out in rx {
let r = match out {
Out::Text(s) => ws::write_text(&mut write_stream, &s),
Out::Pong(p) => ws::write_pong(&mut write_stream, &p),
Out::Close => {
let _ = ws::write_close(&mut write_stream);
break;
}
};
if r.is_err() {
break;
}
}
});
// Reader loop.
let mut read_stream = stream;
let mut match_key: Option<u64> = None;
let mut player_id: Option<u32> = None;
loop {
match ws::read_message(&mut read_stream) {
Ok(Some(ws::Message::Text(raw))) => {
dispatch(&mgr, &tx, &raw, &mut match_key, &mut player_id);
}
Ok(Some(ws::Message::Ping(p))) => {
let _ = tx.send(Out::Pong(p));
}
Ok(Some(ws::Message::Pong)) => {}
Ok(Some(ws::Message::Close)) | Ok(None) => break,
Err(_) => break,
}
}
// Disconnect: deregister so the match continues without corruption.
if let (Some(k), Some(p)) = (match_key, player_id) {
lock(&mgr).disconnect(k, p);
}
let _ = tx.send(Out::Close);
drop(tx);
let _ = writer.join();
Ok(())
}
/// Dispatch one decoded client message. Never panics: a decode failure or any
/// rejected operation becomes a `ValidationReport`/`ErrorEvent`.
fn dispatch(
mgr: &Arc<Mutex<Manager>>,
tx: &Sender<Out>,
raw: &str,
match_key: &mut Option<u64>,
player_id: &mut Option<u32>,
) {
let msg = match ClientMessage::decode(raw) {
Ok(m) => m,
Err(e) => {
send(tx, ServerMessage::ValidationReport {
accepted: false,
detail: format!("malformed packet: {e}"),
diagnostics: RuneDiagnostics::default(),
});
return;
}
};
match msg {
ClientMessage::JoinMatch { name, match_id } => {
if player_id.is_some() {
send(tx, err_event("already_joined", "this connection already joined a match"));
return;
}
let mut m = lock(mgr);
match m.join(&name, match_id, tx.clone()) {
Ok(ok) => {
*match_key = Some(ok.match_id.0);
*player_id = Some(ok.player_id);
send(tx, ok.initial);
send(tx, ok.turn_started);
}
Err(detail) => send(tx, err_event("join_failed", &detail)),
}
}
ClientMessage::SubmitTurn { turn, action } => {
let (Some(k), Some(p)) = (*match_key, *player_id) else {
send(tx, err_event("not_joined", "join a match first"));
return;
};
let res = lock(mgr).submit(k, p, turn, action);
match res {
Ok(()) => send(tx, ServerMessage::ValidationReport {
accepted: true,
detail: format!("action queued for turn {turn}"),
diagnostics: RuneDiagnostics::default(),
}),
Err(detail) => send(tx, ServerMessage::ValidationReport {
accepted: false,
detail,
diagnostics: RuneDiagnostics::default(),
}),
}
}
ClientMessage::EditRuneProgram { tokens } => {
let (Some(k), Some(p)) = (*match_key, *player_id) else {
send(tx, err_event("not_joined", "join a match first"));
return;
};
match lock(mgr).set_program(k, p, tokens) {
Ok(diagnostics) => send(tx, ServerMessage::ValidationReport {
accepted: true,
detail: "program updated".to_string(),
diagnostics,
}),
Err(detail) => send(tx, err_event("edit_failed", &detail)),
}
}
ClientMessage::InspectTarget { target } => {
let Some(k) = *match_key else {
send(tx, err_event("not_joined", "join a match first"));
return;
};
match lock(mgr).inspect(k, target) {
Ok(m) => send(tx, m),
Err(detail) => send(tx, err_event("inspect_failed", &detail)),
}
}
ClientMessage::RequestReplay { match_id } => {
match lock(mgr).replay_chunks(match_id.0) {
Ok(chunks) => {
for c in chunks {
send(tx, c);
}
}
Err(detail) => send(tx, err_event("replay_failed", &detail)),
}
}
ClientMessage::Ping { .. } => {
// Liveness only; the WebSocket layer already handles control pings.
}
}
}
fn send(tx: &Sender<Out>, msg: ServerMessage) {
let _ = tx.send(Out::Text(msg.encode()));
}
/// Acquire the manager lock, recovering a poisoned guard. A panic in any single
/// connection or tick must not permanently brick the server for everyone else.
fn lock(mgr: &Arc<Mutex<Manager>>) -> std::sync::MutexGuard<'_, Manager> {
mgr.lock().unwrap_or_else(|p| p.into_inner())
}
fn err_event(code: &str, detail: &str) -> ServerMessage {
ServerMessage::ErrorEvent { code: code.to_string(), detail: detail.to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manager_join_and_resolve_is_authoritative() {
let mut m = Manager::new(10);
let (tx, _rx) = mpsc::channel();
let ok = m.join("dev", None, tx).unwrap();
assert_eq!(ok.player_id, 1);
let key = ok.match_id.0;
// Submit a cast for the live turn.
assert!(m.submit(key, 1, 0, Action::Cast).is_ok());
// Wrong turn is rejected deterministically.
let e = m.submit(key, 1, 99, Action::Cast).unwrap_err();
assert!(e.contains("wrong turn"), "{e}");
}
#[test]
fn disconnect_drops_session_when_last_leaves() {
let mut m = Manager::new(10);
let (tx, _rx) = mpsc::channel();
let ok = m.join("dev", None, tx).unwrap();
let key = ok.match_id.0;
assert!(m.sessions.contains_key(&key));
m.disconnect(key, 1);
assert!(!m.sessions.contains_key(&key));
}
#[test]
fn duel_assigns_two_human_slots() {
let mut m = Manager::new(10);
let (tx1, _r1) = mpsc::channel();
let (tx2, _r2) = mpsc::channel();
let a = m.join("a", Some(MatchId(42)), tx1).unwrap();
let b = m.join("b", Some(MatchId(42)), tx2).unwrap();
assert_eq!(a.player_id, 1);
assert_eq!(b.player_id, 2);
// Third join to a full duel is rejected.
let (tx3, _r3) = mpsc::channel();
assert!(m.join("c", Some(MatchId(42)), tx3).is_err());
}
}
+8
View File
@@ -0,0 +1,8 @@
//! The Magicka VM web server binary. Serves the embedded browser client and the
//! authoritative WebSocket protocol. Bind address via `MAGICKA_ADDR`
//! (default `127.0.0.1:8080`); turn length via `MAGICKA_TURN_MS`.
fn main() -> std::io::Result<()> {
let addr = std::env::var("MAGICKA_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
server::run(&addr)
}
+342
View File
@@ -0,0 +1,342 @@
//! Minimal WebSocket (RFC 6455) support over `std::net`, no external crates.
//! Implements just what the game needs: the upgrade handshake (SHA1 + base64),
//! masked client-frame reading with fragment reassembly, and unmasked
//! server-frame writing. All reads are length-checked so a hostile frame
//! returns an `Err`, never a panic or unbounded allocation.
use std::io::{self, Read, Write};
const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
/// Reject any single message larger than this (defensive bound).
pub const MAX_MESSAGE: usize = 1 << 20; // 1 MiB
// ---------------------------------------------------------------------------
// SHA-1 (FIPS 180-1). Used only for the handshake accept key.
// ---------------------------------------------------------------------------
fn sha1(data: &[u8]) -> [u8; 20] {
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
let ml = (data.len() as u64) * 8;
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&ml.to_be_bytes());
for chunk in msg.chunks_exact(64) {
let mut w = [0u32; 80];
for (i, wi) in w.iter_mut().enumerate().take(16) {
*wi = u32::from_be_bytes([
chunk[i * 4],
chunk[i * 4 + 1],
chunk[i * 4 + 2],
chunk[i * 4 + 3],
]);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
for (i, &wi) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let tmp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = tmp;
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
}
let mut out = [0u8; 20];
for (i, hi) in h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&hi.to_be_bytes());
}
out
}
// ---------------------------------------------------------------------------
// base64 (standard alphabet).
// ---------------------------------------------------------------------------
fn base64(data: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in data.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(ALPHABET[((n >> 6) & 63) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(ALPHABET[(n & 63) as usize] as char);
} else {
out.push('=');
}
}
out
}
/// Compute the `Sec-WebSocket-Accept` value for a client key.
pub fn accept_key(client_key: &str) -> String {
let mut concat = client_key.to_string();
concat.push_str(WS_GUID);
base64(&sha1(concat.as_bytes()))
}
// ---------------------------------------------------------------------------
// Frames.
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Opcode {
Continuation,
Text,
Binary,
Close,
Ping,
Pong,
}
impl Opcode {
fn from_u8(v: u8) -> Option<Opcode> {
Some(match v {
0x0 => Opcode::Continuation,
0x1 => Opcode::Text,
0x2 => Opcode::Binary,
0x8 => Opcode::Close,
0x9 => Opcode::Ping,
0xA => Opcode::Pong,
_ => return None,
})
}
}
struct Frame {
fin: bool,
opcode: Opcode,
payload: Vec<u8>,
}
fn read_frame<R: Read>(r: &mut R) -> io::Result<Frame> {
let mut hdr = [0u8; 2];
r.read_exact(&mut hdr)?;
let fin = hdr[0] & 0x80 != 0;
let opcode = Opcode::from_u8(hdr[0] & 0x0f)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad opcode"))?;
let masked = hdr[1] & 0x80 != 0;
let len7 = (hdr[1] & 0x7f) as usize;
let len = match len7 {
126 => {
let mut b = [0u8; 2];
r.read_exact(&mut b)?;
u16::from_be_bytes(b) as usize
}
127 => {
let mut b = [0u8; 8];
r.read_exact(&mut b)?;
u64::from_be_bytes(b) as usize
}
n => n,
};
if len > MAX_MESSAGE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
// Per RFC, client frames MUST be masked.
let mask = if masked {
let mut m = [0u8; 4];
r.read_exact(&mut m)?;
Some(m)
} else {
None
};
let mut payload = vec![0u8; len];
r.read_exact(&mut payload)?;
if let Some(m) = mask {
for (i, b) in payload.iter_mut().enumerate() {
*b ^= m[i % 4];
}
}
Ok(Frame { fin, opcode, payload })
}
/// A complete application message read from the socket. Control frames are
/// surfaced rather than answered inline so that *all* socket writes can be
/// funneled through a single writer (avoiding interleaved frames when a server
/// is both broadcasting and answering pings).
pub enum Message {
Text(String),
/// A ping with its payload; the caller must reply with a pong.
Ping(Vec<u8>),
/// A pong (informational).
Pong,
/// The peer requested close.
Close,
}
/// Read one full WebSocket message, reassembling fragments. Returns `Ok(None)`
/// on a clean EOF. Reads only — never writes to the socket.
pub fn read_message<R: Read>(stream: &mut R) -> io::Result<Option<Message>> {
let mut buf: Vec<u8> = Vec::new();
let mut msg_op: Option<Opcode> = None;
loop {
let frame = match read_frame(stream) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
};
match frame.opcode {
Opcode::Close => return Ok(Some(Message::Close)),
Opcode::Ping => return Ok(Some(Message::Ping(frame.payload))),
Opcode::Pong => return Ok(Some(Message::Pong)),
Opcode::Text | Opcode::Binary => {
if msg_op.is_some() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "interleaved frame"));
}
msg_op = Some(frame.opcode);
buf.extend_from_slice(&frame.payload);
}
Opcode::Continuation => {
if msg_op.is_none() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "stray continuation"));
}
buf.extend_from_slice(&frame.payload);
}
}
if buf.len() > MAX_MESSAGE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
}
if frame.fin {
// We only surface text to the application; binary is decoded lossily.
let s = String::from_utf8_lossy(&buf).into_owned();
return Ok(Some(Message::Text(s)));
}
}
}
fn write_frame<W: Write>(w: &mut W, opcode: Opcode, payload: &[u8]) -> io::Result<()> {
let op = match opcode {
Opcode::Continuation => 0x0,
Opcode::Text => 0x1,
Opcode::Binary => 0x2,
Opcode::Close => 0x8,
Opcode::Ping => 0x9,
Opcode::Pong => 0xA,
};
let mut frame = vec![0x80 | op];
let len = payload.len();
if len < 126 {
frame.push(len as u8);
} else if len < 65536 {
frame.push(126);
frame.extend_from_slice(&(len as u16).to_be_bytes());
} else {
frame.push(127);
frame.extend_from_slice(&(len as u64).to_be_bytes());
}
frame.extend_from_slice(payload);
w.write_all(&frame)?;
w.flush()
}
/// Send a text message (server frames are never masked).
pub fn write_text<W: Write>(w: &mut W, text: &str) -> io::Result<()> {
write_frame(w, Opcode::Text, text.as_bytes())
}
/// Send a pong frame echoing a ping payload.
pub fn write_pong<W: Write>(w: &mut W, payload: &[u8]) -> io::Result<()> {
write_frame(w, Opcode::Pong, payload)
}
/// Send a close frame.
pub fn write_close<W: Write>(w: &mut W) -> io::Result<()> {
write_frame(w, Opcode::Close, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc_example_accept_key() {
// The canonical example from RFC 6455 section 1.3.
assert_eq!(
accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
#[test]
fn sha1_known_vector() {
// "abc" -> a9993e364706816aba3e25717850c26c9cd0d89d
let d = sha1(b"abc");
let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
}
#[test]
fn base64_roundtrip_lengths() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
#[test]
fn masked_text_frame_roundtrips_through_reader() {
use std::io::Cursor;
// Build a masked client text frame for "hi".
let payload = b"hi";
let mask = [0x01, 0x02, 0x03, 0x04];
let mut frame = vec![0x81, 0x80 | payload.len() as u8];
frame.extend_from_slice(&mask);
for (i, &b) in payload.iter().enumerate() {
frame.push(b ^ mask[i % 4]);
}
// Cursor implements Read+Write (write goes nowhere useful but pong path
// is not exercised here).
let mut cur = Cursor::new(frame);
match read_message(&mut cur).unwrap() {
Some(Message::Text(s)) => assert_eq!(s, "hi"),
_ => panic!("expected text"),
}
}
#[test]
fn oversized_frame_is_rejected() {
use std::io::Cursor;
// Declares a 127-length (8-byte) payload of u64::MAX — must error, not OOM.
let mut frame = vec![0x81, 0x80 | 127];
frame.extend_from_slice(&u64::MAX.to_be_bytes());
frame.extend_from_slice(&[0, 0, 0, 0]); // partial mask
let mut cur = Cursor::new(frame);
assert!(read_message(&mut cur).is_err());
}
}
+11
View File
@@ -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"
+637
View File
@@ -0,0 +1,637 @@
//! `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<usize> {
(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<CausalEdge>,
}
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<usize> {
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<Vec<f64>> = 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<f64>,
}
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<i64>,
}
impl BehaviorFingerprint {
pub fn from_features(features: Vec<i64>) -> 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<Fault>,
}
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,
],
)
}
/// Serialize the FULL trace — every graph edge, count, and weight — to a
/// single line of whitespace-separated integers. This is the full-trace
/// *evidence* (not a summary): [`deserialize`] reconstructs the trace and
/// [`canonical_hash`] over the result is bit-identical, so an independent
/// verifier can recompute the trace hash from the raw structure rather than
/// trusting a reported digest. `f64` divergence values are stored as raw
/// bits for exact round-trip; the behavior-fingerprint hash is NOT stored —
/// it is re-derived from the features on load, so a fabricated digest cannot
/// survive.
pub fn serialize(&self) -> String {
let mut t: Vec<String> = vec!["trace-v1".to_string()];
let push_access = |t: &mut Vec<String>, g: &DomainAccessGraph| {
for c in &g.access_count {
t.push(c.to_string());
}
t.push(g.edges.len().to_string());
for &(a, b, w) in &g.edges {
t.push(a.to_string());
t.push(b.to_string());
t.push(w.to_string());
}
};
push_access(&mut t, &self.read_graph);
push_access(&mut t, &self.write_graph);
// causal
t.push(self.causal_graph.edges.len().to_string());
for e in &self.causal_graph.edges {
for v in [
e.from.domain as i64, e.from.lane as i64, e.from.hidden as i64, e.from.step as i64,
e.to.domain as i64, e.to.lane as i64, e.to.hidden as i64, e.to.step as i64, e.weight,
] {
t.push(v.to_string());
}
}
// information flow
t.push(self.information_flow.edges.len().to_string());
for &(a, b, w) in &self.information_flow.edges {
t.push(a.to_string());
t.push(b.to_string());
t.push(w.to_string());
}
// divergence (f64 as raw bits)
t.push(self.executor_divergence.executor_count.to_string());
t.push(self.executor_divergence.pairwise.len().to_string());
for &v in &self.executor_divergence.pairwise {
t.push(v.to_bits().to_string());
}
// temporal
t.push(self.temporal_graph.edges.len().to_string());
for &(s, off, d) in &self.temporal_graph.edges {
t.push(s.to_string());
t.push(off.to_string());
t.push(d.to_string());
}
// perturbation response
for v in [
self.perturbation_response.total,
self.perturbation_response.altered_trace,
self.perturbation_response.altered_delta,
self.perturbation_response.altered_future,
self.perturbation_response.neutral_unexplained,
] {
t.push(v.to_string());
}
// behavior features (fingerprint hash re-derived on load)
t.push(self.behavior_fingerprint.features.len().to_string());
for &f in &self.behavior_fingerprint.features {
t.push(f.to_string());
}
t.join(" ")
}
/// Reconstruct a trace from [`serialize`]. Total: returns `None` on any
/// malformed input rather than panicking.
pub fn deserialize(s: &str) -> Option<ExecutionTrace> {
let mut it = s.split_whitespace();
if it.next()? != "trace-v1" {
return None;
}
let nu = |it: &mut std::str::SplitWhitespace| -> Option<u64> { it.next()?.parse().ok() };
let ni = |it: &mut std::str::SplitWhitespace| -> Option<i64> { it.next()?.parse().ok() };
let read_access = |it: &mut std::str::SplitWhitespace| -> Option<DomainAccessGraph> {
let mut access_count = [0u32; NUM_DOMAINS];
for c in access_count.iter_mut() {
*c = nu(it)? as u32;
}
let n = nu(it)? as usize;
let mut edges = Vec::with_capacity(n);
for _ in 0..n {
edges.push((nu(it)? as u8, nu(it)? as u8, nu(it)? as u32));
}
Some(DomainAccessGraph { access_count, edges })
};
let read_graph = read_access(&mut it)?;
let write_graph = read_access(&mut it)?;
// causal
let cn = nu(&mut it)? as usize;
let mut cedges = Vec::with_capacity(cn);
for _ in 0..cn {
let from = CausalNode {
domain: ni(&mut it)? as u8,
lane: ni(&mut it)? as u8,
hidden: ni(&mut it)? != 0,
step: ni(&mut it)? as u32,
};
let to = CausalNode {
domain: ni(&mut it)? as u8,
lane: ni(&mut it)? as u8,
hidden: ni(&mut it)? != 0,
step: ni(&mut it)? as u32,
};
let weight = ni(&mut it)?;
cedges.push(CausalEdge { from, to, weight });
}
// info flow
let fin = nu(&mut it)? as usize;
let mut fedges = Vec::with_capacity(fin);
for _ in 0..fin {
fedges.push((nu(&mut it)? as u8, nu(&mut it)? as u8, nu(&mut it)? as u32));
}
// divergence
let executor_count = nu(&mut it)? as usize;
let pn = nu(&mut it)? as usize;
let mut pairwise = Vec::with_capacity(pn);
for _ in 0..pn {
pairwise.push(f64::from_bits(nu(&mut it)?));
}
// temporal
let tn = nu(&mut it)? as usize;
let mut tedges = Vec::with_capacity(tn);
for _ in 0..tn {
tedges.push((nu(&mut it)? as u32, nu(&mut it)? as u8, nu(&mut it)? as u8));
}
// perturbation response
let pr = PerturbationResponse {
total: nu(&mut it)? as usize,
altered_trace: nu(&mut it)? as usize,
altered_delta: nu(&mut it)? as usize,
altered_future: nu(&mut it)? as usize,
neutral_unexplained: nu(&mut it)? as usize,
};
// behavior features
let bn = nu(&mut it)? as usize;
let mut features = Vec::with_capacity(bn);
for _ in 0..bn {
features.push(ni(&mut it)?);
}
Some(ExecutionTrace {
read_graph,
write_graph,
causal_graph: CausalGraph { edges: cedges },
information_flow: InformationFlowGraph { edges: fedges },
executor_divergence: DivergenceGraph { executor_count, pairwise },
temporal_graph: TemporalGraph { edges: tedges },
perturbation_response: pr,
// Re-derive the fingerprint hash from features (not from a stored digest).
behavior_fingerprint: BehaviorFingerprint::from_features(features),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_trace() -> ExecutionTrace {
let mut read_graph = DomainAccessGraph::default();
read_graph.access_count[0] = 3;
read_graph.access_count[2] = 1;
read_graph.edges.push((0, 2, 5));
let mut write_graph = DomainAccessGraph::default();
write_graph.access_count[2] = 4;
write_graph.edges.push((0, 2, 7));
let causal_graph = CausalGraph {
edges: vec![CausalEdge {
from: CausalNode { domain: 0, lane: 1, hidden: false, step: 2 },
to: CausalNode { domain: 2, lane: 0, hidden: true, step: 2 },
weight: -1234,
}],
};
ExecutionTrace {
read_graph,
write_graph,
causal_graph,
information_flow: InformationFlowGraph { edges: vec![(0, 2, 9), (2, 3, 4)] },
executor_divergence: DivergenceGraph { executor_count: 3, pairwise: vec![0.0, 0.5, 0.25, 0.5, 0.0, 0.125, 0.25, 0.125, 0.0] },
temporal_graph: TemporalGraph { edges: vec![(1, 2, 3)] },
perturbation_response: PerturbationResponse::default(),
behavior_fingerprint: BehaviorFingerprint::from_features(vec![1, -2, 3, -4]),
}
}
#[test]
fn full_trace_serialize_roundtrips_canonical_hash() {
let t = sample_trace();
let s = t.serialize();
let back = ExecutionTrace::deserialize(&s).expect("deserialize");
// The reconstructed trace is structurally equal and hashes identically.
assert_eq!(t, back);
assert_eq!(t.canonical_hash(), back.canonical_hash());
}
#[test]
fn deserialize_is_total_on_garbage() {
for s in ["", "nope", "trace-v1 1 2", "trace-v1 x y z"] {
let _ = ExecutionTrace::deserialize(s); // must not panic
}
}
#[test]
fn rank_of_identity_is_full() {
let id: Vec<Vec<f64>> = (0..5)
.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<f64>> = 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<Vec<f64>> = (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);
}
}
+82
View File
@@ -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<f64>]) -> usize {
if rows_in.is_empty() {
return 0;
}
let mut rows: Vec<Vec<f64>> = 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::<f64>() / nf;
let my = ys[..n].iter().sum::<f64>() / 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())
}
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "web_assets"
version.workspace = true
edition.workspace = true
license.workspace = true
+337
View File
@@ -0,0 +1,337 @@
"use strict";
// Browser client for Magicka VM. The browser only ever sends INTENT; the server
// is the sole authority. This file mirrors the `protocol` crate's wire format
// (version 1, envelope {v,type,body}). It never simulates the world — it renders
// exactly what the server says is observable.
const PROTOCOL_VERSION = 1;
const OPS = ["mix","channel","branch","schedule","resonate","observe",
"collapse","invert","diffuse","anchor","echoback","imprint"];
const state = {
ws: null,
playerId: null,
matchId: null,
turn: 0,
deadline: 0,
locked: false,
snapshot: null,
selectedTarget: null,
program: [],
slots: [[], [], []],
activeSlot: 0,
liveHashes: {}, // turn -> runtime_hash seen live
replay: null, // { turns: [...], final_hash, cursor }
};
// ---- wire helpers -------------------------------------------------------
function send(type, body) {
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
state.ws.send(JSON.stringify({ v: PROTOCOL_VERSION, type, body }));
}
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
state.ws = ws;
ws.onopen = () => {
setConn(true);
send("JoinMatch", { name: "dev-" + Math.floor(Math.random() * 1000), match_id: null });
};
ws.onclose = () => { setConn(false); setTimeout(connect, 1000); };
ws.onerror = () => ws.close();
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (!msg || msg.v !== PROTOCOL_VERSION) return;
handle(msg.type, msg.body || {});
};
}
function setConn(on) {
const el = document.getElementById("conn");
el.textContent = on ? "connected" : "disconnected";
el.className = "badge " + (on ? "on" : "off");
}
// ---- server message handling -------------------------------------------
function handle(type, body) {
switch (type) {
case "MatchState":
state.playerId = body.player_id;
state.matchId = body.match_id;
state.turn = body.turn;
state.snapshot = body.snapshot;
render();
break;
case "TurnStarted":
state.turn = body.turn;
state.deadline = Date.now() + (body.deadline_ms || 0);
state.locked = false;
renderTimer();
break;
case "TurnResolved":
state.turn = body.turn;
state.snapshot = body.snapshot;
state.liveHashes[body.turn] = body.runtime_hash;
(body.events || []).forEach((e) => log(`t${body.turn}: ${e}`));
showHashes();
render();
break;
case "ObservationResult":
renderDiagnostics(body.diagnostics, `target ${body.target}`);
break;
case "ValidationReport":
log((body.accepted ? "✓ " : "✗ ") + body.detail);
if (body.diagnostics) renderDiagnostics(body.diagnostics, "program");
break;
case "ReplayChunk":
loadReplay(body);
break;
case "ErrorEvent":
log(`! ${body.code}: ${body.detail}`);
break;
}
}
// ---- rendering ----------------------------------------------------------
function render() {
if (!state.snapshot) return;
renderArena();
renderDomains();
renderLogHistory();
renderTimer();
}
function renderArena() {
const s = state.snapshot;
const arena = document.getElementById("arena");
arena.style.gridTemplateColumns = `repeat(${s.arena_w}, 34px)`;
arena.innerHTML = "";
const at = {};
(s.observed_entities || []).forEach((e) => { at[`${e.x},${e.y}`] = e; });
for (let y = 0; y < s.arena_h; y++) {
for (let x = 0; x < s.arena_w; x++) {
const cell = document.createElement("div");
cell.className = "cell";
const e = at[`${x},${y}`];
if (e) {
cell.textContent = e.is_self ? "@" : (e.is_dummy ? "▣" : "&");
if (e.is_self) cell.classList.add("self");
if (state.selectedTarget === e.id) cell.classList.add("target");
if (!e.alive) cell.classList.add("dead");
const hp = document.createElement("span");
hp.className = "hp"; hp.textContent = e.hp;
cell.appendChild(hp);
cell.title = `${e.name} (#${e.id}) hp ${e.hp}`;
cell.onclick = () => { state.selectedTarget = e.id; renderArena(); };
}
arena.appendChild(cell);
}
}
}
function renderDomains() {
const s = state.snapshot;
const wrap = document.getElementById("domains");
wrap.innerHTML = "";
(s.observed_domains || []).forEach((d) => {
const el = document.createElement("div");
el.className = "domain";
const name = document.createElement("div");
name.className = "name"; name.textContent = `${d.index}: ${d.name}`;
el.appendChild(name);
d.observed.forEach((v, i) => {
const lane = document.createElement("span");
const k = (d.knowledge && d.knowledge[i]) || "unknown";
lane.className = "lane " + k;
lane.textContent = v === null ? "▒" : v;
lane.title = k;
el.appendChild(lane);
});
wrap.appendChild(el);
});
document.getElementById("redactions").textContent =
`${s.hidden_state_redactions} hidden state values withheld (hidden lanes + masked observations)`;
const inf = document.getElementById("inferred");
inf.innerHTML = "";
(s.inferred_markers || []).forEach((m) => {
const li = document.createElement("li"); li.textContent = m; inf.appendChild(li);
});
}
function renderLogHistory() {
// History from the snapshot is authoritative; live events are appended too.
const known = state.snapshot.known_history || [];
const log = document.getElementById("log");
if (log.dataset.lastTurn !== String(state.turn)) {
log.dataset.lastTurn = String(state.turn);
}
}
function renderTimer() {
const t = document.getElementById("timer");
const remain = Math.max(0, Math.ceil((state.deadline - Date.now()) / 1000));
t.textContent = `turn ${state.turn}${remain}s ${state.locked ? "(locked)" : ""}`;
}
setInterval(() => {
if (state.deadline) {
if (Date.now() > state.deadline) state.locked = true;
renderTimer();
}
}, 250);
function renderDiagnostics(d, label) {
const body = document.getElementById("diag-body");
body.innerHTML = "";
const row = (k, v) => {
const div = document.createElement("div");
div.className = "diag-row";
div.innerHTML = `<span class="diag-key">${k}:</span> ${v}`;
body.appendChild(div);
};
row("for", label);
row("known reads", (d.known_reads || []).join(", ") || "—");
row("known writes", (d.known_writes || []).join(", ") || "—");
row("observed risks", (d.observed_risks || []).join(", ") || "none observed");
row("unknown listeners", `<span class="warn">${d.unknown_listeners || 0}</span> (writes you cannot observe)`);
row("previous outcomes", (d.previous_outcomes || []).join(" | ") || "—");
}
// ---- rune editor --------------------------------------------------------
function renderTokens() {
const wrap = document.getElementById("tokens");
wrap.innerHTML = "";
state.program.forEach((t, i) => {
const el = document.createElement("span");
el.className = "token";
el.textContent = `${OPS[t.op % OPS.length]} ${t.a},${t.b},${t.c}#${t.imm}`;
el.title = "click to remove";
el.onclick = () => { state.program.splice(i, 1); renderTokens(); };
wrap.appendChild(el);
});
}
function renderLibrary() {
const wrap = document.getElementById("library");
wrap.innerHTML = "";
state.slots.forEach((slot, i) => {
const el = document.createElement("div");
el.className = "slot" + (i === state.activeSlot ? " active" : "");
el.textContent = `slot ${i + 1} (${slot.length})`;
el.onclick = () => {
state.slots[state.activeSlot] = state.program.slice();
state.activeSlot = i;
state.program = state.slots[i].slice();
renderTokens(); renderLibrary();
};
wrap.appendChild(el);
});
}
function initEditor() {
const sel = document.getElementById("op-select");
OPS.forEach((op, i) => {
const o = document.createElement("option");
o.value = i; o.textContent = op; sel.appendChild(o);
});
document.getElementById("btn-add").onclick = () => {
state.program.push({
op: parseInt(sel.value, 10),
a: clampByte("tok-a"), b: clampByte("tok-b"), c: clampByte("tok-c"),
imm: parseInt(document.getElementById("tok-imm").value, 10) || 0,
});
renderTokens();
};
document.getElementById("btn-clear").onclick = () => { state.program = []; renderTokens(); };
document.getElementById("btn-save").onclick = () => {
send("EditRuneProgram", { tokens: state.program });
log("saved program (" + state.program.length + " runes)");
};
renderTokens(); renderLibrary();
}
function clampByte(id) {
let v = parseInt(document.getElementById(id).value, 10) || 0;
return Math.max(0, Math.min(255, v));
}
// ---- actions ------------------------------------------------------------
function submit(action) {
if (state.locked) { log("turn locked — submission rejected client-side"); return; }
send("SubmitTurn", { turn: state.turn, action });
}
function initActions() {
document.querySelectorAll("[data-move]").forEach((b) => {
b.onclick = () => {
const [dx, dy] = b.dataset.move.split(",").map((n) => parseInt(n, 10));
submit({ kind: "move", dx, dy });
};
});
document.getElementById("btn-cast").onclick = () => submit({ kind: "cast" });
document.getElementById("btn-wait").onclick = () => submit({ kind: "wait" });
document.getElementById("btn-attack").onclick = () => {
if (state.selectedTarget === null) { log("select a target first"); return; }
submit({ kind: "attack", target: state.selectedTarget });
};
document.getElementById("btn-inspect").onclick = () => {
if (state.selectedTarget === null) { log("select a target first"); return; }
send("InspectTarget", { target: state.selectedTarget });
};
}
// ---- replay -------------------------------------------------------------
function initReplay() {
document.getElementById("btn-replay").onclick = () => {
if (state.matchId === null) return;
state.replay = null;
send("RequestReplay", { match_id: state.matchId });
};
document.getElementById("btn-replay-step").onclick = stepReplay;
}
function loadReplay(chunk) {
if (!state.replay) state.replay = { turns: [], final_hash: chunk.final_hash, cursor: 0, seed: chunk.seed };
state.replay.turns = state.replay.turns.concat(chunk.turns || []);
state.replay.final_hash = chunk.final_hash;
document.getElementById("replay-status").textContent =
`replay loaded: ${state.replay.turns.length} turns (seed ${chunk.seed})`;
}
function stepReplay() {
if (!state.replay || state.replay.cursor >= state.replay.turns.length) {
document.getElementById("replay-status").textContent = "replay complete";
return;
}
const rt = state.replay.turns[state.replay.cursor++];
// Verify browser replay event order/hash matches what we saw live (Phase G).
const live = state.liveHashes[rt.turn];
const ok = live === undefined || live === rt.runtime_hash;
log(`replay t${rt.turn}: hash ${rt.runtime_hash} ${ok ? "✓ matches live" : "✗ MISMATCH"}`);
const hd = document.getElementById("hashes");
hd.innerHTML += `<div class="${ok ? "hash-ok" : "hash-bad"}">t${rt.turn} ${rt.runtime_hash}</div>`;
document.getElementById("replay-status").textContent =
`replay turn ${rt.turn} / ${state.replay.turns.length}`;
}
function showHashes() {
const hd = document.getElementById("hashes");
hd.innerHTML = `<div>live final-turn hash: ${state.liveHashes[state.turn] || "—"}</div>`;
}
// ---- misc ---------------------------------------------------------------
function log(msg) {
const el = document.getElementById("log");
const li = document.createElement("li");
li.textContent = msg;
el.appendChild(li);
el.scrollTop = el.scrollHeight;
}
window.addEventListener("DOMContentLoaded", () => {
initEditor();
initActions();
initReplay();
connect();
});
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Magicka VM — playable window</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header>
<h1>Magicka VM</h1>
<div id="conn" class="badge off">disconnected</div>
<div id="timer" class="timer">turn —</div>
</header>
<main>
<section id="arena-panel" class="panel">
<h2>Arena</h2>
<div id="arena" class="arena" aria-label="arena grid"></div>
<div class="actions">
<div class="dpad">
<button data-move="0,-1"></button>
<div class="dpad-row">
<button data-move="-1,0"></button>
<button data-move="0,1"></button>
<button data-move="1,0"></button>
</div>
</div>
<div class="action-buttons">
<button id="btn-cast">Cast</button>
<button id="btn-attack">Attack</button>
<button id="btn-inspect">Inspect</button>
<button id="btn-wait">Wait</button>
</div>
</div>
<p class="hint">Select a target entity (click it), then Attack/Inspect. Cast uses your current program.</p>
</section>
<section id="editor-panel" class="panel">
<h2>Rune editor</h2>
<div id="library" class="library"></div>
<div id="tokens" class="tokens"></div>
<div class="editor-controls">
<select id="op-select"></select>
<label>a<input id="tok-a" type="number" value="0" min="0" max="255" /></label>
<label>b<input id="tok-b" type="number" value="0" min="0" max="255" /></label>
<label>c<input id="tok-c" type="number" value="0" min="0" max="255" /></label>
<label>imm<input id="tok-imm" type="number" value="0" /></label>
<button id="btn-add">Add rune</button>
<button id="btn-clear">Clear</button>
<button id="btn-save">Save program</button>
</div>
<div id="diagnostics" class="diagnostics">
<h3>Observed diagnostics</h3>
<div id="diag-body">cast or save a program to preview observed diagnostics</div>
</div>
</section>
<section id="domains-panel" class="panel">
<h2>Domains (observed)</h2>
<div id="domains" class="domains"></div>
<p id="redactions" class="redactions"></p>
<h3>Inferred</h3>
<ul id="inferred"></ul>
</section>
<section id="log-panel" class="panel">
<h2>Turn log</h2>
<ul id="log" class="log"></ul>
<h3>Replay</h3>
<div class="replay-controls">
<button id="btn-replay">Request replay</button>
<button id="btn-replay-step">Step ▶</button>
<span id="replay-status">no replay loaded</span>
</div>
<div id="hashes" class="hashes"></div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
:root {
--bg: #0e1014;
--panel: #171a21;
--ink: #d7dce5;
--dim: #828b9c;
--accent: #6ad0ff;
--warn: #ffb454;
--bad: #ff6a6a;
--good: #7be08a;
--grid: #2a2f3a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
header {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: #11131a;
border-bottom: 1px solid var(--grid);
}
h1 { font-size: 18px; margin: 0; color: var(--accent); }
h2 { font-size: 14px; margin: 0 0 8px; color: var(--accent); }
h3 { font-size: 12px; margin: 12px 0 6px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.06em; }
.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; }
.badge.off { background: #3a1f23; color: var(--bad); }
.badge.on { background: #1f3a26; color: var(--good); }
.timer { margin-left: auto; color: var(--warn); }
main {
display: grid;
grid-template-columns: 1.2fr 1.2fr 1fr;
grid-template-rows: auto auto;
gap: 12px;
padding: 12px;
}
.panel { background: var(--panel); border: 1px solid var(--grid); border-radius: 8px; padding: 12px; }
#arena-panel { grid-row: span 2; }
#log-panel { grid-row: span 2; }
.arena {
display: grid;
gap: 2px;
background: var(--grid);
border: 1px solid var(--grid);
width: max-content;
}
.cell {
width: 34px; height: 34px;
background: #10131a;
display: flex; align-items: center; justify-content: center;
font-size: 16px; cursor: pointer; position: relative;
}
.cell.self { outline: 2px solid var(--accent); }
.cell.target { outline: 2px solid var(--warn); }
.cell.dead { opacity: 0.35; }
.cell .hp { position: absolute; bottom: 0; right: 2px; font-size: 9px; color: var(--dim); }
.actions { display: flex; gap: 24px; margin-top: 12px; align-items: center; }
.dpad { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.dpad-row { display: flex; gap: 2px; }
button {
background: #222733; color: var(--ink); border: 1px solid var(--grid);
border-radius: 5px; padding: 6px 10px; cursor: pointer; font: inherit;
}
button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.action-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
.hint { color: var(--dim); font-size: 12px; }
.library { display: flex; gap: 6px; margin-bottom: 8px; }
.slot { border: 1px dashed var(--grid); border-radius: 5px; padding: 4px 8px; cursor: pointer; color: var(--dim); }
.slot.active { border-color: var(--accent); color: var(--accent); }
.tokens { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; padding: 6px; background: #10131a; border-radius: 5px; }
.token { background: #232a36; border: 1px solid var(--grid); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; }
.token:hover { border-color: var(--bad); }
.editor-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
.editor-controls label { display: flex; flex-direction: column; font-size: 10px; color: var(--dim); }
.editor-controls input { width: 64px; background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 3px; }
.editor-controls select { background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 4px; }
.diagnostics { margin-top: 12px; background: #10131a; border-radius: 5px; padding: 8px; }
.diag-row { margin: 2px 0; }
.diag-key { color: var(--dim); }
.warn { color: var(--warn); }
.domains { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.domain { background: #10131a; border: 1px solid var(--grid); border-radius: 5px; padding: 6px; }
.domain .name { color: var(--accent); font-size: 12px; }
.lane { display: inline-block; min-width: 40px; text-align: right; padding: 1px 4px; margin: 1px; border-radius: 3px; font-size: 11px; }
.lane.known { background: #15251a; color: var(--good); }
.lane.newly_observed { background: #2a2410; color: var(--warn); }
.lane.unknown { background: #25151a; color: var(--dim); }
.lane.suspected { background: #1a1a2a; color: #9aa0ff; }
.lane.contradicted { background: #2a1525; color: #ff9ae0; }
.redactions { color: var(--bad); font-size: 12px; }
.log { list-style: none; margin: 0; padding: 0; max-height: 320px; overflow-y: auto; }
.log li { padding: 2px 0; border-bottom: 1px solid #1c2029; font-size: 12px; }
.replay-controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.hashes { margin-top: 8px; font-size: 11px; color: var(--dim); word-break: break-all; }
.hash-ok { color: var(--good); }
.hash-bad { color: var(--bad); }
ul#inferred { margin: 0; padding-left: 16px; color: var(--dim); font-size: 12px; }
+36
View File
@@ -0,0 +1,36 @@
//! `web_assets` — the browser client's static files, embedded at compile time
//! so the server ships as a single binary with no runtime filesystem
//! dependency. The actual HTML/CSS/JS live under `assets/`.
pub const INDEX_HTML: &str = include_str!("../assets/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/style.css");
pub const APP_JS: &str = include_str!("../assets/app.js");
/// A served asset: its bytes and MIME type.
pub struct Asset {
pub body: &'static str,
pub content_type: &'static str,
}
/// Resolve a request path to a static asset. `/` maps to the client shell.
pub fn resolve(path: &str) -> Option<Asset> {
match path {
"/" | "/index.html" => Some(Asset { body: INDEX_HTML, content_type: "text/html; charset=utf-8" }),
"/style.css" => Some(Asset { body: STYLE_CSS, content_type: "text/css; charset=utf-8" }),
"/app.js" => Some(Asset { body: APP_JS, content_type: "application/javascript; charset=utf-8" }),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_and_assets_resolve() {
assert!(resolve("/").is_some());
assert!(resolve("/app.js").is_some());
assert!(resolve("/style.css").is_some());
assert!(resolve("/nope").is_none());
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "web_client"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
web_assets = { path = "../web_assets" }
+54
View File
@@ -0,0 +1,54 @@
//! `web_client` — the client delivery layer. It owns *how* the embedded
//! [`web_assets`] reach the browser (the HTTP response framing), keeping the
//! raw asset bytes (`web_assets`) separate from delivery concerns. The server
//! depends on this crate, not on `web_assets` directly.
pub use web_assets::{resolve, Asset};
/// Build a complete HTTP/1.1 response for a static GET path. Returns `None`
/// for unknown paths so the caller can emit a 404.
pub fn http_response(path: &str) -> Option<Vec<u8>> {
let asset = resolve(path)?;
let body = asset.body.as_bytes();
let mut out = Vec::with_capacity(body.len() + 128);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n",
asset.content_type,
body.len()
);
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(body);
Some(out)
}
/// The canonical 404 response.
pub fn not_found() -> Vec<u8> {
let body = b"404 not found";
let mut out = Vec::new();
let header = format!(
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(body);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serves_shell() {
let r = http_response("/").unwrap();
let s = String::from_utf8_lossy(&r);
assert!(s.starts_with("HTTP/1.1 200 OK"));
assert!(s.contains("text/html"));
assert!(s.contains("Magicka VM"));
}
#[test]
fn unknown_path_is_none() {
assert!(http_response("/secret").is_none());
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "web_tests"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
protocol = { path = "../protocol" }
game_runtime = { path = "../game_runtime" }
world_model = { path = "../world_model" }
rune_ir = { path = "../rune_ir" }
server = { path = "../server" }
+12
View File
@@ -0,0 +1,12 @@
{
"name": "magicka-web-e2e",
"version": "0.1.0",
"private": true,
"description": "Playwright rendered-browser E2E for the Magicka VM web game.",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.40.0"
}
}
+28
View File
@@ -0,0 +1,28 @@
// Playwright config for the rendered-browser E2E layer. This complements the
// headless Rust protocol E2E in `crates/web_tests/tests/e2e.rs`: here a real
// Chromium drives the actual DOM client. It boots the real server (short turns)
// via `webServer` so `npm test` is self-contained.
//
// Requires Node + `npx playwright install chromium`. The Rust CI gates do not
// depend on this; it is the optional rendered-browser proof.
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./specs",
timeout: 30000,
expect: { timeout: 10000 },
use: {
baseURL: "http://127.0.0.1:8099",
headless: true,
},
webServer: {
// Build once, then run the server with fast turns for E2E.
command: "cargo run --release -p server --bin magicka-server",
cwd: "../../..",
env: { MAGICKA_ADDR: "127.0.0.1:8099", MAGICKA_TURN_MS: "1500" },
url: "http://127.0.0.1:8099/",
reuseExistingServer: true,
timeout: 120000,
},
});
+43
View File
@@ -0,0 +1,43 @@
// Rendered-browser E2E: a real Chromium joins a match, the turn timer runs, the
// player edits + saves a rune program, casts, sees filtered results, and replays
// the match — verifying the recorded replay hashes match what was seen live.
//
// This is the browser layer of plan2.md Phase H ("Playwright end-to-end tests").
const { test, expect } = require("@playwright/test");
test("a player can join, cast, and replay a browser match", async ({ page }) => {
await page.goto("/");
// Connects to the authoritative server.
await expect(page.locator("#conn")).toHaveText("connected", { timeout: 10000 });
// The turn timer is running (header shows a turn + countdown).
await expect(page.locator("#timer")).toContainText("turn", { timeout: 10000 });
// The arena rendered with the player's marker.
await expect(page.locator(".cell.self")).toHaveCount(1, { timeout: 10000 });
// Domains panel shows the hidden-state redaction notice (visibility layer).
await expect(page.locator("#redactions")).toContainText("withheld", { timeout: 10000 });
// Edit a rune program: add a couple of runes and save.
await page.fill("#tok-a", "1");
await page.fill("#tok-b", "2");
await page.click("#btn-add");
await page.click("#btn-add");
await page.click("#btn-save");
// Observed diagnostics appear (names/counts only).
await expect(page.locator("#diag-body")).toContainText("known reads", { timeout: 10000 });
// Cast and wait for a resolved-turn log line.
await page.click("#btn-cast");
await expect(page.locator("#log")).toContainText("cast a rune program", { timeout: 15000 });
// Request and step the replay; the client verifies hashes vs. what it saw live.
await page.click("#btn-replay");
await expect(page.locator("#replay-status")).toContainText("replay loaded", { timeout: 10000 });
await page.click("#btn-replay-step");
// A matching hash line is shown (no MISMATCH).
await expect(page.locator(".hash-bad")).toHaveCount(0);
});
+170
View File
@@ -0,0 +1,170 @@
//! `web_tests` — a tiny, dependency-free WebSocket *client* used to drive the
//! real server over a real socket in integration tests. It performs the HTTP
//! upgrade, masks client frames (as RFC 6455 requires), and reads server
//! frames. This is the harness behind the Phase H web CI gates.
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use protocol::{ClientMessage, ServerMessage};
/// A blocking WebSocket client connection to the test server.
pub struct WsClient {
stream: TcpStream,
reader: BufReader<TcpStream>,
}
impl WsClient {
/// Connect, upgrade to WebSocket, and verify the handshake.
pub fn connect(addr: &str) -> io::Result<WsClient> {
let stream = TcpStream::connect(addr)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_nodelay(true).ok();
let mut reader = BufReader::new(stream.try_clone()?);
// A fixed client key keeps the handshake assertion deterministic.
let key = "dGhlIHNhbXBsZSBub25jZQ==";
let mut s = stream.try_clone()?;
let req = format!(
"GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\
Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n\
Sec-WebSocket-Version: 13\r\n\r\n"
);
s.write_all(req.as_bytes())?;
s.flush()?;
// Read the response head.
let mut status = String::new();
reader.read_line(&mut status)?;
if !status.contains("101") {
return Err(io::Error::new(io::ErrorKind::Other, format!("no upgrade: {status:?}")));
}
let mut saw_accept = false;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 || line.trim_end().is_empty() {
break;
}
if line.to_ascii_lowercase().starts_with("sec-websocket-accept:") {
let got = line.split(':').nth(1).unwrap_or("").trim();
// Expected accept for the canonical key above.
if got == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" {
saw_accept = true;
}
}
}
if !saw_accept {
return Err(io::Error::new(io::ErrorKind::Other, "bad Sec-WebSocket-Accept"));
}
Ok(WsClient { stream, reader })
}
/// Send a typed client message.
pub fn send(&mut self, msg: &ClientMessage) -> io::Result<()> {
self.send_raw_text(&msg.encode())
}
/// Send arbitrary text as a masked frame (used by fuzz tests).
pub fn send_raw_text(&mut self, text: &str) -> io::Result<()> {
self.write_masked(0x1, text.as_bytes())
}
/// Send arbitrary bytes as a masked binary frame (fuzz transport).
pub fn send_raw_bytes(&mut self, bytes: &[u8]) -> io::Result<()> {
self.write_masked(0x2, bytes)
}
fn write_masked(&mut self, opcode: u8, payload: &[u8]) -> io::Result<()> {
let mask = [0x12u8, 0x34, 0x56, 0x78];
let mut frame = vec![0x80 | opcode];
let len = payload.len();
if len < 126 {
frame.push(0x80 | len as u8);
} else if len < 65536 {
frame.push(0x80 | 126);
frame.extend_from_slice(&(len as u16).to_be_bytes());
} else {
frame.push(0x80 | 127);
frame.extend_from_slice(&(len as u64).to_be_bytes());
}
frame.extend_from_slice(&mask);
for (i, &b) in payload.iter().enumerate() {
frame.push(b ^ mask[i % 4]);
}
self.stream.write_all(&frame)?;
self.stream.flush()
}
/// Read one server text frame and decode it. Skips control frames.
pub fn recv(&mut self) -> io::Result<ServerMessage> {
let text = self.recv_text()?;
ServerMessage::decode(&text)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}")))
}
/// Read one server text frame (raw).
pub fn recv_text(&mut self) -> io::Result<String> {
loop {
let mut hdr = [0u8; 2];
self.reader.read_exact(&mut hdr)?;
let opcode = hdr[0] & 0x0f;
let masked = hdr[1] & 0x80 != 0;
let len7 = (hdr[1] & 0x7f) as usize;
let len = match len7 {
126 => {
let mut b = [0u8; 2];
self.reader.read_exact(&mut b)?;
u16::from_be_bytes(b) as usize
}
127 => {
let mut b = [0u8; 8];
self.reader.read_exact(&mut b)?;
u64::from_be_bytes(b) as usize
}
n => n,
};
// Server frames are not masked, but tolerate it.
let mask = if masked {
let mut m = [0u8; 4];
self.reader.read_exact(&mut m)?;
Some(m)
} else {
None
};
let mut payload = vec![0u8; len];
self.reader.read_exact(&mut payload)?;
if let Some(m) = mask {
for (i, b) in payload.iter_mut().enumerate() {
*b ^= m[i % 4];
}
}
match opcode {
0x1 | 0x2 => return Ok(String::from_utf8_lossy(&payload).into_owned()),
0x8 => return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "closed")),
_ => continue, // ping/pong/continuation
}
}
}
/// Receive until a predicate matches, returning that message. Bounded so a
/// test never hangs.
pub fn recv_until<F: Fn(&ServerMessage) -> bool>(&mut self, pred: F) -> io::Result<ServerMessage> {
for _ in 0..256 {
let m = self.recv()?;
if pred(&m) {
return Ok(m);
}
}
Err(io::Error::new(io::ErrorKind::Other, "predicate never matched"))
}
}
/// Start a fresh server instance on an ephemeral port for a test, returning the
/// address string. Each call binds a new port so tests are isolated.
pub fn spawn_test_server(turn_ms: u64) -> String {
let addr = server::serve("127.0.0.1:0", server::Config { turn_ms })
.expect("bind test server");
format!("127.0.0.1:{}", addr.port())
}
+64
View File
@@ -0,0 +1,64 @@
//! Phase H gate: replay determinism. The web CI minimum is "1,000 simulated
//! matches" with "0 replay hash mismatches". A match is a pure function of its
//! seed, roster, and ordered inputs, so re-running the recorded inputs must
//! reproduce the final hash exactly.
use game_runtime::{replay, run_scripted, solo_roster, duel_roster};
use protocol::Action;
use world_model::Rng;
/// Build a varied but deterministic script for a given seed.
fn script(seed: u64) -> Vec<Vec<(u32, Action)>> {
let mut rng = Rng::derive(seed, "script");
let mut turns = Vec::new();
for _ in 0..6 {
let mut subs = Vec::new();
for pid in 1..=2u32 {
let a = match rng.below(5) {
0 => Action::Move { dx: rng.range_i64(-1, 1) as i32, dy: rng.range_i64(-1, 1) as i32 },
1 => Action::Cast,
2 => Action::Attack { target: if pid == 1 { 2 } else { 1 } },
3 => Action::Inspect { target: if pid == 1 { 2 } else { 1 } },
_ => Action::Wait,
};
subs.push((pid, a));
}
turns.push(subs);
}
turns
}
#[test]
fn one_thousand_matches_replay_with_zero_drift() {
let mut mismatches = 0u32;
for seed in 0..1000u64 {
let roster = if seed % 2 == 0 {
solo_roster("p")
} else {
duel_roster("a", "b")
};
let scripts = script(seed);
let (m, log) = run_scripted(seed, &roster, &scripts);
let again = replay(seed, &roster, &log.turns);
if m.replay.final_hash != again.final_hash {
mismatches += 1;
}
// Per-turn hashes must also agree.
for (a, b) in m.replay.turns.iter().zip(again.turns.iter()) {
if a.turn_hash != b.turn_hash {
mismatches += 1;
}
}
}
assert_eq!(mismatches, 0, "replay hash mismatches across 1000 matches");
}
#[test]
fn re_executing_same_seed_twice_is_identical() {
for seed in [1u64, 7, 99, 12345, 0xdead_beef] {
let roster = solo_roster("p");
let (a, _) = run_scripted(seed, &roster, &script(seed));
let (b, _) = run_scripted(seed, &roster, &script(seed));
assert_eq!(a.replay.final_hash, b.replay.final_hash, "seed {seed}");
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Phase H gate: end-to-end matches over the real server, real sockets, real
//! protocol. The web CI minimum names "100 browser E2E matches"; a headless
//! browser is not available in this CI, so this drives the full
//! HTTP+WebSocket+protocol+runtime path *headlessly* (it is protocol-level
//! E2E, not a rendered browser — the Playwright harness under `e2e/` covers the
//! rendered browser when Node is present). It asserts: a player can join, the
//! turn timer drives resolution, casts resolve through the runtime, results
//! return as filtered observations, and the recorded replay reproduces the live
//! per-turn hashes (0 replay hash mismatches).
use protocol::{Action, ClientMessage, MatchId, RuneTokenWire, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
struct Played {
match_id: MatchId,
live_hashes: Vec<(u64, String)>,
replay_hashes: Vec<(u64, String)>,
final_hash: String,
saw_filtered_snapshot: bool,
}
/// Play a solo match for `n_turns`, then pull the recorded replay back.
fn play_solo(addr: &str, n_turns: u64, program: &[RuneTokenWire]) -> std::io::Result<Played> {
let mut c = WsClient::connect(addr)?;
c.send(&ClientMessage::JoinMatch { name: "e2e".into(), match_id: None })?;
let (match_id, _player) = match c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. }))? {
ServerMessage::MatchState { match_id, player_id, snapshot, .. } => {
// Filtered snapshot sanity: hidden lanes are redacted.
assert!(snapshot.hidden_state_redactions > 0, "no redactions in snapshot");
(match_id, player_id)
}
_ => unreachable!(),
};
if !program.is_empty() {
c.send(&ClientMessage::EditRuneProgram { tokens: program.to_vec() })?;
}
let mut live_hashes = Vec::new();
let mut saw_filtered = false;
let mut live_turn = 0u64;
for _ in 0..n_turns {
c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast })?;
let expected = live_turn + 1;
let resolved = c.recv_until(|m| {
matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == expected)
})?;
if let ServerMessage::TurnResolved { turn, runtime_hash, snapshot, .. } = resolved {
// Results come back as filtered observations.
if snapshot.hidden_state_redactions > 0 {
saw_filtered = true;
}
live_hashes.push((turn, runtime_hash));
live_turn = turn;
}
}
// Pull the replay back and collect its per-turn hashes.
c.send(&ClientMessage::RequestReplay { match_id })?;
let mut replay_hashes = Vec::new();
let mut final_hash = String::new();
let mut got = 0u32;
let mut total = 1u32;
while got < total {
match c.recv_until(|m| matches!(m, ServerMessage::ReplayChunk { .. }))? {
ServerMessage::ReplayChunk { total: t, turns, final_hash: fh, .. } => {
total = t.max(1);
got += 1;
final_hash = fh;
for rt in turns {
replay_hashes.push((rt.turn, rt.runtime_hash));
}
}
_ => break,
}
}
Ok(Played { match_id, live_hashes, replay_hashes, final_hash, saw_filtered_snapshot: saw_filtered })
}
#[test]
fn single_match_full_playthrough() {
let addr = spawn_test_server(15);
let program = vec![
RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 5 },
RuneTokenWire { op: 5, a: 2, b: 4, c: 1, imm: -3 },
RuneTokenWire { op: 8, a: 0, b: 6, c: 2, imm: 11 },
];
let played = play_solo(&addr, 4, &program).expect("play");
assert_ne!(played.match_id.0, 0, "a match id was assigned");
assert_eq!(played.live_hashes.len(), 4, "all turns resolved");
assert!(played.saw_filtered_snapshot, "results returned as filtered observations");
assert!(!played.final_hash.is_empty());
// Replay must reproduce every live per-turn hash (0 mismatches).
for (turn, live) in &played.live_hashes {
let found = played.replay_hashes.iter().find(|(t, _)| t == turn);
assert!(found.is_some(), "replay missing turn {turn}");
assert_eq!(&found.unwrap().1, live, "replay hash mismatch at turn {turn}");
}
}
#[test]
fn inspection_returns_observed_diagnostics_only() {
let addr = spawn_test_server(50);
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "inspector".into(), match_id: None }).unwrap();
c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Inspect the dummy (entity id 2).
c.send(&ClientMessage::InspectTarget { target: 2 }).unwrap();
let obs = c.recv_until(|m| matches!(m, ServerMessage::ObservationResult { .. })).unwrap();
if let ServerMessage::ObservationResult { diagnostics, .. } = obs {
// Diagnostics are observed names/counts — never guaranteed truth.
// unknown_listeners is a count; reads/writes are domain names.
for s in diagnostics.known_reads.iter().chain(diagnostics.known_writes.iter()) {
assert!(s.chars().any(|ch| ch.is_alphabetic()));
}
} else {
panic!("expected ObservationResult");
}
}
#[test]
fn one_hundred_e2e_matches_zero_hash_mismatches() {
// Single short-turn server; 100 independent matches over real sockets.
let addr = spawn_test_server(8);
let program = vec![
RuneTokenWire { op: 1, a: 3, b: 1, c: 2, imm: 7 },
RuneTokenWire { op: 6, a: 0, b: 5, c: 3, imm: -9 },
];
let mut mismatches = 0u32;
let mut completed = 0u32;
for _ in 0..100 {
let played = play_solo(&addr, 3, &program).expect("e2e match");
completed += 1;
for (turn, live) in &played.live_hashes {
match played.replay_hashes.iter().find(|(t, _)| t == turn) {
Some((_, rh)) if rh == live => {}
_ => mismatches += 1,
}
}
}
assert_eq!(completed, 100, "all 100 matches completed");
assert_eq!(mismatches, 0, "replay hash mismatches across 100 e2e matches");
}
+84
View File
@@ -0,0 +1,84 @@
//! Phase H gate: protocol fuzzing. The web CI minimum is "10,000 protocol fuzz
//! cases" with "0 server panics". Decoding is total, so every byte string must
//! yield `Ok` or `Err` — never an unwind. A sample is also fired at a live
//! server to prove a malformed packet cannot bring it down.
use protocol::{json, ClientMessage};
use web_tests::{spawn_test_server, WsClient};
use world_model::Rng;
/// Generate a pseudo-random byte string from a seed, with a bias toward
/// JSON-ish characters so the parser's deeper paths are exercised.
fn fuzz_bytes(seed: u64) -> Vec<u8> {
let mut rng = Rng::derive(seed, "fuzz");
let alphabet = b"{}[]\":,0123456789tfnuelavabcdef.- \\/\n\t";
let len = rng.below(80);
(0..len)
.map(|_| {
if rng.chance(0.85) {
alphabet[rng.below(alphabet.len())]
} else {
rng.next_u64() as u8
}
})
.collect()
}
#[test]
fn ten_thousand_fuzz_cases_never_panic() {
let mut ok = 0u64;
let mut err = 0u64;
for seed in 0..10_000u64 {
let bytes = fuzz_bytes(seed);
let text = String::from_utf8_lossy(&bytes);
// Raw JSON parse must be total.
let _ = json::parse(&text);
// Full client-message decode must be total.
match ClientMessage::decode(&text) {
Ok(_) => ok += 1,
Err(_) => err += 1,
}
}
// The point is the absence of a panic; both counters are just evidence the
// loop ran to completion.
assert_eq!(ok + err, 10_000);
}
#[test]
fn structured_but_invalid_messages_are_rejected_not_panicked() {
let cases = [
"{}",
"{\"v\":1}",
"{\"v\":2,\"type\":\"Ping\",\"body\":{}}", // wrong version
"{\"v\":1,\"type\":\"Nope\",\"body\":{}}", // unknown type
"{\"v\":1,\"type\":\"SubmitTurn\",\"body\":{}}", // missing fields
"{\"v\":1,\"type\":\"JoinMatch\",\"body\":{\"name\":5}}", // wrong type
];
for c in cases {
assert!(ClientMessage::decode(c).is_err(), "should reject: {c}");
}
}
#[test]
fn live_server_survives_malformed_packets() {
let addr = spawn_test_server(30);
let mut c = WsClient::connect(&addr).expect("connect");
// Fire a burst of garbage frames (kept modest so the join reply is not
// starved behind a flood of rejection reports; decode breadth is covered by
// the 10k case test above).
for seed in 0..40u64 {
let bytes = fuzz_bytes(seed);
let _ = c.send_raw_bytes(&bytes);
}
// Also send raw garbage text.
for s in ["", "{", "garbage", "{\"v\":1,\"type\":\"X\",\"body\":1}"] {
let _ = c.send_raw_text(s);
}
// The server must still be alive and respond to a valid join.
c.send(&ClientMessage::JoinMatch { name: "after-fuzz".into(), match_id: None })
.expect("send join");
let m = c
.recv_until(|m| matches!(m, protocol::ServerMessage::MatchState { .. }))
.expect("server still serving after fuzz");
assert!(matches!(m, protocol::ServerMessage::MatchState { .. }));
}
+94
View File
@@ -0,0 +1,94 @@
//! Phase H gates: disconnect/reconnect and timer edges. A dropped connection
//! must not corrupt a match, and a late or wrong-turn submission must be
//! rejected deterministically.
use protocol::{Action, ClientMessage, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
#[test]
fn wrong_turn_submission_is_rejected_deterministically() {
let addr = spawn_test_server(2000); // long turn so we control timing
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "timer".into(), match_id: None }).unwrap();
c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Submit for a turn that is not live.
c.send(&ClientMessage::SubmitTurn { turn: 999, action: Action::Wait }).unwrap();
let report = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap();
match report {
ServerMessage::ValidationReport { accepted, detail, .. } => {
assert!(!accepted, "wrong-turn submission should be rejected");
assert!(detail.contains("wrong turn"), "reason: {detail}");
}
_ => unreachable!(),
}
// A correct, in-time submission is accepted.
c.send(&ClientMessage::SubmitTurn { turn: 0, action: Action::Wait }).unwrap();
let ok = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap();
match ok {
ServerMessage::ValidationReport { accepted, .. } => assert!(accepted),
_ => unreachable!(),
}
}
#[test]
fn disconnect_does_not_corrupt_an_ongoing_duel() {
let addr = spawn_test_server(10);
// Two players share a duel match by id.
let mut a = WsClient::connect(&addr).unwrap();
a.send(&ClientMessage::JoinMatch { name: "a".into(), match_id: Some(protocol::MatchId(7)) })
.unwrap();
let _ = a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
let mut b = WsClient::connect(&addr).unwrap();
b.send(&ClientMessage::JoinMatch { name: "b".into(), match_id: Some(protocol::MatchId(7)) })
.unwrap();
let _ = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// Player A plays a couple of turns.
let mut live = 0u64;
for _ in 0..2 {
a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap();
let r = a
.recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1))
.unwrap();
if let ServerMessage::TurnResolved { turn, .. } = r {
live = turn;
}
}
// Player B disconnects abruptly.
drop(b);
// The match continues for A without corruption: more turns still resolve.
for _ in 0..2 {
a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap();
let r = a
.recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1))
.unwrap();
if let ServerMessage::TurnResolved { turn, runtime_hash, .. } = r {
assert!(!runtime_hash.is_empty());
live = turn;
}
}
assert!(live >= 4, "match advanced past a mid-match disconnect");
}
#[test]
fn reconnect_into_open_duel_slot_succeeds() {
let addr = spawn_test_server(50);
let mid = protocol::MatchId(21);
let mut a = WsClient::connect(&addr).unwrap();
a.send(&ClientMessage::JoinMatch { name: "host".into(), match_id: Some(mid) }).unwrap();
a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
// A second player takes the open slot.
let mut b = WsClient::connect(&addr).unwrap();
b.send(&ClientMessage::JoinMatch { name: "guest".into(), match_id: Some(mid) }).unwrap();
let ms = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap();
if let ServerMessage::MatchState { player_id, .. } = ms {
assert_eq!(player_id.0, 2, "second human should take slot 2");
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Phase H gate: "0 hidden-state leaks". The hidden ground truth must never
//! cross the wire. These tests check the invariant both in-process (the
//! authoritative match) and over the live socket (the serialized bytes).
use game_runtime::{solo_roster, Match};
use protocol::{Action, ClientMessage, MatchId, ServerMessage};
use web_tests::{spawn_test_server, WsClient};
use world_model::{HIDDEN_LANES, LANES, NUM_DOMAINS};
#[test]
fn visible_snapshot_redacts_all_hidden_lanes() {
let mut m = Match::new(MatchId(1), 4242, solo_roster("dev"));
// Mask half of the observed lanes so redaction is non-trivial too.
for d in 0..NUM_DOMAINS {
for l in 0..LANES {
if (d + l) % 2 == 0 {
m.world.observation_state.visible[d][l] = false;
}
}
}
// The generator may already mask lanes; count actual non-visible lanes.
let masked: u32 = (0..NUM_DOMAINS)
.flat_map(|d| (0..LANES).map(move |l| (d, l)))
.filter(|&(d, l)| !m.world.observation_state.visible[d][l])
.count() as u32;
m.set_program(1, vec![protocol::RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 1 }]);
m.resolve_turn(&[(1, Action::Cast)]);
let snap = m.visible_for(1);
// Every masked observed lane is None.
for vd in &snap.observed_domains {
assert_eq!(vd.observed.len(), LANES);
for (l, o) in vd.observed.iter().enumerate() {
let visible = m.world.observation_state.visible[vd.index as usize][l];
assert_eq!(o.is_some(), visible, "lane visibility/value mismatch");
}
}
// Redaction count accounts for every masked observed lane and every hidden
// lane in the world.
let expected = masked + (NUM_DOMAINS * HIDDEN_LANES) as u32;
assert_eq!(snap.hidden_state_redactions, expected);
// The serialized snapshot must not carry a "hidden" key at all.
let wire = snap.to_json().to_compact();
assert!(!wire.contains("\"hidden\""), "serialized snapshot mentions hidden state");
}
#[test]
fn no_server_message_over_the_wire_carries_hidden_keys() {
let addr = spawn_test_server(12);
let mut c = WsClient::connect(&addr).unwrap();
c.send(&ClientMessage::JoinMatch { name: "leak-check".into(), match_id: None }).unwrap();
let mut checked = 0;
// Drive a few turns and scan every raw frame the server emits.
let mut live_turn = 0u64;
for _ in 0..6 {
c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast }).unwrap();
// Read several frames; scan each.
for _ in 0..4 {
let raw = match c.recv_text() {
Ok(r) => r,
Err(_) => break,
};
checked += 1;
assert!(!raw.contains("\"hidden\""), "raw frame leaked hidden key: {raw}");
// Track turn progression from resolved frames.
if let Ok(ServerMessage::TurnResolved { turn, .. }) = ServerMessage::decode(&raw) {
live_turn = turn;
}
}
}
assert!(checked > 0, "no frames scanned");
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "world_model"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
path = "src/lib.rs"
+107
View File
@@ -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<ExecutionContext> {
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
}
+227
View File
@@ -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<i64>,
pub hidden: Vec<i64>,
}
/// What a domain currently allows to be written.
#[derive(Clone, Debug)]
pub struct WriteSurface {
pub domain: DomainId,
pub writable_observed: Vec<usize>,
pub writable_hidden: Vec<usize>,
}
/// 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<Box<dyn PerturbationAxis>>;
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<Box<dyn PerturbationAxis>> {
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<Box<dyn PerturbationAxis>> = 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(),
}
}
}
+74
View File
@@ -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);
}
}
+152
View File
@@ -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()
}
}
+214
View File
@@ -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
}
}
+306
View File
@@ -0,0 +1,306 @@
//! 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<ScheduledEffect>,
}
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<DomainState>,
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<i64> {
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<i64> {
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<DomainDelta>,
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<DomainId> {
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()
}
/// Serialize the full delta to one line of whitespace-separated integers.
/// Round-trips [`hash`] exactly (used as attestation evidence).
pub fn serialize(&self) -> String {
let mut t: Vec<String> = vec!["delta-v1".to_string(), self.turn_advance.to_string()];
t.push(self.domain_deltas.len().to_string());
for d in &self.domain_deltas {
t.push(d.domain.0.to_string());
for &v in &d.observed {
t.push(v.to_string());
}
for &v in &d.hidden {
t.push(v.to_string());
}
}
t.join(" ")
}
/// Reconstruct a delta from [`serialize`]. Total: `None` on malformed input.
pub fn deserialize(s: &str) -> Option<WorldDelta> {
let mut it = s.split_whitespace();
if it.next()? != "delta-v1" {
return None;
}
let turn_advance: u64 = it.next()?.parse().ok()?;
let n: usize = it.next()?.parse().ok()?;
let mut domain_deltas = Vec::with_capacity(n);
for _ in 0..n {
let domain = DomainId(it.next()?.parse().ok()?);
let mut observed = [0i64; LANES];
for v in observed.iter_mut() {
*v = it.next()?.parse().ok()?;
}
let mut hidden = [0i64; HIDDEN_LANES];
for v in hidden.iter_mut() {
*v = it.next()?.parse().ok()?;
}
domain_deltas.push(DomainDelta { domain, observed, hidden });
}
Some(WorldDelta { domain_deltas, turn_advance })
}
}
+332
View File
@@ -0,0 +1,332 @@
ChatGPT3:26 PM
Rust Phase 0/1 Implementor Specification
Objective
Build the testing framework first, then the runtime.
No spell content is accepted until CI proves the system resists collapse into:
single score
single resource
single effect axis
single executor behavior
single rune behavior
single hidden damage formula
decorative world state
Compliance Model Anti-Collapse
The framework must also resist collapse of the compliance model.
The implementor may not replace a specification obligation with a representative approximation that merely resembles the obligation.
Every acceptance requirement must have all of:
1. A measured artifact.
2. A provenance chain from the measured artifact to the run that produced it.
3. A merge-blocking enforcement point.
4. A failure condition that blocks acceptance if the artifact or provenance is absent.
No requirement may be satisfied by:
sample
summary
approximation
representative subset
default profile
proxy metric
regenerated artifact
local-only executable
documentation claim
unless this specification explicitly defines that weaker substitute as acceptable for that requirement.
The compliance path must be:
Specification requirement
Mandatory enforcement mechanism
Merge blocked if absent
The compliance path must not be:
Specification requirement
Representative approximation
Evidence of approximation
Examples of forbidden substitutions:
100% reference/runtime comparison may not be replaced by comparison of base executions only.
Required artifacts may not be replaced by partial artifacts.
Full trace information may not be replaced by a summarized proxy unless that proxy is explicitly named as the acceptance artifact.
Persisted expectations may not be replaced by expectations regenerated in the same run.
Merge-blocking enforcement may not be replaced by a manually runnable local binary.
If an implementation uses a weaker substitute, the correct result is not partial credit. The correct result is failure of the corresponding acceptance gate.
Hard CI Gates
Minimum per full CI run:
Generated worlds: 50,000
Generated rune programs: 250,000
Executions: 1,000,000
Perturbations per execution: 10
Semantic mutants per run: 500
Replay corpus cases: 10,000 minimum
Reference/runtime comparison: 100% of executions
Fast CI may run 10%, but merge-blocking CI must run full gates.
Repository Layout
crates/
world_model/
rune_ir/
trace_model/
generators/
reference_runtime/
runtime_under_test/
collapse_analysis/
semantic_mutation/
replay_corpus/
ci_reports/
Implementation order is mandatory:
1. world_model
2. trace_model
3. generators
4. collapse_analysis
5. semantic_mutation
6. replay_corpus
7. reference_runtime
8. runtime_under_test
The optimized runtime may not begin before steps 17 pass CI.
World Model
pub struct WorldSnapshot {
pub id: WorldId,
pub turn: u64,
pub domains: Vec<DomainState>,
pub causal_state: CausalState,
pub observation_state: ObservationState,
pub execution_state: ExecutionState,
pub time_state: TimeState,
pub seed: u64,
}
Minimum domain count:
8 independent world domains
Each domain must expose:
pub trait WorldDomain {
fn domain_id(&self) -> DomainId;
fn read_surface(&self) -> ReadSurface;
fn write_surface(&self) -> WriteSurface;
fn perturbation_axes(&self) -> Vec<PerturbationAxis>;
fn fingerprint(&self) -> DomainFingerprint;
}
Domain acceptance:
Each domain appears in ≥ 35% of traces.
Each domain influences execution in ≥ 20% of corpus.
Each domain is mutated by execution in ≥ 20% of corpus.
Removing any domain reduces corpus behavioral diversity by ≥ 10%.
Merging any two domains loses ≥ 8% predictive accuracy.
Rune Program Model
pub struct RuneProgram {
pub id: ProgramId,
pub tokens: Vec<RuneToken>,
pub seed: u64,
}
No rune stream may be rejected as the primary safety path.
Execution always returns:
pub struct ResolutionResult {
pub delta: WorldDelta,
pub trace: ExecutionTrace,
pub faults: FaultLog,
pub replay: ReplayRecord,
}
Engine crashes, panics, undefined Rust behavior, or unlogged failures fail CI.
Trace Model
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,
}
Trace gates:
Median causal edges per execution: ≥ 24
95% of executions causal rank: ≥ 6
Median touched domains per execution: ≥ 4
95% of executions touched domains: ≥ 3
Behavior fingerprint collision rate: < 5%
Largest behavior cluster: < 2% of corpus
Generator Requirements
Generators must reject flat cases.
pub struct GeneratedCase {
pub world: WorldSnapshot,
pub program: RuneProgram,
pub contexts: Vec<ExecutionContext>,
pub contract: SemanticContract,
pub perturbations: Vec<PerturbedCase>,
}
Generated case gates:
estimated_causal_rank ≥ 6
domain_entropy ≥ configured minimum
perturbation_axes ≥ 10
executor_count ≥ 3
future_dependence present within 3 turns
nonzero hidden/observed state divergence
nonuniform domain fingerprints
Semantic Contract
pub struct SemanticContract {
pub min_causal_rank: usize,
pub min_domain_participation: usize,
pub min_future_sensitivity: f64,
pub min_context_divergence: f64,
pub max_compressibility: f64,
}
Default thresholds:
min_causal_rank: 6
min_domain_participation: 4
min_future_sensitivity: 0.50
min_context_divergence: 0.40
max_compressibility: 0.70
A case passes only if measured trace behavior satisfies its contract.
Metamorphic Testing
For every base execution, produce 10 perturbations.
Perturbations are generated from domain surfaces, not a fixed list.
pub trait PerturbationAxis {
fn apply(&self, world: &WorldSnapshot) -> WorldSnapshot;
fn expected_trace_difference(&self) -> TraceDifferenceExpectation;
}
Metamorphic gates:
≥ 90% perturbations alter trace
≥ 75% perturbations alter world delta
≥ 50% perturbations alter state within 3 future turns
≤ 5% perturbations may be observationally neutral without explanation
Collapse Analysis
The framework must attempt compression attacks.
pub trait CollapseAttack {
fn compress(&self, corpus: &BehaviorCorpus) -> CompressedModel;
fn report(&self) -> CollapseReport;
}
Required attack families:
domain removal
domain merging
constant folding
causal edge deletion
state aliasing
latent factor modeling
behavior clustering
surrogate prediction
temporal flattening
observation flattening
executor identity erasure
Collapse gates:
Best 1-factor model predicts < 40%
Best 2-factor model predicts < 55%
Best 4-factor model predicts < 70%
No single domain explains > 30% outcome variance
No pair of domains explains > 55%
Compressed model loses ≥ 35% trace information
If a smaller model predicts above these thresholds, fail CI.
Semantic Mutation
Mutants are generated structurally from the model.
pub trait SemanticMutator {
fn mutate(&self, runtime: RuntimeArtifact) -> RuntimeArtifact;
fn expected_detection_reason(&self) -> DetectionClass;
}
Mutation gates:
500 semantic mutants minimum
0 surviving mutants
Every mutant must fail at least one named acceptance gate
Survivor report blocks merge
A mutant surviving means the tests are invalid, not that the mutant is acceptable.
Reference Runtime
The reference runtime is the executable spec.
pub trait Runtime {
fn resolve(&self, input: ResolutionInput) -> ResolutionResult;
}
Every execution runs:
let expected = reference.resolve(input.clone());
let actual = runtime_under_test.resolve(input);
assert_eq!(canonical(expected), canonical(actual));
Canonical comparison includes:
world delta
trace
faults
replay hash
future-state hash over 3 turns
Replay Corpus
Every failure becomes permanent.
pub struct ReplayCase {
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,
}
Replay gates:
10,000 cases minimum
100% deterministic replay
0 hash drift unless migration explicitly updates corpus
Reports Required Per CI Run
Generate machine-readable JSON and human-readable markdown:
domain_participation_report
causal_rank_report
compression_resistance_report
metamorphic_response_report
mutation_survivor_report
runtime_equivalence_report
replay_report
coverage_report
Merge blocked unless all reports pass.
Absolute Rejection Conditions
Reject if:
A smaller model predicts behavior above thresholds.
Any domain is decorative.
Any domain is read-only or write-only across corpus.
Most programs share the same behavior fingerprint.
Most outcomes reduce to one numeric axis.
Reference/runtime differ.
Replay is nondeterministic.
Any semantic mutant survives.
Trace evidence cannot explain causality.
Phase 0/1 Completion
Phase 0/1 is complete only when:
the adversarial framework exists first
the reference runtime passes it
the optimized runtime matches the reference runtime
collapse attacks fail to simplify the universe
mutation tests kill every generated simplification
generated programs produce diverse, causal, replayable behavior
No spell list. No templates. No cosmetic runes. The deliverable is a Rust engine whose tests make a fake universe fail.
+262
View File
@@ -0,0 +1,262 @@
Web Game Implementation Plan
Prime Directive
Build the browser game around the already-defined Rust simulation/testing system.
The web game must be:
Browser-first
Server-authoritative
Replayable
Deterministic
Test-gated
Playable before pretty
No web feature may bypass the Rust runtime contract.
Architecture
Rust simulation core
Authoritative server
WebSocket protocol
Browser client
UI / arena / rune editor
Browser never decides truth.
Browser only sends:
intent
movement choice
rune program
library slot selection
inspection request
Server returns:
world snapshot
visible observations
turn result
trace excerpts
replay hash
legal player-facing diagnostics
Crates
crates/
world_model
rune_ir
trace_model
reference_runtime
game_runtime
replay_corpus
protocol
server
web_client
web_assets
web_tests
Phase A — Protocol First
Define all client/server messages before UI.
ClientMessage
JoinMatch
SubmitTurn
EditRuneProgram
InspectTarget
RequestReplay
Ping
ServerMessage
MatchState
TurnStarted
TurnResolved
ObservationResult
ValidationReport
ReplayChunk
ErrorEvent
Rules:
All messages versioned
All messages serializable
All messages replay-testable
All server outputs hashable
No client-only game truth
Phase B — Authoritative Match Server
Server owns:
match state
turn timer
submitted actions
rune execution
visibility filtering
knowledge filtering
replay recording
disconnect handling
Server loop:
Create match
Send visible snapshot
Start turn timer
Collect actions
Resolve through runtime
Persist replay event
Send filtered results
Advance turn
Hard gates:
same inputs produce same replay hash
late input rejected deterministically
disconnect does not corrupt match
invalid client packet cannot panic server
client cannot mutate hidden state
Phase C — Browser Client Shell
Client responsibilities:
connect
authenticate anonymously/dev
join match
render visible arena
show entities
show turn timer
edit rune program
submit action
display results
display observations
play replay events
Do not implement complex art yet.
Use debug visuals:
grid
tokens
panels
logs
timers
entity markers
domain indicators
Phase D — Rune Editor
The rune editor is the core UI.
Required:
keyboard-bound rune input
token grid / sequence view
library slot panel
syntax-neutral execution preview
visible cost/risk diagnostics
submission lock on timer expiry
Important:
The editor must not pretend to know full truth.
It can show observed diagnostics only.
Player-facing diagnostics should say:
known reads
known writes
observed risks
unknown listeners
previous outcomes
Not:
guaranteed damage
guaranteed success
full hidden state
Phase E — Arena Interaction
Each turn, player can:
move
inspect
cast rune program
use stick/basic attack
wait
All actions become server commands.
Client-side previews are advisory only.
Phase F — Visibility / Knowledge Layer
Server sends filtered state:
VisibleWorldSnapshot {
observed_domains,
observed_entities,
observed_environment,
known_history,
inferred_markers,
hidden_state_redactions,
}
Knowledge must be game state, not UI notes.
Client displays:
known
unknown
suspected
contradicted
newly observed
Phase G — Replay System
Every match produces:
initial seed
player inputs
turn boundaries
runtime hashes
visible outputs
trace excerpts
final hash
Browser replay consumes the same protocol stream.
CI gate:
recorded replay equals regenerated replay
browser replay event order matches server order
Phase H — Web Testing
Required test layers:
Rust protocol tests
server integration tests
browser protocol tests
Playwright end-to-end tests
replay determinism tests
fuzzed packet tests
disconnect/reconnect tests
timer edge tests
Minimum web CI gates:
1,000 simulated matches
10,000 protocol fuzz cases
100 browser E2E matches
0 server panics
0 replay hash mismatches
0 hidden-state leaks
Phase I — Vertical Slice
First playable slice:
2 players or 1 player + dummy opponent
small arena
turn timer
movement
inspection
basic attack
rune submission
multicast execution
visible consequences
replay viewer
No progression.
No accounts.
No cosmetics.
No marketplace.
No complex content.
Acceptance Criteria
Web phase is accepted only when:
A player can join a browser match.
A turn timer runs.
The player can inspect, move, attack, or cast.
Rune programs execute only on the server.
Results return as filtered observations.
Replay can reproduce the match.
Browser cannot alter hidden truth.
CI proves protocol, replay, visibility, and server authority.
Core rule:
The web game is just a playable window into the Rust universe.
It must not become a second simulation.