Pin seed->word across both languages with a shared hash

engine.ts used mulberry32 and engine.py used random.Random(seed). Same seed,
different word — so every ?seed= permalink on the site would have shown a
different puzzle than the recorded run it claimed to be replaying, and nobody
would have noticed until someone checked one by hand.

Both now derive the index from FNV-1a 32-bit over the decimal seed. A hash
rather than a PRNG because there is no honest one-line JavaScript equivalent of
Mersenne Twister, and this way there is nothing to keep in step: both sides
compute the same integer from the same string. Math.imul on the JS side is
load-bearing — a plain multiply overflows into a double and diverges after the
first few bytes.

Twelve seeds are pinned as a vector in both test suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 15:42:17 -07:00
parent a56f097f28
commit 69607fbfe9
22 changed files with 3508 additions and 86 deletions
+18 -7
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
import hashlib
import json
import random
from collections import Counter
from dataclasses import dataclass, field
from functools import lru_cache
@@ -105,15 +104,27 @@ def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str |
return None
def answer_for_seed(seed: int) -> str:
"""The hidden word for a seed.
def fnv1a32(text: str) -> int:
"""FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.
Seeded from a dedicated Random rather than the module-global one, because
TextArena seeds the process-global RNG and anything sharing it becomes
order-dependent under concurrency.
A language's built-in RNG is not portable: `random.Random(7)` is a
Mersenne Twister and there is no honest one-line JavaScript equivalent, so
seed 7 would pick one word here and a different one in the browser. Every
permalink on the site would then disagree with the recorded run it claims
to show. A hash sidesteps the whole problem — both sides compute the same
integer from the same string, and there is nothing to keep in step.
"""
h = 0x811C9DC5
for byte in text.encode():
h ^= byte
h = (h * 0x01000193) & 0xFFFFFFFF
return h
def answer_for_seed(seed: int) -> str:
"""The hidden word for a seed. Identical in engine.ts — see fnv1a32."""
pool = answers()
return pool[random.Random(seed).randrange(len(pool))]
return pool[fnv1a32(str(seed)) % len(pool)]
@dataclass