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
14 KiB
alert-triage — the environment
One transaction-monitoring alert on one synthetic customer. The agent reads the analyst's screen, may pay analyst-hours for lookups, and ends the episode by closing the alert with a cited rationale or escalating it with a typology and the transactions that constitute it. The grader compares the disposition and the citations to what the generator planted and charges the hours. No model, human or prose is read anywhere in it.
SPEC.md is the specification as written and reviewed before this code
existed. This file records what was implemented, including every change
the reviewers demanded; where the two differ, this file is what ships.
uv run python envs/probe.py --taskset alert-triage # the ladder
uv run pytest envs/alert_triage/tests # ~30 s; hashes 4096 worlds
uv run python -m alert_triage.conformance # the three digests
Layout
| file | what |
|---|---|
alert_triage/rng.py |
FNV-1a seeding, xorshift32, civil dates. No platform RNG, no platform date. |
alert_triage/generator.py |
Templates, overlays, the canonical world, reference_minutes, digest (a). |
alert_triage/engine.py |
Screen, lookups, dispositions, shown-id discipline, the minute meter. |
alert_triage/protocol.py |
The pinned parse rules, the system prompt, text renderers. |
alert_triage/reward.py |
The three components. The # region: pig-demo/reward block is what the page quotes. |
alert_triage/policies.py |
fast, targeted, thorough and the seven cheats. Read only the engine's view. |
alert_triage/ladder.py |
The probe rungs and their thirteen assertions. |
alert_triage/conformance.py |
Digests (b) and (c); writes CONFORMANCE.txt. |
alert_triage/taskset.py |
play_episode, replay, and the optional verifiers Env. |
conformance/replies.json |
The reply corpus digest (c) runs. |
names/ |
The synthetic name and word lists, with PROVENANCE.md. |
The world, and what the reviewers changed
Templates. Six benign builders (B1 seasonal cash, B2 processor settlement, B3 property sale, B4 cash restaurant, B5 family remittance, B6 inheritance) and three visible-suspicious ones (S1 structuring, S3 money mule, S6 smurfing over time). The hidden tier is no longer two fixed twins. A hidden alert is any benign builder run to completion — screen frozen — with one of three overlays applied to lookup data only:
| overlay | where it lives | compatible with | planted set |
|---|---|---|---|
funnel |
the counterparty lookup: the top outbound counterparty on screen is shared with 5–14 other alerted accounts | every benign template | CP-x + its transactions; alternate: the transactions |
serial_closer |
the prior-alerts lookup: every prior alert on the same rule, closed within a day on one identical boilerplate, by different analysts | screens showing ≥ 3 prior alerts and no prior report | the A- ids + the triggering transactions; alternate: the A- ids |
doc_mismatch |
the documents lookup against the counterparty lookup: the document names a party; the funds came from someone else | B2, B3, B6 | the document + CP-x + its triggering credits; alternate: the document + CP-x |
The tier is drawn before the template and the screen, and the prior-alert
count on the KYC block is drawn before any template code runs, so the screen
distribution is the same for hidden and benign by construction, not by
effort. tests/test_triage_screen_leak.py asserts the screen is
byte-identical with every compatible overlay on or off for every seed, and
that no screen feature separates the populations beyond four sigma.
This answers three reviewer findings at once: every rule id now fires on both
labels (R-WIRE-04 and R-DORM-05 carry funnel and serial-closer twins);
each hidden signal lands on several templates so there is no template→lookup
mapping to memorise; and no screen-identifiable segment carries a suspicious
prior above the overall 15 / (55 + 15) ≈ 21 %.
Removed: B4's standalone PREVIOUSLY_REVIEWED alternate. A prior closure
never resolves an alert on its own. The reason stays in the enum as legal and
never planted, so its use renders as a diagnostic.
Documents carry per-world ids (doc.D-<4 hex>), and the engine rejects
any citation of an id it has not rendered. fast therefore pulls
documents on the templates a document explains, and the reference on those
seeds is 0.70 h, not 0.55 h.
The held-out slice is fnv1a(str(seed)) % 8 == 7, about 12.5 % of
seeds. It is generated in a fictional currency, KRN, with a fictional
20,000 KRN reporting threshold (amounts scale ×2), rule ids renamed
TM-A1..A5, the KYC block before the alert and the summary newest-first. It
is never probed and never used to tune anything; the capture stage runs it
once, after the arms are frozen, for the side-by-side numbers on the page.
No claim about any real jurisdiction's threshold is made anywhere.
Canonical world
Sorted keys, no whitespace, null for absent. Every amount is an integer in
minor units; every date is a YYYY-MM-DD string produced by
rng.iso_date from a day ordinal. reference_minutes and
reference_policy are part of the world and therefore of digest (a):
the browser recomputes them by running the ported policies rather than
trusting the recorded number, and a mismatch is a verification failure, not
an "unverifiable".
Draw order is the contract. Reading generator.py top to bottom in the
order the builder calls things is the port; the port must not reorder a
single rng call.
The action set and the pinned parse rules
One JSON object per reply. A ```json fence anywhere wins; otherwise
the first string-aware balanced {…} span; an unparseable or non-object
candidate is a rejection with no further scanning. Enums exact-case. Cites
deduplicated in order, matched exact-case against the shown set. The
twelve-month window is the twelve calendar months ending in the fire month
inclusive. A repeated lookup is rejected. Only accepted lookups add their
price; every turn, accepted or rejected, costs TURN_MINUTES.
aborted is exactly MAX_TURNS = 8 turns spent with no accepted
disposition and scores 0.000. A run that stops earlier with no disposition is
truncated and every component is null — rendered "not scored", never
zero. Correct disposition → solved, wrong → failed.
Hours
Integers in minutes, shown as hours. The generator's config, not measurements — nobody timed an analyst:
| constant | minutes | hours |
|---|---|---|
SCREEN_MINUTES |
30 | 0.50 |
TURN_MINUTES |
3 | 0.05 |
LOOKUP_MINUTES |
24 | 0.40 |
DOC_MINUTES |
6 | 0.10 |
LOOKUP_MINUTES was raised from the spec's 0.25 h to 0.40 h so that the
hours spent investigating clean screens genuinely exceed the hours a
screen-only analyst loses to misses — the trade-off the reviewers said was
manufactured in the spec is real at these constants and the ladder measures
it (rung 8).
The reward
| key | weight | role | definition |
|---|---|---|---|
caught |
0.55 | counterweight | 1.0 on a correct call; 0.0 on a miss or no disposition; 0.5 on a false escalation ("filed on a clean customer"). |
hours |
0.25 | objective | min(1, reference / spent) if correct, else 0. null if no shipped policy got the seed right (CI asserts this never happens). Capped: faster than the reference earns 1.0, not more. |
evidence |
0.20 | objective | F1 of the cites against the best planted alternate; 0 if the call was wrong. |
The 0.5 credit is the reviewers' fix for segment-blind defensive filing: with
it, a blind escalation only beats a well-cited close when P(benign) is below
0.52 (reward.escalate_break_even(), printed by the probe), and no
benign-looking screen segment is that suspicious.
The ladder, measured
uv run python envs/probe.py --taskset alert-triage, 894 main-slice seeds
(seeds 0–1023 minus the held-out bucket). These are the numbers the page may
quote; the spec's ~0.86 vs ~0.85 text may not.
policy caught hours evidence TOTAL hours@0.45
inaction 0.0000 0.0000 0.0000 0.0000 0.0000
malformed 0.0000 0.0000 0.0000 0.0000 0.0000
close_blind 0.5705 0.5705 0.0000 0.4564 0.4868
close_cites_all 0.5705 0.5705 0.0815 0.4727 0.4988
cite_unseen 0.5705 0.5473 0.0000 0.4506 0.4764
escalate_blind 0.7148 0.4295 0.3006 0.5606 0.5257
fast_defensive 0.7148 0.4295 0.3006 0.5606 0.5257
rule_table 0.7550 0.5593 0.3680 0.6287 0.6102
fast 0.8535 0.8535 0.8159 0.8460 0.8480
targeted 1.0000 0.6039 0.9625 0.8935 0.8163
thorough 1.0000 0.2784 1.0000 0.8196 0.6753
thorough_wasteful 1.0000 0.2412 1.0000 0.8103 0.6585
Mean hours: fast 0.60, targeted 1.63, thorough 2.92. Caught on hidden seeds: fast 0.0, targeted 1.0, thorough 1.0.
The three shipped analysts, and the claim the page can make:
fastdecides from the screen (plusdocumentswhere a document should explain the alert). Winshoursoutright. Misses every hidden seed, and only those.targetedisfaston a screen that shows a typology, and otherwise pulls exactly the three lookups that could carry a hidden signal. Catches everything. Wins at shipped weights, 0.894 to 0.846. It definesreference_minuteson hidden seeds.thoroughalways runs the full procedure, including two months of itemised history, and cites every constituting transaction it saw: evidence 1.000 exactly, and the lowest hours.
Move hours to 0.45 (the other two weights keeping their ratio) and
fast wins, 0.848 to 0.816. Nothing else on the page depends on the
direction; rung 8 asserts both margins are at least 0.02.
Note the weakest honest reading: thorough — which gets every call and
every citation right — scores below fast at shipped weights, because
spending five times the hours on the 85 % of alerts the screen settles costs
more than the misses. That is the environment's argument, not a defect.
Rungs a reviewer named
| rung | the cheat | assertion |
|---|---|---|
fast_defensive |
file blind on every benign-looking screen | total < every good policy |
cite_unseen |
close citing a document it never opened | rejected on every seed; total < fast |
rule_table |
close blind on wire/dormancy rules, file blind elsewhere | total < every good policy |
close_cites_all |
cite every id on the screen | evidence < ½ its caught share; < fast's evidence |
thorough_wasteful |
pull a month that carries nothing | total < thorough, same caught and evidence |
The reference analysts see only the view
policies.py functions take Engine.view() and nothing else — the
screen, the lookups so far, the turn and minute counters. Tests assert the
signature, that the view carries none of label, typology, planted,
overlay, tier, template, reference_*, and that every policy emits
identical replies against a world with those fields deleted.
Conformance
Three digests in CONFORMANCE.txt, each a CI gate for the TypeScript port:
- world — canonical world JSON for seeds 0–4095, reference included.
The port must carry the generator, not only the scorer: the Play tab and
?seed=need the same alert on both sides. - scorer —
score_exact()over a fixed grid of episodes. Exact fractions, so no float formatting is hashed. - protocol — parse + engine over
conformance/replies.jsonon fixed worlds: accepted actions, rejection reasons, and the minute meter after every turn. This is the digest wordle does not need.
What this is easier than, for the page
- Every alert has a knowable answer; real SAR decisions are judgements, and a filed report is not a proven crime. Nine builders and three overlays stand in for a typology space in the hundreds.
- The 55/30/15 mix is deliberately not the real base rate. The great
majority of production alerts close as false positives — no figure is
quoted here because none was measured by us. "Hours per thousand alerts"
on the page is Σ
hours_spentover recorded seeds inside this environment, with the constants above beside it. - The hour constants are assumptions.
- The lookups are idealised. Each returns the decisive fact in one call: a counterparty's other-alerted-account count, a prior alert's analyst and rationale, a document's named party. In production those signals are spread across systems and often absent. This environment tests whether the agent asks; it does not test whether the answer exists.
- The narrative prose is unscored.
- The adversary is static: the launderer shaped the data to pass the screen and the held-out slice moves the threshold once. Nothing responds to the agent's policy. The demo must claim exactly that and no more, and the wordle limit that names this demo as its answer should be softened to "nobody designed the data to pass the screen".
caughtis 1.0 on every benign alert by construction; the counterweight is argued from the ladder over seeds, never from one run.
For the next stages
- Port:
rng.py,generator.py,engine.py,protocol.py,reward.py,policies.py(fast,targeted,thorough— verify recomputesreference_minutes). Gate on all three digests. - Capture:
taskset.play_episode(seed, respond)is the loop;taskset.replay(seed, replies)re-scores a fixture from its reply strings. Scripted arms:fast,targeted,thorough, and at least one cheat. Capture the held-out bucket exactly once, after the arms are frozen. - Demo: the board must carry "Synthetic data — no real customer, account
or transaction"; the hour constants as config; the narrative marked "not
scored"; the label reveal only after a disposition. The vertical card's
rewardline must be amended to say "hours as a ratio against a reference analyst policy", not "hours per thousand alerts". SPEC.mdis stage 1's document and was not edited; §3, §4, §6, §7 and §8 are superseded by this file where they differ.