Files
karti-ai 2dfa96939e
ci / web (push) Successful in 2m43s
ci / python (push) Successful in 2m36s
Alert Triage: environment #2, built end to end by the pipeline
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
2026-08-28 19:48:36 -07:00
..

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 514 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 01023 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:

  • fast decides from the screen (plus documents where a document should explain the alert). Wins hours outright. Misses every hidden seed, and only those.
  • targeted is fast on 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 defines reference_minutes on hidden seeds.
  • thorough always 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:

  1. world — canonical world JSON for seeds 04095, reference included. The port must carry the generator, not only the scorer: the Play tab and ?seed= need the same alert on both sides.
  2. scorerscore_exact() over a fixed grid of episodes. Exact fractions, so no float formatting is hashed.
  3. protocol — parse + engine over conformance/replies.json on 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

  1. 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.
  2. 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_spent over recorded seeds inside this environment, with the constants above beside it.
  3. The hour constants are assumptions.
  4. 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.
  5. The narrative prose is unscored.
  6. 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".
  7. caught is 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 recomputes reference_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 reward line must be amended to say "hours as a ratio against a reference analyst policy", not "hours per thousand alerts".
  • SPEC.md is stage 1's document and was not edited; §3, §4, §6, §7 and §8 are superseded by this file where they differ.