From e61c769bede9e216135cf9f7bd3c5c6263501b64 Mon Sep 17 00:00:00 2001 From: Ryan James Date: Tue, 8 Sep 2026 21:41:15 -0600 Subject: [PATCH] =?UTF-8?q?Add=20Tetris=20piece-randomizer=20=CE=B5-machin?= =?UTF-8?q?es=20as=20example=20processes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These unifilar constructors (IID, NES, 7-bag, TGM history, Game Boy) give a family of finite-memory spawn algorithms with known entropy-rate identities. Co-authored-by: Cursor --- docs/examples.rst | 25 ++++ docs/references.bib | 32 +++++ sofic/examples/__init__.py | 22 +++- sofic/examples/tetris.py | 239 +++++++++++++++++++++++++++++++++++++ tests/test_tetris.py | 142 ++++++++++++++++++++++ 5 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 sofic/examples/tetris.py create mode 100644 tests/test_tetris.py diff --git a/docs/examples.rst b/docs/examples.rst index 7b32cd8..566f186 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -143,6 +143,24 @@ figures: * :func:`golden_mean` * :func:`noisy_random_phase_slip` +Tetris randomizers +~~~~~~~~~~~~~~~~~~ + +Unifilar ε-machines for tetromino spawn algorithms. All use the seven-letter +alphabet ``IJLOSTZ`` and the *stationary* piece process (game-start transients +such as TGM's initial history and the first-piece S/Z/O ban are omitted). + +* :func:`tetris_iid` — uniform i.i.d. draws +* :func:`tetris_nes` — idealized NES one-reroll randomizer :cite:`TetrisWikiNES` +* :func:`tetris_bag` — Guideline 7-bag (Random Generator) :cite:`TetrisWikiRandomGenerator` +* :func:`tetris_history` — TGM-style history window with a reroll budget :cite:`TetrisWikiTGM` +* :func:`tetris_tgm`, :func:`tetris_tgm2` — TGM1 (4 history / 4 rolls) and TGM2 (4 / 6) +* :func:`tetris_gameboy` — Game Boy 1989 bitwise-OR randomizer :cite:`HardDropGameBoy` + +TGM3's 35-pool drought randomizer is not expanded: its state space is millions +of configurations. These factories are not registered on +``processes.process_list``. + Process library --------------- @@ -233,3 +251,10 @@ API .. autofunction:: sofic_dyck_fig1_shift .. autofunction:: sofic_dyck_nondeterminizable_shift .. autofunction:: sofic_dyck_zeta_example_shift +.. autofunction:: tetris_iid +.. autofunction:: tetris_nes +.. autofunction:: tetris_bag +.. autofunction:: tetris_history +.. autofunction:: tetris_tgm +.. autofunction:: tetris_tgm2 +.. autofunction:: tetris_gameboy diff --git a/docs/references.bib b/docs/references.bib index a794925..227ccb6 100644 --- a/docs/references.bib +++ b/docs/references.bib @@ -901,3 +901,35 @@ @inproceedings{Volkov2008 year = {2008}, doi = {10.1007/978-3-540-88282-4_4}, } + +@misc{TetrisWikiNES, + author = {{Tetris Wiki}}, + title = {Tetris ({NES})}, + year = {2026}, + url = {https://tetris.wiki/Tetris_(NES)}, + note = {Reverse-engineered Nintendo NES piece randomizer}, +} + +@misc{TetrisWikiRandomGenerator, + author = {{Tetris Wiki}}, + title = {Random Generator}, + year = {2026}, + url = {https://tetris.wiki/Random_Generator}, + note = {Tetris Guideline 7-bag algorithm}, +} + +@misc{TetrisWikiTGM, + author = {{Tetris Wiki}}, + title = {{TGM} randomizer}, + year = {2026}, + url = {https://tetris.wiki/TGM_randomizer}, + note = {History-based tetromino randomizer used in Tetris The Grand Master}, +} + +@misc{HardDropGameBoy, + author = {{Hard Drop Tetris Wiki}}, + title = {Tetris ({Game Boy})}, + year = {2026}, + url = {https://harddrop.com/wiki/Tetris_(Game_Boy)}, + note = {Reverse-engineered Game Boy bitwise-OR randomizer}, +} diff --git a/sofic/examples/__init__.py b/sofic/examples/__init__.py index b7b2156..2900ba2 100644 --- a/sofic/examples/__init__.py +++ b/sofic/examples/__init__.py @@ -41,9 +41,20 @@ sofic_dyck_nondeterminizable_shift, sofic_dyck_zeta_example_shift, ) +from sofic.examples.tetris import ( + TETROMINOES, + tetris_bag, + tetris_gameboy, + tetris_history, + tetris_iid, + tetris_nes, + tetris_tgm, + tetris_tgm2, +) processes = _import_module("sofic.examples.processes") shifts = _import_module("sofic.examples.shifts") +tetris = _import_module("sofic.examples.tetris") __all__ = [ "alternating_biased_coins", @@ -79,8 +90,17 @@ "tent_map_misiurewicz_partition_information_expected", "tent_map_misiurewicz_partition_symbol_matrices", "tent_map_misiurewicz_reverse", + "TETROMINOES", + "tetris_bag", + "tetris_gameboy", + "tetris_history", + "tetris_iid", + "tetris_nes", + "tetris_tgm", + "tetris_tgm2", ] __all__ += _process_all -__all__ = [name for name in __all__ if name not in {"processes", "shifts"}] +__all__ = [name for name in __all__ if name not in {"processes", "shifts", "tetris"}] __all__.append("processes") __all__.append("shifts") +__all__.append("tetris") diff --git a/sofic/examples/tetris.py b/sofic/examples/tetris.py new file mode 100644 index 0000000..f83381e --- /dev/null +++ b/sofic/examples/tetris.py @@ -0,0 +1,239 @@ +"""Tetromino randomizer ε-machines. + +These constructors model the *stationary* piece process. Game-start transients +— TGM's initial ``ZZZZ`` / ``ZZSS`` history and the first-piece ban on S, Z, +and O — are not part of the stationary law and are omitted. + +TGM3's 35-pool drought randomizer is omitted: its state (pool occupancy, a +7-piece drought order, and a 4-piece history) has millions of configurations. + +References +---------- +- NES spawn algorithm: :cite:`TetrisWikiNES` +- Guideline 7-bag (Random Generator): :cite:`TetrisWikiRandomGenerator` +- TGM history randomizer: :cite:`TetrisWikiTGM` +- Game Boy bitwise-OR randomizer: :cite:`HardDropGameBoy` +""" + +from __future__ import annotations + +from collections.abc import Hashable, Sequence +from itertools import combinations, product + +import numpy as np + +from sofic.examples.processes import _edge_machine +from sofic.generators.epsilon_machine import EpsilonMachine + +TETROMINOES: tuple[str, ...] = ("I", "J", "L", "O", "S", "T", "Z") + +# Game Boy ROM numbering, L = 0b000 through Z = 0b110. +_GAMEBOY_INDEX: dict[str, int] = {"L": 0, "J": 1, "I": 2, "O": 3, "S": 4, "T": 5, "Z": 6} + + +def _reroll_emission_probs( + alphabet: Sequence[str], + rolls: int, + rejected: set[str], +) -> dict[str, float]: + """Emission law for a uniform draw with ``rolls`` attempts. + + Each attempt is uniform on ``alphabet``. A draw in ``rejected`` is thrown + out unless it is the last attempt, which is always accepted. + """ + n = len(alphabet) + uniform = 1.0 / n + if rolls <= 1 or not rejected or len(rejected) == n: + return dict.fromkeys(alphabet, uniform) + rho = len(rejected) / n + p_rejected = uniform * (rho ** (rolls - 1)) + p_free = uniform * (1.0 - rho**rolls) / (1.0 - rho) + return {piece: p_rejected if piece in rejected else p_free for piece in alphabet} + + +def _stationary_from_edges( + states: Sequence[Hashable], + edges: Sequence[tuple[Hashable, Hashable, object, float]], + *, + tol: float = 1e-14, + max_iter: int = 100_000, +) -> dict[Hashable, float]: + """Left-stationary distribution by sparse power iteration.""" + n = len(states) + if n == 0: + return {} + index = {state: i for i, state in enumerate(states)} + outgoing: list[list[tuple[int, float]]] = [[] for _ in range(n)] + for source, target, _symbol, prob in edges: + outgoing[index[source]].append((index[target], float(prob))) + + pi = np.full(n, 1.0 / n) + nxt = np.empty(n) + for _ in range(max_iter): + nxt.fill(0.0) + for i, mass in enumerate(pi): + if mass == 0.0: + continue + for j, prob in outgoing[i]: + nxt[j] += mass * prob + total = float(nxt.sum()) + if total <= 0.0: + break + nxt /= total + if float(np.max(np.abs(nxt - pi))) < tol: + pi = nxt + break + pi = nxt.copy() + return {state: float(pi[i]) for i, state in enumerate(states)} + + +def _history_machine( + window: int, + rolls: int, + *, + name: str, + alphabet: Sequence[str] = TETROMINOES, +) -> EpsilonMachine: + states = list(product(alphabet, repeat=window)) + edges: list[tuple[Hashable, Hashable, str, float]] = [] + for history in states: + rejected = set(history) + suffix = history[1:] + for piece, prob in _reroll_emission_probs(alphabet, rolls, rejected).items(): + if prob > 0.0: + edges.append((history, (*suffix, piece), piece, prob)) + initial = _stationary_from_edges(states, edges) + return _edge_machine( + edges, + name=name, + initial_distribution=initial, + normalize=False, + ) + + +def tetris_iid() -> EpsilonMachine: + """Memoryless uniform draw over the seven tetrominoes. + + The original 1984 Electronika 60 game, and many early ports, sample each + piece independently. Entropy rate is ``log2(7)``. + """ + state = "A" + mass = 1.0 / len(TETROMINOES) + return _edge_machine( + [(state, state, piece, mass) for piece in TETROMINOES], + name="Tetris IID", + initial_distribution={state: 1.0}, + normalize=False, + ) + + +def tetris_nes() -> EpsilonMachine: + """Idealized NES Tetris randomizer. + + The first roll is uniform on eight values (the seven pieces plus a dummy). + A dummy or a repeat of the previous piece triggers a second roll, uniform + on the seven pieces :cite:`TetrisWikiNES`. With previous piece ``prev``, + + - ``P(prev | prev) = 1/28`` + - ``P(p | prev) = 9/56`` for ``p ≠ prev`` + + This is the intended "vaguely avoids duplicates" model. The 6502 + implementation also folds in a spawn-count modulo, which biases the + second roll; that 49-state machine is not reproduced here. + """ + # First roll fails with probability 2/8 (dummy or previous), then the + # second roll is uniform on 7, so a repeat has probability (1/4)*(1/7). + p_repeat = 1.0 / 28.0 + p_other = 9.0 / 56.0 + edges = [] + for prev in TETROMINOES: + for piece in TETROMINOES: + prob = p_repeat if piece == prev else p_other + edges.append((prev, piece, piece, prob)) + return _edge_machine(edges, name="Tetris NES", normalize=False) + + +def tetris_bag() -> EpsilonMachine: + """Guideline 7-bag (Random Generator) :cite:`TetrisWikiRandomGenerator`. + + Causal state is the remaining subset of the current bag (``2^7 - 1 = 127`` + nonempty subsets), stored as a sorted tuple. The last piece of a bag + refills to a fresh permutation of all seven. Entropy rate is + ``log2(7!)/7``. + """ + alphabet = TETROMINOES + full = alphabet + edges: list[tuple[Hashable, Hashable, str, float]] = [] + for k in range(1, len(alphabet) + 1): + for remaining in combinations(alphabet, k): + if k == 1: + edges.append((remaining, full, remaining[0], 1.0)) + continue + mass = 1.0 / k + for piece in remaining: + nxt = tuple(symbol for symbol in remaining if symbol != piece) + edges.append((remaining, nxt, piece, mass)) + return _edge_machine(edges, name="Tetris 7-bag", normalize=False) + + +def tetris_history(window: int = 4, rolls: int = 4) -> EpsilonMachine: + """TGM-style history randomizer :cite:`TetrisWikiTGM`. + + The state is the ordered window of the last ``window`` pieces. Each of + ``rolls`` attempts draws uniformly from the seven tetrominoes; a draw that + appears in the window is rejected unless it is the last attempt. + + ``window=4, rolls=4`` is TGM1; ``window=4, rolls=6`` is TGM2 / TAP. The + chain is a Markov process of order ``window`` on ``7**window`` states. + """ + if window < 1: + raise ValueError("window must be >= 1") + if rolls < 1: + raise ValueError("rolls must be >= 1") + return _history_machine(window, rolls, name=f"Tetris history({window}, {rolls})") + + +def tetris_tgm() -> EpsilonMachine: + """Tetris The Grand Master (TGM1): 4-piece history, 4 rolls.""" + return _history_machine(4, 4, name="Tetris TGM") + + +def tetris_tgm2() -> EpsilonMachine: + """Tetris The Absolute The Grand Master 2: 4-piece history, 6 rolls.""" + return _history_machine(4, 6, name="Tetris TGM2") + + +def tetris_gameboy() -> EpsilonMachine: + """Game Boy Tetris (1989) bitwise-OR randomizer :cite:`HardDropGameBoy`. + + State is the last two pieces ``(locking, preview)``. A candidate is + accepted when ``(locking | preview | candidate) != locking`` in the ROM's + piece numbering (L=0, …, Z=6). Up to three rolls; the third is always + taken. Intended to suppress three-in-a-row of the same piece; the bitwise + test makes L the rarest piece and O/S/T the most common. + """ + alphabet = TETROMINOES + rolls = 3 + edges: list[tuple[Hashable, Hashable, str, float]] = [] + for locking, preview in product(alphabet, repeat=2): + rejected = { + piece + for piece in alphabet + if (_GAMEBOY_INDEX[locking] | _GAMEBOY_INDEX[preview] | _GAMEBOY_INDEX[piece]) == _GAMEBOY_INDEX[locking] + } + for piece, prob in _reroll_emission_probs(alphabet, rolls, rejected).items(): + if prob > 0.0: + edges.append(((locking, preview), (preview, piece), piece, prob)) + return _edge_machine(edges, name="Tetris Game Boy", normalize=False) + + +__all__ = [ + "TETROMINOES", + "tetris_bag", + "tetris_gameboy", + "tetris_history", + "tetris_iid", + "tetris_nes", + "tetris_tgm", + "tetris_tgm2", +] diff --git a/tests/test_tetris.py b/tests/test_tetris.py new file mode 100644 index 0000000..e61f4a3 --- /dev/null +++ b/tests/test_tetris.py @@ -0,0 +1,142 @@ +"""Tests for tetromino randomizer ε-machines.""" + +from __future__ import annotations + +import math +from collections import Counter + +import pytest + +from sofic.examples import ( + TETROMINOES, + tetris_bag, + tetris_gameboy, + tetris_history, + tetris_iid, + tetris_nes, + tetris_tgm, + tetris_tgm2, +) +from sofic.examples.tetris import _reroll_emission_probs +from sofic.generators.epsilon_machine import EpsilonMachine + +_ALPHABET = frozenset(TETROMINOES) + + +def _history_order1() -> EpsilonMachine: + return tetris_history(1, 2) + + +@pytest.mark.parametrize( + "constructor, n_states", + [ + (tetris_iid, 1), + (tetris_nes, 7), + (tetris_bag, 127), + (tetris_gameboy, 49), + (_history_order1, 7), + ], +) +def test_small_randomizers_validate(constructor, n_states): + machine = constructor() + machine.validate() + assert machine.is_unifilar() + assert machine.observation_alphabet == _ALPHABET + assert len(list(machine.states())) == n_states + + +def test_history_parameter_validation(): + with pytest.raises(ValueError, match="window"): + tetris_history(window=0) + with pytest.raises(ValueError, match="rolls"): + tetris_history(rolls=0) + + +def test_iid_entropy_rate(): + pytest.importorskip("dit") + assert tetris_iid().entropy_rate() == pytest.approx(math.log2(7)) + + +def test_nes_transition_probs(): + nes = tetris_nes() + edges = {(t.source, t.data["emission"], t.target): t.data["prob"] for t in nes.transitions()} + for prev in TETROMINOES: + assert edges[(prev, prev, prev)] == pytest.approx(1.0 / 28.0) + others = [piece for piece in TETROMINOES if piece != prev] + for piece in others: + assert edges[(prev, piece, piece)] == pytest.approx(9.0 / 56.0) + + +def test_bag_singleton_refills(): + bag = tetris_bag() + full = TETROMINOES + edges = {(t.source, t.data["emission"], t.target): t.data["prob"] for t in bag.transitions()} + singleton = ("I",) + assert edges[(singleton, "I", full)] == pytest.approx(1.0) + six = tuple(piece for piece in TETROMINOES if piece != "I") + assert edges[(full, "I", six)] == pytest.approx(1.0 / 7.0) + + +def test_bag_entropy_rate_and_complexity(): + pytest.importorskip("dit") + bag = tetris_bag() + assert bag.entropy_rate() == pytest.approx(math.log2(math.factorial(7)) / 7.0) + expected_cmu = math.log2(7) + (1.0 / 7.0) * sum(math.log2(math.comb(7, k)) for k in range(1, 8)) + assert bag.statistical_complexity() == pytest.approx(expected_cmu) + + +def test_history_avoids_window_pieces(): + history = ("I", "J", "L", "O") + forbidden = set(history) + tgm_probs = _reroll_emission_probs(TETROMINOES, 4, forbidden) + tgm2_probs = _reroll_emission_probs(TETROMINOES, 6, forbidden) + uniform = 1.0 / 7.0 + for piece in forbidden: + assert tgm_probs[piece] < uniform + assert tgm2_probs[piece] < tgm_probs[piece] + + +@pytest.fixture(scope="module") +def tgm_machine(): + return tetris_tgm() + + +@pytest.fixture(scope="module") +def tgm2_machine(): + return tetris_tgm2() + + +def test_tgm_validates(tgm_machine): + tgm_machine.validate() + assert tgm_machine.is_unifilar() + assert tgm_machine.observation_alphabet == _ALPHABET + assert len(list(tgm_machine.states())) == 7**4 + + +def test_tgm_history_emissions_match_formula(tgm_machine, tgm2_machine): + history = ("I", "J", "L", "O") + forbidden = set(history) + tgm_edges = {t.data["emission"]: t.data["prob"] for t in tgm_machine.transitions() if t.source == history} + tgm2_edges = {t.data["emission"]: t.data["prob"] for t in tgm2_machine.transitions() if t.source == history} + expected_tgm = _reroll_emission_probs(TETROMINOES, 4, forbidden) + expected_tgm2 = _reroll_emission_probs(TETROMINOES, 6, forbidden) + for piece in TETROMINOES: + assert tgm_edges[piece] == pytest.approx(expected_tgm[piece]) + assert tgm2_edges[piece] == pytest.approx(expected_tgm2[piece]) + if piece in forbidden: + assert tgm_edges[piece] < 1.0 / 7.0 + assert tgm2_edges[piece] < tgm_edges[piece] + + +def test_gameboy_l_is_rarest(): + gb = tetris_gameboy() + gb.validate() + mass: Counter[str] = Counter() + pi = gb.initial_distribution + for transition in gb.transitions(): + mass[transition.data["emission"]] += pi[transition.source] * transition.data["prob"] + total = sum(mass.values()) + freqs = {piece: mass[piece] / total for piece in TETROMINOES} + assert min(freqs, key=freqs.get) == "L" + for common in ("O", "S", "T"): + assert freqs[common] > freqs["L"]