2dfa96939e
The first environment shipped through .claude/workflows/new-environment.js: specification, three adversarial reviews (all 'fixable', none fatal), the Python environment, the TypeScript port, captured rollouts, and the demo page. Eleven agents, no errors. The proof that the platform scales is one line long. Alert Triage has a completely different shape from Word Five — JSON actions, priced lookups, an analyst screen instead of a grid — and the only change under src/components/demo/ is a comment edit, because the isolation lint refused the word "wordle" there. Zero shell code changed. 415 contract checks now pass against two demos, up from 206 against one. The environment is honest by construction. Every alert is synthetic, generated from the seed, and the banner saying so sits inside the board surface. Two of the eleven scenario templates are hidden-suspicious: generated by the same code as their benign twin with the signal overlaid only in lookup data, so the free screen is identically distributed and a screen-only policy STRUCTURALLY cannot tell them apart. The probe ladder measures it: `fast` catches 0.0 of hidden seeds. That is the counterweight made real rather than asserted. Twelve policies, thirteen ladder assertions, a genuine three-way trade: fast 0.846 wins hours (0.85), misses every hidden case targeted 0.894 wins the shipped total thorough 0.820 wins evidence (1.00), spends 2.9 hours None dominates. 92 Python tests, 35 TypeScript tests, 65 fixtures replaying at delta 0, and conformance gated on world + scorer + protocol so the browser shows the same alert for ?seed= that Python generated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
275 lines
10 KiB
Python
275 lines
10 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 [--taskset wordle-five|alert-triage]
|
|
Runs every registered taskset by default. 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"))
|
|
sys.path.insert(0, str(Path(__file__).parent / "alert_triage"))
|
|
|
|
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
|
|
|
|
from alert_triage import ladder as alert_triage_ladder # 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_wordle() -> 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
|
|
|
|
|
|
# Each taskset registers its ladder here. The alert-triage rungs live in
|
|
# envs/alert_triage/alert_triage/ladder.py beside the policies they probe.
|
|
TASKSETS = {
|
|
"wordle-five": run_wordle,
|
|
"alert-triage": alert_triage_ladder.run,
|
|
}
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--taskset", choices=sorted(TASKSETS), action="append")
|
|
args = parser.parse_args(argv)
|
|
chosen = args.taskset or list(TASKSETS)
|
|
status = 0
|
|
for i, name in enumerate(chosen):
|
|
if i:
|
|
print("\n" + "=" * 78 + "\n")
|
|
status |= TASKSETS[name]()
|
|
return status
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|