Files
PIG-Demo/envs/wordle_five/wordle_five/reward.py
T
karti-ai a56f097f28 wordle-five: the engine, the reward, the solver and the probe that checks them
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
2026-08-28 15:39:03 -07:00

133 lines
4.3 KiB
Python

"""The reward. This file is quoted verbatim on the demo page, so it is written
to be read by someone who does not write Python.
Three components, weights summing to 1.0. Two of them pull toward winning. The
third pulls against them on purpose.
# region: pig-demo/reward
"""
from __future__ import annotations
from dataclasses import dataclass
from .engine import GREEN, WORD_LENGTH, score_guess
SOLVED_WEIGHT = 0.50
ECONOMY_WEIGHT = 0.30
CONSISTENCY_WEIGHT = 0.20
@dataclass
class Episode:
"""Everything the reward needs from one played game."""
answer: str
guesses: list[str]
patterns: list[str]
rejected: int
reference_depth: int
max_guesses: int = 6
def solved(ep: Episode) -> float:
"""Did it win? The objective, and half the reward."""
return 1.0 if ep.patterns and ep.patterns[-1] == GREEN * WORD_LENGTH else 0.0
def economy(ep: Episode) -> float:
"""How few turns it took, against how few the reference player needed.
Always a ratio against a reference on the SAME hidden word, never an
absolute count — otherwise the reward would mostly measure whether the word
happened to be easy, and a model would be punished for a hard draw.
"""
if not solved(ep):
return 0.0
return min(1.0, ep.reference_depth / max(1, len(ep.guesses)))
def consistency(ep: Episode) -> float:
"""The share of guesses that were still possible answers when they were made.
This is the counterweight, and it is genuinely in tension with the other
two. A player maximising information will deliberately guess words that
CANNOT win, because a word that splits the remaining candidates evenly
teaches it more than a word that might happen to be right. That is good
play, and it costs consistency. The reference solver scores below 1.0 here.
So the three components describe a real trade: win, win fast, and do not
spend turns on moves that could not have won. You cannot max all three, and
which one you weight is a decision about what you actually want — which is
the entire argument this demo exists to make.
Scored over every turn SPENT, not every guess accepted — see below. Without
that, a policy which never produces a parseable word scores a vacuous 1.0
here (no guesses, therefore no contradictions) and collects 0.2 for doing
nothing at all.
"""
spent = len(ep.guesses) + ep.rejected
if spent == 0:
return 0.0
viable = 0
for i, guess in enumerate(ep.guesses):
# A guess is viable iff it is consistent with every earlier piece of
# feedback — that is, it was still a possible answer at the moment it
# was played.
if all(score_guess(ep.guesses[k], guess) == ep.patterns[k] for k in range(i)):
viable += 1
# The denominator is every turn SPENT, including replies the game refused.
# Counting only legal guesses would hand a free 1.0 to a policy that plays
# one word and then jams the parser five times: one guess, no
# contradictions, perfect score. Measuring against turns spent asks the
# question that actually matters — of the attempts you made, how many were
# a move that could have won?
return viable / spent
def score(ep: Episode) -> dict[str, float]:
"""The weighted components, unweighted. The caller applies the weights."""
return {
"solved": solved(ep),
"economy": economy(ep),
"consistency": consistency(ep),
}
def total(ep: Episode) -> float:
parts = score(ep)
return (
parts["solved"] * SOLVED_WEIGHT
+ parts["economy"] * ECONOMY_WEIGHT
+ parts["consistency"] * CONSISTENCY_WEIGHT
)
# endregion: pig-demo/reward
def metrics(ep: Episode) -> dict[str, float]:
"""Diagnostics. Weight zero — reported, never summed into the reward."""
return {
"guesses_used": float(len(ep.guesses)),
"rejected_replies": float(ep.rejected),
"reference_depth": float(ep.reference_depth),
"turns_granted": float(ep.max_guesses),
"inconsistent_guesses": float(round((1.0 - consistency(ep)) * len(ep.guesses))),
}
WEIGHTS = {
"solved": SOLVED_WEIGHT,
"economy": ECONOMY_WEIGHT,
"consistency": CONSISTENCY_WEIGHT,
}
ROLES = {
"solved": "objective",
"economy": "objective",
"consistency": "counterweight",
}