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
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Taking a reward apart, and putting it back together with different weights.
|
||||
*
|
||||
* The reward editor is the point of the whole site: change what "good" means
|
||||
* and watch the ranking move. This module is the arithmetic behind that, and it
|
||||
* has one job beyond adding numbers up — never to manufacture one. An unscored
|
||||
* component stays `null` all the way to the bar chart.
|
||||
*/
|
||||
|
||||
import { isNotScored, rewardTotal } from './episode';
|
||||
import type { RewardComponent, RewardValues } from './types';
|
||||
|
||||
/** Weights closer than this are the same weight. Guards float drift in sliders. */
|
||||
export const WEIGHT_EPSILON = 1e-9;
|
||||
|
||||
/** A user's edits to the shipped weights, keyed by component. */
|
||||
export type WeightOverrides = Readonly<Record<string, number>>;
|
||||
|
||||
export interface RewardRow {
|
||||
key: string;
|
||||
/** Plain English, straight off the component. */
|
||||
label: string;
|
||||
description: string;
|
||||
/** The raw score the environment emitted. `null` means not scored. */
|
||||
score: number | null;
|
||||
/** The weight in force — shipped, or edited, depending what you passed in. */
|
||||
weight: number;
|
||||
/** `score x weight`, the component's actual contribution. `null` when unscored. */
|
||||
value: number | null;
|
||||
role: RewardComponent['role'];
|
||||
}
|
||||
|
||||
export interface RewardBreakdown {
|
||||
rows: RewardRow[];
|
||||
/** Sum of the scored contributions, or `null` when nothing was scored. */
|
||||
total: number | null;
|
||||
/** How many components carry a real number. */
|
||||
scored: number;
|
||||
/** Components the environment did not grade. Rendered as "not scored". */
|
||||
unscored: string[];
|
||||
}
|
||||
|
||||
/** Per-component rows plus the total, ready to render. */
|
||||
export function decompose(
|
||||
values: RewardValues,
|
||||
components: readonly RewardComponent[],
|
||||
): RewardBreakdown {
|
||||
const rows: RewardRow[] = components.map((component) => {
|
||||
const raw = values[component.key];
|
||||
const scored = !isNotScored(raw);
|
||||
return {
|
||||
key: component.key,
|
||||
label: component.label,
|
||||
description: component.description,
|
||||
score: scored ? raw : null,
|
||||
weight: component.weight,
|
||||
value: scored ? raw * component.weight : null,
|
||||
role: component.role,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
total: rewardTotal(values, components),
|
||||
scored: rows.filter((row) => row.score !== null).length,
|
||||
unscored: rows.filter((row) => row.score === null).map((row) => row.key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the visitor's weight edits and renormalise so the weights sum to 1.0.
|
||||
*
|
||||
* Renormalising is what makes the editor honest. Without it, dragging one
|
||||
* slider up raises the total for every arm at once and the ranking looks like
|
||||
* it moved when only the scale did. With it, the visitor is trading weight
|
||||
* between components — which is the actual decision a reward designer makes.
|
||||
*
|
||||
* Negative weights are clamped to zero: a negative weight survives
|
||||
* normalisation as a sign flip somewhere else in the vector, and the resulting
|
||||
* chart is arithmetically correct and completely unreadable. If you want a
|
||||
* component to subtract, that belongs in the environment's grader, not here.
|
||||
*
|
||||
* If every weight is edited to zero the result is all zeros — there is no
|
||||
* honest way to normalise a zero vector, and inventing an equal split would be
|
||||
* putting words in the visitor's mouth. Call `weightsAreDegenerate()` on the
|
||||
* result and render "no weight assigned" rather than a 0.00 total.
|
||||
*/
|
||||
export function reweight(
|
||||
components: readonly RewardComponent[],
|
||||
overrides: WeightOverrides,
|
||||
): RewardComponent[] {
|
||||
const clamped = components.map((component) => {
|
||||
const override = overrides[component.key];
|
||||
const weight = override === undefined || !Number.isFinite(override) ? component.weight : override;
|
||||
return { component, weight: Math.max(0, weight) };
|
||||
});
|
||||
|
||||
const sum = clamped.reduce((acc, entry) => acc + entry.weight, 0);
|
||||
if (sum <= WEIGHT_EPSILON) {
|
||||
return clamped.map(({ component }) => ({ ...component, weight: 0 }));
|
||||
}
|
||||
|
||||
return clamped.map(({ component, weight }) => ({ ...component, weight: weight / sum }));
|
||||
}
|
||||
|
||||
/** True when `reweight` could not normalise, i.e. everything was zeroed. */
|
||||
export function weightsAreDegenerate(components: readonly RewardComponent[]): boolean {
|
||||
return components.reduce((acc, c) => acc + c.weight, 0) <= WEIGHT_EPSILON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the visitor actually changed anything?
|
||||
*
|
||||
* Pass `components` whenever you have them. Without them this can only ask
|
||||
* "are there any override keys", which reports an edit for a slider that was
|
||||
* dragged and put back — and then the page shows a "modified reward" badge over
|
||||
* the shipped numbers, which is a lie in the other direction.
|
||||
*/
|
||||
export function isEdited(
|
||||
overrides: WeightOverrides,
|
||||
components?: readonly RewardComponent[],
|
||||
): boolean {
|
||||
const keys = Object.keys(overrides);
|
||||
if (keys.length === 0) return false;
|
||||
if (!components) return true;
|
||||
|
||||
return components.some((component) => {
|
||||
const override = overrides[component.key];
|
||||
if (override === undefined || !Number.isFinite(override)) return false;
|
||||
return Math.abs(override - component.weight) > WEIGHT_EPSILON;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop overrides that match the shipped weight, so a reset yields a clean URL. */
|
||||
export function pruneOverrides(
|
||||
overrides: WeightOverrides,
|
||||
components: readonly RewardComponent[],
|
||||
): WeightOverrides {
|
||||
const out: Record<string, number> = {};
|
||||
for (const component of components) {
|
||||
const override = overrides[component.key];
|
||||
if (override === undefined || !Number.isFinite(override)) continue;
|
||||
if (Math.abs(override - component.weight) > WEIGHT_EPSILON) out[component.key] = override;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user