**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)** - Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`. - `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean. - Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`). - Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅. - Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed. - No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
219 lines
6.7 KiB
Python
219 lines
6.7 KiB
Python
"""`sim/rng.py` is golden-vector locked — the replay contract starts here (A21).
|
|
|
|
The numbers below are not "whatever the code returns today": they are splitmix64
|
|
as published (Steele/Lea/Venc 2014, Algorithm 5), written down once and then
|
|
never moved. If a future change to the generator breaks them, a committed replay
|
|
golden would silently describe a different game, and that is exactly what a
|
|
replay is supposed to catch.
|
|
|
|
Beyond the vectors, the tests pin the properties the kernel relies on: the
|
|
counter wraps at 2**64, `next_float` is a half-open [0,1) draw that is actually
|
|
spread out, `range_int` hits both of its ends, `fork` is reproducible and never
|
|
disturbs its parent, and nothing in the module is shared global state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from undercooked.sim.rng import GOLDEN_GAMMA, MASK64, Rng, mix64
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
# --- published splitmix64 vectors ---------------------------------------------------
|
|
|
|
|
|
def test_seed_zero_u64_vector() -> None:
|
|
rng = Rng(0)
|
|
assert [rng.next_u64() for _ in range(4)] == [
|
|
16294208416658607535,
|
|
7960286522194355700,
|
|
487617019471545679,
|
|
17909611376780542444,
|
|
]
|
|
|
|
|
|
def test_round_seed_vector() -> None:
|
|
# 0x5EED0401 is the `random_seed` fixture value: what a real round draws first.
|
|
rng = Rng(0x5EED_0401)
|
|
assert [rng.next_u64() for _ in range(2)] == [
|
|
966088850652082199,
|
|
13654240339074911480,
|
|
]
|
|
|
|
|
|
def test_next_float_vector() -> None:
|
|
rng = Rng(0)
|
|
assert rng.next_float() == pytest.approx(0.8833108082136426, abs=0.0)
|
|
assert rng.next_float() == pytest.approx(0.43152799704850997, abs=0.0)
|
|
assert rng.next_float() == pytest.approx(0.026433771592597743, abs=0.0)
|
|
|
|
|
|
def test_range_int_vector() -> None:
|
|
rng = Rng(0)
|
|
assert [rng.range_int(0, 5) for _ in range(8)] == [1, 0, 1, 4, 1, 0, 5, 2]
|
|
|
|
|
|
def test_choice_vector() -> None:
|
|
rng = Rng(1)
|
|
assert [rng.choice(["a", "b", "c", "d"]) for _ in range(6)] == [
|
|
"b",
|
|
"d",
|
|
"c",
|
|
"d",
|
|
"b",
|
|
"a",
|
|
]
|
|
|
|
|
|
def test_fork_vector() -> None:
|
|
assert Rng(0).fork(0).state == 5197578548964807871
|
|
assert Rng(0).fork(7).state == 8409816773569330625
|
|
assert Rng(0).fork(8).state == 15655543297350242377
|
|
assert Rng(0).fork(0).next_u64() == 6235967106033911276
|
|
|
|
|
|
def test_mix64_is_the_published_finalizer() -> None:
|
|
assert mix64(0) == 0 # the fixed point `fork` steps around
|
|
assert mix64(GOLDEN_GAMMA) == 16294208416658607535 # == Rng(0).next_u64()
|
|
|
|
|
|
# --- the counter --------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("seed", [0, -1, -2**64, 2**64, 2**65 + 7, MASK64])
|
|
def test_any_seed_is_reduced_to_64_bits(seed: int) -> None:
|
|
rng = Rng(seed)
|
|
assert 0 <= rng.state <= MASK64
|
|
|
|
|
|
def test_seeds_that_agree_mod_2p64_agree() -> None:
|
|
assert Rng(-1).state == MASK64
|
|
assert Rng(-1).next_u64() == Rng(MASK64).next_u64()
|
|
|
|
|
|
def test_counter_wraps_and_never_leaves_64_bits() -> None:
|
|
rng = Rng(MASK64 - 2)
|
|
for _ in range(6):
|
|
rng.next_u64()
|
|
assert 0 <= rng.state <= MASK64
|
|
|
|
|
|
def test_zero_seed_is_not_a_fixed_point() -> None:
|
|
rng = Rng(0)
|
|
first = rng.next_u64()
|
|
assert rng.state != 0
|
|
assert first != rng.next_u64()
|
|
|
|
|
|
# --- draw distributions ---------------------------------------------------------------
|
|
|
|
|
|
def test_next_float_is_half_open_over_many_draws() -> None:
|
|
rng = Rng(0xC0FFEE)
|
|
draws = [rng.next_float() for _ in range(10_000)]
|
|
assert all(0.0 <= draw < 1.0 for draw in draws)
|
|
|
|
|
|
def test_next_float_is_spread_out() -> None:
|
|
rng = Rng(0xC0FFEE)
|
|
buckets = [0] * 10
|
|
for _ in range(10_000):
|
|
buckets[int(rng.next_float() * 10)] += 1
|
|
assert all(850 <= count <= 1150 for count in buckets), buckets
|
|
mean_bucket = sum(count * (index + 0.5) for index, count in enumerate(buckets)) / 10_000
|
|
assert 4.5 <= mean_bucket <= 5.5
|
|
|
|
|
|
def test_next_float_uses_the_whole_range() -> None:
|
|
rng = Rng(424_242)
|
|
draws = [rng.next_float() for _ in range(10_000)]
|
|
assert min(draws) < 0.01
|
|
assert max(draws) > 0.99
|
|
|
|
|
|
def test_range_int_stays_in_bounds_and_reaches_both_ends() -> None:
|
|
rng = Rng(2024)
|
|
draws = [rng.range_int(3, 9) for _ in range(5_000)]
|
|
assert set(draws) == {3, 4, 5, 6, 7, 8, 9}
|
|
|
|
|
|
def test_range_int_of_a_single_value_never_moves() -> None:
|
|
rng = Rng(9)
|
|
assert [rng.range_int(-2, -2) for _ in range(4)] == [-2, -2, -2, -2]
|
|
|
|
|
|
def test_range_int_rejects_an_empty_range() -> None:
|
|
with pytest.raises(ValueError, match=r"empty integer range 5\.\.2"):
|
|
Rng(0).range_int(5, 2)
|
|
|
|
|
|
def test_choice_only_returns_members() -> None:
|
|
rng = Rng(7)
|
|
flavors = ["tomato", "onion", "lettuce"]
|
|
assert {rng.choice(flavors) for _ in range(500)} == set(flavors)
|
|
|
|
|
|
def test_choice_rejects_an_empty_sequence() -> None:
|
|
with pytest.raises(ValueError, match="empty sequence"):
|
|
Rng(0).choice([])
|
|
|
|
|
|
# --- streams are independent, and fork does not disturb the parent ----------------------
|
|
|
|
|
|
def test_two_generators_from_one_seed_agree() -> None:
|
|
left, right = Rng(1234), Rng(1234)
|
|
assert [left.next_u64() for _ in range(32)] == [right.next_u64() for _ in range(32)]
|
|
|
|
|
|
def test_a_generator_is_not_shared_state() -> None:
|
|
first, second = Rng(5), Rng(5)
|
|
for _ in range(4):
|
|
first.next_u64()
|
|
assert second.state == 5
|
|
assert second.next_u64() == Rng(5).next_u64()
|
|
|
|
|
|
def test_fork_is_reproducible_and_independent() -> None:
|
|
parent = Rng(0xC1)
|
|
child_a, child_b = parent.fork(3), parent.fork(3)
|
|
assert child_a == child_b
|
|
assert [child_a.next_u64() for _ in range(8)] == [child_b.next_u64() for _ in range(8)]
|
|
|
|
|
|
def test_fork_leaves_the_parent_stream_untouched() -> None:
|
|
parent = Rng(0xC1)
|
|
before = parent.state
|
|
for salt in range(6):
|
|
parent.fork(salt)
|
|
assert parent.state == before
|
|
assert parent.next_u64() == Rng(0xC1).next_u64()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("salt", "differs_from_zero"),
|
|
[(0, False), (1, True), (2, True), (7, True), (-1, True), (MASK64, True)],
|
|
)
|
|
def test_the_salt_selects_the_stream(salt: int, differs_from_zero: bool) -> None:
|
|
parent = Rng(0)
|
|
other = parent.fork(salt)
|
|
assert (other.state != parent.fork(0).state) is differs_from_zero
|
|
|
|
|
|
def test_fork_zero_is_not_the_parent_stream() -> None:
|
|
# mix64(0) == 0, so a naive `state ^ mix64(salt)` would clone the parent here.
|
|
assert Rng(0).fork(0).state != 0
|
|
assert Rng(0).fork(0).next_u64() != Rng(0).next_u64()
|
|
|
|
|
|
def test_nested_forks_are_reproducible() -> None:
|
|
outer = Rng(77)
|
|
deep_a = outer.fork(1).fork(2).next_u64()
|
|
deep_b = Rng(77).fork(1).fork(2).next_u64()
|
|
assert deep_a == deep_b
|
|
|
|
|
|
def test_fork_accepts_any_integer_salt() -> None:
|
|
assert Rng(3).fork(-42).state == Rng(3).fork(-42 + (1 << 64)).state
|