What changed, and why
The reach-rater is a small classifier that reads only a player's message and the figure's era, then rates how far the message reaches beyond that era: in-period, ahead, far-ahead, or impossible. That tier feeds a deterministic amplifier on the turn's score. The bug: it treated clever, era-appropriate play as if it came from the future. Storing Caesar's scrolls in fireproof concrete vaults rated "ahead"; telling Geiseric to feed Rome instead of sacking it rated "ahead"; a same-day "a bomb was already thrown, cancel the drive" to Franz Ferdinand sometimes rated "impossible". None of these need any knowledge their era lacked.
Why a wrong tier matters, and why it skews toward punishing the player: the amplifier is asymmetric. It widens losses harder than gains. So a perfectly in-period plea, mis-rated "far-ahead", has its downside multiplied 1.6x while its upside would only have grown 1.35x. The historically-savvy move the game wants to reward gets taxed.
The asymmetric factors: losses (1.25/1.6/2) bite harder than gains (1.15/1.35/1.6). A mis-tier rides this.
server/utils/anachronism.ts · 91 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 51 lines hidden (lines 41–91)
The rubric rewrite — one test, and the named traps
The old rubric asked "how far beyond what the figure could know or grasp does the message reach?" and listed four tiers. That "or grasp" let the model read ambition, scale, precision, and period-available technique as future knowledge. The rewrite pins the rating to a single, narrower question and makes in-period the default: does acting on this require knowledge that did not yet exist in the figure's time? If not, it is in-period no matter how bold or sweeping the ask. The model rates up only when it can name the later age the core knowledge belongs to.
The new rubric. Note the single test, the 'do NOT mistake these' trap list (each anchored to a real failing case), and the tiers sharpened to require named, dated later knowledge.
server/utils/prompt-builder.ts · 608 lines
⋯ 254 lines hidden (lines 1–254)
⋯ 327 lines hidden (lines 282–608)
The trap list is the few-shot, woven in: period-available technique (Roman concrete, cataloguing, quarantine, dynastic gifts), foresight about one's own legacy (Antony gifting Pergamon's scrolls), a reasoned thought experiment (Newton's cannonball circling the Earth, his own argument), and a same-day event. Each line names a real playtest miss, so the model has a concrete anchor, not just a principle. The blind framing, the reach-before-tier output shape, and the prompt-injection fence below it are all unchanged.
The docstring records the calibration intent and points at anachronism.ts (why a mis-tier hurts) and the eval gate.
server/utils/prompt-builder.ts · 608 lines
⋯ 227 lines hidden (lines 1–227)
⋯ 371 lines hidden (lines 238–608)
The acceptance fixtures and the eval gate
The issue made an eval gate the required acceptance test. Three fixture sets carry it. REACH_PERIOD_PLAUSIBLE is the eight playtest misses plus a plain control, each of which must rate in-period. The floor that guards the other direction is two-sided: REACH_OBVIOUS is the gross check (gunpowder to Caesar, antibiotics to Cleopatra), and REACH_SUBTLE is the sharper one — future knowledge in period dress (germ theory as "wash the blades", relativity as "reason from your laws") that the loosening could most plausibly have pulled down to in-period, but that must still reach. A ReachCase is exactly the inputs callReachRater takes, nothing more.
The fixtures: period-plausible (want in-period), then the two-sided floor — obvious anachronisms and the subtle period-dressed ones that must still reach.
evals/fixtures.ts · 148 lines
⋯ 63 lines hidden (lines 1–63)
⋯ 30 lines hidden (lines 119–148)
The invariant samples each case a few times, pools the tiers, and scores two numbers: the share of period-plausible samples that rate in-period, and the share of controls (obvious plus subtle) that still reach. It passes when the first clears 0.8 and the floor holds. The harness records a scorecard row; it never asserts on a single stochastic call.
The gate. Obvious + subtle controls pooled into one floor, sampling via the harness's concurrency-capped sample(), a 0.8 in-period threshold with margin.
evals/behavior.eval.ts · 302 lines
⋯ 109 lines hidden (lines 1–109)
⋯ 155 lines hidden (lines 148–302)
Locking the rubric so it can't drift back
A prompt is easy to soften by accident in a later edit. A unit test pins the load- bearing language: the single-test sentence, the "rate up only when you can name the later age" rule, and each of the five named traps. If a future change drops them, this test goes red. It runs in the default suite, no API key needed, because it only inspects the built prompt string.
The rubric lock: asserts the calibration sentences and all five trap headers are present in the built prompt.
tests/unit/server/utils/prompt-builder.spec.ts · 515 lines
⋯ 246 lines hidden (lines 1–246)
⋯ 256 lines hidden (lines 260–515)
Verification
The fix was validated by running the real reach-rater across the case set before and after the rubric change (Haiku, temp 0, three samples per case). The over-tiering cleared completely while the floor held.
| Measure | Before | After |
|---|---|---|
| Period-plausible rated in-period | 19/27 (70%) | 27/27 (100%) |
| Obvious anachronism still reaches | 9/9 (100%) | 9/9 (100%) |
The concrete-vaults and feed-Rome "ahead" mis-tiers and the Franz Ferdinand "impossible" outlier all resolve to in-period; gunpowder, the voice-machine, and penicillin stay far-ahead or impossible.
The standing eval gate is green on the live Haiku route (in-period 16/16, controls reach 12/12 across obvious and subtle), the pre-existing reach invariant is unchanged and still 8/8, the full unit suite (1231) passes, and the typecheck is clean. Full notes, including both adversarial probes, live in evals/results/2026-06-28-reach-calibration-185.md. Because the cheapest lever was enough, the issue's option 3 (a Sonnet route, with hot-path latency and cost) was not needed.