Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a90e27ab63 | ||
|
|
f4c75fc8cf | ||
|
|
11162ae448 | ||
|
|
bea076df43 | ||
|
|
1e50c80627 | ||
|
|
93c78d9c76 | ||
|
|
9d9d5ce41c | ||
|
|
659544f0b2 |
@@ -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
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
/target
|
/target
|
||||||
/ci_out
|
/ci_out
|
||||||
|
/ci_out_merge
|
||||||
Cargo.lock
|
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
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ members = [
|
|||||||
"crates/semantic_mutation",
|
"crates/semantic_mutation",
|
||||||
"crates/replay_corpus",
|
"crates/replay_corpus",
|
||||||
"crates/ci_reports",
|
"crates/ci_reports",
|
||||||
|
"crates/attestation",
|
||||||
|
"crates/protocol",
|
||||||
|
"crates/game_runtime",
|
||||||
|
"crates/web_assets",
|
||||||
|
"crates/web_client",
|
||||||
|
"crates/server",
|
||||||
|
"crates/web_tests",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -9,6 +9,43 @@ 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,
|
to let the universe collapse into a single score, resource, effect axis,
|
||||||
executor, rune, hidden formula, or decorative domain.
|
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
|
## Workspace layout
|
||||||
|
|
||||||
Built in the mandatory order from the spec:
|
Built in the mandatory order from the spec:
|
||||||
@@ -19,17 +56,18 @@ Built in the mandatory order from the spec:
|
|||||||
| – | `rune_ir` | Rune token / program model (no stream is ever rejected) |
|
| – | `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 |
|
| 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 |
|
| 3 | `generators` | Worlds, programs, executors, contracts, perturbations; rejects flat cases |
|
||||||
| 4 | `collapse_analysis` | The 11 compression attacks + collapse gates |
|
| 4 | `collapse_analysis` | The 11 compression attacks over real trace structure + collapse gates |
|
||||||
| 5 | `semantic_mutation` | Structurally generated mutant runtimes; proves every one is killed |
|
| 5 | `semantic_mutation` | Structurally generated mutant runtimes; proves every one fails its named gate |
|
||||||
| 6 | `replay_corpus` | Permanent, bit-exact replay cases |
|
| 6 | `replay_corpus` | Permanent, bit-exact replay cases persisted to `corpus/replay_corpus.tsv` |
|
||||||
| 7 | `reference_runtime` | The executable spec engine (`Runtime` trait, `resolve`) |
|
| 7 | `reference_runtime` | The executable spec engine (`Runtime` trait, `resolve`) |
|
||||||
| 8 | `runtime_under_test` | Config-driven engine, proven equivalent to the reference |
|
| 8 | `runtime_under_test` | An **independent** interpreter (`native`) proven equivalent to the reference |
|
||||||
| – | `ci_reports` | Orchestrator + `ci` binary; emits the 8 required reports |
|
| – | `ci_reports` | Orchestrator + `ci` binary; emits 8 gate reports + a provenance report |
|
||||||
|
|
||||||
The optimized runtime may not begin before steps 1–7 pass CI; until then the
|
The runtime under test does not call the reference engine. It re-derives the
|
||||||
runtime under test is the reference engine driven through the same config
|
canonical behavior from the spec in a different code organization, so 100%
|
||||||
surface, which is equivalent by construction. Mutation swaps in a *mutated*
|
agreement is *evidence* the spec is implemented correctly rather than a
|
||||||
config to prove the suite detects any divergence.
|
tautology. (`native_matches_reference_bit_for_bit` checks this over a 2000-seed
|
||||||
|
sweep.)
|
||||||
|
|
||||||
## The engine in one paragraph
|
## The engine in one paragraph
|
||||||
|
|
||||||
@@ -40,53 +78,149 @@ domains, mixes them through a nonlinear avalanche keyed by per-domain
|
|||||||
constants, the world coupling, and the executor's salt, then writes back —
|
constants, the world coupling, and the executor's salt, then writes back —
|
||||||
recording causal/read/write/information-flow/temporal edges as it goes.
|
recording causal/read/write/information-flow/temporal edges as it goes.
|
||||||
Scheduled effects and coupling diffusion propagate changes 3 turns into the
|
Scheduled effects and coupling diffusion propagate changes 3 turns into the
|
||||||
future. Because every output bit depends on all inputs, the universe is
|
future.
|
||||||
high-rank, incompressible, future-sensitive, and executor-divergent — exactly
|
|
||||||
the properties the gates demand.
|
|
||||||
|
|
||||||
## Running CI
|
## Running CI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test # unit tests for every crate
|
cargo test # unit tests + negative controls
|
||||||
cargo run --release -p ci_reports --bin ci # full gate run (fast scale)
|
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 `ci_out/` (8 JSON files + `ci_summary.md`). The binary
|
Reports are written to the output dir (8 gate reports + `provenance_report.json`
|
||||||
exits non-zero if any gate fails.
|
+ `compliance_report.json` + `ci_summary.md`). The binary exits non-zero if any
|
||||||
|
gate fails or any required artifact is absent.
|
||||||
|
|
||||||
### Scales
|
### Profiles
|
||||||
|
|
||||||
`MAGICKA_SCALE` selects the corpus size; individual counts can be overridden.
|
`MAGICKA_PROFILE` (or `MAGICKA_SCALE`) selects the run profile.
|
||||||
|
|
||||||
| Scale | executions | replay | mutants | notes |
|
| Profile | executions | replay | mutants | role |
|
||||||
|-------|-----------|--------|---------|-------|
|
|---------|-----------|--------|---------|------|
|
||||||
| `tiny` | 120 | 120 | 520 | smoke (~80 ms) |
|
| `fast` (default) | 600 | 10,000 (committed) | 520 | **advisory only — never acceptance** |
|
||||||
| `fast` (default) | 600 | 600 | 520 | every gate, ~0.3 s |
|
| `tiny` | 120 | 10,000 | 520 | smoke |
|
||||||
| `full` | 1,000,000 | 10,000 | 600 | merge-blocking spec gates |
|
| `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
|
```bash
|
||||||
MAGICKA_SCALE=full cargo run --release -p ci_reports --bin ci
|
cargo run --release -p replay_corpus --bin freeze -- 10000
|
||||||
# or override individual counts:
|
|
||||||
MAGICKA_EXECUTIONS=20000 MAGICKA_REPLAY=10000 cargo run --release -p ci_reports --bin ci
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The gate *thresholds* are identical across scales — only the corpus size
|
|
||||||
changes. Fast CI runs a representative slice; merge-blocking CI runs `full`.
|
|
||||||
|
|
||||||
## Gates enforced (all must pass)
|
|
||||||
|
|
||||||
- **runtime_equivalence** — 100% of executions: `canonical(reference) == canonical(runtime_under_test)` over delta, trace, faults, replay hash, and 3-turn future hash.
|
|
||||||
- **causal_rank / trace** — median causal edges ≥ 24, 95% causal rank ≥ 6, median touched domains ≥ 4, 95% ≥ 3, fingerprint collision rate < 5%, largest cluster < 2%.
|
|
||||||
- **domain_participation** — each domain appears in ≥ 35% of traces, influences ≥ 20%, is mutated in ≥ 20%; removing any domain loses ≥ 10% behavioral diversity; merging any pair loses ≥ 8%; no read-only or write-only domain.
|
|
||||||
- **metamorphic_response** — ≥ 90% of perturbations alter the trace, ≥ 75% the delta, ≥ 50% the 3-turn future; ≤ 5% unexplained neutral.
|
|
||||||
- **compression_resistance** — best 1/2/4-factor models predict < 40/55/70%; no single domain > 30%, no pair > 55%; every compressed model loses ≥ 35% information. All 11 attack families are run.
|
|
||||||
- **mutation_survivor** — ≥ 500 structurally generated mutants, 0 survivors.
|
|
||||||
- **contract** — every admitted case satisfies its semantic contract (min causal rank, domain participation, future sensitivity, context divergence, max compressibility); contract-violating cases are rejected at admission.
|
|
||||||
- **replay** — 100% deterministic, 0 hash drift.
|
|
||||||
- **coverage** — generated/contract rejection accounting; no admitted case fails the generated gates.
|
|
||||||
|
|
||||||
## Determinism
|
## Determinism
|
||||||
|
|
||||||
Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content
|
Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content
|
||||||
hashing, wrapping/guarded arithmetic). No floating point enters a canonical
|
hashing, wrapping/guarded arithmetic). No floating point enters a canonical
|
||||||
hash, so replay is bit-exact across machines and runs. No external crates.
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1136
-100
File diff suppressed because it is too large
Load Diff
+436
-23
@@ -1,9 +1,10 @@
|
|||||||
//! The `ci` binary: runs the full adversarial framework against the reference
|
//! The `ci` binary: runs the full adversarial framework against the reference
|
||||||
//! and runtime-under-test, writes the eight required reports (JSON + markdown),
|
//! and the independently-implemented runtime under test, writes the required
|
||||||
//! and exits nonzero if any gate fails.
|
//! 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::json::Json;
|
||||||
use ci_reports::{run_all, CiResults, Scale};
|
use ci_reports::{run_all_to, CiResults, Profile, Scale};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -18,6 +19,9 @@ fn fails(v: &[String]) -> Json {
|
|||||||
fn pass_field(v: &[String]) -> Json {
|
fn pass_field(v: &[String]) -> Json {
|
||||||
Json::Bool(v.is_empty())
|
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) {
|
fn write_report(dir: &Path, name: &str, j: &Json) {
|
||||||
let path = dir.join(format!("{name}.json"));
|
let path = dir.join(format!("{name}.json"));
|
||||||
@@ -25,6 +29,93 @@ fn write_report(dir: &Path, name: &str, j: &Json) {
|
|||||||
f.write_all(j.to_pretty().as_bytes()).expect("write 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) {
|
fn build_reports(dir: &Path, r: &CiResults) {
|
||||||
// 1. domain_participation_report
|
// 1. domain_participation_report
|
||||||
write_report(
|
write_report(
|
||||||
@@ -74,7 +165,7 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
|||||||
.map(|rep| {
|
.map(|rep| {
|
||||||
Json::Obj(vec![
|
Json::Obj(vec![
|
||||||
("attack".into(), Json::s(rep.attack.clone())),
|
("attack".into(), Json::s(rep.attack.clone())),
|
||||||
("predicts".into(), Json::Num(rep.predicts)),
|
("reconstructs".into(), Json::Num(rep.predicts)),
|
||||||
("info_loss".into(), Json::Num(rep.info_loss)),
|
("info_loss".into(), Json::Num(rep.info_loss)),
|
||||||
("detail".into(), Json::s(rep.detail.clone())),
|
("detail".into(), Json::s(rep.detail.clone())),
|
||||||
])
|
])
|
||||||
@@ -85,6 +176,7 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
|||||||
"compression_resistance_report",
|
"compression_resistance_report",
|
||||||
&Json::Obj(vec![
|
&Json::Obj(vec![
|
||||||
("pass".into(), pass_field(&r.collapse.failures)),
|
("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_1factor".into(), Json::Num(r.collapse.best_1factor)),
|
||||||
("best_2factor".into(), Json::Num(r.collapse.best_2factor)),
|
("best_2factor".into(), Json::Num(r.collapse.best_2factor)),
|
||||||
("best_4factor".into(), Json::Num(r.collapse.best_4factor)),
|
("best_4factor".into(), Json::Num(r.collapse.best_4factor)),
|
||||||
@@ -106,20 +198,38 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
|||||||
("altered_trace".into(), Json::Num(r.metamorphic.altered_trace)),
|
("altered_trace".into(), Json::Num(r.metamorphic.altered_trace)),
|
||||||
("altered_delta".into(), Json::Num(r.metamorphic.altered_delta)),
|
("altered_delta".into(), Json::Num(r.metamorphic.altered_delta)),
|
||||||
("altered_future".into(), Json::Num(r.metamorphic.altered_future)),
|
("altered_future".into(), Json::Num(r.metamorphic.altered_future)),
|
||||||
("neutral_unexplained".into(), Json::Num(r.metamorphic.neutral_unexplained)),
|
("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)),
|
("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
|
// 5. mutation_survivor_report
|
||||||
let survivors: Vec<Json> = r
|
let survivors: Vec<Json> = r
|
||||||
.mutation
|
.mutation
|
||||||
.survivors
|
.survivors
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, name)| {
|
.map(|(id, reason)| {
|
||||||
Json::Obj(vec![
|
Json::Obj(vec![
|
||||||
("id".into(), Json::Int(*id as i64)),
|
("id".into(), Json::Int(*id as i64)),
|
||||||
("name".into(), Json::s(name.clone())),
|
("reason".into(), Json::s(reason.clone())),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -128,6 +238,7 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
|||||||
"mutation_survivor_report",
|
"mutation_survivor_report",
|
||||||
&Json::Obj(vec![
|
&Json::Obj(vec![
|
||||||
("pass".into(), Json::Bool(r.mutation.passed())),
|
("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)),
|
("total_mutants".into(), Json::Int(r.mutation.total as i64)),
|
||||||
("killed".into(), Json::Int(r.mutation.killed as i64)),
|
("killed".into(), Json::Int(r.mutation.killed as i64)),
|
||||||
("survivors".into(), Json::Arr(survivors)),
|
("survivors".into(), Json::Arr(survivors)),
|
||||||
@@ -140,6 +251,10 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
|||||||
"runtime_equivalence_report",
|
"runtime_equivalence_report",
|
||||||
&Json::Obj(vec![
|
&Json::Obj(vec![
|
||||||
("pass".into(), pass_field(&r.equivalence.failures)),
|
("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)),
|
("total".into(), Json::Int(r.equivalence.total as i64)),
|
||||||
("matched".into(), Json::Int(r.equivalence.matched as i64)),
|
("matched".into(), Json::Int(r.equivalence.matched as i64)),
|
||||||
("failures".into(), fails(&r.equivalence.failures)),
|
("failures".into(), fails(&r.equivalence.failures)),
|
||||||
@@ -152,9 +267,13 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
|||||||
"replay_report",
|
"replay_report",
|
||||||
&Json::Obj(vec![
|
&Json::Obj(vec![
|
||||||
("pass".into(), pass_field(&r.replay.failures)),
|
("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)),
|
("total".into(), Json::Int(r.replay.total as i64)),
|
||||||
("deterministic".into(), Json::Int(r.replay.deterministic as i64)),
|
("deterministic".into(), Json::Int(r.replay.deterministic as i64)),
|
||||||
("drift".into(), Json::Int(r.replay.drift 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)),
|
("failures".into(), fails(&r.replay.failures)),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -175,6 +294,238 @@ fn build_reports(dir: &Path, r: &CiResults) {
|
|||||||
("failures".into(), fails(&r.coverage.failures)),
|
("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 {
|
fn status(v: bool) -> &'static str {
|
||||||
@@ -186,22 +537,38 @@ fn status(v: bool) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_markdown(dir: &Path, r: &CiResults) {
|
fn write_markdown(dir: &Path, r: &CiResults) {
|
||||||
|
let p = &r.provenance;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
s.push_str("# Magicka VM — Phase 0/1 CI Report\n\n");
|
s.push_str("# Magicka VM — Phase 0/1 CI Report\n\n");
|
||||||
s.push_str(&format!(
|
s.push_str(&format!("Overall: **{}**\n\n", status(r.passed())));
|
||||||
"Overall: **{}**\n\n",
|
s.push_str(&format!("Profile: **{}**", p.profile.name()));
|
||||||
status(r.passed())
|
if p.profile == Profile::Fast {
|
||||||
));
|
s.push_str(" (representative slice — NOT merge-blocking)");
|
||||||
|
}
|
||||||
|
s.push_str("\n\n");
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"Scale: executions={}, mutants={}, replay={}, collapse_samples={}\n\n",
|
"Scale: executions={}, mutants={}, replay={}, collapse_samples={}\n\n",
|
||||||
r.scale.executions, r.scale.mutants, r.scale.replay_cases, r.scale.collapse_samples
|
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("| Report | Status | Key metrics |\n|---|---|---|\n");
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"| runtime_equivalence | {} | {}/{} matched |\n",
|
"| runtime_equivalence | {} | {}/{} matched, independent impls, engines agree={} |\n",
|
||||||
status(r.equivalence.failures.is_empty()),
|
status(r.equivalence.failures.is_empty()),
|
||||||
r.equivalence.matched,
|
r.equivalence.matched,
|
||||||
r.equivalence.total
|
r.equivalence.total,
|
||||||
|
p.engines_agree
|
||||||
));
|
));
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"| causal_rank/trace | {} | rank med={} p95={}, edges med={}, touched med={} |\n",
|
"| causal_rank/trace | {} | rank med={} p95={}, edges med={}, touched med={} |\n",
|
||||||
@@ -234,7 +601,7 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
|||||||
r.collapse.min_info_loss
|
r.collapse.min_info_loss
|
||||||
));
|
));
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"| mutation_survivor | {} | killed {}/{} |\n",
|
"| mutation_survivor | {} | killed {}/{} by named gate |\n",
|
||||||
status(r.mutation.passed()),
|
status(r.mutation.passed()),
|
||||||
r.mutation.killed,
|
r.mutation.killed,
|
||||||
r.mutation.total
|
r.mutation.total
|
||||||
@@ -246,7 +613,7 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
|||||||
r.contract.total
|
r.contract.total
|
||||||
));
|
));
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"| replay | {} | {}/{} deterministic, drift={} |\n",
|
"| replay | {} | {}/{} deterministic (committed corpus), drift={} |\n",
|
||||||
status(r.replay.failures.is_empty()),
|
status(r.replay.failures.is_empty()),
|
||||||
r.replay.deterministic,
|
r.replay.deterministic,
|
||||||
r.replay.total,
|
r.replay.total,
|
||||||
@@ -259,6 +626,11 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
|||||||
r.coverage.perturbations,
|
r.coverage.perturbations,
|
||||||
r.coverage.generated_rejected
|
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");
|
s.push_str("\n## Failures\n\n");
|
||||||
let mut any = false;
|
let mut any = false;
|
||||||
@@ -268,9 +640,9 @@ fn write_markdown(dir: &Path, r: &CiResults) {
|
|||||||
s.push_str(&format!("- **{}**: {}\n", name, msg));
|
s.push_str(&format!("- **{}**: {}\n", name, msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (id, name) in &r.mutation.survivors {
|
for (id, reason) in &r.mutation.survivors {
|
||||||
any = true;
|
any = true;
|
||||||
s.push_str(&format!("- **mutation_survivor**: mutant {} ({}) survived\n", id, name));
|
s.push_str(&format!("- **mutation_survivor**: mutant {} survived ({})\n", id, reason));
|
||||||
}
|
}
|
||||||
if !any {
|
if !any {
|
||||||
s.push_str("None. The fake universe failed to collapse. ✅\n");
|
s.push_str("None. The fake universe failed to collapse. ✅\n");
|
||||||
@@ -287,15 +659,31 @@ fn main() {
|
|||||||
fs::create_dir_all(dir).expect("create out dir");
|
fs::create_dir_all(dir).expect("create out dir");
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"running CI: executions={} mutants={} replay={} collapse_samples={}",
|
"running CI: profile={} executions={} mutants={} replay={} collapse_samples={}",
|
||||||
scale.executions, scale.mutants, scale.replay_cases, scale.collapse_samples
|
scale.profile.name(), scale.executions, scale.mutants, scale.replay_cases, scale.collapse_samples
|
||||||
);
|
);
|
||||||
|
for v in &scale.override_violations {
|
||||||
|
eprintln!(" override rejected: {v}");
|
||||||
|
}
|
||||||
|
let merge = scale.profile == Profile::Merge;
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let results = run_all(scale);
|
// 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();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
build_reports(dir, &results);
|
build_reports(dir, &results);
|
||||||
|
write_evidence(dir, &results);
|
||||||
write_markdown(dir, &results);
|
write_markdown(dir, &results);
|
||||||
|
let compliance_ok = build_compliance_report(dir, &results);
|
||||||
|
|
||||||
println!("\n=== Magicka VM CI ({:?}) ===", elapsed);
|
println!("\n=== Magicka VM CI ({:?}) ===", elapsed);
|
||||||
for (name, f) in results.all_failures() {
|
for (name, f) in results.all_failures() {
|
||||||
@@ -313,13 +701,38 @@ fn main() {
|
|||||||
format!(", {} survivors", results.mutation.survivors.len())
|
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);
|
println!("reports written to {}/", out_dir);
|
||||||
|
|
||||||
if results.passed() {
|
let gates_pass = results.passed() && compliance_ok;
|
||||||
println!("\nOVERALL: PASS — the adversarial framework could not collapse the universe.");
|
|
||||||
|
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);
|
std::process::exit(0);
|
||||||
} else {
|
} else {
|
||||||
println!("\nOVERALL: FAIL");
|
println!("\nACCEPTANCE: FAIL");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+263
-163
@@ -1,28 +1,80 @@
|
|||||||
//! `collapse_analysis` — the framework's compression attacks. Each attack
|
//! `collapse_analysis` — the framework's compression attacks.
|
||||||
//! tries to predict execution behavior with a *simpler* model. If any small
|
|
||||||
//! model predicts above the configured thresholds, the universe has collapsed
|
|
||||||
//! and CI must fail.
|
|
||||||
//!
|
//!
|
||||||
//! The corpus is a set of (input, output) samples: `input` is the world ground
|
//! Each attack tries to reconstruct the *actual serialized trace* from a
|
||||||
//! truth (8 domains x (observed+hidden) lanes) and `output` is the behavior
|
//! simpler (compressed) representation of it. If a small model reconstructs the
|
||||||
//! feature vector produced by the runtime. Inputs/outputs are standardized so
|
//! trace above the configured thresholds, the universe has collapsed and CI must
|
||||||
//! that no single high-magnitude axis dominates the variance accounting.
|
//! 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;
|
pub mod linalg;
|
||||||
|
|
||||||
use linalg::{ols_r2, pca_scores, Mat};
|
use linalg::{ols_r2, pca_scores, Mat};
|
||||||
use world_model::{Hash, HIDDEN_LANES, LANES, NUM_DOMAINS};
|
use trace_model::ExecutionTrace;
|
||||||
|
use world_model::{WorldDelta, NUM_DOMAINS};
|
||||||
|
|
||||||
/// Input columns belonging to one domain (observed + hidden lanes).
|
/// The single definition of a trace feature row. The collapse corpus is built
|
||||||
pub const DOMAIN_BLOCK: usize = LANES + HIDDEN_LANES;
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
/// A standardized behavior corpus.
|
/// 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 {
|
pub struct BehaviorCorpus {
|
||||||
/// `n x (NUM_DOMAINS*DOMAIN_BLOCK)` standardized input.
|
/// `n x FEATURE_W` standardized trace features.
|
||||||
pub x: Mat,
|
pub traces: Mat,
|
||||||
/// `n x m` standardized output (behavior features).
|
|
||||||
pub y: Mat,
|
|
||||||
pub fingerprints: Vec<Hash>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn standardize(rows: &[Vec<f64>]) -> Mat {
|
fn standardize(rows: &[Vec<f64>]) -> Mat {
|
||||||
@@ -34,7 +86,6 @@ fn standardize(rows: &[Vec<f64>]) -> Mat {
|
|||||||
m.set(r, c, rows[r][c]);
|
m.set(r, c, rows[r][c]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// center + scale to unit std per column
|
|
||||||
for c in 0..cols {
|
for c in 0..cols {
|
||||||
let mut mean = 0.0;
|
let mut mean = 0.0;
|
||||||
for r in 0..n {
|
for r in 0..n {
|
||||||
@@ -57,47 +108,65 @@ fn standardize(rows: &[Vec<f64>]) -> Mat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BehaviorCorpus {
|
impl BehaviorCorpus {
|
||||||
pub fn build(inputs: Vec<Vec<f64>>, outputs: Vec<Vec<f64>>, fingerprints: Vec<Hash>) -> Self {
|
pub fn build(rows: Vec<Vec<f64>>) -> Self {
|
||||||
BehaviorCorpus {
|
BehaviorCorpus {
|
||||||
x: standardize(&inputs),
|
traces: standardize(&rows),
|
||||||
y: standardize(&outputs),
|
|
||||||
fingerprints,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn n(&self) -> usize {
|
pub fn n(&self) -> usize {
|
||||||
self.x.rows
|
self.traces.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cols(&self) -> usize {
|
||||||
|
self.traces.cols
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_cols(d: usize) -> Vec<usize> {
|
||||||
|
(d * BLOCK_W..(d + 1) * BLOCK_W).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select(&self, cols: &[usize]) -> Mat {
|
fn select(&self, cols: &[usize]) -> Mat {
|
||||||
let mut m = Mat::zeros(self.x.rows, cols.len());
|
let mut m = Mat::zeros(self.traces.rows, cols.len());
|
||||||
for r in 0..self.x.rows {
|
for r in 0..self.traces.rows {
|
||||||
for (j, &c) in cols.iter().enumerate() {
|
for (j, &c) in cols.iter().enumerate() {
|
||||||
m.set(r, j, self.x.at(r, c));
|
m.set(r, j, self.traces.at(r, c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
fn domain_cols(domain: usize) -> Vec<usize> {
|
fn complement(&self, cols: &[usize]) -> Vec<usize> {
|
||||||
(domain * DOMAIN_BLOCK..(domain + 1) * DOMAIN_BLOCK).collect()
|
(0..self.cols()).filter(|c| !cols.contains(c)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// R² of predicting output from the top-`k` PCA factors of the full input.
|
/// R² of reconstructing the columns `target` from the columns `source`.
|
||||||
pub fn predict_k_factor(&self, k: usize) -> f64 {
|
fn reconstruct(&self, source: &[usize], target: &[usize]) -> f64 {
|
||||||
if self.n() == 0 {
|
if source.is_empty() || target.is_empty() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let scores = pca_scores(&self.x, k);
|
let x = self.select(source);
|
||||||
ols_r2(&scores, &self.y)
|
let y = self.select(target);
|
||||||
|
ols_r2(&x, &y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best R² obtainable using just a single domain's input block.
|
/// Explained-variance fraction of the whole trace from its top-`k` PCA
|
||||||
|
/// factors (a genuine k-factor reconstruction quality).
|
||||||
|
pub fn predict_k_factor(&self, k: usize) -> f64 {
|
||||||
|
if self.n() == 0 || self.cols() == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let scores = pca_scores(&self.traces, k);
|
||||||
|
ols_r2(&scores, &self.traces)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best fraction of the *rest of the trace* explained by a single domain.
|
||||||
pub fn max_single_domain(&self) -> (usize, f64) {
|
pub fn max_single_domain(&self) -> (usize, f64) {
|
||||||
let mut best = (0usize, 0.0);
|
let mut best = (0usize, 0.0);
|
||||||
for d in 0..NUM_DOMAINS {
|
for d in 0..NUM_DOMAINS {
|
||||||
let sub = self.select(&Self::domain_cols(d));
|
let src = Self::block_cols(d);
|
||||||
let r2 = ols_r2(&sub, &self.y);
|
let tgt = self.complement(&src);
|
||||||
|
let r2 = self.reconstruct(&src, &tgt);
|
||||||
if r2 > best.1 {
|
if r2 > best.1 {
|
||||||
best = (d, r2);
|
best = (d, r2);
|
||||||
}
|
}
|
||||||
@@ -105,15 +174,15 @@ impl BehaviorCorpus {
|
|||||||
best
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best R² obtainable using any pair of domain blocks.
|
/// Best fraction of the rest explained by any pair of domains.
|
||||||
pub fn max_pair(&self) -> ((usize, usize), f64) {
|
pub fn max_pair(&self) -> ((usize, usize), f64) {
|
||||||
let mut best = ((0usize, 1usize), 0.0);
|
let mut best = ((0usize, 1usize), 0.0);
|
||||||
for a in 0..NUM_DOMAINS {
|
for a in 0..NUM_DOMAINS {
|
||||||
for b in (a + 1)..NUM_DOMAINS {
|
for b in (a + 1)..NUM_DOMAINS {
|
||||||
let mut cols = Self::domain_cols(a);
|
let mut src = Self::block_cols(a);
|
||||||
cols.extend(Self::domain_cols(b));
|
src.extend(Self::block_cols(b));
|
||||||
let sub = self.select(&cols);
|
let tgt = self.complement(&src);
|
||||||
let r2 = ols_r2(&sub, &self.y);
|
let r2 = self.reconstruct(&src, &tgt);
|
||||||
if r2 > best.1 {
|
if r2 > best.1 {
|
||||||
best = ((a, b), r2);
|
best = ((a, b), r2);
|
||||||
}
|
}
|
||||||
@@ -121,13 +190,9 @@ impl BehaviorCorpus {
|
|||||||
}
|
}
|
||||||
best
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
fn full_r2(&self) -> f64 {
|
|
||||||
ols_r2(&self.x, &self.y)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A compressed model's predictive power and information loss.
|
/// A compressed model's reconstruction power and (genuine) information loss.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CompressedModel {
|
pub struct CompressedModel {
|
||||||
pub predicts: f64,
|
pub predicts: f64,
|
||||||
@@ -139,6 +204,15 @@ pub struct CompressedModel {
|
|||||||
pub trait CollapseAttack {
|
pub trait CollapseAttack {
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
fn compress(&self, corpus: &BehaviorCorpus) -> CompressedModel;
|
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.
|
/// One attack's outcome.
|
||||||
@@ -150,8 +224,21 @@ pub struct CollapseReport {
|
|||||||
pub detail: String,
|
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 {
|
macro_rules! attack {
|
||||||
($name:ident, $label:expr, $body:expr) => {
|
($name:ident, $label:expr, $body:expr) => {
|
||||||
|
attack!($name, $label, false, $body);
|
||||||
|
};
|
||||||
|
($name:ident, $label:expr, $whole:expr, $body:expr) => {
|
||||||
pub struct $name;
|
pub struct $name;
|
||||||
impl CollapseAttack for $name {
|
impl CollapseAttack for $name {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
@@ -161,130 +248,129 @@ macro_rules! attack {
|
|||||||
let f: fn(&BehaviorCorpus) -> CompressedModel = $body;
|
let f: fn(&BehaviorCorpus) -> CompressedModel = $body;
|
||||||
f(corpus)
|
f(corpus)
|
||||||
}
|
}
|
||||||
|
fn whole_trace(&self) -> bool {
|
||||||
|
$whole
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn model(predicts: f64, detail: &str) -> CompressedModel {
|
|
||||||
CompressedModel {
|
|
||||||
predicts,
|
|
||||||
info_loss: (1.0 - predicts).clamp(0.0, 1.0),
|
|
||||||
detail: detail.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attack!(DomainRemoval, "domain_removal", |c| {
|
attack!(DomainRemoval, "domain_removal", |c| {
|
||||||
// Best prediction achievable while *removing* each domain in turn.
|
// Can the rest of the trace reconstruct each removed domain's own block?
|
||||||
let mut best = 0.0;
|
let mut best = 0.0;
|
||||||
for d in 0..NUM_DOMAINS {
|
for d in 0..NUM_DOMAINS {
|
||||||
let cols: Vec<usize> = (0..NUM_DOMAINS)
|
let tgt = BehaviorCorpus::block_cols(d);
|
||||||
.filter(|&x| x != d)
|
let src = c.complement(&tgt);
|
||||||
.flat_map(BehaviorCorpus::domain_cols)
|
best = f64::max(best, c.reconstruct(&src, &tgt));
|
||||||
.collect();
|
|
||||||
let sub = c.select(&cols);
|
|
||||||
best = f64::max(best, ols_r2(&sub, &c.y));
|
|
||||||
}
|
}
|
||||||
model(best, "predict with one domain removed")
|
model(best, "reconstruct a removed domain from the rest")
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(DomainMerging, "domain_merging", |c| {
|
attack!(DomainMerging, "domain_merging", |c| {
|
||||||
// Merge each pair into a summed block; best prediction over pairs.
|
// Merge each pair (sum their blocks); can the merged sum reconstruct the two
|
||||||
|
// separate blocks? If so, distinguishing the domains is redundant.
|
||||||
let mut best = 0.0;
|
let mut best = 0.0;
|
||||||
for a in 0..NUM_DOMAINS {
|
for a in 0..NUM_DOMAINS {
|
||||||
for b in (a + 1)..NUM_DOMAINS {
|
for b in (a + 1)..NUM_DOMAINS {
|
||||||
let mut merged = Mat::zeros(c.x.rows, (NUM_DOMAINS - 1) * DOMAIN_BLOCK);
|
let mut merged = Mat::zeros(c.traces.rows, BLOCK_W);
|
||||||
for r in 0..c.x.rows {
|
for r in 0..c.traces.rows {
|
||||||
let mut out_col = 0;
|
for l in 0..BLOCK_W {
|
||||||
for d in 0..NUM_DOMAINS {
|
let v = c.traces.at(r, a * BLOCK_W + l) + c.traces.at(r, b * BLOCK_W + l);
|
||||||
if d == b {
|
merged.set(r, l, v);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for l in 0..DOMAIN_BLOCK {
|
|
||||||
let mut v = c.x.at(r, d * DOMAIN_BLOCK + l);
|
|
||||||
if d == a {
|
|
||||||
v += c.x.at(r, b * DOMAIN_BLOCK + l);
|
|
||||||
}
|
|
||||||
merged.set(r, out_col, v);
|
|
||||||
out_col += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
best = f64::max(best, ols_r2(&merged, &c.y));
|
let mut tgt_cols = BehaviorCorpus::block_cols(a);
|
||||||
|
tgt_cols.extend(BehaviorCorpus::block_cols(b));
|
||||||
|
let tgt = c.select(&tgt_cols);
|
||||||
|
best = f64::max(best, ols_r2(&merged, &tgt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model(best, "predict with two domains merged")
|
model(best, "reconstruct two domains from their merged sum")
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(ConstantFolding, "constant_folding", |_c| {
|
attack!(ConstantFolding, "constant_folding", true, |_c| {
|
||||||
// Folding the world to constants leaves no predictive features at all.
|
// Folding the trace to constants reconstructs nothing.
|
||||||
model(0.0, "world folded to constants")
|
model(0.0, "trace folded to constants")
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(CausalEdgeDeletion, "causal_edge_deletion", |c| {
|
attack!(CausalEdgeDeletion, "causal_edge_deletion", |c| {
|
||||||
// Keep only each domain's first observed lane (no cross-domain structure).
|
// Drop all cross-domain causal/flow columns; can local features
|
||||||
let cols: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * DOMAIN_BLOCK).collect();
|
// (counts/deltas) reconstruct the deleted causal structure?
|
||||||
let sub = c.select(&cols);
|
let mut deleted = Vec::new();
|
||||||
model(ols_r2(&sub, &c.y), "diagonal-only features")
|
for d in 0..NUM_DOMAINS {
|
||||||
|
for l in 0..4 {
|
||||||
|
// infl_out, infl_in, flow_out, flow_in
|
||||||
|
deleted.push(d * BLOCK_W + l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kept = c.complement(&deleted);
|
||||||
|
model(c.reconstruct(&kept, &deleted), "reconstruct deleted causal edges from local features")
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(StateAliasing, "state_aliasing", |c| {
|
attack!(StateAliasing, "state_aliasing", true, |c| {
|
||||||
// Alias all domains into a single aggregate column.
|
// Alias the whole trace into one aggregate column; reconstruct the full trace.
|
||||||
let mut agg = Mat::zeros(c.x.rows, 1);
|
let mut agg = Mat::zeros(c.traces.rows, 1);
|
||||||
for r in 0..c.x.rows {
|
for r in 0..c.traces.rows {
|
||||||
let mut s = 0.0;
|
let mut s = 0.0;
|
||||||
for col in 0..c.x.cols {
|
for col in 0..c.traces.cols {
|
||||||
s += c.x.at(r, col);
|
s += c.traces.at(r, col);
|
||||||
}
|
}
|
||||||
agg.set(r, 0, s);
|
agg.set(r, 0, s);
|
||||||
}
|
}
|
||||||
model(ols_r2(&agg, &c.y), "single aliased aggregate")
|
model(ols_r2(&agg, &c.traces), "reconstruct trace from a single aliased aggregate")
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(LatentFactorModeling, "latent_factor_modeling", |c| {
|
attack!(LatentFactorModeling, "latent_factor_modeling", true, |c| {
|
||||||
model(c.predict_k_factor(4), "top-4 latent factors")
|
model(c.predict_k_factor(4), "top-4 latent factors")
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(BehaviorClustering, "behavior_clustering", |c| {
|
attack!(BehaviorClustering, "behavior_clustering", true, |c| {
|
||||||
let recon = kmeans_reconstruct(&c.y, 4);
|
let recon = kmeans_reconstruct(&c.traces, 4);
|
||||||
let scores = pca_scores(&c.x, 4);
|
model(reconstruction_r2(&c.traces, &recon), "4-cluster behavior model")
|
||||||
model(ols_r2(&scores, &recon), "4-cluster behavior model")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(SurrogatePrediction, "surrogate_prediction", |c| {
|
attack!(SurrogatePrediction, "surrogate_prediction", true, |c| {
|
||||||
model(c.full_r2(), "full linear surrogate")
|
// 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| {
|
attack!(TemporalFlattening, "temporal_flattening", |c| {
|
||||||
// Drop hidden lanes (time-carrying state); predict from observed only.
|
// Drop temporal columns; can the rest reconstruct temporal reach?
|
||||||
let cols: Vec<usize> = (0..NUM_DOMAINS)
|
let temporal: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * BLOCK_W + 6).collect();
|
||||||
.flat_map(|d| (0..LANES).map(move |l| d * DOMAIN_BLOCK + l))
|
let kept = c.complement(&temporal);
|
||||||
.collect();
|
model(c.reconstruct(&kept, &temporal), "reconstruct temporal reach from non-temporal features")
|
||||||
let sub = c.select(&cols);
|
|
||||||
model(ols_r2(&sub, &c.y), "time-flattened (observed lanes only)")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(ObservationFlattening, "observation_flattening", |c| {
|
attack!(ObservationFlattening, "observation_flattening", |c| {
|
||||||
// Use only hidden lanes (collapse the observed surface).
|
// Drop hidden-state delta columns; reconstruct them from the observed side.
|
||||||
let cols: Vec<usize> = (0..NUM_DOMAINS)
|
let hidden: Vec<usize> = (0..NUM_DOMAINS).map(|d| d * BLOCK_W + 8).collect();
|
||||||
.flat_map(|d| (0..HIDDEN_LANES).map(move |l| d * DOMAIN_BLOCK + LANES + l))
|
let kept = c.complement(&hidden);
|
||||||
.collect();
|
model(c.reconstruct(&kept, &hidden), "reconstruct hidden deltas from observed features")
|
||||||
let sub = c.select(&cols);
|
|
||||||
model(ols_r2(&sub, &c.y), "observation-flattened (hidden lanes only)")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
attack!(ExecutorIdentityErasure, "executor_identity_erasure", |c| {
|
attack!(ExecutorIdentityErasure, "executor_identity_erasure", |c| {
|
||||||
// Predict all-but-last output dim (the divergence summary) from input.
|
// The divergence summary is the last global column; reconstruct it from the
|
||||||
if c.y.cols <= 1 {
|
// rest (erasing executor identity).
|
||||||
return model(0.0, "no executor dim");
|
let div = vec![c.cols() - 1];
|
||||||
}
|
let kept = c.complement(&div);
|
||||||
let mut y2 = Mat::zeros(c.y.rows, c.y.cols - 1);
|
model(c.reconstruct(&kept, &div), "reconstruct executor divergence from the rest")
|
||||||
for r in 0..c.y.rows {
|
});
|
||||||
for col in 0..c.y.cols - 1 {
|
|
||||||
y2.set(r, col, c.y.at(r, col));
|
fn reconstruction_r2(original: &Mat, recon: &Mat) -> f64 {
|
||||||
|
let mut ss_res = 0.0;
|
||||||
|
let mut ss_tot = 0.0;
|
||||||
|
for r in 0..original.rows {
|
||||||
|
for col in 0..original.cols {
|
||||||
|
let o = original.at(r, col);
|
||||||
|
ss_res += (o - recon.at(r, col)).powi(2);
|
||||||
|
ss_tot += o.powi(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model(ols_r2(&c.x, &y2), "executor identity erased")
|
if ss_tot <= 1e-9 {
|
||||||
});
|
return 0.0;
|
||||||
|
}
|
||||||
|
(1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
|
fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
|
||||||
let n = y.rows;
|
let n = y.rows;
|
||||||
@@ -292,11 +378,11 @@ fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
|
|||||||
return y.clone();
|
return y.clone();
|
||||||
}
|
}
|
||||||
let k = k.min(n);
|
let k = k.min(n);
|
||||||
// deterministic init: spread initial centroids across the data
|
let mut centroids: Vec<Vec<f64>> = (0..k)
|
||||||
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();
|
.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];
|
let mut assign = vec![0usize; n];
|
||||||
for _ in 0..12 {
|
for _ in 0..12 {
|
||||||
// assign
|
|
||||||
for r in 0..n {
|
for r in 0..n {
|
||||||
let mut best = 0;
|
let mut best = 0;
|
||||||
let mut bestd = f64::MAX;
|
let mut bestd = f64::MAX;
|
||||||
@@ -312,7 +398,6 @@ fn kmeans_reconstruct(y: &Mat, k: usize) -> Mat {
|
|||||||
}
|
}
|
||||||
assign[r] = best;
|
assign[r] = best;
|
||||||
}
|
}
|
||||||
// update
|
|
||||||
let mut sums = vec![vec![0.0; y.cols]; k];
|
let mut sums = vec![vec![0.0; y.cols]; k];
|
||||||
let mut counts = vec![0usize; k];
|
let mut counts = vec![0usize; k];
|
||||||
for r in 0..n {
|
for r in 0..n {
|
||||||
@@ -377,10 +462,16 @@ impl CollapseSummary {
|
|||||||
/// Run every attack and check all collapse gates.
|
/// Run every attack and check all collapse gates.
|
||||||
pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary {
|
pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary {
|
||||||
let mut reports = Vec::new();
|
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;
|
let mut min_info_loss: f64 = 1.0;
|
||||||
for atk in all_attacks() {
|
for atk in all_attacks() {
|
||||||
let m = atk.compress(corpus);
|
let m = atk.compress(corpus);
|
||||||
min_info_loss = min_info_loss.min(m.info_loss);
|
if atk.whole_trace() {
|
||||||
|
min_info_loss = min_info_loss.min(m.info_loss);
|
||||||
|
}
|
||||||
reports.push(CollapseReport {
|
reports.push(CollapseReport {
|
||||||
attack: atk.name().to_string(),
|
attack: atk.name().to_string(),
|
||||||
predicts: m.predicts,
|
predicts: m.predicts,
|
||||||
@@ -397,13 +488,13 @@ pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary {
|
|||||||
|
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
if best_1 >= 0.40 {
|
if best_1 >= 0.40 {
|
||||||
failures.push(format!("1-factor predicts {:.3} >= 0.40", best_1));
|
failures.push(format!("1-factor reconstructs {:.3} >= 0.40", best_1));
|
||||||
}
|
}
|
||||||
if best_2 >= 0.55 {
|
if best_2 >= 0.55 {
|
||||||
failures.push(format!("2-factor predicts {:.3} >= 0.55", best_2));
|
failures.push(format!("2-factor reconstructs {:.3} >= 0.55", best_2));
|
||||||
}
|
}
|
||||||
if best_4 >= 0.70 {
|
if best_4 >= 0.70 {
|
||||||
failures.push(format!("4-factor predicts {:.3} >= 0.70", best_4));
|
failures.push(format!("4-factor reconstructs {:.3} >= 0.70", best_4));
|
||||||
}
|
}
|
||||||
if max_single > 0.30 {
|
if max_single > 0.30 {
|
||||||
failures.push(format!("single domain explains {:.3} > 0.30", max_single));
|
failures.push(format!("single domain explains {:.3} > 0.30", max_single));
|
||||||
@@ -430,41 +521,50 @@ pub fn analyze(corpus: &BehaviorCorpus) -> CollapseSummary {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use world_model::{Hasher, Rng};
|
use world_model::Rng;
|
||||||
|
|
||||||
|
fn random_trace_rows(n: usize, seed: u64) -> Vec<Vec<f64>> {
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
(0..n)
|
||||||
|
.map(|_| {
|
||||||
|
(0..FEATURE_W)
|
||||||
|
.map(|_| rng.range_i64(-5000, 5000) as f64)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn random_behavior_resists_collapse() {
|
fn high_entropy_trace_resists_collapse() {
|
||||||
// Inputs random; outputs an avalanche hash of inputs -> no small linear
|
let corpus = BehaviorCorpus::build(random_trace_rows(400, 1));
|
||||||
// model should predict them.
|
|
||||||
let mut rng = Rng::new(1);
|
|
||||||
let mut inputs = Vec::new();
|
|
||||||
let mut outputs = Vec::new();
|
|
||||||
let mut fps = Vec::new();
|
|
||||||
for _ in 0..400 {
|
|
||||||
let inp: Vec<f64> = (0..NUM_DOMAINS * DOMAIN_BLOCK)
|
|
||||||
.map(|_| rng.range_i64(-5000, 5000) as f64)
|
|
||||||
.collect();
|
|
||||||
let mut h = Hasher::new();
|
|
||||||
for &v in &inp {
|
|
||||||
h.write_i64(v as i64);
|
|
||||||
}
|
|
||||||
let base = h.finish().0;
|
|
||||||
let out: Vec<f64> = (0..12)
|
|
||||||
.map(|k| {
|
|
||||||
let mut hh = Hasher::new();
|
|
||||||
hh.write_u64(base);
|
|
||||||
hh.write_u64(k);
|
|
||||||
(hh.finish().0 as i64) as f64
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
fps.push(world_model::Hash(base));
|
|
||||||
inputs.push(inp);
|
|
||||||
outputs.push(out);
|
|
||||||
}
|
|
||||||
let corpus = BehaviorCorpus::build(inputs, outputs, fps);
|
|
||||||
let summary = analyze(&corpus);
|
let summary = analyze(&corpus);
|
||||||
assert!(summary.passed(), "collapse failures: {:?}", summary.failures);
|
assert!(summary.passed(), "collapse failures: {:?}", summary.failures);
|
||||||
assert!(summary.best_1factor < 0.40);
|
}
|
||||||
assert!(summary.min_info_loss >= 0.35);
|
|
||||||
|
/// Negative control: a single-factor (rank-1) corpus is genuinely
|
||||||
|
/// collapsible. Every feature is a fixed loading times one latent value plus
|
||||||
|
/// tiny noise, so a 1-factor model reconstructs almost everything. The gate
|
||||||
|
/// MUST reject it — proving the collapse analysis discriminates.
|
||||||
|
#[test]
|
||||||
|
fn single_factor_corpus_is_rejected() {
|
||||||
|
let mut rng = Rng::new(7);
|
||||||
|
let loadings: Vec<f64> = (0..FEATURE_W).map(|i| 1.0 + (i % 5) as f64).collect();
|
||||||
|
let rows: Vec<Vec<f64>> = (0..400)
|
||||||
|
.map(|_| {
|
||||||
|
let latent = rng.range_i64(-1000, 1000) as f64;
|
||||||
|
loadings
|
||||||
|
.iter()
|
||||||
|
.map(|&load| load * latent + rng.range_i64(-2, 2) as f64)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let corpus = BehaviorCorpus::build(rows);
|
||||||
|
let summary = analyze(&corpus);
|
||||||
|
assert!(
|
||||||
|
!summary.passed(),
|
||||||
|
"collapse gate failed to reject a single-factor universe (1f={:.3}, info_loss={:.3})",
|
||||||
|
summary.best_1factor,
|
||||||
|
summary.min_info_loss
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ pub struct PerturbedCase {
|
|||||||
pub axis_name: String,
|
pub axis_name: String,
|
||||||
pub world: WorldSnapshot,
|
pub world: WorldSnapshot,
|
||||||
pub expectation: TraceDifferenceExpectation,
|
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).
|
/// A complete generated case (per spec).
|
||||||
@@ -156,6 +160,7 @@ pub fn generate_perturbations(world: &WorldSnapshot, seed: u64, count: usize) ->
|
|||||||
axis_name: axis.name(),
|
axis_name: axis.name(),
|
||||||
world: axis.apply(world),
|
world: axis.apply(world),
|
||||||
expectation: axis.expected_trace_difference(),
|
expectation: axis.expected_trace_difference(),
|
||||||
|
target_domain: axis.target().0 as usize,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ rune_ir = { path = "../rune_ir" }
|
|||||||
trace_model = { path = "../trace_model" }
|
trace_model = { path = "../trace_model" }
|
||||||
generators = { path = "../generators" }
|
generators = { path = "../generators" }
|
||||||
reference_runtime = { path = "../reference_runtime" }
|
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]
|
[lib]
|
||||||
path = "src/lib.rs"
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
-30
@@ -1,12 +1,26 @@
|
|||||||
//! `replay_corpus` — every case is permanent and must replay bit-for-bit.
|
//! `replay_corpus` — every case is permanent and must replay bit-for-bit.
|
||||||
//! A replay case stores the seeds plus the three canonical hashes (trace,
|
//!
|
||||||
//! delta, future). Replaying regenerates the case deterministically from its
|
//! The corpus is **persisted to a committed file** (`corpus/replay_corpus.tsv`).
|
||||||
//! master seed, re-executes the reference runtime, and asserts zero hash drift.
|
//! 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 generators::generate_accepted_case;
|
||||||
use reference_runtime::{execute, EngineConfig, ResolutionInput};
|
use reference_runtime::{execute, EngineConfig, ResolutionInput};
|
||||||
|
use std::path::PathBuf;
|
||||||
use world_model::Hash;
|
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
|
/// A permanent replay case (per spec) plus the master seed needed to
|
||||||
/// regenerate the full case deterministically.
|
/// regenerate the full case deterministically.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -21,9 +35,8 @@ pub struct ReplayCase {
|
|||||||
pub expected_future_hash: Hash,
|
pub expected_future_hash: Hash,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
|
pub(crate) fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
|
||||||
let (case, accepted_seed) = generate_accepted_case(master_seed);
|
let (case, _accepted_seed) = generate_accepted_case(master_seed);
|
||||||
let _ = accepted_seed;
|
|
||||||
let input = ResolutionInput {
|
let input = ResolutionInput {
|
||||||
world: case.world.clone(),
|
world: case.world.clone(),
|
||||||
program: case.program.clone(),
|
program: case.program.clone(),
|
||||||
@@ -40,7 +53,13 @@ fn input_for(master_seed: u64) -> (ResolutionInput, u64, u64, u64, u64) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a single replay case from a master seed.
|
/// Master seed for the `i`-th corpus case (stable, deterministic).
|
||||||
|
pub fn master_seed_for(base_seed: u64, i: usize) -> u64 {
|
||||||
|
base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a single replay case from a master seed using the *current* reference.
|
||||||
|
/// Used when freezing the corpus to disk.
|
||||||
pub fn build_case(master_seed: u64) -> ReplayCase {
|
pub fn build_case(master_seed: u64) -> ReplayCase {
|
||||||
let (input, ws, ps, cs, prs) = input_for(master_seed);
|
let (input, ws, ps, cs, prs) = input_for(master_seed);
|
||||||
let r = execute(&EngineConfig::reference(), &input);
|
let r = execute(&EngineConfig::reference(), &input);
|
||||||
@@ -56,13 +75,93 @@ pub fn build_case(master_seed: u64) -> ReplayCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a replay corpus of `n` cases.
|
/// Build an in-memory corpus of `n` cases (used by `freeze`).
|
||||||
pub fn build_corpus(n: usize, base_seed: u64) -> Vec<ReplayCase> {
|
pub fn build_corpus(n: usize, base_seed: u64) -> Vec<ReplayCase> {
|
||||||
(0..n)
|
(0..n).map(|i| build_case(master_seed_for(base_seed, i))).collect()
|
||||||
.map(|i| build_case(base_seed ^ (i as u64).wrapping_mul(0x9e3779b97f4a7c15)))
|
|
||||||
.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.
|
/// A single replay verification outcome.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct ReplayDrift {
|
pub struct ReplayDrift {
|
||||||
@@ -78,15 +177,16 @@ impl ReplayDrift {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replay one case and check for drift.
|
/// Replay one *stored* case: regenerate it and compare a fresh reference run to
|
||||||
pub fn replay(case: &ReplayCase) -> ReplayDrift {
|
/// the expectation read from disk.
|
||||||
let (input, ..) = input_for(case.master_seed);
|
pub fn replay_against_stored(stored: &ReplayCase) -> ReplayDrift {
|
||||||
|
let (input, ..) = input_for(stored.master_seed);
|
||||||
let r = execute(&EngineConfig::reference(), &input);
|
let r = execute(&EngineConfig::reference(), &input);
|
||||||
ReplayDrift {
|
ReplayDrift {
|
||||||
master_seed: case.master_seed,
|
master_seed: stored.master_seed,
|
||||||
trace_ok: r.trace.canonical_hash() == case.expected_trace_hash,
|
trace_ok: r.trace.canonical_hash() == stored.expected_trace_hash,
|
||||||
delta_ok: r.delta.hash() == case.expected_delta_hash,
|
delta_ok: r.delta.hash() == stored.expected_delta_hash,
|
||||||
future_ok: r.replay.future_hash == case.expected_future_hash,
|
future_ok: r.replay.future_hash == stored.expected_future_hash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,20 +196,21 @@ pub struct ReplayReport {
|
|||||||
pub total: usize,
|
pub total: usize,
|
||||||
pub deterministic: usize,
|
pub deterministic: usize,
|
||||||
pub drift: Vec<u64>,
|
pub drift: Vec<u64>,
|
||||||
|
pub loaded_from_disk: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReplayReport {
|
impl ReplayReport {
|
||||||
pub fn passed(&self, minimum: usize) -> bool {
|
pub fn passed(&self, minimum: usize) -> bool {
|
||||||
self.drift.is_empty() && self.total >= minimum
|
self.loaded_from_disk && self.drift.is_empty() && self.total >= minimum
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the whole corpus replays deterministically.
|
/// Verify a corpus (already loaded from disk) replays without drift.
|
||||||
pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
|
pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
|
||||||
let mut drift = Vec::new();
|
let mut drift = Vec::new();
|
||||||
let mut deterministic = 0;
|
let mut deterministic = 0;
|
||||||
for case in corpus {
|
for case in corpus {
|
||||||
let d = replay(case);
|
let d = replay_against_stored(case);
|
||||||
if d.ok() {
|
if d.ok() {
|
||||||
deterministic += 1;
|
deterministic += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -120,6 +221,20 @@ pub fn verify_corpus(corpus: &[ReplayCase]) -> ReplayReport {
|
|||||||
total: corpus.len(),
|
total: corpus.len(),
|
||||||
deterministic,
|
deterministic,
|
||||||
drift,
|
drift,
|
||||||
|
loaded_from_disk: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the committed corpus and verify it. The single entry point CI uses.
|
||||||
|
pub fn verify_persisted_corpus() -> ReplayReport {
|
||||||
|
match load_persisted_corpus() {
|
||||||
|
Ok(corpus) => verify_corpus(&corpus),
|
||||||
|
Err(_) => ReplayReport {
|
||||||
|
total: 0,
|
||||||
|
deterministic: 0,
|
||||||
|
drift: Vec::new(),
|
||||||
|
loaded_from_disk: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,18 +243,35 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_is_deterministic_zero_drift() {
|
fn committed_corpus_loads_and_replays_without_drift() {
|
||||||
let corpus = build_corpus(80, 0x5EED);
|
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);
|
let report = verify_corpus(&corpus);
|
||||||
assert_eq!(report.total, 80);
|
assert!(report.drift.is_empty(), "drift in committed corpus: {:?}", report.drift);
|
||||||
assert_eq!(report.deterministic, 80);
|
assert_eq!(report.deterministic, report.total);
|
||||||
assert!(report.drift.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn case_hashes_are_stable() {
|
fn serialize_roundtrips() {
|
||||||
let a = build_case(123);
|
let corpus = build_corpus(20, 0x1234);
|
||||||
let b = build_case(123);
|
let text = serialize_corpus(&corpus);
|
||||||
assert_eq!(a, b);
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,8 @@ rune_ir = { path = "../rune_ir" }
|
|||||||
trace_model = { path = "../trace_model" }
|
trace_model = { path = "../trace_model" }
|
||||||
reference_runtime = { path = "../reference_runtime" }
|
reference_runtime = { path = "../reference_runtime" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
negative_controls = []
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|||||||
@@ -1,53 +1,52 @@
|
|||||||
//! `runtime_under_test` — the runtime that CI proves equivalent to the
|
//! `runtime_under_test` — the runtime that CI proves equivalent to the
|
||||||
//! reference. It is configuration-driven: the canonical configuration must
|
//! reference. Unlike the reference, this crate does **not** call the reference
|
||||||
//! match the reference bit-for-bit, while semantic mutation swaps in a mutated
|
//! engine: it carries its own independent interpreter ([`native::native_resolve`])
|
||||||
//! configuration to verify the test suite can detect any divergence.
|
//! re-derived from the spec. The runtime-equivalence gate therefore compares
|
||||||
//!
|
//! two genuinely separate implementations, so 100% agreement is *evidence* that
|
||||||
//! Per the spec's mandatory order, the *optimized* runtime may not begin until
|
//! the spec is implemented correctly rather than a tautology. A transcription
|
||||||
//! steps 1–7 pass CI; until then this runtime is the reference engine driven
|
//! error in either implementation surfaces as an equivalence failure (proven by
|
||||||
//! through the same config surface, which is by construction equivalent.
|
//! the negative-control test below).
|
||||||
|
|
||||||
use reference_runtime::{execute, EngineConfig, ResolutionInput, ResolutionResult, Runtime};
|
pub mod native;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
use reference_runtime::{ResolutionInput, ResolutionResult, Runtime};
|
||||||
pub struct RuntimeUnderTest {
|
|
||||||
pub config: EngineConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RuntimeUnderTest {
|
pub use native::native_resolve;
|
||||||
fn default() -> Self {
|
|
||||||
RuntimeUnderTest {
|
#[derive(Clone, Debug, Default)]
|
||||||
config: EngineConfig::reference(),
|
pub struct RuntimeUnderTest;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuntimeUnderTest {
|
impl RuntimeUnderTest {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
RuntimeUnderTest
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct with a specific engine config (used by semantic mutation to
|
|
||||||
/// install a mutated artifact).
|
|
||||||
pub fn with_config(config: EngineConfig) -> Self {
|
|
||||||
RuntimeUnderTest { config }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Runtime for RuntimeUnderTest {
|
impl Runtime for RuntimeUnderTest {
|
||||||
fn resolve(&self, input: ResolutionInput) -> ResolutionResult {
|
fn resolve(&self, input: ResolutionInput) -> ResolutionResult {
|
||||||
execute(&self.config, &input)
|
native_resolve(&input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A deliberately broken runtime used as a negative control: it shares the
|
||||||
|
/// independent interpreter but corrupts one recorded value. The equivalence
|
||||||
|
/// gate **must** reject it. This proves the gate can fail.
|
||||||
|
#[cfg(any(test, feature = "negative_controls"))]
|
||||||
|
pub fn buggy_resolve(input: &ResolutionInput) -> ResolutionResult {
|
||||||
|
let mut r = native_resolve(input);
|
||||||
|
// Drop a single causal edge — a subtle bug an honest gate has to catch.
|
||||||
|
r.trace.causal_graph.edges.pop();
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use reference_runtime::{canonical, execute, ReferenceRuntime};
|
use reference_runtime::{canonical, execute, EngineConfig};
|
||||||
use rune_ir::{Op, RuneProgram, RuneToken};
|
use rune_ir::{Op, RuneProgram, RuneToken, ALL_OPS};
|
||||||
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, NUM_DOMAINS};
|
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, NUM_DOMAINS};
|
||||||
|
|
||||||
fn random_input(seed: u64) -> ResolutionInput {
|
fn rich_input(seed: u64) -> ResolutionInput {
|
||||||
let mut rng = Rng::new(seed);
|
let mut rng = Rng::new(seed);
|
||||||
let mut w = WorldSnapshot::blank(WorldId(seed), seed);
|
let mut w = WorldSnapshot::blank(WorldId(seed), seed);
|
||||||
for d in &mut w.domains {
|
for d in &mut w.domains {
|
||||||
@@ -63,9 +62,9 @@ mod tests {
|
|||||||
w.causal_state.coupling[j][i] = rng.range_i64(-17, 17);
|
w.causal_state.coupling[j][i] = rng.range_i64(-17, 17);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let tokens: Vec<RuneToken> = (0..30)
|
let tokens: Vec<RuneToken> = (0..40)
|
||||||
.map(|_| RuneToken {
|
.map(|i| RuneToken {
|
||||||
op: Op::from_u8(rng.next_u64() as u8),
|
op: if i % 3 == 0 { ALL_OPS[i % 12] } else { Op::from_u8(rng.next_u64() as u8) },
|
||||||
a: rng.next_u64() as u8,
|
a: rng.next_u64() as u8,
|
||||||
b: rng.next_u64() as u8,
|
b: rng.next_u64() as u8,
|
||||||
c: rng.next_u64() as u8,
|
c: rng.next_u64() as u8,
|
||||||
@@ -75,25 +74,40 @@ mod tests {
|
|||||||
ResolutionInput {
|
ResolutionInput {
|
||||||
world: w,
|
world: w,
|
||||||
program: RuneProgram { id: ProgramId(seed), tokens, seed },
|
program: RuneProgram { id: ProgramId(seed), tokens, seed },
|
||||||
contexts: standard_executors(seed, 3),
|
contexts: standard_executors(seed, 4),
|
||||||
contract_seed: seed,
|
contract_seed: seed,
|
||||||
perturbation_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]
|
#[test]
|
||||||
fn rut_matches_reference() {
|
fn native_matches_reference_bit_for_bit() {
|
||||||
use reference_runtime::Runtime;
|
let cfg = EngineConfig::reference();
|
||||||
let rut = RuntimeUnderTest::new();
|
for s in 0..2000u64 {
|
||||||
let reference = ReferenceRuntime::new();
|
let input = rich_input(s.wrapping_mul(0x9e3779b97f4a7c15) ^ 0xabc);
|
||||||
for s in 0..200 {
|
let a = canonical(&execute(&cfg, &input));
|
||||||
let input = random_input(s);
|
let b = canonical(&native_resolve(&input));
|
||||||
let a = canonical(&reference.resolve(input.clone()));
|
assert_eq!(a, b, "independent interpreter diverged at seed {s}");
|
||||||
let b = canonical(&rut.resolve(input.clone()));
|
|
||||||
assert_eq!(a, b, "divergence at seed {s}");
|
|
||||||
// and against the raw engine path
|
|
||||||
let c = canonical(&execute(&EngineConfig::reference(), &input));
|
|
||||||
assert_eq!(a, c);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Negative control: a runtime with a real bug is rejected by the canonical
|
||||||
|
/// comparison. Proves the equivalence gate is not vacuous.
|
||||||
|
#[test]
|
||||||
|
fn buggy_runtime_is_rejected() {
|
||||||
|
let cfg = EngineConfig::reference();
|
||||||
|
let mut caught = 0;
|
||||||
|
for s in 0..200u64 {
|
||||||
|
let input = rich_input(s + 1);
|
||||||
|
let a = canonical(&execute(&cfg, &input));
|
||||||
|
let b = canonical(&buggy_resolve(&input));
|
||||||
|
if a != b {
|
||||||
|
caught += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(caught > 0, "the equivalence gate failed to catch a buggy runtime");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
//! An *independent* interpreter for the canonical engine behavior.
|
||||||
|
//!
|
||||||
|
//! This is the whole point of the runtime-equivalence gate: if the runtime
|
||||||
|
//! under test merely called `reference_runtime::execute`, agreement would be a
|
||||||
|
//! tautology and a bug in the shared interpreter would hide in both. This file
|
||||||
|
//! re-derives the executable spec's canonical behavior from scratch, in a
|
||||||
|
//! different code organization (a register-machine `Vm` rather than the
|
||||||
|
//! reference's free-function dispatch), depending only on the shared *data*
|
||||||
|
//! crates (`world_model`, `trace_model`, `rune_ir`) and never on the
|
||||||
|
//! reference's engine. When the two implementations agree it is evidence; when
|
||||||
|
//! a transcription error is introduced, the equivalence gate catches it (see
|
||||||
|
//! the negative-control tests).
|
||||||
|
//!
|
||||||
|
//! Because the runtime under test only ever needs to reproduce the *canonical*
|
||||||
|
//! reference configuration, the engine constants are inlined here as literals —
|
||||||
|
//! they are the spec, independently restated, not imported.
|
||||||
|
|
||||||
|
use reference_runtime::{ResolutionInput, ResolutionResult};
|
||||||
|
use rune_ir::{Op, RuneProgram, RuneToken};
|
||||||
|
use trace_model::{
|
||||||
|
BehaviorFingerprint, CausalEdge, CausalGraph, CausalNode, DivergenceGraph, DomainAccessGraph,
|
||||||
|
ExecutionTrace, FaultCode, FaultLog, InformationFlowGraph, PerturbationResponse, ReplayRecord,
|
||||||
|
TemporalGraph,
|
||||||
|
};
|
||||||
|
use world_model::{
|
||||||
|
DomainKind, ExecutionContext, Hash, Hasher, ScheduledEffect, WorldDelta, WorldSnapshot,
|
||||||
|
DomainId, HIDDEN_LANES, LANES, NUM_DOMAINS, REGS,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- The canonical engine constants, independently restated. ---------------
|
||||||
|
const C1: u64 = 0xff51afd7ed558ccd;
|
||||||
|
const C2: u64 = 0xc4ceb9fe1a85ec53;
|
||||||
|
const S1: u32 = 33;
|
||||||
|
const S2: u32 = 29;
|
||||||
|
const S3: u32 = 32;
|
||||||
|
const FUTURE_TURNS: usize = 3;
|
||||||
|
const DIFFUSE_SPAN: usize = 3;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn avalanche(z: i64) -> i64 {
|
||||||
|
let mut u = z as u64;
|
||||||
|
u ^= u >> S1;
|
||||||
|
u = u.wrapping_mul(C1);
|
||||||
|
u ^= u >> S2;
|
||||||
|
u = u.wrapping_mul(C2);
|
||||||
|
u ^= u >> S3;
|
||||||
|
u as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn combine(ctx: &ExecutionContext, a: i64, b: i64, coupling: i64, kc: u64) -> i64 {
|
||||||
|
let mut z = a.wrapping_mul(kc as i64);
|
||||||
|
z ^= b.rotate_left(((kc & 31) as u32) + 1);
|
||||||
|
z = z.wrapping_add(coupling.wrapping_mul(b & 0xffff));
|
||||||
|
z ^= ctx.salt() as i64;
|
||||||
|
z = z.wrapping_add(ctx.profile.bias);
|
||||||
|
z = z.rotate_left((ctx.profile.rotate % 63) + 1);
|
||||||
|
avalanche(z)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A register-machine view of one single-executor run. Holds the working world,
|
||||||
|
/// the trace graphs accumulated as the program executes, and the accumulator
|
||||||
|
/// file. Organised as a stateful object with `&mut self` methods, deliberately
|
||||||
|
/// unlike the reference's stateless free functions.
|
||||||
|
struct Vm<'a> {
|
||||||
|
w: WorldSnapshot,
|
||||||
|
ctx: &'a ExecutionContext,
|
||||||
|
read_graph: DomainAccessGraph,
|
||||||
|
write_graph: DomainAccessGraph,
|
||||||
|
causal_graph: CausalGraph,
|
||||||
|
info_flow: InformationFlowGraph,
|
||||||
|
temporal: TemporalGraph,
|
||||||
|
faults: FaultLog,
|
||||||
|
acc: [i64; REGS],
|
||||||
|
acc_src: [usize; REGS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Vm<'a> {
|
||||||
|
fn new(world: &WorldSnapshot, ctx: &'a ExecutionContext) -> Self {
|
||||||
|
let acc = world.execution_state.accumulator;
|
||||||
|
Vm {
|
||||||
|
w: world.clone(),
|
||||||
|
ctx,
|
||||||
|
read_graph: DomainAccessGraph::default(),
|
||||||
|
write_graph: DomainAccessGraph::default(),
|
||||||
|
causal_graph: CausalGraph::default(),
|
||||||
|
info_flow: InformationFlowGraph::default(),
|
||||||
|
temporal: TemporalGraph::default(),
|
||||||
|
faults: FaultLog::default(),
|
||||||
|
acc,
|
||||||
|
acc_src: [0; REGS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn rd(&self, dom: usize, lane: usize, hidden: bool) -> i64 {
|
||||||
|
if hidden {
|
||||||
|
self.w.domains[dom].hidden[lane % HIDDEN_LANES]
|
||||||
|
} else {
|
||||||
|
self.w.domains[dom].observed[lane % LANES]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn wr(&mut self, dom: usize, lane: usize, hidden: bool, val: i64) {
|
||||||
|
if hidden {
|
||||||
|
self.w.domains[dom].hidden[lane % HIDDEN_LANES] = val;
|
||||||
|
} else {
|
||||||
|
self.w.domains[dom].observed[lane % LANES] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn coupling(&self, to: usize, from: usize) -> i64 {
|
||||||
|
self.w.causal_state.coupling[to][from]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn mix(&self, a: i64, b: i64, coupling: i64, kc: u64) -> i64 {
|
||||||
|
combine(self.ctx, a, b, coupling, kc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one data-movement edge across every graph, in the canonical order.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn flow(
|
||||||
|
&mut self,
|
||||||
|
from_dom: usize,
|
||||||
|
from_lane: usize,
|
||||||
|
from_hidden: bool,
|
||||||
|
to_dom: usize,
|
||||||
|
to_lane: usize,
|
||||||
|
to_hidden: bool,
|
||||||
|
step: u32,
|
||||||
|
weight: i64,
|
||||||
|
) {
|
||||||
|
self.read_graph.access_count[from_dom] += 1;
|
||||||
|
self.write_graph.access_count[to_dom] += 1;
|
||||||
|
self.read_graph.edges.push((from_dom as u8, to_dom as u8, 1));
|
||||||
|
self.write_graph.edges.push((from_dom as u8, to_dom as u8, 1));
|
||||||
|
self.info_flow
|
||||||
|
.edges
|
||||||
|
.push((from_dom as u8, to_dom as u8, (weight as u64).count_ones()));
|
||||||
|
self.causal_graph.edges.push(CausalEdge {
|
||||||
|
from: CausalNode {
|
||||||
|
domain: from_dom as u8,
|
||||||
|
lane: from_lane as u8,
|
||||||
|
hidden: from_hidden,
|
||||||
|
step,
|
||||||
|
},
|
||||||
|
to: CausalNode {
|
||||||
|
domain: to_dom as u8,
|
||||||
|
lane: to_lane as u8,
|
||||||
|
hidden: to_hidden,
|
||||||
|
step,
|
||||||
|
},
|
||||||
|
weight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&mut self, program: &RuneProgram) {
|
||||||
|
for (i, tok) in program.tokens.iter().enumerate() {
|
||||||
|
let step = i as u32;
|
||||||
|
self.step(tok, step);
|
||||||
|
let dst = tok.dst_domain();
|
||||||
|
let lane = tok.lane();
|
||||||
|
let r = (step as usize) % REGS;
|
||||||
|
self.acc[r] = self.acc[r].wrapping_add(self.w.domains[dst].observed[lane]);
|
||||||
|
}
|
||||||
|
self.w.execution_state.accumulator = self.acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step(&mut self, tok: &RuneToken, step: u32) {
|
||||||
|
let src = tok.src_domain();
|
||||||
|
let dst = tok.dst_domain();
|
||||||
|
let lane = tok.lane();
|
||||||
|
let lane2 = tok.lane2();
|
||||||
|
let kc = DomainKind::from_index(dst).mix_const();
|
||||||
|
let coupling = self.coupling(dst, src);
|
||||||
|
|
||||||
|
match tok.op {
|
||||||
|
Op::Mix => {
|
||||||
|
let a = self.rd(src, lane, false);
|
||||||
|
let b = self.rd(dst, lane2, false);
|
||||||
|
let v = self.mix(a, b, coupling, kc);
|
||||||
|
self.wr(dst, lane, false, v);
|
||||||
|
self.flow(src, lane, false, dst, lane, false, step, v);
|
||||||
|
self.flow(dst, lane2, false, dst, lane, false, step, v);
|
||||||
|
}
|
||||||
|
Op::Channel => {
|
||||||
|
let a = self.rd(src, lane, false);
|
||||||
|
let v = self.mix(a, coupling, coupling, kc);
|
||||||
|
self.wr(dst, lane2, false, v);
|
||||||
|
self.flow(src, lane, false, dst, lane2, false, step, v);
|
||||||
|
}
|
||||||
|
Op::Branch => {
|
||||||
|
let probe = self.rd(src, lane, false);
|
||||||
|
let take_hot =
|
||||||
|
probe.wrapping_add(self.ctx.profile.bias) > self.ctx.profile.branch_threshold;
|
||||||
|
if take_hot {
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
let v = self.mix(probe, b, coupling, kc);
|
||||||
|
self.wr(dst, lane, false, v);
|
||||||
|
self.flow(src, lane, false, dst, lane, false, step, v);
|
||||||
|
} else {
|
||||||
|
let b = self.rd(dst, lane2, false);
|
||||||
|
let v = self.mix(b, probe, coupling, kc).wrapping_add(0x5bd1e9);
|
||||||
|
self.wr(dst, lane2, false, v);
|
||||||
|
self.flow(src, lane, false, dst, lane2, false, step, v);
|
||||||
|
self.faults.push(FaultCode::UnreachableBranch, step, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Op::Schedule => {
|
||||||
|
let a = self.rd(src, lane, false);
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
let v = self.mix(a, b, coupling, kc);
|
||||||
|
let offset = 1 + (tok.imm.rem_euclid(3)) as u8;
|
||||||
|
let hidden = tok.mode() & 1 == 1;
|
||||||
|
self.w.time_state.pending.push(ScheduledEffect {
|
||||||
|
turn_offset: offset,
|
||||||
|
domain: DomainId(dst as u8),
|
||||||
|
lane,
|
||||||
|
hidden,
|
||||||
|
value: v,
|
||||||
|
});
|
||||||
|
self.temporal.edges.push((step, offset, dst as u8));
|
||||||
|
self.flow(src, lane, false, dst, lane, hidden, step, v);
|
||||||
|
}
|
||||||
|
Op::Resonate => {
|
||||||
|
let a = self.rd(src, lane, false);
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
let m = self.mix(a, b, coupling, kc);
|
||||||
|
let va = a.wrapping_add(m);
|
||||||
|
let vb = b ^ m;
|
||||||
|
self.wr(src, lane, false, va);
|
||||||
|
self.wr(dst, lane, false, vb);
|
||||||
|
self.flow(dst, lane, false, src, lane, false, step, va);
|
||||||
|
self.flow(src, lane, false, dst, lane, false, step, vb);
|
||||||
|
}
|
||||||
|
Op::Observe => {
|
||||||
|
let reg = tok.mode() % REGS;
|
||||||
|
let mut z: i64 = self.acc[reg];
|
||||||
|
let proj = self.w.observed_projection();
|
||||||
|
for k in 0..3 {
|
||||||
|
let d = (src + k) % NUM_DOMAINS;
|
||||||
|
let idx = d * LANES + (lane + k) % LANES;
|
||||||
|
let cpl = self.coupling(dst, d);
|
||||||
|
z = self.mix(z, proj[idx], cpl, kc);
|
||||||
|
self.flow(d, (lane + k) % LANES, false, dst, lane, true, step, z);
|
||||||
|
}
|
||||||
|
self.acc[reg] = z;
|
||||||
|
self.acc_src[reg] = src;
|
||||||
|
self.wr(dst, tok.mode() % HIDDEN_LANES, true, z);
|
||||||
|
}
|
||||||
|
Op::Collapse => {
|
||||||
|
let reg = tok.mode() % REGS;
|
||||||
|
let a = self.acc[reg];
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
if a == 0 {
|
||||||
|
self.faults.push(FaultCode::EmptyAccumulator, step, reg as i64);
|
||||||
|
}
|
||||||
|
let v = self.mix(a, b, coupling, kc);
|
||||||
|
self.wr(dst, lane, false, v);
|
||||||
|
let asrc = self.acc_src[reg];
|
||||||
|
self.flow(asrc, 0, true, dst, lane, false, step, v);
|
||||||
|
}
|
||||||
|
Op::Invert => {
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
let mut v = avalanche((!b).wrapping_add(tok.imm));
|
||||||
|
v ^= self.ctx.salt() as i64;
|
||||||
|
v = v.wrapping_add(self.ctx.profile.bias);
|
||||||
|
self.wr(dst, lane, false, v);
|
||||||
|
self.flow(dst, lane, false, dst, lane, false, step, v);
|
||||||
|
}
|
||||||
|
Op::Diffuse => {
|
||||||
|
let a = self.rd(src, lane, false);
|
||||||
|
for k in 0..DIFFUSE_SPAN {
|
||||||
|
let d = (src + 1 + k) % NUM_DOMAINS;
|
||||||
|
let tl = (lane + k) % LANES;
|
||||||
|
let prev = self.rd(d, tl, false);
|
||||||
|
let cpl = self.coupling(d, src);
|
||||||
|
let kc2 = DomainKind::from_index(d).mix_const();
|
||||||
|
let v = self.mix(a, prev, cpl, kc2);
|
||||||
|
self.wr(d, tl, false, prev.wrapping_add(v));
|
||||||
|
self.flow(src, lane, false, d, tl, false, step, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Op::Anchor => {
|
||||||
|
let bound = (tok.imm.unsigned_abs() % 1_000_000) as i64 + 1;
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
let diag = self.coupling(dst, dst);
|
||||||
|
let mut mixed = b.wrapping_add(diag);
|
||||||
|
mixed = mixed
|
||||||
|
.wrapping_add(self.ctx.profile.bias)
|
||||||
|
.wrapping_add((self.ctx.salt() & 0xffff) as i64);
|
||||||
|
let clamped = mixed.clamp(-bound, bound);
|
||||||
|
if clamped != mixed {
|
||||||
|
self.faults.push(FaultCode::Saturated, step, bound);
|
||||||
|
}
|
||||||
|
self.wr(dst, lane, false, clamped);
|
||||||
|
self.flow(dst, lane, false, dst, lane, false, step, clamped);
|
||||||
|
}
|
||||||
|
Op::Echoback => {
|
||||||
|
let h = self.rd(dst, tok.mode() % HIDDEN_LANES, true);
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
let v = self.mix(h, b, coupling, kc);
|
||||||
|
self.wr(dst, lane, false, v);
|
||||||
|
self.flow(dst, tok.mode() % HIDDEN_LANES, true, dst, lane, false, step, v);
|
||||||
|
}
|
||||||
|
Op::Imprint => {
|
||||||
|
let b = self.rd(dst, lane, false);
|
||||||
|
let hl = tok.mode() % HIDDEN_LANES;
|
||||||
|
let prevh = self.rd(dst, hl, true);
|
||||||
|
let v = self.mix(b, prevh, coupling, kc);
|
||||||
|
self.wr(dst, hl, true, v);
|
||||||
|
self.flow(dst, lane, false, dst, hl, true, step, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the world one turn: resolve due scheduled effects, then run coupling
|
||||||
|
/// diffusion. Identical semantics to the reference's `step_world`, restated.
|
||||||
|
fn step_world(w: &mut WorldSnapshot) {
|
||||||
|
let pending = std::mem::take(&mut w.time_state.pending);
|
||||||
|
let mut still = Vec::new();
|
||||||
|
for e in pending {
|
||||||
|
if e.turn_offset <= 1 {
|
||||||
|
let d = e.domain.0 as usize;
|
||||||
|
if e.hidden {
|
||||||
|
let l = e.lane % HIDDEN_LANES;
|
||||||
|
w.domains[d].hidden[l] = w.domains[d].hidden[l].wrapping_add(e.value);
|
||||||
|
} else {
|
||||||
|
let l = e.lane % LANES;
|
||||||
|
w.domains[d].observed[l] = w.domains[d].observed[l].wrapping_add(e.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
still.push(ScheduledEffect {
|
||||||
|
turn_offset: e.turn_offset - 1,
|
||||||
|
..e
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.time_state.pending = still;
|
||||||
|
|
||||||
|
let snap = w.domains.clone();
|
||||||
|
for j in 0..NUM_DOMAINS {
|
||||||
|
for lane in 0..LANES {
|
||||||
|
let mut z = w.domains[j].observed[lane];
|
||||||
|
for i in 0..NUM_DOMAINS {
|
||||||
|
let c = w.causal_state.coupling[j][i];
|
||||||
|
z = z.wrapping_add(c.wrapping_mul(snap[i].observed[lane] & 0xff));
|
||||||
|
}
|
||||||
|
w.domains[j].observed[lane] = avalanche(z);
|
||||||
|
}
|
||||||
|
for hl in 0..HIDDEN_LANES {
|
||||||
|
let base = w.domains[j].hidden[hl].wrapping_add(snap[j].observed[0]);
|
||||||
|
w.domains[j].hidden[hl] = avalanche(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.turn = w.turn.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn future_hash(start: &WorldSnapshot) -> Hash {
|
||||||
|
let mut w = start.clone();
|
||||||
|
let mut h = Hasher::new();
|
||||||
|
h.write_tag("future-3");
|
||||||
|
for _ in 0..FUTURE_TURNS {
|
||||||
|
step_world(&mut w);
|
||||||
|
for v in w.ground_truth() {
|
||||||
|
h.write_i64(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_divergence(finals: &[WorldSnapshot]) -> DivergenceGraph {
|
||||||
|
let n = finals.len();
|
||||||
|
let mut pairwise = vec![0.0f64; n * n];
|
||||||
|
let total = (NUM_DOMAINS * LANES) as f64;
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
if i == j {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut diff = 0usize;
|
||||||
|
for d in 0..NUM_DOMAINS {
|
||||||
|
for l in 0..LANES {
|
||||||
|
if finals[i].domains[d].observed[l] != finals[j].domains[d].observed[l] {
|
||||||
|
diff += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pairwise[i * n + j] = diff as f64 / total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DivergenceGraph {
|
||||||
|
executor_count: n,
|
||||||
|
pairwise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn behavior_fingerprint(
|
||||||
|
delta: &WorldDelta,
|
||||||
|
causal: &CausalGraph,
|
||||||
|
read_graph: &DomainAccessGraph,
|
||||||
|
write_graph: &DomainAccessGraph,
|
||||||
|
info_flow: &InformationFlowGraph,
|
||||||
|
temporal: &TemporalGraph,
|
||||||
|
divergence: &DivergenceGraph,
|
||||||
|
future: Hash,
|
||||||
|
) -> BehaviorFingerprint {
|
||||||
|
let mut features: Vec<i64> = Vec::new();
|
||||||
|
for dd in &delta.domain_deltas {
|
||||||
|
let mut s = 0i64;
|
||||||
|
for &v in &dd.observed {
|
||||||
|
s = s.wrapping_add(v);
|
||||||
|
}
|
||||||
|
features.push(s);
|
||||||
|
}
|
||||||
|
for dd in &delta.domain_deltas {
|
||||||
|
let mut s = 0i64;
|
||||||
|
for &v in &dd.hidden {
|
||||||
|
s = s.wrapping_add(v);
|
||||||
|
}
|
||||||
|
features.push(s);
|
||||||
|
}
|
||||||
|
features.push(causal.causal_rank() as i64);
|
||||||
|
features.push(causal.edge_count() as i64);
|
||||||
|
features.push(read_graph.touched_count() as i64);
|
||||||
|
features.push(write_graph.touched_count() as i64);
|
||||||
|
features.push(info_flow.total_bits() as i64);
|
||||||
|
features.push(temporal.edge_count() as i64);
|
||||||
|
features.push((divergence.mean_divergence() * 1_000_000.0) as i64);
|
||||||
|
features.push(future.0 as i64);
|
||||||
|
BehaviorFingerprint::from_features(features)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The independent implementation of the canonical resolution.
|
||||||
|
pub fn native_resolve(input: &ResolutionInput) -> ResolutionResult {
|
||||||
|
let contexts = if input.contexts.is_empty() {
|
||||||
|
world_model::standard_executors(input.world.seed, 3)
|
||||||
|
} else {
|
||||||
|
input.contexts.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let world0 = input.world.clone();
|
||||||
|
|
||||||
|
let mut finals: Vec<WorldSnapshot> = Vec::with_capacity(contexts.len());
|
||||||
|
let mut primary: Option<Vm> = None;
|
||||||
|
for (idx, ctx) in contexts.iter().enumerate() {
|
||||||
|
let mut vm = Vm::new(&world0, ctx);
|
||||||
|
vm.run(&input.program);
|
||||||
|
finals.push(vm.w.clone());
|
||||||
|
if idx == 0 {
|
||||||
|
primary = Some(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let vm = primary.expect("at least one executor");
|
||||||
|
|
||||||
|
let delta = WorldDelta::between(&world0, &vm.w);
|
||||||
|
let divergence = compute_divergence(&finals);
|
||||||
|
let fhash = future_hash(&vm.w);
|
||||||
|
let behavior = behavior_fingerprint(
|
||||||
|
&delta,
|
||||||
|
&vm.causal_graph,
|
||||||
|
&vm.read_graph,
|
||||||
|
&vm.write_graph,
|
||||||
|
&vm.info_flow,
|
||||||
|
&vm.temporal,
|
||||||
|
&divergence,
|
||||||
|
fhash,
|
||||||
|
);
|
||||||
|
|
||||||
|
let trace = ExecutionTrace {
|
||||||
|
read_graph: vm.read_graph,
|
||||||
|
write_graph: vm.write_graph,
|
||||||
|
causal_graph: vm.causal_graph,
|
||||||
|
information_flow: vm.info_flow,
|
||||||
|
executor_divergence: divergence,
|
||||||
|
temporal_graph: vm.temporal,
|
||||||
|
perturbation_response: PerturbationResponse::default(),
|
||||||
|
behavior_fingerprint: behavior,
|
||||||
|
};
|
||||||
|
let trace_hash = trace.canonical_hash();
|
||||||
|
let delta_hash = delta.hash();
|
||||||
|
let replay = ReplayRecord {
|
||||||
|
world_seed: input.world.seed,
|
||||||
|
program_seed: input.program.seed,
|
||||||
|
contract_seed: input.contract_seed,
|
||||||
|
perturbation_seed: input.perturbation_seed,
|
||||||
|
trace_hash,
|
||||||
|
delta_hash,
|
||||||
|
future_hash: fhash,
|
||||||
|
};
|
||||||
|
|
||||||
|
ResolutionResult {
|
||||||
|
delta,
|
||||||
|
trace,
|
||||||
|
faults: vm.faults,
|
||||||
|
replay,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
//! `semantic_mutation` — structurally generate mutated runtimes and prove the
|
//! `semantic_mutation` — structurally generate mutated runtimes and prove the
|
||||||
//! test suite kills every one. A mutant is an [`EngineConfig`] (the runtime
|
//! test suite kills every one **by the named acceptance gate it targets**.
|
||||||
//! artifact) with exactly one behavior-affecting knob changed. Every mutant
|
//!
|
||||||
//! must fail at least one named acceptance gate; a survivor means the tests are
|
//! A mutant is an [`EngineConfig`] (the runtime artifact) with exactly one
|
||||||
//! invalid and blocks merge.
|
//! 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 reference_runtime::{canonical, execute, Canonical, EngineConfig, ResolutionInput};
|
||||||
use world_model::NUM_DOMAINS;
|
use world_model::NUM_DOMAINS;
|
||||||
@@ -10,6 +16,13 @@ use world_model::NUM_DOMAINS;
|
|||||||
/// A mutant runtime artifact.
|
/// A mutant runtime artifact.
|
||||||
pub type RuntimeArtifact = EngineConfig;
|
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.
|
/// The named acceptance gate a mutant is expected to fail.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub enum DetectionClass {
|
pub enum DetectionClass {
|
||||||
@@ -23,8 +36,8 @@ impl DetectionClass {
|
|||||||
pub fn name(self) -> &'static str {
|
pub fn name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
DetectionClass::RuntimeEquivalence => "runtime_equivalence",
|
DetectionClass::RuntimeEquivalence => "runtime_equivalence",
|
||||||
DetectionClass::CausalGate => "causal_gate",
|
DetectionClass::CausalGate => "causal_rank/trace",
|
||||||
DetectionClass::TemporalGate => "temporal_gate",
|
DetectionClass::TemporalGate => "metamorphic_response/temporal",
|
||||||
DetectionClass::DomainParticipation => "domain_participation",
|
DetectionClass::DomainParticipation => "domain_participation",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,9 +49,6 @@ pub trait SemanticMutator {
|
|||||||
fn expected_detection_reason(&self) -> DetectionClass;
|
fn expected_detection_reason(&self) -> DetectionClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each generated mutant is also a [`SemanticMutator`]: applying it to any base
|
|
||||||
/// artifact reproduces its single-knob change, and it names the gate it must
|
|
||||||
/// fail. This ties the structural generator to the spec's trait surface.
|
|
||||||
impl SemanticMutator for Mutant {
|
impl SemanticMutator for Mutant {
|
||||||
fn mutate(&self, _base: &RuntimeArtifact) -> RuntimeArtifact {
|
fn mutate(&self, _base: &RuntimeArtifact) -> RuntimeArtifact {
|
||||||
self.config.clone()
|
self.config.clone()
|
||||||
@@ -58,7 +68,8 @@ pub struct Mutant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `i`-th mutant deterministically from the reference artifact.
|
/// Build the `i`-th mutant deterministically from the reference artifact.
|
||||||
/// Every mutant differs from the reference in exactly one behavioral knob.
|
/// Every mutant differs from the reference in exactly one behavioral knob, and
|
||||||
|
/// is tagged with the named gate that change must trip.
|
||||||
pub fn mutant_for(i: usize) -> Mutant {
|
pub fn mutant_for(i: usize) -> Mutant {
|
||||||
let base = EngineConfig::reference();
|
let base = EngineConfig::reference();
|
||||||
let mut cfg = base.clone();
|
let mut cfg = base.clone();
|
||||||
@@ -145,7 +156,6 @@ pub fn mutant_for(i: usize) -> Mutant {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Safety net: guarantee the mutant is not accidentally identical.
|
|
||||||
if cfg == base {
|
if cfg == base {
|
||||||
cfg.use_hidden = !cfg.use_hidden;
|
cfg.use_hidden = !cfg.use_hidden;
|
||||||
}
|
}
|
||||||
@@ -169,20 +179,153 @@ pub fn reference_canon(inputs: &[ResolutionInput]) -> Vec<Canonical> {
|
|||||||
inputs.iter().map(|inp| canonical(&execute(&cfg, inp))).collect()
|
inputs.iter().map(|inp| canonical(&execute(&cfg, inp))).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `Some(case_index)` of the first execution where the mutant diverges
|
// --- Named-gate evaluators. -------------------------------------------------
|
||||||
/// from the reference (i.e. the mutant is killed), or `None` if it survives.
|
//
|
||||||
pub fn kill_index(
|
// Each evaluator computes, for a given engine config over the input corpus, the
|
||||||
mutant: &EngineConfig,
|
// metric a named CI gate checks, and returns whether that gate FAILS. The
|
||||||
inputs: &[ResolutionInput],
|
// reference config must pass all of them (asserted in tests); each mutant must
|
||||||
reference: &[Canonical],
|
// fail the one it targets.
|
||||||
) -> Option<usize> {
|
|
||||||
for (i, inp) in inputs.iter().enumerate() {
|
fn percentile(mut v: Vec<f64>, p: f64) -> f64 {
|
||||||
let c = canonical(&execute(mutant, inp));
|
if v.is_empty() {
|
||||||
if c != reference[i] {
|
return 0.0;
|
||||||
return Some(i);
|
}
|
||||||
|
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let idx = (((v.len() - 1) as f64) * p).round() as usize;
|
||||||
|
v[idx.min(v.len() - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn median(v: Vec<f64>) -> f64 {
|
||||||
|
if v.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut s = v;
|
||||||
|
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
s[s.len() / 2]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the causal/trace gate fails under `cfg`.
|
||||||
|
fn causal_gate_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
|
||||||
|
let edges: Vec<f64> = inputs
|
||||||
|
.iter()
|
||||||
|
.map(|inp| execute(cfg, inp).trace.causal_edge_count() as f64)
|
||||||
|
.collect();
|
||||||
|
let ranks: Vec<f64> = inputs
|
||||||
|
.iter()
|
||||||
|
.map(|inp| execute(cfg, inp).trace.causal_rank() as f64)
|
||||||
|
.collect();
|
||||||
|
median(edges) < CAUSAL_EDGES_MIN || percentile(ranks, 0.05) < CAUSAL_RANK_P95_MIN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the domain-participation gate fails under `cfg`.
|
||||||
|
fn domain_gate_fails(cfg: &EngineConfig, inputs: &[ResolutionInput]) -> bool {
|
||||||
|
let n = inputs.len().max(1) as f64;
|
||||||
|
let mut appears = [0u32; NUM_DOMAINS];
|
||||||
|
let mut mutated = [0u32; NUM_DOMAINS];
|
||||||
|
for inp in inputs {
|
||||||
|
let r = execute(cfg, inp);
|
||||||
|
for d in 0..NUM_DOMAINS {
|
||||||
|
if r.trace.read_graph.access_count[d] > 0 || r.trace.write_graph.access_count[d] > 0 {
|
||||||
|
appears[d] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dd in &r.delta.domain_deltas {
|
||||||
|
if !dd.is_zero() {
|
||||||
|
mutated[dd.domain.0 as usize] += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
(0..NUM_DOMAINS).any(|d| {
|
||||||
|
(appears[d] as f64 / n) < DOMAIN_APPEARS_MIN
|
||||||
|
|| (mutated[d] as f64 / n) < DOMAIN_MUTATED_MIN
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a fixed structural perturbation (bump domain 0, observed lane 0).
|
||||||
|
fn perturbed(input: &ResolutionInput) -> ResolutionInput {
|
||||||
|
let mut p = input.clone();
|
||||||
|
p.world.domains[0].observed[0] = p.world.domains[0].observed[0].wrapping_add(101);
|
||||||
|
p.world.mark_perturbed();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the temporal gate fails under `cfg`. The temporal gate asserts the
|
||||||
|
/// runtime carries genuine 3-turn future dynamics: it must (a) record temporal
|
||||||
|
/// edges, (b) have a future sensitive to perturbation, and (c) reproduce the
|
||||||
|
/// reference's 3-turn future. Any of these failing fails the gate.
|
||||||
|
fn temporal_gate_fails(
|
||||||
|
cfg: &EngineConfig,
|
||||||
|
inputs: &[ResolutionInput],
|
||||||
|
reference: &[Canonical],
|
||||||
|
) -> bool {
|
||||||
|
// (a) temporal edges present.
|
||||||
|
let tedges: Vec<f64> = inputs
|
||||||
|
.iter()
|
||||||
|
.map(|inp| execute(cfg, inp).trace.temporal_graph.edge_count() as f64)
|
||||||
|
.collect();
|
||||||
|
if median(tedges) < 1.0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// (b) future sensitive to perturbation.
|
||||||
|
let mut altered = 0usize;
|
||||||
|
for inp in inputs {
|
||||||
|
let base_future = execute(cfg, inp).replay.future_hash;
|
||||||
|
let pert_future = execute(cfg, &perturbed(inp)).replay.future_hash;
|
||||||
|
if base_future != pert_future {
|
||||||
|
altered += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let alt_rate = altered as f64 / inputs.len().max(1) as f64;
|
||||||
|
if alt_rate < FUTURE_ALT_MIN {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// (c) future matches the reference's 3-turn future on every input.
|
||||||
|
for (inp, ref_c) in inputs.iter().zip(reference) {
|
||||||
|
if execute(cfg, inp).replay.future_hash != ref_c.future_hash {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the runtime-equivalence gate fails under `cfg` (i.e. the mutant
|
||||||
|
/// diverges from the reference canonical view on at least one input).
|
||||||
|
fn equivalence_gate_fails(
|
||||||
|
cfg: &EngineConfig,
|
||||||
|
inputs: &[ResolutionInput],
|
||||||
|
reference: &[Canonical],
|
||||||
|
) -> bool {
|
||||||
|
inputs
|
||||||
|
.iter()
|
||||||
|
.zip(reference)
|
||||||
|
.any(|(inp, ref_c)| canonical(&execute(cfg, inp)) != *ref_c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate whether a mutant is killed by its **named** gate. Returns `None` if
|
||||||
|
/// killed (the named gate fails), or `Some(reason)` describing the survival.
|
||||||
|
pub fn survival_reason(
|
||||||
|
mutant: &Mutant,
|
||||||
|
inputs: &[ResolutionInput],
|
||||||
|
reference: &[Canonical],
|
||||||
|
) -> Option<String> {
|
||||||
|
let killed = match mutant.expected {
|
||||||
|
DetectionClass::RuntimeEquivalence => {
|
||||||
|
equivalence_gate_fails(&mutant.config, inputs, reference)
|
||||||
|
}
|
||||||
|
DetectionClass::CausalGate => causal_gate_fails(&mutant.config, inputs),
|
||||||
|
DetectionClass::TemporalGate => temporal_gate_fails(&mutant.config, inputs, reference),
|
||||||
|
DetectionClass::DomainParticipation => domain_gate_fails(&mutant.config, inputs),
|
||||||
|
};
|
||||||
|
if killed {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!(
|
||||||
|
"mutant {} ({}) did not fail its named gate {}",
|
||||||
|
mutant.id,
|
||||||
|
mutant.name,
|
||||||
|
mutant.expected.name()
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of running the full mutation suite.
|
/// Result of running the full mutation suite.
|
||||||
@@ -199,17 +342,16 @@ impl MutationOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all mutants against the input corpus.
|
/// Run all mutants against the input corpus, killing each by its named gate.
|
||||||
pub fn run_suite(count: usize, inputs: &[ResolutionInput]) -> MutationOutcome {
|
pub fn run_suite(count: usize, inputs: &[ResolutionInput]) -> MutationOutcome {
|
||||||
let reference = reference_canon(inputs);
|
let reference = reference_canon(inputs);
|
||||||
let mutants = generate_mutants(count);
|
let mutants = generate_mutants(count);
|
||||||
let mut killed = 0;
|
let mut killed = 0;
|
||||||
let mut survivors = Vec::new();
|
let mut survivors = Vec::new();
|
||||||
for m in &mutants {
|
for m in &mutants {
|
||||||
if kill_index(&m.config, inputs, &reference).is_some() {
|
match survival_reason(m, inputs, &reference) {
|
||||||
killed += 1;
|
None => killed += 1,
|
||||||
} else {
|
Some(reason) => survivors.push((m.id, reason)),
|
||||||
survivors.push((m.id, m.name.clone()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MutationOutcome {
|
MutationOutcome {
|
||||||
@@ -223,13 +365,13 @@ pub fn run_suite(count: usize, inputs: &[ResolutionInput]) -> MutationOutcome {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use rune_ir::{RuneProgram, RuneToken, ALL_OPS};
|
use rune_ir::{RuneProgram, RuneToken, ALL_OPS};
|
||||||
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, NUM_DOMAINS};
|
use world_model::{standard_executors, ProgramId, Rng, WorldId, WorldSnapshot, LANES, NUM_DOMAINS};
|
||||||
|
|
||||||
fn rich_input(seed: u64) -> ResolutionInput {
|
fn rich_input(seed: u64) -> ResolutionInput {
|
||||||
let mut rng = Rng::new(seed);
|
let mut rng = Rng::new(seed);
|
||||||
let mut w = WorldSnapshot::blank(WorldId(seed), seed);
|
let mut w = WorldSnapshot::blank(WorldId(seed), seed);
|
||||||
for d in &mut w.domains {
|
for d in &mut w.domains {
|
||||||
for l in 0..world_model::LANES {
|
for l in 0..LANES {
|
||||||
d.observed[l] = rng.range_i64(-5000, 5000);
|
d.observed[l] = rng.range_i64(-5000, 5000);
|
||||||
}
|
}
|
||||||
for l in 0..world_model::HIDDEN_LANES {
|
for l in 0..world_model::HIDDEN_LANES {
|
||||||
@@ -241,7 +383,6 @@ mod tests {
|
|||||||
w.causal_state.coupling[j][i] = rng.range_i64(-17, 17);
|
w.causal_state.coupling[j][i] = rng.range_i64(-17, 17);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// cover every op and every domain
|
|
||||||
let tokens: Vec<RuneToken> = (0..40)
|
let tokens: Vec<RuneToken> = (0..40)
|
||||||
.map(|i| RuneToken {
|
.map(|i| RuneToken {
|
||||||
op: ALL_OPS[i % ALL_OPS.len()],
|
op: ALL_OPS[i % ALL_OPS.len()],
|
||||||
@@ -260,6 +401,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn corpus() -> Vec<ResolutionInput> {
|
||||||
|
(0..16).map(|s| rich_input(s + 1)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_passes_every_named_gate() {
|
||||||
|
let inputs = corpus();
|
||||||
|
let reference = reference_canon(&inputs);
|
||||||
|
let cfg = EngineConfig::reference();
|
||||||
|
assert!(!causal_gate_fails(&cfg, &inputs), "reference fails causal gate");
|
||||||
|
assert!(!domain_gate_fails(&cfg, &inputs), "reference fails domain gate");
|
||||||
|
assert!(
|
||||||
|
!temporal_gate_fails(&cfg, &inputs, &reference),
|
||||||
|
"reference fails temporal gate"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!equivalence_gate_fails(&cfg, &inputs, &reference),
|
||||||
|
"reference fails equivalence gate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_mutant_survives_its_named_gate() {
|
||||||
|
let inputs = corpus();
|
||||||
|
let outcome = run_suite(520, &inputs);
|
||||||
|
assert!(outcome.passed(), "survivors: {:?}", outcome.survivors);
|
||||||
|
assert_eq!(outcome.killed, outcome.total);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_mutant_differs_from_reference() {
|
fn every_mutant_differs_from_reference() {
|
||||||
let base = EngineConfig::reference();
|
let base = EngineConfig::reference();
|
||||||
@@ -267,12 +437,4 @@ mod tests {
|
|||||||
assert_ne!(mutant_for(i).config, base, "mutant {i} equals reference");
|
assert_ne!(mutant_for(i).config, base, "mutant {i} equals reference");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_mutant_survives() {
|
|
||||||
let inputs: Vec<ResolutionInput> = (0..12).map(|s| rich_input(s + 1)).collect();
|
|
||||||
let outcome = run_suite(520, &inputs);
|
|
||||||
assert!(outcome.passed(), "survivors: {:?}", outcome.survivors);
|
|
||||||
assert_eq!(outcome.killed, outcome.total);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -404,12 +404,216 @@ impl ExecutionTrace {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn rank_of_identity_is_full() {
|
fn rank_of_identity_is_full() {
|
||||||
let id: Vec<Vec<f64>> = (0..5)
|
let id: Vec<Vec<f64>> = (0..5)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -262,4 +262,45 @@ impl WorldDelta {
|
|||||||
}
|
}
|
||||||
h.finish()
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,54 @@ single executor behavior
|
|||||||
single rune behavior
|
single rune behavior
|
||||||
single hidden damage formula
|
single hidden damage formula
|
||||||
decorative world state
|
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
|
Hard CI Gates
|
||||||
Minimum per full CI run:
|
Minimum per full CI run:
|
||||||
|
|
||||||
|
|||||||
@@ -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