Compare commits
11
Commits
0305f683cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37c9dab0d | ||
|
|
a90e27ab63 | ||
|
|
f4c75fc8cf | ||
|
|
11162ae448 | ||
|
|
bea076df43 | ||
|
|
1e50c80627 | ||
|
|
93c78d9c76 | ||
|
|
9d9d5ce41c | ||
|
|
659544f0b2 | ||
|
|
2fe989bcb3 | ||
|
|
39386a81c9 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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 |
|
||||
|
||||
@@ -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" }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[package]
|
||||
name = "web_assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "web_client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
web_assets = { path = "../web_assets" }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "world_model"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Execution contexts (executors). Different executors interpret the same
|
||||
//! rune stream differently, which produces executor divergence. The spec
|
||||
//! requires at least 3 distinct executors per case.
|
||||
|
||||
use crate::primitives::{Hasher, Rng};
|
||||
|
||||
/// Distinct executor interpretation styles. Each one mixes rune operands
|
||||
/// differently, so the same program produces different traces under each.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum ExecutorKind {
|
||||
/// Aggressive forward mixing; favors multiplicative coupling.
|
||||
Surge,
|
||||
/// Lateral mixing; favors xor/rotate coupling across domains.
|
||||
Weave,
|
||||
/// Conservative mixing; clamps and favors additive coupling.
|
||||
Anchor,
|
||||
/// Phase-shifting; reorders operand roles.
|
||||
Phase,
|
||||
}
|
||||
|
||||
pub const ALL_EXECUTOR_KINDS: [ExecutorKind; 4] = [
|
||||
ExecutorKind::Surge,
|
||||
ExecutorKind::Weave,
|
||||
ExecutorKind::Anchor,
|
||||
ExecutorKind::Phase,
|
||||
];
|
||||
|
||||
impl ExecutorKind {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ExecutorKind::Surge => "surge",
|
||||
ExecutorKind::Weave => "weave",
|
||||
ExecutorKind::Anchor => "anchor",
|
||||
ExecutorKind::Phase => "phase",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
ExecutorKind::Surge => 0,
|
||||
ExecutorKind::Weave => 1,
|
||||
ExecutorKind::Anchor => 2,
|
||||
ExecutorKind::Phase => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn salt(self) -> u64 {
|
||||
match self {
|
||||
ExecutorKind::Surge => 0x51_75_72_67_65_00_00_01,
|
||||
ExecutorKind::Weave => 0x57_65_61_76_65_00_00_02,
|
||||
ExecutorKind::Anchor => 0x41_6e_63_68_72_00_00_03,
|
||||
ExecutorKind::Phase => 0x50_68_61_73_65_00_00_04,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters that modulate rune interpretation for one executor.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct ExecutorProfile {
|
||||
pub kind: ExecutorKind,
|
||||
pub bias: i64,
|
||||
pub rotate: u32,
|
||||
pub branch_threshold: i64,
|
||||
}
|
||||
|
||||
/// One executor.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct ExecutionContext {
|
||||
pub executor_id: u32,
|
||||
pub profile: ExecutorProfile,
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
pub fn hash_into(&self, h: &mut Hasher) {
|
||||
h.write_tag("executor");
|
||||
h.write_u64(self.executor_id as u64);
|
||||
h.write_u8(self.profile.kind.index() as u8);
|
||||
h.write_i64(self.profile.bias);
|
||||
h.write_u64(self.profile.rotate as u64);
|
||||
h.write_i64(self.profile.branch_threshold);
|
||||
}
|
||||
|
||||
pub fn salt(&self) -> u64 {
|
||||
self.profile.kind.salt() ^ (self.executor_id as u64).wrapping_mul(0x9e3779b97f4a7c15)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `count` distinct executors deterministically from a seed. Always
|
||||
/// produces at least 3 with distinct kinds.
|
||||
pub fn standard_executors(seed: u64, count: usize) -> Vec<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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 1–7 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user