a56f097f28
The Python is the source of truth; src/demos/wordle/engine.ts will be a port of it, and CI gates the two against a SHA-256 over all 21.2M (guess, answer) pattern pairs rather than a hand-picked vector file — a vector file only ever catches the cases somebody thought of. The reward is three weighted components, and the third one is the reason this demo is worth building. `solved` and `economy` pull toward winning. `consistency` pulls against them, because a player maximising information deliberately guesses words that cannot win — a word that splits the remaining candidates evenly teaches more than a word that might happen to be right. That is good play, and it costs consistency. The probe ladder proves the tension is real rather than asserted: inaction 0.0000 crude 0.0111 plausible 0.1224 candidate_only 0.8925 exhaustive 0.9031 oracle 0.9458 The two good policies are 0.05 apart and neither dominates — the entropy oracle takes 1.00 economy and 0.73 consistency, the candidate-only player takes 0.75 and 1.00. Which one wins is a decision about what you want, which is the whole argument the site exists to make. probe.py fails CI if either starts dominating. Two traps found by building it. `consistency` is scored over turns SPENT, not guesses accepted: counting only legal guesses hands a free 1.0 to a policy that plays one word and then jams the parser five times — one guess, no contradictions, perfect score. And `economy`'s denominator is the depth the SHIPPED solver reaches, not a depth-optimal search: entropy-greedy is not depth-optimal, so grading it against an exact optimum would make the oracle rung fail its own assertion on some seeds. The word lists are built from Wordnik (MIT) intersected with SCOWL, never from the original game's 2,315 answers. 4,603 answers makes this materially harder than the original, so the published SALET/3.4212 results are cited as belonging to that list and our own reference player's TARES/3.72 is measured here. verifiers is an optional extra. The engine, reward, solver and probe all run — and gate — without an RL stack resolvable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
248 lines
9.2 KiB
Python
248 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""The probe ladder: does this reward actually measure anything?
|
|
|
|
A reward function is a claim, and an unprobed one is an unchecked claim. The
|
|
ladder plays a set of deliberately-crafted policies — from doing nothing at all
|
|
to the best play we can write — and asserts that the reward orders them the way
|
|
a person would, per weighted component rather than as one blended float.
|
|
|
|
A blended float hides the failure this is built to catch: a component that is
|
|
flat across every policy is measuring nothing, and it will still move the total
|
|
because the other components move.
|
|
|
|
Run: uv run python envs/probe.py
|
|
Exits non-zero on any violated assertion.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent / "wordle_five"))
|
|
|
|
from wordle_five import solver as S # noqa: E402
|
|
from wordle_five.engine import ( # noqa: E402
|
|
MAX_GUESSES,
|
|
Game,
|
|
answer_for_seed,
|
|
answers,
|
|
score_guess,
|
|
)
|
|
from wordle_five.protocol import parse_guess # noqa: E402
|
|
from wordle_five.reward import ROLES, WEIGHTS, Episode, metrics, score, total # noqa: E402
|
|
|
|
SEEDS = list(range(16))
|
|
COMPONENTS = ("solved", "economy", "consistency")
|
|
|
|
|
|
# ---------------------------------------------------------------- policies --
|
|
# Each policy is a function (game) -> reply text. They return TEXT, not moves,
|
|
# so the parser and the rejection path are exercised exactly as a model would
|
|
# exercise them. A policy that returns unparseable prose is a valid policy.
|
|
|
|
|
|
def policy_inaction(game: Game) -> str:
|
|
"""Reasons at length and never commits. The floor: must measure 0.000."""
|
|
return "Let me think carefully about which word would be most informative here."
|
|
|
|
|
|
def policy_spammer(game: Game) -> str:
|
|
"""Multiple bracketed tokens. Only the first is read; the rest are noise."""
|
|
return "Maybe [crane] or [slate] or [adieu]?"
|
|
|
|
|
|
def policy_crude(game: Game) -> str:
|
|
"""One valid word, forever. Legal, useless, and it repeats itself."""
|
|
return "[tares]"
|
|
|
|
|
|
def policy_plausible(game: Game) -> str:
|
|
"""Keeps greens, drops known-absent letters. What a person does unaided."""
|
|
pool = answers()
|
|
if not game.history:
|
|
return "[tares]"
|
|
greens: dict[int, str] = {}
|
|
absent: set[str] = set()
|
|
for guess, pattern in game.history:
|
|
for i, tile in enumerate(pattern):
|
|
if tile == "G":
|
|
greens[i] = guess[i]
|
|
elif tile == "X":
|
|
absent.add(guess[i])
|
|
played = {g for g, _ in game.history}
|
|
for word in pool:
|
|
if word in played:
|
|
continue
|
|
if any(word[i] != c for i, c in greens.items()):
|
|
continue
|
|
if any(c in absent for c in word):
|
|
continue
|
|
return f"[{word}]"
|
|
return "[tares]"
|
|
|
|
|
|
def policy_candidate_only(game: Game) -> str:
|
|
"""Only ever guesses words that could still be the answer.
|
|
|
|
This is the policy the counterweight rewards. It never wastes a guess, and
|
|
it is measurably slower than the oracle because it cannot buy information
|
|
with a word that has no chance of winning.
|
|
"""
|
|
alive = S.consistent_candidates(game.history)
|
|
played = {g for g, _ in game.history}
|
|
for word in alive:
|
|
if word not in played:
|
|
return f"[{word}]"
|
|
return "[tares]"
|
|
|
|
|
|
def policy_oracle(game: Game) -> str:
|
|
"""Greedy maximum entropy — the reference player, and the economy denominator."""
|
|
import numpy as np
|
|
|
|
pool = answers()
|
|
if not game.history:
|
|
return f"[{pool[S._best_opener()]}]"
|
|
alive = S.consistent_candidates(game.history)
|
|
if not alive:
|
|
return "[tares]"
|
|
index = np.array([pool.index(w) for w in alive])
|
|
return f"[{pool[S.best_guess(index)]}]"
|
|
|
|
|
|
def policy_exhaustive(game: Game) -> str:
|
|
"""Knows the answer but burns three turns on known-wrong probes first.
|
|
|
|
Proves the turn budget actually binds: this must score strictly below the
|
|
oracle, or `economy` is not doing its job.
|
|
"""
|
|
if len(game.history) < 3:
|
|
probes = ["fuzzy", "jumbo", "whelp"]
|
|
pick = probes[len(game.history)]
|
|
return f"[{pick if pick != game.answer else 'tares'}]"
|
|
return f"[{game.answer}]"
|
|
|
|
|
|
POLICIES = {
|
|
"inaction": policy_inaction,
|
|
"spammer": policy_spammer,
|
|
"crude": policy_crude,
|
|
"plausible": policy_plausible,
|
|
"candidate_only": policy_candidate_only,
|
|
"exhaustive": policy_exhaustive,
|
|
"oracle": policy_oracle,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------ runner --
|
|
|
|
|
|
def play(policy, seed: int) -> Episode:
|
|
game = Game(seed=seed)
|
|
for _ in range(MAX_GUESSES * 3): # room for rejected replies
|
|
if game.over:
|
|
break
|
|
guess = parse_guess(policy(game))
|
|
if guess is None:
|
|
game.rejected += 1
|
|
# An unparseable reply still costs a turn of patience, or a policy
|
|
# that never guesses would loop until the budget above runs out and
|
|
# look identical to one that guessed badly.
|
|
if game.rejected >= MAX_GUESSES:
|
|
break
|
|
continue
|
|
game.play(guess)
|
|
return Episode(
|
|
answer=game.answer,
|
|
guesses=[g for g, _ in game.history],
|
|
patterns=[p for _, p in game.history],
|
|
rejected=game.rejected,
|
|
reference_depth=S.reference_depth(game.answer),
|
|
)
|
|
|
|
|
|
def run() -> int:
|
|
print(f"Probing wordle-five over {len(SEEDS)} seeds\n")
|
|
results: dict[str, dict[str, float]] = {}
|
|
extra: dict[str, dict[str, float]] = {}
|
|
|
|
for name, policy in POLICIES.items():
|
|
episodes = [play(policy, s) for s in SEEDS]
|
|
scores = [score(e) for e in episodes]
|
|
results[name] = {c: sum(s[c] for s in scores) / len(scores) for c in COMPONENTS}
|
|
results[name]["total"] = sum(total(e) for e in episodes) / len(episodes)
|
|
mets = [metrics(e) for e in episodes]
|
|
extra[name] = {k: sum(m[k] for m in mets) / len(mets) for k in mets[0]}
|
|
|
|
width = max(len(n) for n in POLICIES)
|
|
header = f"{'policy':<{width}} " + " ".join(f"{c:>12}" for c in COMPONENTS) + f" {'TOTAL':>8}"
|
|
print(header)
|
|
print("-" * len(header))
|
|
for name in POLICIES:
|
|
row = results[name]
|
|
cells = " ".join(f"{row[c]:>12.4f}" for c in COMPONENTS)
|
|
print(f"{name:<{width}} {cells} {row['total']:>8.4f}")
|
|
|
|
print(f"\nweights: {WEIGHTS}")
|
|
print(f"roles: {ROLES}")
|
|
print("\nguesses used (mean):", {n: round(extra[n]['guesses_used'], 2) for n in POLICIES})
|
|
|
|
failures: list[str] = []
|
|
|
|
# 1. Doing nothing must measure exactly nothing. If a policy that never
|
|
# plays can collect reward, every number above it is inflated.
|
|
if abs(results["inaction"]["total"]) > 1e-9:
|
|
failures.append(f"inaction scored {results['inaction']['total']:.6f}, must be 0.000")
|
|
|
|
# 2. The reference player must reach the top of both objective components,
|
|
# or `economy`'s denominator disagrees with the policy that defines it.
|
|
if abs(results["oracle"]["solved"] - 1.0) > 1e-9:
|
|
failures.append(f"oracle solved {results['oracle']['solved']:.4f}, must be 1.0")
|
|
if abs(results["oracle"]["economy"] - 1.0) > 1e-9:
|
|
failures.append(f"oracle economy {results['oracle']['economy']:.4f}, must be 1.0")
|
|
|
|
# 3. The counterweight must actually bite the policy it is aimed at. The
|
|
# oracle buys information with guesses that cannot win; if that were
|
|
# free, `consistency` would be a gate wearing a counterweight's name.
|
|
if results["oracle"]["consistency"] >= 1.0 - 1e-9:
|
|
failures.append("oracle scored a perfect consistency — the counterweight is not in tension")
|
|
|
|
# 4. And the policy that pays the counterweight's price must collect it.
|
|
if abs(results["candidate_only"]["consistency"] - 1.0) > 1e-9:
|
|
failures.append(
|
|
f"candidate_only consistency {results['candidate_only']['consistency']:.4f}, must be 1.0"
|
|
)
|
|
|
|
# 5. Neither of the two good policies may dominate the other on every
|
|
# component. If one did, the reward would encode a single right answer
|
|
# and the reward editor would be theatre.
|
|
oracle, cand = results["oracle"], results["candidate_only"]
|
|
if all(oracle[c] >= cand[c] - 1e-9 for c in COMPONENTS):
|
|
failures.append("oracle dominates candidate_only on every component — no real trade-off")
|
|
if all(cand[c] >= oracle[c] - 1e-9 for c in COMPONENTS):
|
|
failures.append("candidate_only dominates oracle on every component — no real trade-off")
|
|
|
|
# 6. Burning turns on known-wrong probes must cost.
|
|
if results["exhaustive"]["total"] >= results["oracle"]["total"] - 1e-9:
|
|
failures.append("exhaustive >= oracle — the turn budget does not bind")
|
|
|
|
# 7. No weighted component may be flat. A component with the same value for
|
|
# every policy is measuring nothing and still moves the total.
|
|
for component in COMPONENTS:
|
|
values = [results[n][component] for n in POLICIES]
|
|
if max(values) - min(values) < 1e-9:
|
|
failures.append(f"component '{component}' is flat across the ladder — it measures nothing")
|
|
|
|
print()
|
|
if failures:
|
|
for f in failures:
|
|
print(f"FAIL: {f}")
|
|
return 1
|
|
print(f"All {7} ladder assertions hold.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(run())
|