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
This commit is contained in:
@@ -744,7 +744,7 @@ function ShellSkeleton() {
|
||||
* The run a visitor sees before choosing one.
|
||||
*
|
||||
* `runs[0]` is the manifest's first entry — the weakest agent on seed 0, which
|
||||
* for wordle is a failed game with thinking off. So the Watch tab opened on a
|
||||
* for the first demo shipped was a failed game with thinking off. So the Watch tab opened on a
|
||||
* loss with an empty reasoning panel and the Reward tab on a row of zeros:
|
||||
* the model's least interesting attempt, chosen by accident of sort order.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
Engine,
|
||||
XorShift32,
|
||||
canonicalJson,
|
||||
civilFromDays,
|
||||
daysFromCivil,
|
||||
daysInMonth,
|
||||
extractCandidate,
|
||||
fnv1a32,
|
||||
generate,
|
||||
isHeldOut,
|
||||
isoDate,
|
||||
parseReply,
|
||||
protocolTrace,
|
||||
pyJsonDumps,
|
||||
pyJsonLoads,
|
||||
pyRepr,
|
||||
referenceFor,
|
||||
runPolicy,
|
||||
screenOf,
|
||||
scoreExact,
|
||||
worldForSeed,
|
||||
POLICIES,
|
||||
} from '../engine';
|
||||
import type { CorpusCase } from '../engine';
|
||||
|
||||
/*
|
||||
* The cross-language digest in envs/alert_triage/CONFORMANCE.txt is the real
|
||||
* gate (`pnpm conformance`). These vectors exist so that a break is LOCATED,
|
||||
* not merely detected: each one is pinned identically in
|
||||
* envs/alert_triage/tests/, or was produced by the Python on the day the port
|
||||
* landed, and names the primitive that drifted.
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------- rng.py */
|
||||
|
||||
test('fnv1a32 vectors match test_triage_rng.py', () => {
|
||||
assert.equal(fnv1a32('0'), 0x350ca8af);
|
||||
assert.equal(fnv1a32('1'), 0x340ca71c);
|
||||
assert.equal(fnv1a32('42'), 0x87e38583);
|
||||
assert.equal(fnv1a32('4095'), 0x43875f5f);
|
||||
});
|
||||
|
||||
test('xorshift32 from seed 0 is pinned', () => {
|
||||
const rng = new XorShift32(fnv1a32('0'));
|
||||
assert.deepEqual(Array.from({ length: 5 }, () => rng.next()), [2738490563, 3068243922, 3765331391, 3085691315, 2439018365]);
|
||||
assert.notEqual(new XorShift32(0).state, 0);
|
||||
});
|
||||
|
||||
test('sample is a distinct partial shuffle', () => {
|
||||
const out = new XorShift32(7).sample([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 4);
|
||||
assert.equal(out.length, 4);
|
||||
assert.equal(new Set(out).size, 4);
|
||||
});
|
||||
|
||||
test('civil dates round-trip and hit the known ones', () => {
|
||||
for (let day = daysFromCivil(1999, 12, 25); day < daysFromCivil(2030, 3, 2); day += 1) {
|
||||
const [y, m, d] = civilFromDays(day);
|
||||
assert.equal(daysFromCivil(y, m, d), day);
|
||||
}
|
||||
assert.equal(daysFromCivil(1970, 1, 1), 0);
|
||||
assert.equal(daysFromCivil(2026, 3, 1), 20513);
|
||||
assert.equal(daysFromCivil(2000, 2, 29), 11016);
|
||||
assert.equal(isoDate(20000), '2024-10-04');
|
||||
assert.equal(isoDate(0), '1970-01-01');
|
||||
assert.equal(isoDate(-1), '1969-12-31');
|
||||
assert.equal(daysInMonth(2024, 2), 29);
|
||||
assert.equal(daysInMonth(2026, 2), 28);
|
||||
assert.equal(daysInMonth(2026, 12), 31);
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------- generator.py */
|
||||
|
||||
test('the held-out slice is fnv1a(seed) % 8 == 7', () => {
|
||||
const heldOut = Array.from({ length: 64 }, (_, s) => s).filter(isHeldOut);
|
||||
assert.deepEqual(heldOut, [0, 8, 11, 19, 20, 28, 37, 46, 55]);
|
||||
});
|
||||
|
||||
// Produced by `world_for_seed` in Python the day the port landed. A different
|
||||
// template or alert id here means an RNG draw moved; a different reference
|
||||
// means a policy diverged.
|
||||
const WORLD_VECTORS: [number, string, string, string | null, string, string, string, number, string, string][] = [
|
||||
[0, 'B6', 'hidden', 'funnel', 'suspicious', 'A-9571', '2026-04-18', 123, 'targeted', 'Emeka Zapata'],
|
||||
[1, 'B4', 'benign', null, 'benign', 'A-8971', '2026-04-26', 33, 'fast', 'Oakhill Kitchen Group'],
|
||||
[2, 'B6', 'benign', null, 'benign', 'A-9760', '2026-04-23', 42, 'fast', 'Gabriela Kowalczyk'],
|
||||
[3, 'B4', 'benign', null, 'benign', 'A-9453', '2026-04-23', 33, 'fast', 'Lakeside Kitchen LLC'],
|
||||
[4, 'S1', 'visible', null, 'suspicious', 'A-8199', '2026-06-14', 33, 'fast', 'Bilal Quintero'],
|
||||
[5, 'B3', 'benign', null, 'benign', 'A-6081', '2026-03-22', 42, 'fast', 'Andre Nakamura'],
|
||||
[6, 'B1', 'benign', null, 'benign', 'A-7014', '2026-05-15', 33, 'fast', 'Lakeside Landscaping Inc.'],
|
||||
[7, 'B6', 'benign', null, 'benign', 'A-5644', '2026-06-14', 42, 'fast', 'Leila Bergstrom'],
|
||||
[8, 'S3', 'visible', null, 'suspicious', 'A-5828', '2026-04-24', 33, 'fast', 'Dana Yilmaz'],
|
||||
[9, 'B3', 'hidden', 'funnel', 'suspicious', 'A-9537', '2026-04-19', 123, 'targeted', 'Samir Tanaka'],
|
||||
[10, 'S1', 'visible', null, 'suspicious', 'A-5846', '2026-06-26', 33, 'fast', 'Malik Marchetti'],
|
||||
[11, 'B3', 'benign', null, 'benign', 'A-9882', '2026-05-16', 42, 'fast', 'Naomi Abara'],
|
||||
];
|
||||
|
||||
test('world vectors match the Python generator', () => {
|
||||
for (const [seed, template, tier, overlay, label, alertId, fired, minutes, policy, name] of WORLD_VECTORS) {
|
||||
const w = worldForSeed(seed);
|
||||
assert.deepEqual(
|
||||
[w.template, w.tier, w.overlay, w.label, w.alert.id, w.alert.fired, w.reference_minutes, w.reference_policy, w.customer.name],
|
||||
[template, tier, overlay, label, alertId, fired, minutes, policy, name],
|
||||
`seed ${seed}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the screen is byte-identical with a hidden overlay on or off', () => {
|
||||
for (let seed = 0; seed < 64; seed += 1) {
|
||||
const auto = generate(seed, 'auto');
|
||||
if (auto.tier === 'visible') continue;
|
||||
const twin = generate(seed, null);
|
||||
assert.equal(canonicalJson(screenOf(auto)), canonicalJson(screenOf(twin)), `seed ${seed}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('canonical JSON is sorted, compact and drops undefined', () => {
|
||||
assert.equal(canonicalJson({ b: 1, a: [null, true, 'x'], c: undefined }), '{"a":[null,true,"x"],"b":1}');
|
||||
assert.equal(canonicalJson('dash — “quotes”'), '"dash — “quotes”"');
|
||||
assert.throws(() => canonicalJson(1.5));
|
||||
});
|
||||
|
||||
/* ----------------------------------------------------------- protocol.py */
|
||||
|
||||
test('python repr vectors', () => {
|
||||
const strings: [string, string][] = [
|
||||
['a', "'a'"],
|
||||
["it's", '"it\'s"'],
|
||||
['say "hi"', "'say \"hi\"'"],
|
||||
['it\'s "both"', "'it\\'s \"both\"'"],
|
||||
['tab\there', "'tab\\there'"],
|
||||
['nl\n', "'nl\\n'"],
|
||||
['\x01\x7f\x80\xa0', "'\\x01\\x7f\\x80\\xa0'"],
|
||||
['café
', "'café \\u2028 \\u200b'"],
|
||||
['😀', "'😀'"],
|
||||
['back\\slash', "'back\\\\slash'"],
|
||||
];
|
||||
for (const [input, expected] of strings) assert.equal(pyRepr(input), expected, JSON.stringify(input));
|
||||
|
||||
const numbers: [string, string][] = [
|
||||
['1', '1'],
|
||||
['-0', '0'],
|
||||
['1.0', '1.0'],
|
||||
['1.5', '1.5'],
|
||||
['1e5', '100000.0'],
|
||||
['1E16', '1e+16'],
|
||||
['1e-5', '1e-05'],
|
||||
['0.0001', '0.0001'],
|
||||
['12345678901234567890', '12345678901234567890'],
|
||||
['-2.5e-7', '-2.5e-07'],
|
||||
['1e22', '1e+22'],
|
||||
['123456789012345.6', '123456789012345.6'],
|
||||
['1e400', 'inf'],
|
||||
['0.1', '0.1'],
|
||||
['100.0', '100.0'],
|
||||
];
|
||||
for (const [raw, expected] of numbers) assert.equal(pyRepr(pyJsonLoads(raw)), expected, raw);
|
||||
|
||||
assert.equal(pyRepr(null), 'None');
|
||||
assert.equal(pyRepr(undefined), 'None');
|
||||
assert.equal(pyRepr(true), 'True');
|
||||
assert.equal(pyRepr(pyJsonLoads('[1, "a", null]')), "[1, 'a', None]");
|
||||
assert.equal(pyRepr(pyJsonLoads('{"k": 2.0, "z": [true]}')), "{'k': 2.0, 'z': [True]}");
|
||||
});
|
||||
|
||||
test('the JSON reader accepts what json.loads accepts and nothing more', () => {
|
||||
assert.throws(() => pyJsonLoads('{"a":1,}'));
|
||||
assert.throws(() => pyJsonLoads('{"a":NaN}'));
|
||||
assert.throws(() => pyJsonLoads('{"a":Infinity}'));
|
||||
assert.throws(() => pyJsonLoads('{"a":"x\ny"}'));
|
||||
assert.throws(() => pyJsonLoads('{"a":1} {"b":2}'));
|
||||
assert.throws(() => pyJsonLoads("{'a':1}"));
|
||||
assert.equal(pyJsonLoads(' \t"\\ud83d\\ude00\\u00e9" \n'), '😀é');
|
||||
assert.equal(pyJsonLoads('"\\ud800"'), '\ud800');
|
||||
const obj = pyJsonLoads('{"__proto__": 1, "a": {"b": [1e2]}}');
|
||||
assert.ok(obj instanceof Map);
|
||||
assert.equal(pyRepr(obj), "{'__proto__': 1, 'a': {'b': [100.0]}}");
|
||||
});
|
||||
|
||||
test('the pinned candidate rules', () => {
|
||||
assert.equal(extractCandidate('{"a":1} ```json\n{"b":2}\n```'), '{"b":2}\n');
|
||||
assert.equal(extractCandidate('text {"a":"}"} {"b":2}'), '{"a":"}"}');
|
||||
assert.equal(extractCandidate('text {"a":"\\"}"} x'), '{"a":"\\"}"}');
|
||||
assert.equal(extractCandidate('{"a":1'), null);
|
||||
assert.equal(extractCandidate('no braces'), null);
|
||||
assert.equal(extractCandidate('[{"a":1}]'), '{"a":1}');
|
||||
assert.deepEqual(parseReply(''), [null, 'no JSON object found in the reply']);
|
||||
assert.deepEqual(parseReply(null), [null, 'no JSON object found in the reply']);
|
||||
assert.deepEqual(parseReply('{"a":1,} {"a":1}'), [null, 'the JSON object could not be parsed']);
|
||||
assert.deepEqual(parseReply('```json\n[1]\n```'), [null, 'the reply parsed but is not a JSON object']);
|
||||
});
|
||||
|
||||
test('pyJsonDumps writes what json.dumps writes', () => {
|
||||
assert.equal(pyJsonDumps({ action: 'close', cites: ['a', 'b'], note: 'é\x7f"' }), '{"action": "close", "cites": ["a", "b"], "note": "\\u00e9\\u007f\\""}');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------- engine.py */
|
||||
|
||||
test('the rejection reasons quote Python repr', () => {
|
||||
const engine = new Engine(worldForSeed(1));
|
||||
assert.equal(engine.step('{"action":"Lookup"}').reason, "unknown action 'Lookup'; use lookup, close or escalate");
|
||||
assert.equal(engine.step('{"what":"documents"}').reason, 'unknown action None; use lookup, close or escalate');
|
||||
assert.equal(engine.step('{"action":"lookup","what":3.0}').reason, "unknown lookup 3.0; use history, counterparty, prior_alerts or documents");
|
||||
assert.equal(engine.step('{"action":"lookup","what":"history","month":["x"]}').reason, "month ['x'] is not in the twelve-month window");
|
||||
assert.equal(engine.step('{"action":"close","reason":"CONSISTENT_WITH_PROFILE","cites":["doc.x"]}').reason, 'cites an id you have not been shown: doc.x');
|
||||
assert.equal(engine.turns, 5);
|
||||
assert.equal(engine.rejected, 5);
|
||||
assert.equal(engine.minutes, 30 + 5 * 3);
|
||||
});
|
||||
|
||||
test('aborted is exactly eight turns; fewer is truncated and unscored', () => {
|
||||
const aborted = new Engine(worldForSeed(3));
|
||||
for (let i = 0; i < 8; i += 1) aborted.step('thinking');
|
||||
assert.equal(aborted.outcome, 'aborted');
|
||||
assert.deepEqual(scoreExact(aborted.episode()), { caught: [0, 1], hours: [0, 1], evidence: [0, 1] });
|
||||
|
||||
const truncated = new Engine(worldForSeed(3));
|
||||
truncated.step('thinking');
|
||||
assert.equal(truncated.outcome, null);
|
||||
assert.deepEqual(scoreExact(truncated.episode()), { caught: null, hours: null, evidence: null });
|
||||
});
|
||||
|
||||
test('the reference is the cheapest correct shipped policy and never null', () => {
|
||||
for (let seed = 0; seed < 96; seed += 1) {
|
||||
const world = generate(seed);
|
||||
const [minutes, policy] = referenceFor(world);
|
||||
assert.notEqual(minutes, null, `seed ${seed}`);
|
||||
assert.equal(runPolicy(POLICIES[policy!]!, world).minutes, minutes);
|
||||
if (world.tier === 'hidden') {
|
||||
assert.notEqual(runPolicy(POLICIES.fast!, world).outcome, 'solved', `fast cannot see the hidden signal on seed ${seed}`);
|
||||
assert.equal(runPolicy(POLICIES.targeted!, world).outcome, 'solved', `targeted catches seed ${seed}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the committed corpus replays to the same rejection reasons the Python recorded', () => {
|
||||
const corpus: CorpusCase[] = JSON.parse(
|
||||
readFileSync(new URL('../../../../envs/alert_triage/conformance/replies.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
const reasons = protocolTrace(corpus)
|
||||
.flatMap((entry) => entry.turns as { reason: string | null }[])
|
||||
.map((t) => t.reason)
|
||||
.filter((r): r is string => r !== null);
|
||||
assert.deepEqual(reasons, [
|
||||
"month 'BADMONTH' is not in the twelve-month window",
|
||||
'the JSON object could not be parsed',
|
||||
"unknown close reason 'consistent_with_profile'",
|
||||
"unknown action 'Lookup'; use lookup, close or escalate",
|
||||
'cites must be a non-empty list of ids',
|
||||
'cites must be a non-empty list of ids',
|
||||
"unknown typology 'structuring'",
|
||||
'cites must be a non-empty list of ids',
|
||||
'prior_alerts has already been looked up',
|
||||
"counterparty 'CP-99' has not appeared on your screen or in a fetched month",
|
||||
"counterparty 'cp-1' has not appeared on your screen or in a fetched month",
|
||||
"month '2024-01' is not in the twelve-month window",
|
||||
"month '2026-1' is not in the twelve-month window",
|
||||
'cites an id you have not been shown: doc.probate_letter',
|
||||
'no JSON object found in the reply',
|
||||
'no JSON object found in the reply',
|
||||
'no JSON object found in the reply',
|
||||
'the JSON object could not be parsed',
|
||||
'the reply parsed but is not a JSON object',
|
||||
'unknown action None; use lookup, close or escalate',
|
||||
'no JSON object found in the reply',
|
||||
'unknown action None; use lookup, close or escalate',
|
||||
"counterparty 'CP-3' has not appeared on your screen or in a fetched month",
|
||||
'no JSON object found in the reply',
|
||||
"unknown lookup 'emails'; use history, counterparty, prior_alerts or documents",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { describe } from '../adapter';
|
||||
import { Engine, isHeldOut, worldForSeed } from '../engine';
|
||||
import { initState, play, replay } from '../game';
|
||||
import { thorough, closeBlind, inaction } from '../policies';
|
||||
|
||||
/**
|
||||
* The seam, exercised the way the shell exercises it: a fresh board per
|
||||
* seed, one reply at a time through `play()`, and a recording through
|
||||
* `replay()`. The engine's own conformance is the port's business; these
|
||||
* check that the board never shows what it must not and always agrees with
|
||||
* the engine it wraps.
|
||||
*/
|
||||
|
||||
const SEEDS = Array.from({ length: 48 }, (_, i) => i);
|
||||
|
||||
test('a fresh board shows the screen, the free-screen hours, and no answer', () => {
|
||||
for (const seed of SEEDS) {
|
||||
const state = initState(seed);
|
||||
assert.equal(state.seed, seed);
|
||||
assert.equal(state.reveal, null, `seed ${seed} leaked the label before a disposition`);
|
||||
assert.equal(state.status, 'playing');
|
||||
assert.equal(state.turnsSpent, 0);
|
||||
assert.equal(state.minutesSpent, 30);
|
||||
assert.equal(state.lookups.length, 0);
|
||||
assert.equal(state.screen.summary.length, 12);
|
||||
assert.ok(state.shown.includes(state.screen.alert.id));
|
||||
assert.equal(state.screen.screen_order, isHeldOut(seed) ? 'kyc_first' : 'alert_first');
|
||||
}
|
||||
});
|
||||
|
||||
test('the thorough analyst, played through play(), reaches the right call on every seed', () => {
|
||||
for (const seed of SEEDS) {
|
||||
let state = initState(seed);
|
||||
let guard = 0;
|
||||
while (state.status === 'playing' && guard < 12) {
|
||||
// The policy reads the engine's view; rebuild the same view off the board.
|
||||
const view = {
|
||||
screen: state.screen,
|
||||
lookups: state.lookups,
|
||||
turns_spent: state.turnsSpent,
|
||||
turns_left: 8 - state.turnsSpent,
|
||||
rejected: state.rejected,
|
||||
minutes_spent: state.minutesSpent,
|
||||
last_rejection: state.lastRejection,
|
||||
};
|
||||
state = play(state, thorough(view));
|
||||
guard += 1;
|
||||
}
|
||||
assert.equal(state.status === 'closed' || state.status === 'escalated', true, `seed ${seed} never decided`);
|
||||
assert.equal(state.outcome, 'solved', `seed ${seed}: thorough got it wrong`);
|
||||
assert.ok(state.reveal, `seed ${seed} has no reveal after the episode ended`);
|
||||
assert.equal(state.reveal?.label, worldForSeed(seed).label);
|
||||
assert.equal(state.reveal?.referenceMinutes, worldForSeed(seed).reference_minutes);
|
||||
assert.equal(state.rejected, 0);
|
||||
// Playing on after the end is a no-op, never a new board.
|
||||
assert.equal(play(state, '{"action":"lookup","what":"documents"}'), state);
|
||||
}
|
||||
});
|
||||
|
||||
test('replay() agrees with the engine step for step and one snapshot per reply', () => {
|
||||
for (const seed of SEEDS.slice(0, 16)) {
|
||||
const engine = new Engine(worldForSeed(seed));
|
||||
const replies: string[] = [];
|
||||
while (!engine.done) {
|
||||
const reply = thorough(engine.view());
|
||||
replies.push(reply);
|
||||
engine.step(reply);
|
||||
}
|
||||
const snapshots = replay(seed, replies);
|
||||
assert.equal(snapshots.length, replies.length);
|
||||
const final = snapshots[snapshots.length - 1]!;
|
||||
assert.equal(final.minutesSpent, engine.minutes);
|
||||
assert.equal(final.turnsSpent, engine.turns);
|
||||
assert.deepEqual(final.disposition, engine.disposition);
|
||||
assert.deepEqual(final.shown.sort(), [...engine.shown].sort());
|
||||
// A fixture's own reference wins over the world's for the reveal.
|
||||
assert.equal(replay(seed, replies, { referenceMinutes: 99 }).at(-1)?.reveal?.referenceMinutes, 99);
|
||||
}
|
||||
});
|
||||
|
||||
test('refusals cost a turn, are announced, and eight of them abort', () => {
|
||||
let state = initState(3);
|
||||
state = play(state, inaction());
|
||||
assert.equal(state.turnsSpent, 1);
|
||||
assert.equal(state.rejected, 1);
|
||||
assert.equal(state.minutesSpent, 33);
|
||||
assert.ok(state.lastRejection);
|
||||
assert.equal(state.reveal, null);
|
||||
assert.match(describe(state, initState(3), 0).announce, /refused/);
|
||||
|
||||
for (let i = 1; i < 8; i += 1) state = play(state, inaction());
|
||||
assert.equal(state.status, 'aborted');
|
||||
assert.equal(state.outcome, 'aborted');
|
||||
assert.ok(state.reveal, 'an aborted episode still reveals the answer');
|
||||
assert.match(describe(state, null, 7).announce, /stays in the queue/);
|
||||
});
|
||||
|
||||
test('citing an id never shown is refused, and a blind close is scored against the label', () => {
|
||||
const state = play(initState(5), '{"action":"close","reason":"DOCUMENTED_SOURCE_OF_FUNDS","cites":["doc.probate_letter"],"note":""}');
|
||||
assert.equal(state.rejected, 1);
|
||||
assert.match(state.lastRejection ?? '', /not been shown/);
|
||||
|
||||
const blind = play(initState(5), closeBlind());
|
||||
assert.equal(blind.status, 'closed');
|
||||
assert.equal(blind.outcome, worldForSeed(5).label === 'benign' ? 'solved' : 'failed');
|
||||
const step = describe(blind, initState(5), 0);
|
||||
assert.equal(step.caption, 'closed');
|
||||
assert.match(step.announce, /citing 1 id/);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { bestAlternate } from '../grade';
|
||||
import { evidence, f1Exact, score, type Episode } from '../score';
|
||||
|
||||
/**
|
||||
* Vectors the page's copy relies on, checked against the browser scorer. The
|
||||
* cross-language digest proves the port matches the Python; these prove the
|
||||
* sentences on the Reward tab match the port.
|
||||
*/
|
||||
|
||||
const base: Episode = {
|
||||
label: 'benign',
|
||||
planted: [['kyc.expected_monthly_cash'], ['kyc.expected_monthly_cash', 'kyc.business_type']],
|
||||
disposition: 'close',
|
||||
cites: ['kyc.expected_monthly_cash'],
|
||||
minutes_spent: 33,
|
||||
reference_minutes: 33,
|
||||
turns_spent: 1,
|
||||
rejected: 0,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
test('a correct, well-cited close at the reference scores 1.0 everywhere', () => {
|
||||
assert.deepEqual(score(base), { caught: 1, hours: 1, evidence: 1 });
|
||||
});
|
||||
|
||||
test('a missed escalation scores exactly 0.000 on every component', () => {
|
||||
assert.deepEqual(score({ ...base, label: 'suspicious', planted: [['T-1', 'T-2']] }), {
|
||||
caught: 0,
|
||||
hours: 0,
|
||||
evidence: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a false escalation earns the credit on caught and nothing else', () => {
|
||||
assert.deepEqual(score({ ...base, disposition: 'escalate', cites: ['T-1'] }), {
|
||||
caught: 0.5,
|
||||
hours: 0,
|
||||
evidence: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('no disposition is zero, not null; a truncated run is null, not zero', () => {
|
||||
assert.deepEqual(score({ ...base, disposition: null, cites: [] }), { caught: 0, hours: 0, evidence: 0 });
|
||||
assert.deepEqual(score({ ...base, truncated: true }), { caught: null, hours: null, evidence: null });
|
||||
});
|
||||
|
||||
test('hours is capped at one and null without a reference', () => {
|
||||
assert.equal(score({ ...base, minutes_spent: 20 }).hours, 1);
|
||||
assert.equal(score({ ...base, minutes_spent: 66 }).hours, 0.5);
|
||||
assert.equal(score({ ...base, reference_minutes: null }).hours, null);
|
||||
});
|
||||
|
||||
test('precision bites: one planted id among eight cited is F1 0.22', () => {
|
||||
const cites = ['kyc.expected_monthly_cash', 'T-1', 'T-2', 'T-3', 'T-4', 'T-5', 'T-6', 'T-7'];
|
||||
assert.deepEqual(f1Exact(cites, ['kyc.expected_monthly_cash']), [2, 9]);
|
||||
assert.equal(evidence({ ...base, cites }), 2 / 9);
|
||||
});
|
||||
|
||||
test('the best alternate is scored, and the board can recover which one', () => {
|
||||
const cites = ['kyc.expected_monthly_cash', 'kyc.business_type'];
|
||||
assert.deepEqual(bestAlternate(cites, base.planted), base.planted[1]);
|
||||
assert.equal(evidence({ ...base, cites }), 1);
|
||||
// A tie goes to the first alternate, as the scorer's strict `>` does.
|
||||
assert.deepEqual(bestAlternate(['T-9'], base.planted), base.planted[0]);
|
||||
});
|
||||
|
||||
test('planted ids are matched exact-case and an unshown id simply misses', () => {
|
||||
assert.equal(evidence({ ...base, cites: ['KYC.EXPECTED_MONTHLY_CASH'] }), 0);
|
||||
assert.equal(evidence({ ...base, cites: ['doc.probate_letter', 'kyc.expected_monthly_cash'] }), 2 / 3);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DemoEpisode, DemoStep } from '@/lib/demo-kit';
|
||||
|
||||
import { hours } from './format';
|
||||
import { replay } from './game';
|
||||
import type { TriageState } from './types';
|
||||
|
||||
/**
|
||||
* A recorded episode becomes a list of board snapshots.
|
||||
*
|
||||
* Nothing is read off the fixture but the seed and the reply strings. The
|
||||
* world is regenerated from the seed and every reply is stepped through the
|
||||
* same engine the environment ran, so the board you scrub through is what the
|
||||
* engine did with what the model said — not what the capture script wrote
|
||||
* down about it. A fixture whose `info` disagrees with its own replies cannot
|
||||
* fool this adapter, because the adapter never looks.
|
||||
*
|
||||
* `reference_hours` is the one metric read, and only for the reveal: it comes
|
||||
* from running the shipped analysts, which the browser has no business doing.
|
||||
* Its absence leaves the reveal without a reference, never with a zero.
|
||||
*
|
||||
* Snapshots, not deltas: the scrubber jumps to any step.
|
||||
*/
|
||||
export function adapt(episode: DemoEpisode): DemoStep<TriageState>[] {
|
||||
const referenceHours = episode.metrics?.['reference_hours'];
|
||||
const referenceMinutes = typeof referenceHours === 'number' && Number.isFinite(referenceHours)
|
||||
? Math.round(referenceHours * 60)
|
||||
: null;
|
||||
|
||||
const snapshots = replay(
|
||||
episode.seed,
|
||||
episode.turns.map((turn) => turn.reply),
|
||||
{ referenceMinutes },
|
||||
);
|
||||
|
||||
let previous: TriageState | null = null;
|
||||
|
||||
return episode.turns.map((turn, index) => {
|
||||
const state = snapshots[index] ?? snapshots[snapshots.length - 1];
|
||||
if (!state) {
|
||||
// Cannot happen: replay returns one snapshot per reply. Typed defensively
|
||||
// so a broken port degrades to a readable step rather than a crash.
|
||||
throw new Error(`replay produced no snapshot for turn ${index + 1}`);
|
||||
}
|
||||
const { announce, caption } = describe(state, previous, index);
|
||||
previous = state;
|
||||
return {
|
||||
index,
|
||||
state,
|
||||
reply: turn.reply,
|
||||
reasoning: turn.reasoning,
|
||||
call: turn.call,
|
||||
announce,
|
||||
caption,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a screen reader hears when a step lands, and the six words under the
|
||||
* chip. Derived from the change between snapshots, so it says what the engine
|
||||
* did rather than what the reply claimed.
|
||||
*/
|
||||
export function describe(
|
||||
state: TriageState,
|
||||
previous: TriageState | null,
|
||||
index: number,
|
||||
): { announce: string; caption: string } {
|
||||
const turn = `Turn ${index + 1}`;
|
||||
const meter = `${hours(state.minutesSpent)} hours spent.`;
|
||||
|
||||
const wasDone = previous !== null && previous.status !== 'playing';
|
||||
if (wasDone) {
|
||||
return { announce: `${turn}: the episode was already over.`, caption: 'after the end' };
|
||||
}
|
||||
|
||||
if (state.lastRejection !== null && state.turnsSpent === (previous?.turnsSpent ?? 0) + 1 && state.lookups.length === (previous?.lookups.length ?? 0) && state.disposition === null) {
|
||||
const why = state.lastRejection;
|
||||
return {
|
||||
announce: `${turn} refused: ${why}. It still cost a turn. ${meter}`,
|
||||
caption: `refused: ${shorten(why)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const d = state.disposition;
|
||||
if (d !== null) {
|
||||
const what = d.action === 'close' ? `closed, ${d.reason.toLowerCase().replace(/_/g, ' ')}` : `escalated as ${d.typology.toLowerCase().replace(/_/g, ' ')}`;
|
||||
const cites = `citing ${d.cites.length} ${d.cites.length === 1 ? 'id' : 'ids'}`;
|
||||
const verdict =
|
||||
state.outcome === 'solved'
|
||||
? 'Right call.'
|
||||
: state.reveal?.label === 'suspicious'
|
||||
? 'Missed: the alert was suspicious.'
|
||||
: 'A clean customer was escalated.';
|
||||
return {
|
||||
announce: `${turn}: ${what}, ${cites}. ${verdict} ${meter}`,
|
||||
caption: d.action === 'close' ? 'closed' : 'escalated',
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'aborted') {
|
||||
return {
|
||||
announce: `${turn}: no disposition in eight replies. The alert stays in the queue. ${meter}`,
|
||||
caption: 'aborted',
|
||||
};
|
||||
}
|
||||
|
||||
const lookup = state.lookups[state.lookups.length - 1];
|
||||
if (lookup && state.lookups.length > (previous?.lookups.length ?? 0)) {
|
||||
switch (lookup.what) {
|
||||
case 'history':
|
||||
return {
|
||||
announce: `${turn}: looked up ${lookup.key}, ${lookup.result.transactions.length} transactions. ${meter}`,
|
||||
caption: `history ${lookup.key}`,
|
||||
};
|
||||
case 'counterparty':
|
||||
return {
|
||||
announce: `${turn}: looked up ${lookup.key}, ${lookup.result.name}, shared with ${lookup.result.other_alerted_accounts} other alerted accounts. ${meter}`,
|
||||
caption: `counterparty ${lookup.key}`,
|
||||
};
|
||||
case 'prior_alerts':
|
||||
return {
|
||||
announce: `${turn}: looked up prior alerts, ${lookup.result.alerts.length} on file. ${meter}`,
|
||||
caption: 'prior alerts',
|
||||
};
|
||||
case 'documents':
|
||||
return {
|
||||
announce: `${turn}: looked up documents, ${lookup.result.documents.length} on file. ${meter}`,
|
||||
caption: 'documents',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { announce: `${turn}: nothing changed. ${meter}`, caption: 'no change' };
|
||||
}
|
||||
|
||||
function shorten(text: string): string {
|
||||
return text.length > 40 ? `${text.slice(0, 37)}…` : text;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { enumLabel, fieldLabel, hours } from './format';
|
||||
import { MAX_TURNS, PRICES, play } from './game';
|
||||
import { DOC_MINUTES, LOOKUP_MINUTES, TURN_MINUTES } from './score';
|
||||
import { CLOSE_REASONS, TYPOLOGIES, type CloseReason, type TriageState, type Typology } from './types';
|
||||
|
||||
/**
|
||||
* The analyst's controls: four lookups with their prices on the button, and
|
||||
* a disposition with a citation checklist.
|
||||
*
|
||||
* Every control produces exactly the JSON object a model would have to
|
||||
* produce, and hands it to the same engine. The visitor never sees a control
|
||||
* the model does not have, and the reply the engine received is shown under
|
||||
* the controls so the action set is learned by using it. The controls own no
|
||||
* state the board does not: the next board comes back from `play()` and goes
|
||||
* to `onChange`, and the only local state here is what is half-typed.
|
||||
*/
|
||||
|
||||
type Panel = 'none' | 'close' | 'escalate';
|
||||
|
||||
const price = (minutes: number) => `+${hours(minutes)} h`;
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<fieldset className="min-w-0 rounded-lg border border-border p-3">
|
||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-muted">{title}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const SELECT =
|
||||
'tap min-w-0 rounded-md border border-border bg-surface px-2 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50';
|
||||
|
||||
/** The citable ids, grouped the way the screen groups them. */
|
||||
function groupShown(shown: readonly string[]) {
|
||||
const groups: { label: string; ids: string[] }[] = [
|
||||
{ label: 'KYC file', ids: [] },
|
||||
{ label: 'Transactions', ids: [] },
|
||||
{ label: 'Counterparties', ids: [] },
|
||||
{ label: 'Prior alerts', ids: [] },
|
||||
{ label: 'Documents', ids: [] },
|
||||
{ label: 'Other', ids: [] },
|
||||
];
|
||||
const byId = (a: string, b: string) => {
|
||||
const na = Number(a.replace(/^\D+/, ''));
|
||||
const nb = Number(b.replace(/^\D+/, ''));
|
||||
return Number.isFinite(na) && Number.isFinite(nb) && na !== nb ? na - nb : a.localeCompare(b);
|
||||
};
|
||||
for (const id of shown) {
|
||||
const index = id.startsWith('kyc.')
|
||||
? 0
|
||||
: id.startsWith('T-')
|
||||
? 1
|
||||
: id.startsWith('CP-')
|
||||
? 2
|
||||
: id.startsWith('A-')
|
||||
? 3
|
||||
: id.startsWith('doc.')
|
||||
? 4
|
||||
: 5;
|
||||
groups[index]!.ids.push(id);
|
||||
}
|
||||
for (const group of groups) group.ids.sort(byId);
|
||||
return groups.filter((group) => group.ids.length > 0);
|
||||
}
|
||||
|
||||
export function Controls({
|
||||
state,
|
||||
onChange,
|
||||
}: {
|
||||
state: TriageState;
|
||||
onChange: (next: TriageState) => void;
|
||||
}) {
|
||||
const ids = useId();
|
||||
const [month, setMonth] = useState('');
|
||||
const [cp, setCp] = useState('');
|
||||
const [panel, setPanel] = useState<Panel>('none');
|
||||
const [reason, setReason] = useState<CloseReason>('CONSISTENT_WITH_PROFILE');
|
||||
const [typology, setTypology] = useState<Typology>('STRUCTURING');
|
||||
const [cites, setCites] = useState<string[]>([]);
|
||||
const [prose, setProse] = useState('');
|
||||
const [lastReply, setLastReply] = useState<string | null>(null);
|
||||
|
||||
const done = state.status !== 'playing';
|
||||
const left = MAX_TURNS - state.turnsSpent;
|
||||
|
||||
const send = (object: Record<string, unknown>) => {
|
||||
const reply = JSON.stringify(object);
|
||||
setLastReply(reply);
|
||||
onChange(play(state, reply));
|
||||
};
|
||||
|
||||
const monthsLeft = useMemo(
|
||||
() => state.screen.months.filter((m) => !state.lookups.some((l) => l.what === 'history' && l.key === m)),
|
||||
[state.screen.months, state.lookups],
|
||||
);
|
||||
const cpsLeft = useMemo(
|
||||
() =>
|
||||
state.shown
|
||||
.filter((id) => id.startsWith('CP-'))
|
||||
.filter((id) => !state.lookups.some((l) => l.what === 'counterparty' && l.key === id))
|
||||
.sort((a, b) => Number(a.slice(3)) - Number(b.slice(3))),
|
||||
[state.shown, state.lookups],
|
||||
);
|
||||
const priorsMade = state.lookups.some((l) => l.what === 'prior_alerts');
|
||||
const docsMade = state.lookups.some((l) => l.what === 'documents');
|
||||
const groups = useMemo(() => groupShown(state.shown), [state.shown]);
|
||||
|
||||
const toggleCite = (id: string) =>
|
||||
setCites((current) => (current.includes(id) ? current.filter((c) => c !== id) : [...current, id]));
|
||||
|
||||
const submitDisposition = () => {
|
||||
if (cites.length === 0 || done) return;
|
||||
if (panel === 'close') send({ action: 'close', reason, cites, note: prose });
|
||||
else if (panel === 'escalate') send({ action: 'escalate', typology, cites, narrative: prose });
|
||||
setPanel('none');
|
||||
setCites([]);
|
||||
setProse('');
|
||||
};
|
||||
|
||||
const status = done
|
||||
? state.status === 'aborted'
|
||||
? `No disposition in ${MAX_TURNS} replies. The alert stays in the queue.`
|
||||
: `Disposition recorded after ${hours(state.minutesSpent)} hours. The board shows what the generator planted.`
|
||||
: state.lastRejection
|
||||
? `That reply was not accepted: ${state.lastRejection}. It still cost a turn. ${left} ${left === 1 ? 'reply' : 'replies'} left.`
|
||||
: `Hours spent so far: ${hours(state.minutesSpent)}. ${left} ${left === 1 ? 'reply' : 'replies'} left.`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
|
||||
{/* The board is a group of tables; the outcome of every click is said
|
||||
here in words, where it announces itself. */}
|
||||
<p aria-live="polite" className="text-sm text-muted">
|
||||
{status}
|
||||
</p>
|
||||
|
||||
<Section title={`Lookups — each reply also costs ${price(TURN_MINUTES)}`}>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<label className="sr-only" htmlFor={`${ids}-month`}>
|
||||
Month to itemise
|
||||
</label>
|
||||
<select
|
||||
id={`${ids}-month`}
|
||||
className={cn(SELECT, 'min-w-[7rem] flex-1')}
|
||||
value={month}
|
||||
disabled={done || monthsLeft.length === 0}
|
||||
onChange={(event) => setMonth(event.target.value)}
|
||||
>
|
||||
<option value="">{monthsLeft.length === 0 ? 'every month itemised' : 'month…'}</option>
|
||||
{monthsLeft.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={done || month === ''}
|
||||
onClick={() => {
|
||||
send({ action: 'lookup', what: 'history', month });
|
||||
setMonth('');
|
||||
}}
|
||||
>
|
||||
History <span className="nums text-muted">{price(PRICES.history)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<label className="sr-only" htmlFor={`${ids}-cp`}>
|
||||
Counterparty to look up
|
||||
</label>
|
||||
<select
|
||||
id={`${ids}-cp`}
|
||||
className={cn(SELECT, 'min-w-[7rem] flex-1')}
|
||||
value={cp}
|
||||
disabled={done || cpsLeft.length === 0}
|
||||
onChange={(event) => setCp(event.target.value)}
|
||||
>
|
||||
<option value="">{cpsLeft.length === 0 ? 'no counterparty left' : 'counterparty…'}</option>
|
||||
{cpsLeft.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={done || cp === ''}
|
||||
onClick={() => {
|
||||
send({ action: 'lookup', what: 'counterparty', id: cp });
|
||||
setCp('');
|
||||
}}
|
||||
>
|
||||
Counterparty <span className="nums text-muted">{price(PRICES.counterparty)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={done || priorsMade}
|
||||
onClick={() => send({ action: 'lookup', what: 'prior_alerts' })}
|
||||
>
|
||||
Prior alerts <span className="nums text-muted">{priorsMade ? 'done' : price(LOOKUP_MINUTES)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={done || docsMade}
|
||||
onClick={() => send({ action: 'lookup', what: 'documents' })}
|
||||
>
|
||||
Documents <span className="nums text-muted">{docsMade ? 'done' : price(DOC_MINUTES)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Disposition — ends the episode">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Choose a disposition">
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={panel === 'close' ? 'default' : 'outline'}
|
||||
aria-pressed={panel === 'close'}
|
||||
disabled={done}
|
||||
onClick={() => setPanel(panel === 'close' ? 'none' : 'close')}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={panel === 'escalate' ? 'default' : 'outline'}
|
||||
aria-pressed={panel === 'escalate'}
|
||||
disabled={done}
|
||||
onClick={() => setPanel(panel === 'escalate' ? 'none' : 'escalate')}
|
||||
>
|
||||
Escalate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{panel !== 'none' && !done ? (
|
||||
<form
|
||||
className="mt-3 flex flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitDisposition();
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1 text-xs text-muted">
|
||||
{panel === 'close' ? 'Reason' : 'Typology'}
|
||||
{panel === 'close' ? (
|
||||
<select className={SELECT} value={reason} onChange={(event) => setReason(event.target.value as CloseReason)}>
|
||||
{CLOSE_REASONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{enumLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select className={SELECT} value={typology} onChange={(event) => setTypology(event.target.value as Typology)}>
|
||||
{TYPOLOGIES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{enumLabel(t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="mb-1 text-xs text-muted">
|
||||
Cite the facts that decide it — only ids you have been shown
|
||||
</legend>
|
||||
<div className="flex max-h-56 flex-col gap-2 overflow-y-auto rounded-md border border-border p-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-1 text-[0.65rem] font-semibold uppercase tracking-wide text-muted">{group.label}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.ids.map((id) => {
|
||||
const on = cites.includes(id);
|
||||
return (
|
||||
<label
|
||||
key={id}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer select-none items-center gap-1 rounded-md border px-1.5 py-1 font-mono text-[0.7rem] transition-colors duration-1',
|
||||
on ? 'border-brand bg-accent-subtle text-fg' : 'border-border text-muted hover:bg-surface-2',
|
||||
)}
|
||||
title={id.startsWith('kyc.') ? fieldLabel(id) : id}
|
||||
>
|
||||
<input type="checkbox" className="sr-only" checked={on} onChange={() => toggleCite(id)} />
|
||||
{id}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs text-muted">
|
||||
<span>
|
||||
{panel === 'close' ? 'Note' : 'Narrative'}{' '}
|
||||
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
|
||||
displayed, never scored
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
className="min-h-16 rounded-md border border-border bg-surface px-2 py-1 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
value={prose}
|
||||
onChange={(event) => setProse(event.target.value)}
|
||||
placeholder={panel === 'close' ? 'Why this is consistent with the file…' : 'What the pattern is and why it is reportable…'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" size="touch" disabled={cites.length === 0}>
|
||||
{panel === 'close' ? 'Close the alert' : 'Escalate for filing'}
|
||||
</Button>
|
||||
<span className="nums text-xs text-muted">
|
||||
{cites.length} cited · {price(TURN_MINUTES)}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
{lastReply ? (
|
||||
<details className="text-xs text-muted">
|
||||
<summary className="cursor-pointer">The reply the engine received</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded-md bg-surface-2 p-2 font-mono text-[0.7rem] text-fg [contain:inline-size]">
|
||||
{lastReply}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Controls;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineDemo, type DemoEpisode, type RewardValues } from '@/lib/demo-kit';
|
||||
|
||||
import { adapt } from './adapter';
|
||||
import Controls from './controls';
|
||||
import { initState, replay } from './game';
|
||||
import meta from './meta';
|
||||
import { narrative } from './narrative';
|
||||
import { reward } from './reward';
|
||||
import { score } from './score';
|
||||
import Board from './surface';
|
||||
import type { TriageState } from './types';
|
||||
|
||||
export default defineDemo<TriageState>({
|
||||
meta,
|
||||
narrative,
|
||||
reward,
|
||||
anatomy: {
|
||||
task:
|
||||
'Triage one transaction-monitoring alert on a synthetic customer: read the screen, pay analyst-hours for any lookups, then close it with a reason or escalate it with a typology, citing the facts.',
|
||||
actions:
|
||||
'One JSON object per reply — a lookup (a month’s history, a counterparty, prior alerts, documents) or a disposition (close with a reason, escalate with a typology), citing only ids you were shown. Anything else is refused and still costs a turn.',
|
||||
grader:
|
||||
'Regenerates the alert from the seed, compares the disposition and the cited ids to what the generator planted, and charges the hours. Deterministic Python: no model, no human, no prose read anywhere.',
|
||||
score:
|
||||
'Over half for nothing slipping through, a quarter for clearing it within the reference hours, a fifth for citing the facts that decide it. A missed escalation zeroes all three.',
|
||||
},
|
||||
provenance: {
|
||||
envPackage: 'alert_triage',
|
||||
tasksetId: 'alert-triage',
|
||||
verifiersVersion: '0.3.2.dev12',
|
||||
command: 'uv run python envs/probe.py --taskset alert-triage',
|
||||
credits: [
|
||||
{
|
||||
label: 'FinCEN — guidance on preparing a complete and sufficient SAR narrative (the templates are written from public typology descriptions, not from any case)',
|
||||
href: 'https://www.fincen.gov/sites/default/files/shared/sarnarrcompletguidfinal_112003.pdf',
|
||||
},
|
||||
{
|
||||
label: 'FATF — methods and trends (typology reports)',
|
||||
href: 'https://www.fatf-gafi.org/en/topics/methods-and-trends.html',
|
||||
},
|
||||
{
|
||||
label: 'verifiers — the environment API this mirrors',
|
||||
href: 'https://github.com/PrimeIntellect-ai/verifiers',
|
||||
},
|
||||
{
|
||||
label: 'Our synthetic name and word lists, and where they came from',
|
||||
href: 'https://git.karti.ai/PIG/PIG-Demo/src/branch/main/envs/alert_triage/names/PROVENANCE.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
adapt,
|
||||
Surface: Board,
|
||||
interactive: {
|
||||
init: initState,
|
||||
Controls: Controls,
|
||||
},
|
||||
/**
|
||||
* Re-derive the score from the replies alone.
|
||||
*
|
||||
* The world is regenerated from the seed and the replies are stepped through
|
||||
* the engine; the label, the planted set and the hours all come out of that,
|
||||
* never off the fixture. The one thing read from the recording is
|
||||
* `reference_hours` — it comes from running the shipped analysts, which the
|
||||
* browser has no business doing — and a missing one makes the run
|
||||
* *unverifiable* rather than wrong.
|
||||
*/
|
||||
verify: (episode: DemoEpisode): RewardValues | null => {
|
||||
const referenceHours = episode.metrics?.['reference_hours'];
|
||||
const referenceMinutes =
|
||||
typeof referenceHours === 'number' && Number.isFinite(referenceHours) ? Math.round(referenceHours * 60) : null;
|
||||
|
||||
const snapshots = replay(episode.seed, episode.turns.map((turn) => turn.reply), { referenceMinutes });
|
||||
const final = snapshots[snapshots.length - 1];
|
||||
// A run with no replies, or one that stopped before a disposition and
|
||||
// before the eighth turn, never reached a terminal state: not scored.
|
||||
if (!final || final.reveal === null) return null;
|
||||
|
||||
const d = final.disposition;
|
||||
if (final.status === 'playing') return null;
|
||||
return score({
|
||||
label: final.reveal.label,
|
||||
planted: final.reveal.planted,
|
||||
disposition: d === null ? null : d.action,
|
||||
cites: d === null ? [] : d.cites,
|
||||
minutes_spent: final.minutesSpent,
|
||||
reference_minutes: referenceMinutes,
|
||||
turns_spent: final.turnsSpent,
|
||||
rejected: final.rejected,
|
||||
truncated: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* One episode, in the browser: the free screen, up to eight replies, and the
|
||||
* hour meter.
|
||||
*
|
||||
* This is the entry point of the port of `envs/alert_triage/alert_triage/` —
|
||||
* the generator, the protocol, the reward and the scripted analysts live in
|
||||
* the sibling modules and are re-exported here. "Port" is meant strictly:
|
||||
* `scripts/conformance.mjs` computes the three digests in
|
||||
* `envs/alert_triage/CONFORMANCE.txt` from this module and fails the build
|
||||
* if any differs from what the Python computed. That gate is what lets the
|
||||
* page claim it verified a recorded run rather than merely replayed it.
|
||||
*
|
||||
* Two discipline rules, enforced here rather than in the grader:
|
||||
*
|
||||
* - **Shown ids.** A disposition may cite only ids the engine has actually
|
||||
* rendered — KYC fields and triggering transactions from the screen, plus
|
||||
* whatever lookups returned.
|
||||
* - **Abort versus truncate.** `aborted` is exactly MAX_TURNS turns spent
|
||||
* with no accepted disposition, and scores 0.000. Anything that stops
|
||||
* earlier with no disposition is `truncated` and is not scored at all.
|
||||
*
|
||||
* Keep this module pure and dependency-free. It runs on the main thread and
|
||||
* under `node --test`; hashing is the conformance script's business.
|
||||
*/
|
||||
|
||||
import { MAX_TURNS, canonicalJson, generate, screenOf } from './generator';
|
||||
import type { Counterparty, Document, Label, PriorAlert, Screen, Txn, World } from './generator';
|
||||
import { ACTIONS, CLOSE_REASONS, LOOKUPS, TYPOLOGIES, isWindowMonth, parseReply, pyRepr } from './protocol';
|
||||
import type { JsonValue, Lookup, LookupResult } from './protocol';
|
||||
import { POLICIES, SHIPPED } from './policies';
|
||||
import type { LookupRecord, Policy, View } from './policies';
|
||||
import { DOC_MINUTES, LOOKUP_MINUTES, SCREEN_MINUTES, TURN_MINUTES, scoreExact } from './score';
|
||||
import type { Episode } from './score';
|
||||
|
||||
export * from './rng';
|
||||
export * from './generator';
|
||||
export * from './protocol';
|
||||
export * from './score';
|
||||
export * from './policies';
|
||||
|
||||
const LOOKUP_PRICE: Record<Lookup, number> = {
|
||||
history: LOOKUP_MINUTES,
|
||||
counterparty: LOOKUP_MINUTES,
|
||||
prior_alerts: LOOKUP_MINUTES,
|
||||
documents: DOC_MINUTES,
|
||||
};
|
||||
|
||||
/** The canonical form of an accepted action. */
|
||||
export type EngineAction =
|
||||
| { action: 'lookup'; what: 'history'; month: string }
|
||||
| { action: 'lookup'; what: 'counterparty'; id: string }
|
||||
| { action: 'lookup'; what: 'prior_alerts' | 'documents' }
|
||||
| { action: 'close'; reason: string; cites: string[]; note: string }
|
||||
| { action: 'escalate'; typology: string; cites: string[]; narrative: string };
|
||||
|
||||
export type Disposition = Extract<EngineAction, { action: 'close' | 'escalate' }>;
|
||||
|
||||
export interface Step {
|
||||
accepted: boolean;
|
||||
action: EngineAction | null;
|
||||
/** Why a rejected reply was rejected. */
|
||||
reason: string | null;
|
||||
/** What an accepted lookup returned. */
|
||||
result: LookupResult | null;
|
||||
minutes_spent: number;
|
||||
turns_spent: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export type Outcome = 'solved' | 'failed' | 'aborted';
|
||||
|
||||
export class Engine {
|
||||
readonly world: World;
|
||||
turns = 0;
|
||||
rejected = 0;
|
||||
minutes = SCREEN_MINUTES;
|
||||
actions: EngineAction[] = [];
|
||||
lookups: LookupRecord[] = [];
|
||||
disposition: Disposition | null = null;
|
||||
shown = new Set<string>();
|
||||
lastRejection: string | null = null;
|
||||
private readonly screenCache: Screen;
|
||||
|
||||
constructor(world: World) {
|
||||
this.world = world;
|
||||
this.screenCache = this.buildScreen();
|
||||
const screen = this.screenCache;
|
||||
for (const k of Object.keys(screen.customer)) {
|
||||
if (k !== 'id' && k !== 'name' && (screen.customer as unknown as Record<string, unknown>)[k] !== undefined) this.shown.add(`kyc.${k}`);
|
||||
}
|
||||
this.shown.add(screen.alert.id);
|
||||
for (const t of screen.triggering_transactions) {
|
||||
this.shown.add(t.id);
|
||||
if (t.cp) this.shown.add(t.cp);
|
||||
}
|
||||
for (const cp of screen.screen_counterparties) this.shown.add(cp.id);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- views */
|
||||
|
||||
private buildScreen(): Screen {
|
||||
const screen = screenOf(this.world);
|
||||
if (screen.screen_order === 'kyc_first') screen.summary = [...screen.summary].reverse();
|
||||
return screen;
|
||||
}
|
||||
|
||||
screen(): Screen {
|
||||
return this.screenCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the model has been shown, and nothing it has not. The scripted
|
||||
* policies take this and only this: no label, typology, planted set,
|
||||
* overlay or reference is in it.
|
||||
*/
|
||||
view(): View {
|
||||
return {
|
||||
screen: this.screenCache,
|
||||
lookups: [...this.lookups],
|
||||
turns_spent: this.turns,
|
||||
turns_left: MAX_TURNS - this.turns,
|
||||
rejected: this.rejected,
|
||||
minutes_spent: this.minutes,
|
||||
last_rejection: this.lastRejection,
|
||||
};
|
||||
}
|
||||
|
||||
get done(): boolean {
|
||||
return this.disposition !== null || this.turns >= MAX_TURNS;
|
||||
}
|
||||
|
||||
get aborted(): boolean {
|
||||
return this.disposition === null && this.turns >= MAX_TURNS;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- step */
|
||||
|
||||
step(reply: string | null | undefined): Step {
|
||||
if (this.done) {
|
||||
return { accepted: false, action: null, reason: 'the episode is over', result: null, minutes_spent: this.minutes, turns_spent: this.turns, done: true };
|
||||
}
|
||||
const [obj, parseReason] = parseReply(reply);
|
||||
let action: EngineAction | null = null;
|
||||
let reason = parseReason;
|
||||
if (obj !== null) [action, reason] = this.validate(obj);
|
||||
this.turns += 1;
|
||||
this.minutes += TURN_MINUTES;
|
||||
if (action === null) {
|
||||
this.rejected += 1;
|
||||
this.lastRejection = reason;
|
||||
return { accepted: false, action: null, reason, result: null, minutes_spent: this.minutes, turns_spent: this.turns, done: this.done };
|
||||
}
|
||||
|
||||
this.lastRejection = null;
|
||||
this.actions.push(action);
|
||||
let result: LookupResult | null = null;
|
||||
if (action.action === 'lookup') {
|
||||
this.minutes += LOOKUP_PRICE[action.what];
|
||||
result = this.lookup(action);
|
||||
const key = action.what === 'history' ? action.month : action.what === 'counterparty' ? action.id : null;
|
||||
this.lookups.push({ what: action.what, key, result });
|
||||
} else {
|
||||
this.disposition = action;
|
||||
}
|
||||
return { accepted: true, action, reason: null, result, minutes_spent: this.minutes, turns_spent: this.turns, done: this.done };
|
||||
}
|
||||
|
||||
private validate(obj: Map<string, JsonValue>): [EngineAction | null, string | null] {
|
||||
// A missing key is Python's `.get()` None; `pyRepr` prints both as None.
|
||||
const get = (k: string): JsonValue | undefined => obj.get(k);
|
||||
const action = get('action');
|
||||
if (!(ACTIONS as readonly unknown[]).includes(action)) {
|
||||
return [null, `unknown action ${pyRepr(action)}; use lookup, close or escalate`];
|
||||
}
|
||||
if (action === 'lookup') {
|
||||
const what = get('what');
|
||||
if (!(LOOKUPS as readonly unknown[]).includes(what)) {
|
||||
return [null, `unknown lookup ${pyRepr(what)}; use history, counterparty, prior_alerts or documents`];
|
||||
}
|
||||
const lookup = what as Lookup;
|
||||
if (lookup === 'history') {
|
||||
const month = get('month');
|
||||
if (!isWindowMonth(month, this.world.months)) {
|
||||
return [null, `month ${pyRepr(month)} is not in the twelve-month window`];
|
||||
}
|
||||
if (this.lookups.some((l) => l.what === 'history' && l.key === month)) {
|
||||
return [null, `history for ${month} has already been looked up`];
|
||||
}
|
||||
return [{ action: 'lookup', what: 'history', month }, null];
|
||||
}
|
||||
if (lookup === 'counterparty') {
|
||||
const cid = get('id');
|
||||
if (typeof cid !== 'string' || !this.shown.has(cid) || !cid.startsWith('CP-')) {
|
||||
return [null, `counterparty ${pyRepr(cid)} has not appeared on your screen or in a fetched month`];
|
||||
}
|
||||
if (this.lookups.some((l) => l.what === 'counterparty' && l.key === cid)) {
|
||||
return [null, `counterparty ${cid} has already been looked up`];
|
||||
}
|
||||
return [{ action: 'lookup', what: 'counterparty', id: cid }, null];
|
||||
}
|
||||
if (this.lookups.some((l) => l.what === lookup)) {
|
||||
return [null, `${lookup} has already been looked up`];
|
||||
}
|
||||
return [{ action: 'lookup', what: lookup }, null];
|
||||
}
|
||||
|
||||
const cites = get('cites');
|
||||
if (!Array.isArray(cites) || cites.length === 0) return [null, 'cites must be a non-empty list of ids'];
|
||||
const seen: string[] = [];
|
||||
for (const c of cites) {
|
||||
if (typeof c !== 'string') return [null, 'cites must be a non-empty list of ids'];
|
||||
if (!seen.includes(c)) seen.push(c);
|
||||
}
|
||||
for (const c of seen) {
|
||||
if (!this.shown.has(c)) return [null, `cites an id you have not been shown: ${c}`];
|
||||
}
|
||||
if (action === 'close') {
|
||||
const reason = get('reason');
|
||||
if (!(CLOSE_REASONS as readonly unknown[]).includes(reason)) return [null, `unknown close reason ${pyRepr(reason)}`];
|
||||
const note = get('note');
|
||||
return [{ action: 'close', reason: reason as string, cites: seen, note: typeof note === 'string' ? note : '' }, null];
|
||||
}
|
||||
const typology = get('typology');
|
||||
if (!(TYPOLOGIES as readonly unknown[]).includes(typology)) return [null, `unknown typology ${pyRepr(typology)}`];
|
||||
const narrative = get('narrative');
|
||||
return [{ action: 'escalate', typology: typology as string, cites: seen, narrative: typeof narrative === 'string' ? narrative : '' }, null];
|
||||
}
|
||||
|
||||
private lookup(action: Extract<EngineAction, { action: 'lookup' }>): LookupResult {
|
||||
const w = this.world;
|
||||
if (action.what === 'history') {
|
||||
const rows: Txn[] = w.transactions.filter((t) => t.month === action.month);
|
||||
for (const t of rows) {
|
||||
this.shown.add(t.id);
|
||||
if (t.cp) this.shown.add(t.cp);
|
||||
}
|
||||
return { month: action.month, transactions: rows };
|
||||
}
|
||||
if (action.what === 'counterparty') {
|
||||
const cp = w.counterparties.find((c) => c.id === action.id)!;
|
||||
for (const id of cp.transactions) this.shown.add(id);
|
||||
const copy: Counterparty = { ...cp };
|
||||
return copy;
|
||||
}
|
||||
if (action.what === 'prior_alerts') {
|
||||
for (const p of w.prior_alerts) this.shown.add(p.id);
|
||||
const alerts: PriorAlert[] = [...w.prior_alerts];
|
||||
return { alerts };
|
||||
}
|
||||
for (const d of w.documents) this.shown.add(d.id);
|
||||
const documents: Document[] = [...w.documents];
|
||||
return { documents };
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- outcome */
|
||||
|
||||
/** solved | failed | aborted, or null while running / if truncated. */
|
||||
get outcome(): Outcome | null {
|
||||
if (this.disposition !== null) {
|
||||
const label: Label = this.world.label;
|
||||
const right = (label === 'benign') === (this.disposition.action === 'close');
|
||||
return right ? 'solved' : 'failed';
|
||||
}
|
||||
return this.aborted ? 'aborted' : null;
|
||||
}
|
||||
|
||||
episode(truncated: boolean | null = null): Episode {
|
||||
const d = this.disposition;
|
||||
return {
|
||||
label: this.world.label,
|
||||
planted: this.world.planted,
|
||||
disposition: d === null ? null : d.action,
|
||||
cites: d === null ? [] : [...d.cites],
|
||||
minutes_spent: this.minutes,
|
||||
reference_minutes: this.world.reference_minutes ?? null,
|
||||
turns_spent: this.turns,
|
||||
rejected: this.rejected,
|
||||
truncated: truncated === null ? !this.done : truncated,
|
||||
typology: d === null ? null : d.action === 'escalate' ? d.typology : null,
|
||||
true_typology: this.world.typology ?? null,
|
||||
lookups: this.lookups.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ reference */
|
||||
|
||||
/** Play a world to the end under one policy. Returns the finished Engine. */
|
||||
export function runPolicy(policy: Policy, world: World): Engine {
|
||||
const engine = new Engine(world);
|
||||
while (!engine.done) engine.step(policy(engine.view()));
|
||||
return engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheapest shipped policy that reaches the correct disposition, in
|
||||
* minutes. `[null, null]` if none does — which the Python's tests assert
|
||||
* never happens for seeds 0–4095.
|
||||
*/
|
||||
export function referenceFor(world: World): [number | null, string | null] {
|
||||
let best: [number, string] | null = null;
|
||||
for (const name of SHIPPED) {
|
||||
const engine = runPolicy(POLICIES[name]!, world);
|
||||
if (engine.outcome === 'solved' && (best === null || engine.minutes < best[0])) best = [engine.minutes, name];
|
||||
}
|
||||
return best ?? [null, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* The world with `reference_minutes` attached — part of the digest.
|
||||
*
|
||||
* The policies read only the engine's view, never the world, so attaching
|
||||
* their result here is not circular; it is the same move as wordle's
|
||||
* `reference_depth`, made part of the world so the browser recomputes it
|
||||
* instead of trusting the recorded number.
|
||||
*/
|
||||
export function worldForSeed(seed: number): World {
|
||||
const world = generate(seed);
|
||||
const [minutes, policy] = referenceFor(world);
|
||||
world.reference_minutes = minutes;
|
||||
world.reference_policy = policy;
|
||||
return world;
|
||||
}
|
||||
|
||||
/** Re-score a recorded run from its reply strings alone. What `verify` does. */
|
||||
export function replay(seed: number, replies: readonly (string | null)[], world: World | null = null): { engine: Engine; episode: Episode } {
|
||||
const engine = new Engine(world ?? worldForSeed(seed));
|
||||
for (const reply of replies) {
|
||||
if (engine.done) break;
|
||||
engine.step(reply);
|
||||
}
|
||||
return { engine, episode: engine.episode() };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- conformance */
|
||||
// The three digests, as the lines that get hashed. `conformance.py` is the
|
||||
// definition; these produce the same byte strings for SHA-256 to consume. No
|
||||
// hashing here so the module stays browser-clean.
|
||||
|
||||
const GRID_LABELS = ['benign', 'suspicious'] as const;
|
||||
const GRID_DISPOSITIONS = ['close', 'escalate', 'none', 'truncated'] as const;
|
||||
const GRID_CITES: string[][] = [['a'], ['a', 'b'], ['a', 'b', 'c', 'd'], ['x'], ['a', 'x'], ['a', 'b', 'x', 'y', 'z'], ['a', 'a', 'b'], ['kyc.z']];
|
||||
const GRID_PLANTED: string[][][] = [[['a']], [['a', 'b']], [['a', 'b'], ['a']], [['c'], ['a', 'b', 'c', 'd']]];
|
||||
const GRID_MINUTES = [33, 42, 60, 96, 150, 204];
|
||||
const GRID_REFERENCE: (number | null)[] = [33, 42, 96, null];
|
||||
|
||||
/** Digest (a): one canonical world per seed, `reference_minutes` included. */
|
||||
export function* worldLines(seeds: Iterable<number>): Generator<string> {
|
||||
for (const seed of seeds) yield canonicalJson(worldForSeed(seed));
|
||||
}
|
||||
|
||||
/** Digest (b): `scoreExact` over the fixed grid, in `conformance.py`'s loop order. */
|
||||
export function* scorerLines(): Generator<string> {
|
||||
for (const label of GRID_LABELS) {
|
||||
for (const disposition of GRID_DISPOSITIONS) {
|
||||
for (const cites of GRID_CITES) {
|
||||
for (const planted of GRID_PLANTED) {
|
||||
for (const minutes of GRID_MINUTES) {
|
||||
for (const reference of GRID_REFERENCE) {
|
||||
const none = disposition === 'none' || disposition === 'truncated';
|
||||
const ep: Episode = {
|
||||
label,
|
||||
planted,
|
||||
disposition: none ? null : disposition,
|
||||
cites: none ? [] : cites,
|
||||
minutes_spent: minutes,
|
||||
reference_minutes: reference,
|
||||
turns_spent: 1,
|
||||
rejected: 0,
|
||||
truncated: disposition === 'truncated',
|
||||
};
|
||||
yield canonicalJson(scoreExact(ep));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CorpusCase {
|
||||
seed: number;
|
||||
note?: string;
|
||||
replies: (string | null)[];
|
||||
}
|
||||
|
||||
/** Digest (c): parse + engine over the reply corpus on fixed worlds. */
|
||||
export function protocolTrace(corpus: readonly CorpusCase[]): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const c of corpus) {
|
||||
const engine = new Engine(worldForSeed(c.seed));
|
||||
const turns: Record<string, unknown>[] = [];
|
||||
for (const reply of c.replies) {
|
||||
if (engine.done) break;
|
||||
const step = engine.step(reply);
|
||||
turns.push({ accepted: step.accepted, action: step.action, reason: step.reason, minutes: step.minutes_spent, turns: step.turns_spent });
|
||||
}
|
||||
const ep = engine.episode();
|
||||
out.push({ seed: c.seed, turns, outcome: engine.outcome, truncated: ep.truncated, score: scoreExact(ep) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function* protocolLines(corpus: readonly CorpusCase[]): Generator<string> {
|
||||
for (const entry of protocolTrace(corpus)) yield canonicalJson(entry);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Rendering helpers shared by the board and the controls. Pure, no React.
|
||||
*
|
||||
* `money` mirrors `protocol.money()` in the Python so the board shows an
|
||||
* amount the way the model was shown it — the same digits, the same currency
|
||||
* code after them. The currency is fictional on held-out seeds (KRN), which is
|
||||
* exactly why it is a code and never a symbol.
|
||||
*/
|
||||
|
||||
export function money(cents: number, currency: string): string {
|
||||
const sign = cents < 0 ? '-' : '';
|
||||
const abs = Math.abs(cents);
|
||||
const whole = Math.trunc(abs / 100).toLocaleString('en-US');
|
||||
const minor = String(abs % 100).padStart(2, '0');
|
||||
return `${sign}${whole}.${minor} ${currency}`;
|
||||
}
|
||||
|
||||
/** Whole units, no minor part, for the summary table. */
|
||||
export function wholeUnits(cents: number): string {
|
||||
return Math.trunc(cents / 100).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function hours(minutes: number): string {
|
||||
return (minutes / 60).toFixed(2);
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL: Record<string, string> = {
|
||||
cash_in: 'cash in',
|
||||
cash_out: 'cash out',
|
||||
wire_in: 'wire in',
|
||||
wire_out: 'wire out',
|
||||
ach_in: 'ACH in',
|
||||
ach_out: 'ACH out',
|
||||
p2p_in: 'P2P in',
|
||||
p2p_out: 'P2P out',
|
||||
};
|
||||
|
||||
export function channelLabel(channel: string): string {
|
||||
return CHANNEL_LABEL[channel] ?? channel.replace('_', ' ');
|
||||
}
|
||||
|
||||
/** `kyc.expected_monthly_cash` -> `expected monthly cash`. */
|
||||
export function fieldLabel(key: string): string {
|
||||
return key.replace(/^kyc\./, '').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** `CONSISTENT_WITH_PROFILE` -> `consistent with profile`. */
|
||||
export function enumLabel(value: string): string {
|
||||
return value.toLowerCase().replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** The KYC keys the surface renders, in the order the Python renders them (sorted). */
|
||||
export function kycEntries(customer: Record<string, unknown>): [string, unknown][] {
|
||||
return Object.keys(customer)
|
||||
.filter((key) => key !== 'id' && key !== 'name')
|
||||
.sort()
|
||||
.map((key) => [key, customer[key]]);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The seam between the board and the engine.
|
||||
*
|
||||
* Everything the demo knows about `./engine` passes through here: the adapter
|
||||
* replays a recording, the controls take the visitor's next reply, `verify`
|
||||
* re-scores, and all three get a `TriageState` snapshot back. The engine is a
|
||||
* mutable object with a `step()`; the shell wants immutable snapshots it can
|
||||
* scrub through. So a snapshot carries the replies that produced it, and the
|
||||
* next step is "rebuild from the seed, replay them all, step once more". That
|
||||
* is a few hundred RNG draws and at most eight steps — cheap enough to do on
|
||||
* every click, and it keeps `play()` pure, which is what makes the Play tab
|
||||
* and the replay agree by construction rather than by care.
|
||||
*
|
||||
* `worldForSeed` runs the three shipped analysts to find the reference, so a
|
||||
* world is memoised per seed. The engine never mutates its world.
|
||||
*/
|
||||
|
||||
import { Engine, MAX_TURNS, worldForSeed, type World } from './engine';
|
||||
import { DOC_MINUTES, LOOKUP_MINUTES } from './score';
|
||||
import type { Disposition, Lookup, LookupKind, Reveal, TriageState } from './types';
|
||||
|
||||
export { MAX_TURNS };
|
||||
|
||||
/** What each lookup adds to the hour meter, in minutes. The generator's config. */
|
||||
export const PRICES: Record<LookupKind, number> = {
|
||||
history: LOOKUP_MINUTES,
|
||||
counterparty: LOOKUP_MINUTES,
|
||||
prior_alerts: LOOKUP_MINUTES,
|
||||
documents: DOC_MINUTES,
|
||||
};
|
||||
|
||||
const worlds = new Map<number, World>();
|
||||
|
||||
function worldFor(seed: number): World {
|
||||
let world = worlds.get(seed);
|
||||
if (!world) {
|
||||
world = worldForSeed(seed);
|
||||
worlds.set(seed, world);
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine's lookup records, narrowed onto the board's discriminated union.
|
||||
* The engine validated `what`, so the result shape follows from it.
|
||||
*/
|
||||
function lookupsOf(engine: Engine): Lookup[] {
|
||||
return engine.lookups.map((record): Lookup => {
|
||||
switch (record.what) {
|
||||
case 'history':
|
||||
return { what: 'history', key: String(record.key), result: record.result as Extract<Lookup, { what: 'history' }>['result'] };
|
||||
case 'counterparty':
|
||||
return { what: 'counterparty', key: String(record.key), result: record.result as Extract<Lookup, { what: 'counterparty' }>['result'] };
|
||||
case 'prior_alerts':
|
||||
return { what: 'prior_alerts', key: null, result: record.result as Extract<Lookup, { what: 'prior_alerts' }>['result'] };
|
||||
case 'documents':
|
||||
return { what: 'documents', key: null, result: record.result as Extract<Lookup, { what: 'documents' }>['result'] };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
engine: Engine,
|
||||
seed: number,
|
||||
replies: (string | null)[],
|
||||
referenceMinutes: number | null | undefined,
|
||||
): TriageState {
|
||||
const d = engine.disposition;
|
||||
const status: TriageState['status'] =
|
||||
d !== null ? (d.action === 'close' ? 'closed' : 'escalated') : engine.aborted ? 'aborted' : 'playing';
|
||||
|
||||
// The answer key stays off the board until the episode is over.
|
||||
const reveal: Reveal | null = engine.done
|
||||
? {
|
||||
label: engine.world.label,
|
||||
typology: engine.world.typology ?? null,
|
||||
planted: engine.world.planted,
|
||||
template: engine.world.template,
|
||||
overlay: engine.world.overlay,
|
||||
tier: engine.world.tier ?? null,
|
||||
// A recording's own metric wins over the world's, so the reveal shows
|
||||
// the number the fixture was scored against; a fresh board uses what
|
||||
// `worldForSeed` computed.
|
||||
referenceMinutes: referenceMinutes !== undefined ? referenceMinutes : engine.world.reference_minutes ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
seed,
|
||||
screen: engine.screen(),
|
||||
lookups: lookupsOf(engine),
|
||||
turnsSpent: engine.turns,
|
||||
rejected: engine.rejected,
|
||||
minutesSpent: engine.minutes,
|
||||
lastRejection: engine.lastRejection,
|
||||
// The engine checked `reason`/`typology` against the enums, so the
|
||||
// widening from `string` is a fact about the engine, not a hope.
|
||||
disposition: d as Disposition | null,
|
||||
status,
|
||||
outcome: engine.outcome,
|
||||
shown: [...engine.shown],
|
||||
replies,
|
||||
reveal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One snapshot per reply, in order. A reply after the episode is over
|
||||
* produces the same board again — the engine ignores it, as the Python does.
|
||||
*/
|
||||
export function replay(
|
||||
seed: number,
|
||||
replies: readonly (string | null)[],
|
||||
options: { referenceMinutes?: number | null } = {},
|
||||
): TriageState[] {
|
||||
const engine = new Engine(worldFor(seed));
|
||||
const played: (string | null)[] = [];
|
||||
const out: TriageState[] = [];
|
||||
for (const reply of replies) {
|
||||
played.push(reply);
|
||||
if (!engine.done) engine.step(reply);
|
||||
out.push(snapshot(engine, seed, [...played], options.referenceMinutes));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A fresh board for a seed. Deterministic in the seed — the shell relies on it. */
|
||||
export function initState(seed: number): TriageState {
|
||||
return snapshot(new Engine(worldFor(seed)), seed, [], undefined);
|
||||
}
|
||||
|
||||
/** Play one reply, returning the next board. Pure — never mutates its input. */
|
||||
export function play(state: TriageState, reply: string | null): TriageState {
|
||||
if (state.status !== 'playing') return state;
|
||||
const snapshots = replay(state.seed, [...state.replies, reply]);
|
||||
return snapshots[snapshots.length - 1] ?? state;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* One helper the scorer does not need but the board does.
|
||||
*
|
||||
* `evidence()` in `./score` takes the best alternate's F1 and discards which
|
||||
* alternate it was. The reveal on the board wants to mark each cited id as
|
||||
* planted or not, and "planted" is only well-defined against the alternate the
|
||||
* grader actually scored — so this recovers it, with the same tie-break the
|
||||
* scorer uses (first alternate wins a tie). Pure, no `?raw`, testable.
|
||||
*/
|
||||
|
||||
import { f1Exact } from './score';
|
||||
|
||||
export function bestAlternate(cites: readonly string[], planted: readonly string[][]): string[] {
|
||||
let best: string[] = planted[0] ?? [];
|
||||
let bestNum = 0;
|
||||
let bestDen = 1;
|
||||
for (const alt of planted) {
|
||||
const [num, den] = f1Exact(cites, alt);
|
||||
if (num * bestDen > bestNum * den) {
|
||||
bestNum = num;
|
||||
bestDen = den;
|
||||
best = alt;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineMeta } from '@/lib/demo-kit';
|
||||
|
||||
/**
|
||||
* Eager, serialisable, React-free: this is imported for every demo on every
|
||||
* page. Everything expensive is behind `demo.tsx`.
|
||||
*
|
||||
* `status` starts as `spec`: `check-demos` rule 7 refuses `live` until the
|
||||
* capture stage has put recorded runs for this slug in the traces manifest.
|
||||
* The ship stage flips it. Nothing else here changes at that point.
|
||||
*/
|
||||
export default defineMeta({
|
||||
slug: 'alert-triage',
|
||||
title: 'Alert Triage',
|
||||
tagline:
|
||||
'Work one transaction-monitoring alert against the customer’s file and twelve months of history, then close it or escalate it for a filing.',
|
||||
vertical: 'financial-crime',
|
||||
status: 'spec',
|
||||
order: 0,
|
||||
icon: 'Siren',
|
||||
persona: 'BSA / AML Officer',
|
||||
rewardLine: 'Miss nothing, then clear it fast',
|
||||
ogImage: '/og/alert-triage.png',
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The synthetic name and word lists, imported from the environment so the
|
||||
* two sides can never drift: one file, read by both. Provenance is in
|
||||
* `envs/alert_triage/names/PROVENANCE.md`. All four are tiny (under 2 kB
|
||||
* together) and are inlined into this demo's lazy chunk.
|
||||
*/
|
||||
|
||||
import firstNamesJson from '../../../envs/alert_triage/names/first_names.json';
|
||||
import surnamesJson from '../../../envs/alert_triage/names/surnames.json';
|
||||
import wordsJson from '../../../envs/alert_triage/names/words.json';
|
||||
import jurisdictionsJson from '../../../envs/alert_triage/names/jurisdictions.json';
|
||||
|
||||
export const FIRST_NAMES: readonly string[] = firstNamesJson;
|
||||
export const SURNAMES: readonly string[] = surnamesJson;
|
||||
|
||||
export const WORDS: {
|
||||
readonly adjectives: readonly string[];
|
||||
readonly nouns: readonly string[];
|
||||
readonly suffixes: readonly string[];
|
||||
readonly branches: readonly string[];
|
||||
readonly streets: readonly string[];
|
||||
} = wordsJson;
|
||||
|
||||
export const JURISDICTIONS: {
|
||||
readonly home: Readonly<Record<string, string>>;
|
||||
readonly monitored: readonly string[];
|
||||
readonly other: readonly string[];
|
||||
} = jurisdictionsJson;
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Narrative } from '@/lib/demo-kit';
|
||||
|
||||
/**
|
||||
* What this environment argues, tab by tab, addressed to the BSA officer.
|
||||
*
|
||||
* Every claim below is one a reader can check against the surface under it.
|
||||
* The two that matter most are the reward claim — which says what the
|
||||
* recorded seeds show AND what the full ladder shows, because on eight seeds
|
||||
* they disagree — and the limits, which say where this is easier than the
|
||||
* job it stands in for.
|
||||
*
|
||||
* The numbers quoted in `claims.reward` were measured on 2026-08-28 from
|
||||
* `uv run python envs/probe.py --taskset alert-triage` (894 main-slice seeds)
|
||||
* and from the 24 fixtures in `public/traces/alert-triage/`. Re-measure
|
||||
* before re-quoting them after any change to the reward or the traces.
|
||||
*/
|
||||
export const narrative: Narrative = {
|
||||
thesis:
|
||||
'Almost every alert an analyst reads is a false positive, and the operating budget is built on how ' +
|
||||
'fast the team clears them. The few that are not are the whole conversation with the regulator. ' +
|
||||
'This environment puts both numbers in one reward: it pays, in analyst-hours against a reference, for ' +
|
||||
'clearing an alert with the facts that decide it, and it takes more away for a missed escalation than ' +
|
||||
'every hour a policy could save. Every alert is synthetic and every one has a planted answer, so the ' +
|
||||
'grader computes rather than opines — and a policy that never opens the file cannot collect all of ' +
|
||||
'the counterweight, because the cases built to pass the screen only give themselves away in a lookup.',
|
||||
anxiety:
|
||||
'Almost every alert my team reads is a false positive. The handful that are not is the entire conversation with my regulator.',
|
||||
claims: {
|
||||
play:
|
||||
'Work one alert yourself: read the free screen, pay hours for the lookups you think you need, ' +
|
||||
'and close or escalate it citing the facts. Only then does the board show what the generator ' +
|
||||
'planted — and what your call cost against the reference.',
|
||||
watch:
|
||||
'A recorded run of one of three scripted reference analysts — not a language model; none has been ' +
|
||||
'run against this environment yet — replayed one reply at a time: every lookup it paid for, every ' +
|
||||
'reply the engine refused, and the disposition it reached. The note it wrote is shown beside the ' +
|
||||
'call and never scored.',
|
||||
reward:
|
||||
'Three scripted analysts, re-scored under your weights. On the eight recorded seeds the one who ' +
|
||||
'reads the screen edges the one who checks the hidden tells at the shipped weights, 0.871 to 0.866; ' +
|
||||
'over the 894-seed probe ladder the order is the other way, 0.894 to 0.846, because eight seeds ' +
|
||||
'hold one hidden case. Drag the hours weight down one notch and the ranking on these seeds flips ' +
|
||||
'too. The asymmetry a policy memo can only assert is sitting in a slider here.',
|
||||
evidence:
|
||||
'The grader is a short Python function, printed with the hour constants it assumes, the digests ' +
|
||||
'that prove your browser plays the same alert the environment did, and a plain list of the ways ' +
|
||||
'this is easier than the real job.',
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
text:
|
||||
'Every alert here has a knowable answer, because the generator planted it. A real SAR decision is a ' +
|
||||
'human judgement, and a filed report is not a proven crime. Where a rationale the grader refuses is ' +
|
||||
'one an officer would accept, the fix is another alternate in the template — never a judge.',
|
||||
answeredBy: 'playbook-redline',
|
||||
},
|
||||
{
|
||||
text:
|
||||
'The label mix is 55 percent benign by design, far from the real false-positive rate, so a small ' +
|
||||
'seed set meets both labels. "Hours per thousand alerts" is a ratio inside this environment, not ' +
|
||||
'a production number, and the hour constants are the generator’s config — nobody timed an analyst.',
|
||||
},
|
||||
{
|
||||
text:
|
||||
'The lookups are idealised. Each returns the decisive fact in one call — a counterparty’s count of ' +
|
||||
'other alerted accounts, a prior alert’s closing analyst, the party a document names. In production ' +
|
||||
'those signals sit across systems and are often absent. This tests whether the agent asks, not ' +
|
||||
'whether the answer exists.',
|
||||
},
|
||||
{
|
||||
text:
|
||||
'The adversary designed the data to pass the screen and moved the reporting threshold once, on the ' +
|
||||
'held-out seeds. Nothing responds to the agent while it trains. That is a moved adversary, not an ' +
|
||||
'adapting one.',
|
||||
},
|
||||
{
|
||||
text:
|
||||
'Nine scenario templates and three hidden overlays stand in for a typology space in the hundreds, ' +
|
||||
'and no institution, real case or real jurisdiction is behind any of them.',
|
||||
},
|
||||
{
|
||||
text:
|
||||
'There is no rule the agent could break while it optimises: no privacy boundary, no tipping-off ' +
|
||||
'offence, no policy it must satisfy on the way to the score.',
|
||||
answeredBy: 'denial-appeal',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* The scripted analysts: three shipped references and the probe ladder's cheats.
|
||||
*
|
||||
* A port of `envs/alert_triage/alert_triage/policies.py`. Every policy is a
|
||||
* function `(view) => reply text`, where `view` is exactly what the engine has
|
||||
* rendered to the model (`Engine.view()`), and nothing else. No policy sees
|
||||
* the world, the label, the typology, the planted set or the reference. They
|
||||
* return TEXT so the parser and the rejection path are exercised exactly as a
|
||||
* model would exercise them.
|
||||
*
|
||||
* The browser needs `fast`, `targeted` and `thorough` to recompute
|
||||
* `reference_minutes` rather than trust the recorded number; the cheats are
|
||||
* here so a recorded cheat arm can be replayed live too.
|
||||
*
|
||||
* fast decides from the screen; pulls `documents` only where the screen
|
||||
* says a document should explain the alert. Cannot see a hidden signal.
|
||||
* targeted `fast` on a screen that shows a typology; otherwise the three
|
||||
* lookups that could carry a hidden signal, then decides. No history.
|
||||
* thorough always the full procedure, plus itemised history for the two most
|
||||
* recent months; cites every constituting transaction it saw.
|
||||
*/
|
||||
|
||||
import { INBOUND, NOMINAL_TYPOLOGY, RULE_IDS } from './generator';
|
||||
import { floorDiv } from './rng';
|
||||
import type { Counterparty, Document, Family, PriorAlert, Screen, Txn } from './generator';
|
||||
import { pyJsonDumps } from './protocol';
|
||||
import type { Lookup, LookupResult } from './protocol';
|
||||
|
||||
/** What the engine has shown, and nothing it has not. */
|
||||
export interface View {
|
||||
screen: Screen;
|
||||
lookups: LookupRecord[];
|
||||
turns_spent: number;
|
||||
turns_left: number;
|
||||
rejected: number;
|
||||
minutes_spent: number;
|
||||
last_rejection: string | null;
|
||||
}
|
||||
|
||||
export interface LookupRecord {
|
||||
what: Lookup;
|
||||
key: string | null;
|
||||
result: LookupResult;
|
||||
}
|
||||
|
||||
export type Policy = (view: View) => string;
|
||||
|
||||
interface Decision {
|
||||
action: 'close' | 'escalate';
|
||||
reason?: string;
|
||||
typology?: string;
|
||||
cites: string[];
|
||||
note?: string;
|
||||
narrative?: string;
|
||||
}
|
||||
|
||||
const FAMILY_OF = new Map<string, Family>();
|
||||
for (const table of Object.values(RULE_IDS)) {
|
||||
for (const [fam, rid] of Object.entries(table)) FAMILY_OF.set(rid, fam as Family);
|
||||
}
|
||||
export const EXPLAINING_DOCS: readonly string[] = ['processing_agreement', 'closing_statement', 'executor_letter'];
|
||||
|
||||
const emit = (obj: unknown): string => pyJsonDumps(obj);
|
||||
|
||||
const family = (view: View): Family => FAMILY_OF.get(view.screen.alert.rule) ?? 'STR';
|
||||
|
||||
function looked(view: View, what: Lookup, key: string | null = null): LookupResult | null {
|
||||
for (const l of view.lookups) {
|
||||
if (l.what === what && (key === null || l.key === key)) return l.result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const asDocuments = (r: LookupResult | null) => (r as { documents: Document[] } | null)?.documents ?? null;
|
||||
const asAlerts = (r: LookupResult | null) => (r as { alerts: PriorAlert[] } | null)?.alerts ?? null;
|
||||
|
||||
function band(view: View, t: Txn): boolean {
|
||||
const threshold = view.screen.threshold;
|
||||
return t.channel === 'cash_in' && floorDiv(threshold * 8, 10) <= t.amount && t.amount < threshold;
|
||||
}
|
||||
|
||||
const close = (reason: string, cites: string[], note: string): Decision => ({ action: 'close', reason, cites, note });
|
||||
const escalate = (typology: string, cites: string[], narrative: string): Decision => ({ action: 'escalate', typology, cites, narrative });
|
||||
|
||||
function dedupe<T>(ids: readonly T[]): T[] {
|
||||
const out: T[] = [];
|
||||
for (const i of ids) if (!out.includes(i)) out.push(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- decisions */
|
||||
|
||||
/** What the screen alone supports. null means "a document should explain this". */
|
||||
export function screenDecision(view: View): Decision | null {
|
||||
const screen = view.screen;
|
||||
const fam = family(view);
|
||||
const trig = screen.triggering_transactions;
|
||||
const trigIds = trig.map((t) => t.id);
|
||||
const kyc = screen.customer;
|
||||
const maxCash = Math.max(...screen.summary.map((r) => r.cash_in));
|
||||
const expected = kyc.expected_monthly_cash ?? 0;
|
||||
|
||||
if (fam === 'STR') {
|
||||
const inBand = trig.filter((t) => band(view, t));
|
||||
const branches = new Set(inBand.map((t) => t.branch));
|
||||
if (inBand.length >= 5 || (inBand.length >= 4 && branches.size >= 3)) {
|
||||
return escalate('STRUCTURING', inBand.map((t) => t.id), 'Repeated cash deposits just under the threshold across branches.');
|
||||
}
|
||||
if (expected >= maxCash) {
|
||||
return close('CONSISTENT_WITH_PROFILE', ['kyc.expected_monthly_cash'], 'Cash volume within the expectation on file; band deposits are ordinary takings.');
|
||||
}
|
||||
return escalate('STRUCTURING', inBand.length ? inBand.map((t) => t.id) : trigIds, 'Band deposits with no expectation on file to cover them.');
|
||||
}
|
||||
|
||||
if (fam === 'CASH') {
|
||||
if (expected >= maxCash) {
|
||||
return close('CONSISTENT_WITH_PROFILE', ['kyc.expected_activity', 'kyc.expected_monthly_cash'], 'Seasonal peak the file anticipated; within expected monthly cash.');
|
||||
}
|
||||
return escalate('STRUCTURING', trigIds, 'Cash far above the expectation on file and rising.');
|
||||
}
|
||||
|
||||
if (fam === 'VEL') {
|
||||
const senders = new Set(trig.filter((t) => t.channel === 'p2p_in').map((t) => t.cp));
|
||||
if (senders.size >= 3) {
|
||||
return escalate('MONEY_MULE', trigIds, 'Credits from unrelated individuals moved to an exchange within a day.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fam === 'WIRE') {
|
||||
if ((kyc.remittance_declared ?? 'none declared') !== 'none declared') {
|
||||
return close('CONSISTENT_WITH_PROFILE', ['kyc.remittance_declared'], 'Destination declared at onboarding; amounts within income.');
|
||||
}
|
||||
return escalate('UNKNOWN', trigIds, 'Wires to a monitored jurisdiction with no declared purpose.');
|
||||
}
|
||||
|
||||
return null; // DORM: needs the document
|
||||
}
|
||||
|
||||
/** After `documents`: close on the explaining document, or escalate if there is none. */
|
||||
export function documentDecision(view: View): Decision {
|
||||
const docs = asDocuments(looked(view, 'documents'));
|
||||
const trigIds = view.screen.triggering_transactions.map((t) => t.id);
|
||||
if (docs) {
|
||||
for (const d of docs) {
|
||||
if (EXPLAINING_DOCS.includes(d.kind)) return close('DOCUMENTED_SOURCE_OF_FUNDS', [d.id], `Source of funds documented: ${d.kind}.`);
|
||||
}
|
||||
}
|
||||
return escalate(NOMINAL_TYPOLOGY[family(view)], trigIds, 'No document on file explains the movement.');
|
||||
}
|
||||
|
||||
/** A hidden signal in the lookups, or null. */
|
||||
export function lookupDecision(view: View): Decision | null {
|
||||
const screen = view.screen;
|
||||
const fam = family(view);
|
||||
const trig = screen.triggering_transactions;
|
||||
const trigIds = trig.map((t) => t.id);
|
||||
|
||||
for (const l of view.lookups) {
|
||||
if (l.what === 'counterparty' && (l.result as Counterparty).other_alerted_accounts >= 3) {
|
||||
const r = l.result as Counterparty;
|
||||
return escalate('FUNNEL_ACCOUNT', [r.id, ...r.transactions], `${r.id} is shared with ${r.other_alerted_accounts} other alerted accounts; funds converge on it.`);
|
||||
}
|
||||
}
|
||||
|
||||
const alerts = asAlerts(looked(view, 'prior_alerts'));
|
||||
if (alerts && alerts.length >= 3) {
|
||||
const rationales = new Set(alerts.map((p) => p.rationale));
|
||||
if (alerts.every((p) => p.disposition === 'closed' && p.elapsed_days <= 1) && rationales.size === 1) {
|
||||
return escalate(NOMINAL_TYPOLOGY[fam], [...alerts.map((p) => p.id), ...trigIds], 'Repeat alerts closed within a day on one boilerplate rationale; nobody has looked.');
|
||||
}
|
||||
}
|
||||
|
||||
const docs = asDocuments(looked(view, 'documents'));
|
||||
if (docs) {
|
||||
for (const d of docs) {
|
||||
if (!d.party) continue;
|
||||
for (const cpId of dedupe(trig.filter((t) => INBOUND.includes(t.channel) && t.cp).map((t) => t.cp as string))) {
|
||||
const cp = looked(view, 'counterparty', cpId) as Counterparty | null;
|
||||
if (cp && cp.name !== d.party) {
|
||||
const credits = trig.filter((t) => t.cp === cpId && INBOUND.includes(t.channel)).map((t) => t.id);
|
||||
return escalate(NOMINAL_TYPOLOGY[fam], [d.id, cpId, ...credits], `The document names ${d.party}; the funds came from ${cp.name}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** thorough's final call: the screen rules, with citations widened to everything itemised. */
|
||||
export function enrichedDecision(view: View): Decision {
|
||||
const d = screenDecision(view);
|
||||
const fam = family(view);
|
||||
const screen = view.screen;
|
||||
const months = screen.months;
|
||||
let seen: Txn[] = [...screen.triggering_transactions];
|
||||
for (const l of view.lookups) {
|
||||
if (l.what === 'history') seen = seen.concat((l.result as { transactions: Txn[] }).transactions);
|
||||
}
|
||||
if (d !== null && d.action === 'escalate') {
|
||||
if (fam === 'STR') {
|
||||
const last = months[months.length - 1];
|
||||
d.cites = dedupe([...seen.filter((t) => band(view, t) && t.month === last).map((t) => t.id), ...d.cites]);
|
||||
} else if (fam === 'CASH') {
|
||||
const lastTwo = new Set(months.slice(-2));
|
||||
d.cites = dedupe([...seen.filter((t) => t.channel === 'cash_in' && lastTwo.has(t.month)).map((t) => t.id), ...d.cites]);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
if (d === null) return documentDecision(view);
|
||||
return d;
|
||||
}
|
||||
|
||||
type Plan = [Lookup, string | null][];
|
||||
|
||||
function planNext(view: View, plan: Plan): string | null {
|
||||
for (const [what, key] of plan) {
|
||||
if (looked(view, what, key) === null) {
|
||||
if (what === 'history') return emit({ action: 'lookup', what: 'history', month: key });
|
||||
if (what === 'counterparty') return emit({ action: 'lookup', what: 'counterparty', id: key });
|
||||
return emit({ action: 'lookup', what });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- shipped */
|
||||
|
||||
export function fast(view: View): string {
|
||||
const d = screenDecision(view);
|
||||
if (d !== null) return emit(d);
|
||||
if (looked(view, 'documents') === null) return emit({ action: 'lookup', what: 'documents' });
|
||||
return emit(documentDecision(view));
|
||||
}
|
||||
|
||||
function investigationPlan(view: View): Plan {
|
||||
const plan: Plan = [['documents', null], ['prior_alerts', null]];
|
||||
for (const cp of view.screen.screen_counterparties) plan.push(['counterparty', cp.id]);
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function targeted(view: View): string {
|
||||
const d = screenDecision(view);
|
||||
if (d !== null && d.action === 'escalate') return emit(d);
|
||||
const next = planNext(view, investigationPlan(view));
|
||||
if (next) return next;
|
||||
const found = lookupDecision(view);
|
||||
if (found) return emit(found);
|
||||
return emit(d !== null ? d : documentDecision(view));
|
||||
}
|
||||
|
||||
export function thorough(view: View): string {
|
||||
const months = view.screen.months;
|
||||
const plan: Plan = [...investigationPlan(view), ['history', months[months.length - 1]!], ['history', months[months.length - 2]!]];
|
||||
const next = planNext(view, plan);
|
||||
if (next) return next;
|
||||
const found = lookupDecision(view);
|
||||
return emit(found ?? enrichedDecision(view));
|
||||
}
|
||||
|
||||
/** thorough plus a month that carries nothing. Proves the hour meter binds. */
|
||||
export function thoroughWasteful(view: View): string {
|
||||
const months = view.screen.months;
|
||||
const plan: Plan = [
|
||||
...investigationPlan(view),
|
||||
['history', months[months.length - 1]!],
|
||||
['history', months[months.length - 2]!],
|
||||
['history', months[months.length - 3]!],
|
||||
];
|
||||
const next = planNext(view, plan);
|
||||
if (next) return next;
|
||||
const found = lookupDecision(view);
|
||||
return emit(found ?? enrichedDecision(view));
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- cheats */
|
||||
|
||||
export function inaction(): string {
|
||||
return 'Let me reason about this alert carefully before committing to a disposition.';
|
||||
}
|
||||
|
||||
export function malformed(): string {
|
||||
return emit({ action: 'file_sar', cites: [] });
|
||||
}
|
||||
|
||||
export function closeBlind(): string {
|
||||
return emit(close('CONSISTENT_WITH_PROFILE', ['kyc.customer_type'], 'Looks fine.'));
|
||||
}
|
||||
|
||||
export function closeCitesAll(view: View): string {
|
||||
const screen = view.screen;
|
||||
const record = screen.customer as unknown as Record<string, unknown>;
|
||||
const cites = Object.keys(record)
|
||||
.filter((k) => k !== 'id' && k !== 'name' && record[k] !== undefined)
|
||||
.map((k) => `kyc.${k}`);
|
||||
cites.push(...screen.triggering_transactions.map((t) => t.id));
|
||||
return emit(close('CONSISTENT_WITH_PROFILE', cites, 'See everything.'));
|
||||
}
|
||||
|
||||
export function escalateBlind(view: View): string {
|
||||
const trigIds = view.screen.triggering_transactions.map((t) => t.id);
|
||||
return emit(escalate(NOMINAL_TYPOLOGY[family(view)], trigIds, 'Filing defensively.'));
|
||||
}
|
||||
|
||||
/** fast where the screen shows a typology; a blind filing everywhere else. */
|
||||
export function fastDefensive(view: View): string {
|
||||
const d = screenDecision(view);
|
||||
if (d !== null && d.action === 'escalate') return emit(d);
|
||||
return escalateBlind(view);
|
||||
}
|
||||
|
||||
/** Closes every wire-corridor and dormancy alert blind; files on everything else. */
|
||||
export function ruleTable(view: View): string {
|
||||
const fam = family(view);
|
||||
if (fam === 'WIRE') return emit(close('CONSISTENT_WITH_PROFILE', ['kyc.remittance_declared'], 'Wires are always remittances.'));
|
||||
if (fam === 'DORM') return emit(close('CONSISTENT_WITH_PROFILE', ['kyc.account_opened'], 'Old accounts wake up.'));
|
||||
return escalateBlind(view);
|
||||
}
|
||||
|
||||
/** Cites a document it never opened; rejected, then falls back to closing blind. */
|
||||
export function citeUnseen(view: View): string {
|
||||
if (view.rejected === 0) return emit(close('DOCUMENTED_SOURCE_OF_FUNDS', ['doc.probate_letter'], 'Per the letter on file.'));
|
||||
return closeBlind();
|
||||
}
|
||||
|
||||
export const POLICIES: Record<string, Policy> = {
|
||||
inaction,
|
||||
malformed,
|
||||
close_blind: closeBlind,
|
||||
close_cites_all: closeCitesAll,
|
||||
cite_unseen: citeUnseen,
|
||||
escalate_blind: escalateBlind,
|
||||
fast_defensive: fastDefensive,
|
||||
rule_table: ruleTable,
|
||||
fast,
|
||||
targeted,
|
||||
thorough,
|
||||
thorough_wasteful: thoroughWasteful,
|
||||
};
|
||||
|
||||
export const SHIPPED = ['fast', 'targeted', 'thorough'] as const;
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Turning model text into one action, and engine results into text.
|
||||
*
|
||||
* A port of `envs/alert_triage/alert_triage/protocol.py`. The one rule, as in
|
||||
* wordle: **never throw**. A malformed reply is a thing the model did, not an
|
||||
* error in the harness. It becomes a rejection that costs a turn.
|
||||
*
|
||||
* The parse rules are pinned because this is where two runtimes disagree, and
|
||||
* CI hashes a committed corpus of reply strings through this parser and the
|
||||
* engine on both sides (digest `protocol` in CONFORMANCE.txt):
|
||||
*
|
||||
* 1. If a ```json fence appears anywhere, its contents are the candidate.
|
||||
* A bare `{` before the fence does not win.
|
||||
* 2. Otherwise the candidate is the first string-aware balanced `{…}` span:
|
||||
* braces inside JSON strings do not count, backslash escapes are honoured.
|
||||
* 3. If the candidate is not valid JSON, or is JSON but not an object, the
|
||||
* reply is rejected. There is no further scanning for a second candidate.
|
||||
* 4. Enum values are matched exact-case. Cites are deduplicated in order and
|
||||
* matched exact-case against what the engine has shown.
|
||||
*
|
||||
* Why this file carries its own JSON reader rather than `JSON.parse`: the
|
||||
* engine's rejection reasons quote the offending value with Python's `repr`
|
||||
* (`unknown action None`, `month 3.0 is not in the twelve-month window`), and
|
||||
* those strings are hashed. `JSON.parse` cannot tell `3` from `3.0`, so the
|
||||
* reader keeps each number's source text, and `pyRepr` formats it the way
|
||||
* Python would. The reader accepts exactly what `json.loads` accepts — with
|
||||
* NaN/Infinity refused, as the Python does via `parse_constant`.
|
||||
*/
|
||||
|
||||
import { MAX_TURNS } from './generator';
|
||||
import type { Counterparty, Document, PriorAlert, Screen, Txn } from './generator';
|
||||
import { DOC_MINUTES, LOOKUP_MINUTES, SCREEN_MINUTES, TURN_MINUTES } from './score';
|
||||
|
||||
export const ACTIONS = ['lookup', 'close', 'escalate'] as const;
|
||||
export const LOOKUPS = ['history', 'counterparty', 'prior_alerts', 'documents'] as const;
|
||||
export const CLOSE_REASONS = ['CONSISTENT_WITH_PROFILE', 'DOCUMENTED_SOURCE_OF_FUNDS', 'PREVIOUSLY_REVIEWED', 'RULE_ARTEFACT'] as const;
|
||||
export const TYPOLOGIES = ['STRUCTURING', 'FUNNEL_ACCOUNT', 'MONEY_MULE', 'RAPID_MOVEMENT', 'UNKNOWN'] as const;
|
||||
|
||||
export type Action = (typeof ACTIONS)[number];
|
||||
export type Lookup = (typeof LOOKUPS)[number];
|
||||
export type CloseReason = (typeof CLOSE_REASONS)[number];
|
||||
export type Typology = (typeof TYPOLOGIES)[number];
|
||||
|
||||
/* ----------------------------------------------------------- JSON values */
|
||||
|
||||
/**
|
||||
* A number as `json.loads` would hold it: `int` when the token had no
|
||||
* fraction or exponent, `float` otherwise. `raw` is the token's own text.
|
||||
*/
|
||||
export class JsonNumber {
|
||||
constructor(
|
||||
readonly raw: string,
|
||||
readonly value: number,
|
||||
) {}
|
||||
get isInt(): boolean {
|
||||
return !/[.eE]/.test(this.raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** Objects are Maps so a key named `__proto__` is data, as it is in Python. */
|
||||
export type JsonValue = null | boolean | string | JsonNumber | JsonValue[] | Map<string, JsonValue>;
|
||||
|
||||
export const isJsonObject = (v: JsonValue | undefined): v is Map<string, JsonValue> => v instanceof Map;
|
||||
|
||||
class JsonError extends Error {}
|
||||
|
||||
/**
|
||||
* `json.loads`, to the letter that matters: RFC 8259 grammar, `NaN` and
|
||||
* `Infinity` refused, control characters refused inside strings, lone
|
||||
* surrogate escapes allowed, trailing content refused. Throws `JsonError`.
|
||||
*/
|
||||
export function pyJsonLoads(text: string): JsonValue {
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
|
||||
const ws = () => {
|
||||
while (i < n) {
|
||||
const c = text.charCodeAt(i);
|
||||
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i += 1;
|
||||
else break;
|
||||
}
|
||||
};
|
||||
const fail = (what: string): never => {
|
||||
throw new JsonError(`${what} at ${i}`);
|
||||
};
|
||||
|
||||
const string = (): string => {
|
||||
// text[i] is the opening quote
|
||||
i += 1;
|
||||
let out = '';
|
||||
for (;;) {
|
||||
if (i >= n) fail('unterminated string');
|
||||
const c = text.charCodeAt(i);
|
||||
if (c === 0x22) {
|
||||
i += 1;
|
||||
return out;
|
||||
}
|
||||
if (c === 0x5c) {
|
||||
const e = text[i + 1];
|
||||
i += 2;
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
const hex = text.slice(i, i + 4);
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad \\u escape');
|
||||
i += 4;
|
||||
out += String.fromCharCode(parseInt(hex, 16));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
fail('bad escape');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c < 0x20) fail('control character in string');
|
||||
out += text[i];
|
||||
i += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const number = (): JsonNumber => {
|
||||
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?/.exec(text.slice(i));
|
||||
if (!m) fail('bad number');
|
||||
const raw = m![0];
|
||||
i += raw.length;
|
||||
return new JsonNumber(raw, Number(raw));
|
||||
};
|
||||
|
||||
const value = (): JsonValue => {
|
||||
ws();
|
||||
if (i >= n) fail('unexpected end');
|
||||
const c = text[i]!;
|
||||
if (c === '{') {
|
||||
i += 1;
|
||||
const obj = new Map<string, JsonValue>();
|
||||
ws();
|
||||
if (text[i] === '}') {
|
||||
i += 1;
|
||||
return obj;
|
||||
}
|
||||
for (;;) {
|
||||
ws();
|
||||
if (text[i] !== '"') fail('expected property name');
|
||||
const key = string();
|
||||
ws();
|
||||
if (text[i] !== ':') fail('expected colon');
|
||||
i += 1;
|
||||
obj.set(key, value());
|
||||
ws();
|
||||
if (text[i] === ',') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === '}') {
|
||||
i += 1;
|
||||
return obj;
|
||||
}
|
||||
fail('expected , or }');
|
||||
}
|
||||
}
|
||||
if (c === '[') {
|
||||
i += 1;
|
||||
const arr: JsonValue[] = [];
|
||||
ws();
|
||||
if (text[i] === ']') {
|
||||
i += 1;
|
||||
return arr;
|
||||
}
|
||||
for (;;) {
|
||||
arr.push(value());
|
||||
ws();
|
||||
if (text[i] === ',') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === ']') {
|
||||
i += 1;
|
||||
return arr;
|
||||
}
|
||||
fail('expected , or ]');
|
||||
}
|
||||
}
|
||||
if (c === '"') return string();
|
||||
if (text.startsWith('true', i)) {
|
||||
i += 4;
|
||||
return true;
|
||||
}
|
||||
if (text.startsWith('false', i)) {
|
||||
i += 5;
|
||||
return false;
|
||||
}
|
||||
if (text.startsWith('null', i)) {
|
||||
i += 4;
|
||||
return null;
|
||||
}
|
||||
if (c === '-' || (c >= '0' && c <= '9')) return number();
|
||||
// NaN, Infinity, -Infinity and anything else: json.loads with parse_constant raising.
|
||||
return fail('unexpected token');
|
||||
};
|
||||
|
||||
const result = value();
|
||||
ws();
|
||||
if (i < n) fail('extra data');
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Python repr */
|
||||
|
||||
/** Python's `str.isprintable()` for one code point. The ASCII space is the one printable separator. */
|
||||
const NOT_PRINTABLE = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}\p{Zs}]/u;
|
||||
|
||||
function pyStrRepr(s: string): string {
|
||||
const quote = s.includes("'") && !s.includes('"') ? '"' : "'";
|
||||
let out = quote;
|
||||
for (const ch of s) {
|
||||
const cp = ch.codePointAt(0)!;
|
||||
if (ch === quote || ch === '\\') out += `\\${ch}`;
|
||||
else if (ch === '\n') out += '\\n';
|
||||
else if (ch === '\r') out += '\\r';
|
||||
else if (ch === '\t') out += '\\t';
|
||||
else if (cp !== 0x20 && NOT_PRINTABLE.test(ch)) {
|
||||
const hex = cp.toString(16);
|
||||
out += cp < 0x100 ? `\\x${hex.padStart(2, '0')}` : cp < 0x10000 ? `\\u${hex.padStart(4, '0')}` : `\\U${hex.padStart(8, '0')}`;
|
||||
} else out += ch;
|
||||
}
|
||||
return out + quote;
|
||||
}
|
||||
|
||||
/** `repr(float(raw))`: shortest round-trip digits, exponent form outside 1e-4 ≤ |x| < 1e16. */
|
||||
export function pyFloatRepr(x: number): string {
|
||||
if (Number.isNaN(x)) return 'nan';
|
||||
if (x === Infinity) return 'inf';
|
||||
if (x === -Infinity) return '-inf';
|
||||
if (x === 0) return Object.is(x, -0) ? '-0.0' : '0.0';
|
||||
const sign = x < 0 ? '-' : '';
|
||||
const [mantissa, expText] = Math.abs(x).toExponential().split('e');
|
||||
const digits = mantissa!.replace('.', '');
|
||||
const exp = Number(expText);
|
||||
if (exp < -4 || exp >= 16) {
|
||||
const m = digits.length > 1 ? `${digits[0]}.${digits.slice(1)}` : digits;
|
||||
const e = Math.abs(exp).toString().padStart(2, '0');
|
||||
return `${sign}${m}e${exp < 0 ? '-' : '+'}${e}`;
|
||||
}
|
||||
if (exp >= 0) {
|
||||
const whole = digits.length > exp + 1 ? digits.slice(0, exp + 1) : digits + '0'.repeat(exp + 1 - digits.length);
|
||||
const frac = digits.length > exp + 1 ? digits.slice(exp + 1) : '0';
|
||||
return `${sign}${whole}.${frac}`;
|
||||
}
|
||||
return `${sign}0.${'0'.repeat(-exp - 1)}${digits}`;
|
||||
}
|
||||
|
||||
/** Python's `repr` of a value `json.loads` produced, or of a missing key. */
|
||||
export function pyRepr(v: JsonValue | undefined): string {
|
||||
if (v === null || v === undefined) return 'None';
|
||||
if (v === true) return 'True';
|
||||
if (v === false) return 'False';
|
||||
if (typeof v === 'string') return pyStrRepr(v);
|
||||
if (v instanceof JsonNumber) return v.isInt ? BigInt(v.raw).toString() : pyFloatRepr(v.value);
|
||||
if (Array.isArray(v)) return `[${v.map(pyRepr).join(', ')}]`;
|
||||
return `{${[...v].map(([k, val]) => `${pyStrRepr(k)}: ${pyRepr(val)}`).join(', ')}}`;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- extract */
|
||||
|
||||
/**
|
||||
* Python's `\s` for str patterns is `str.isspace()`, which is not JavaScript's
|
||||
* `\s`: Python includes U+001C–U+001F and U+0085, JavaScript includes U+FEFF.
|
||||
*/
|
||||
const PY_WS = '[\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]';
|
||||
const FENCE = new RegExp(`\`\`\`json${PY_WS}*([\\s\\S]*?)\`\`\``);
|
||||
const MONTH = /^\d{4}-\d{2}$/;
|
||||
|
||||
export function extractCandidate(text: string): string | null {
|
||||
const match = FENCE.exec(text);
|
||||
if (match) return match[1]!;
|
||||
const start = text.indexOf('{');
|
||||
if (start < 0) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === '{') depth += 1;
|
||||
else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `[object, null]` or `[null, reason]`. Validation of fields is the engine's job. */
|
||||
export function parseReply(text: string | null | undefined): [Map<string, JsonValue> | null, string | null] {
|
||||
if (!text) return [null, 'no JSON object found in the reply'];
|
||||
const candidate = extractCandidate(text);
|
||||
if (candidate === null) return [null, 'no JSON object found in the reply'];
|
||||
let value: JsonValue;
|
||||
try {
|
||||
value = pyJsonLoads(candidate);
|
||||
} catch {
|
||||
return [null, 'the JSON object could not be parsed'];
|
||||
}
|
||||
if (!isJsonObject(value)) return [null, 'the reply parsed but is not a JSON object'];
|
||||
return [value, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `month` a YYYY-MM inside the window? Python's `\d` and `$` are looser
|
||||
* than JavaScript's, but the membership test that follows makes the regex
|
||||
* redundant either way: only a month that IS in the window passes.
|
||||
*/
|
||||
export function isWindowMonth(month: unknown, months: readonly string[]): month is string {
|
||||
return typeof month === 'string' && MONTH.test(month) && months.includes(month);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ emit */
|
||||
|
||||
/**
|
||||
* Python's `json.dumps(obj)` with its defaults: `", "` and `": "` separators,
|
||||
* `ensure_ascii=True`. The scripted policies emit their replies through this
|
||||
* so a recorded arm's reply strings are byte-identical on both sides.
|
||||
*/
|
||||
export function pyJsonDumps(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'string') {
|
||||
// ensure_ascii escapes everything above '~', DEL included, one \uXXXX per UTF-16 unit.
|
||||
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isInteger(value)) throw new Error(`pyJsonDumps: non-integer number ${value}`);
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) return `[${value.map(pyJsonDumps).join(', ')}]`;
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.filter((k) => record[k] !== undefined)
|
||||
.map((k) => `${pyJsonDumps(k)}: ${pyJsonDumps(record[k])}`)
|
||||
.join(', ')}}`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- render */
|
||||
|
||||
const hours2 = (minutes: number) => (minutes / 60).toFixed(2);
|
||||
|
||||
export function systemPrompt(): string {
|
||||
const screen = hours2(SCREEN_MINUTES);
|
||||
const turn = hours2(TURN_MINUTES);
|
||||
const lookup = hours2(LOOKUP_MINUTES);
|
||||
const doc = hours2(DOC_MINUTES);
|
||||
return `You are a transaction-monitoring analyst at a bank. One alert has been assigned to you.
|
||||
|
||||
All data is synthetic. No real customer, account, transaction or counterparty exists.
|
||||
|
||||
Your screen shows the alert, the transactions that triggered it, the customer's KYC file and a twelve-month summary. Reading it has already cost ${screen} analyst-hours. You have ${MAX_TURNS} replies. Each reply must be exactly ONE JSON object (in a \`\`\`json fence, or as the first {...} in the reply), and every reply costs ${turn} hours whether or not it is accepted.
|
||||
|
||||
Lookups (each may be made once):
|
||||
{"action":"lookup","what":"history","month":"YYYY-MM"} itemised transactions for a month in the window +${lookup} h
|
||||
{"action":"lookup","what":"counterparty","id":"CP-n"} name, type, jurisdiction, first seen, how many OTHER alerted accounts share it, its transactions +${lookup} h
|
||||
{"action":"lookup","what":"prior_alerts"} prior alerts on this customer with disposition, analyst and rationale +${lookup} h
|
||||
{"action":"lookup","what":"documents"} documents on file +${doc} h
|
||||
|
||||
Dispositions (end the episode):
|
||||
{"action":"close","reason":R,"cites":[ids],"note":"..."}
|
||||
R is one of CONSISTENT_WITH_PROFILE, DOCUMENTED_SOURCE_OF_FUNDS, PREVIOUSLY_REVIEWED, RULE_ARTEFACT
|
||||
{"action":"escalate","typology":T,"cites":[ids],"narrative":"..."}
|
||||
T is one of STRUCTURING, FUNNEL_ACCOUNT, MONEY_MULE, RAPID_MOVEMENT, UNKNOWN
|
||||
|
||||
\`cites\` must be a non-empty list of ids you have actually been shown: kyc.<field>, T-<n>, CP-<n>, A-<n> (a prior alert), doc.<id>. Citing an id you have not been shown is rejected. The note or narrative is displayed and never scored.
|
||||
|
||||
A close on an alert that should have been escalated scores zero. An escalation on a clean customer scores half on the counterweight and zero on the rest. Cite the facts that decide the case, not everything you saw. If you reach your last reply without a disposition the alert stays in the queue and the episode scores zero.`;
|
||||
}
|
||||
|
||||
const commas = (n: number) => {
|
||||
const s = String(n);
|
||||
let out = '';
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
if (i > 0 && (s.length - i) % 3 === 0) out += ',';
|
||||
out += s[i];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export function money(cents: number, currency: string): string {
|
||||
const sign = cents < 0 ? '-' : '';
|
||||
const abs = Math.abs(cents);
|
||||
return `${sign}${commas(Math.floor(abs / 100))}.${String(abs % 100).padStart(2, '0')} ${currency}`;
|
||||
}
|
||||
|
||||
const padEnd = (s: string, w: number) => s.padEnd(w);
|
||||
const padStart = (s: string, w: number) => s.padStart(w);
|
||||
|
||||
function txnLine(t: Txn, currency: string): string {
|
||||
const where = t.branch ? `branch ${t.branch}` : `${t.cp}`;
|
||||
return ` ${padEnd(t.id, 6)} ${t.date} ${padEnd(t.channel, 9)} ${padStart(money(t.amount, currency), 18)} ${where}`;
|
||||
}
|
||||
|
||||
export function renderScreen(screen: Screen): string {
|
||||
const cur = screen.currency;
|
||||
const c = screen.customer;
|
||||
const a = screen.alert;
|
||||
const alertBlock = [
|
||||
`ALERT ${a.id} rule ${a.rule} — ${a.rule_text}`,
|
||||
` fired ${a.fired}`,
|
||||
' triggering transactions:',
|
||||
...screen.triggering_transactions.map((t) => txnLine(t, cur)),
|
||||
];
|
||||
const kycLines = [`KYC FILE ${c.id} ${c.name}`];
|
||||
const record = c as unknown as Record<string, string | number | boolean | undefined>;
|
||||
for (const key of Object.keys(record).sort()) {
|
||||
if (key === 'id' || key === 'name' || record[key] === undefined) continue;
|
||||
let value: string | number | boolean = record[key]!;
|
||||
if (key === 'stated_income' || key === 'expected_monthly_cash') value = money(value as number, cur);
|
||||
else if (typeof value === 'boolean') value = value ? 'True' : 'False';
|
||||
kycLines.push(` kyc.${key} = ${value}`);
|
||||
}
|
||||
const summary = [
|
||||
`TWELVE-MONTH SUMMARY (${cur})`,
|
||||
` ${padEnd('month', 8)}${padStart('cash in', 16)}${padStart('cash out', 16)}${padStart('wires in', 16)}${padStart('wires out', 16)}${padStart('ach in', 16)}${padStart('ach out', 16)}${padStart('txns', 6)}`,
|
||||
];
|
||||
for (const r of screen.summary) {
|
||||
const cell = (v: number) => padStart(commas(Math.floor(v / 100)), 16);
|
||||
summary.push(
|
||||
` ${padEnd(r.month, 8)}${cell(r.cash_in)}${cell(r.cash_out)}${cell(r.wires_in)}${cell(r.wires_out)}${cell(r.ach_in)}${cell(r.ach_out)}${padStart(String(r.count), 6)}`,
|
||||
);
|
||||
}
|
||||
const cps = ['COUNTERPARTIES on screen (top by twelve-month volume):'];
|
||||
for (const cp of screen.screen_counterparties) {
|
||||
cps.push(` ${padEnd(cp.id, 6)} ${padEnd(cp.direction, 4)} ${padStart(String(cp.count), 3)} txns ${money(cp.total, cur)}`);
|
||||
}
|
||||
const blocks = screen.screen_order === 'kyc_first' ? [kycLines, alertBlock] : [alertBlock, kycLines];
|
||||
blocks.push(summary, cps, [
|
||||
`Reporting threshold in this jurisdiction: ${money(screen.threshold, cur)}`,
|
||||
'SYNTHETIC DATA — no real customer, account or transaction.',
|
||||
]);
|
||||
return blocks.map((b) => b.join('\n')).join('\n');
|
||||
}
|
||||
|
||||
export type LookupResult =
|
||||
| { month: string; transactions: Txn[] }
|
||||
| Counterparty
|
||||
| { alerts: PriorAlert[] }
|
||||
| { documents: Document[] };
|
||||
|
||||
export function renderLookup(what: Lookup, result: LookupResult, currency: string): string {
|
||||
if (what === 'history') {
|
||||
const r = result as { month: string; transactions: Txn[] };
|
||||
return [`HISTORY ${r.month}: ${r.transactions.length} transactions`, ...r.transactions.map((t) => txnLine(t, currency))].join('\n');
|
||||
}
|
||||
if (what === 'counterparty') {
|
||||
const r = result as Counterparty;
|
||||
return [
|
||||
`COUNTERPARTY ${r.id} ${r.name}`,
|
||||
` type ${r.type}; jurisdiction ${r.jurisdiction}; first seen ${r.first_seen}`,
|
||||
` other alerted accounts sharing this counterparty in twelve months: ${r.other_alerted_accounts}`,
|
||||
` transactions: ${r.transactions.join(', ')}`,
|
||||
].join('\n');
|
||||
}
|
||||
if (what === 'prior_alerts') {
|
||||
const r = result as { alerts: PriorAlert[] };
|
||||
if (!r.alerts.length) return 'PRIOR ALERTS: none on file.';
|
||||
const lines = ['PRIOR ALERTS:'];
|
||||
for (const p of r.alerts) {
|
||||
lines.push(` ${p.id} ${p.rule} fired ${p.fired} ${p.disposition} after ${p.elapsed_days} day(s) by ${p.analyst}`);
|
||||
lines.push(` ${p.rationale}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
const r = result as { documents: Document[] };
|
||||
return ['DOCUMENTS ON FILE:', ...r.documents.map((d) => ` ${d.id} [${d.kind}] ${d.description}`)].join('\n');
|
||||
}
|
||||
|
||||
export function renderRejection(reason: string, turnsLeft: number): string {
|
||||
const plural = turnsLeft === 1 ? 'y' : 'ies';
|
||||
return `That reply was not accepted: ${reason}. It still cost a turn. You have ${turnsLeft} repl${plural} left.`;
|
||||
}
|
||||
|
||||
export function renderCost(minutesSpent: number, turnsLeft: number): string {
|
||||
const plural = turnsLeft === 1 ? 'y' : 'ies';
|
||||
return `Hours spent so far: ${hours2(minutesSpent)}. You have ${turnsLeft} repl${plural} left.`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { RewardSpec } from '@/lib/demo-kit';
|
||||
import rewardSource from '../../../envs/alert_triage/alert_triage/reward.py?raw';
|
||||
|
||||
/**
|
||||
* The reward, mirrored from `envs/alert_triage/alert_triage/reward.py`.
|
||||
*
|
||||
* The labels are for the BSA officer, not the variable names. `caught` is a
|
||||
* fair identifier and says nothing to someone deciding a budget; "nothing
|
||||
* slipped through" is the same quantity in the words they already use.
|
||||
*
|
||||
* The constants are restated here as literals rather than imported, because
|
||||
* `check-demos` reads the weights off the page beside the code. If reward.py
|
||||
* changes, the receipt panel shows the change and these must follow it.
|
||||
*/
|
||||
|
||||
export const reward: RewardSpec = {
|
||||
components: [
|
||||
{
|
||||
key: 'caught',
|
||||
label: 'Nothing slipped through',
|
||||
description:
|
||||
'One point for any correct call. Half a point if a clean customer was escalated — nothing slipped, but a report was filed on them. Nothing if a suspicious alert was closed or no call was made. This is the counterweight, and it is over half the reward: a miss also zeroes the other two, so one miss costs more than every hour a policy could save.',
|
||||
weight: 0.55,
|
||||
role: 'counterweight',
|
||||
},
|
||||
{
|
||||
key: 'hours',
|
||||
label: 'Cleared it in the hours budgeted',
|
||||
description:
|
||||
'Analyst-hours spent — 0.50 to read the screen, 0.05 per reply, 0.40 per lookup, 0.10 for the documents — as a ratio against the cheapest shipped analyst that got this same alert right. Capped at one. A fast wrong answer saves nothing.',
|
||||
weight: 0.25,
|
||||
role: 'objective',
|
||||
},
|
||||
{
|
||||
key: 'evidence',
|
||||
label: 'Named the facts that decide it',
|
||||
description:
|
||||
'F1 between the ids the run cited and the facts the generator planted: the explaining KYC or document fact on a clean alert, the transactions that constitute the typology on a suspicious one. Cite everything and precision pays you what it thinks of that. Zero if the call was wrong. The narrative prose is never read.',
|
||||
weight: 0.2,
|
||||
role: 'objective',
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{ key: 'hours_spent', label: 'Hours spent', description: 'Screen, replies and lookups, at the generator’s prices.' },
|
||||
{
|
||||
key: 'reference_hours',
|
||||
label: 'Reference took',
|
||||
description: 'Hours the cheapest shipped analyst needed to get this same alert right.',
|
||||
},
|
||||
{ key: 'lookups', label: 'Lookups made', description: 'History months, counterparties, prior alerts, documents.' },
|
||||
{ key: 'turns_used', label: 'Replies spent', description: 'Accepted or refused. Eight ends the episode.' },
|
||||
{
|
||||
key: 'rejected_replies',
|
||||
label: 'Replies refused',
|
||||
description: 'No JSON object, an unknown action, an empty cites list, an id never shown, or a repeated lookup. Costs a turn.',
|
||||
},
|
||||
{
|
||||
key: 'false_escalation',
|
||||
label: 'Escalated a clean customer',
|
||||
description: 'One if a report was filed on a benign alert.',
|
||||
},
|
||||
{ key: 'missed', label: 'Closed a suspicious alert', description: 'One if the alert should have been escalated and was closed instead.' },
|
||||
{
|
||||
key: 'typology_match',
|
||||
label: 'Named the right typology',
|
||||
description: 'One if the escalation named what the generator planted. Diagnostic only; not scored, because it would double-count the call with an enum the agent can guess.',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
path: 'envs/alert_triage/alert_triage/reward.py',
|
||||
code: rewardSource,
|
||||
marker: 'reward',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Portable randomness and portable dates.
|
||||
*
|
||||
* A port of `envs/alert_triage/alert_triage/rng.py`, line for line. Nothing
|
||||
* in the generator may touch a language built-in for either: a built-in RNG
|
||||
* picks a different alert for the same seed on the two sides, and `Date` is
|
||||
* timezone-sensitive, so CI in UTC passes and a visitor elsewhere sees a
|
||||
* different fire date for the same `?seed=`.
|
||||
*
|
||||
* Pure and dependency-free. Runs on the main thread and under `node --test`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* FNV-1a, 32-bit, over the UTF-8 bytes. Identical to wordle's.
|
||||
*
|
||||
* `Math.imul` is what makes the 32-bit multiply exact; a plain `*` overflows
|
||||
* into a double and silently diverges from Python after the first few bytes.
|
||||
*/
|
||||
export function fnv1a32(text: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (const byte of new TextEncoder().encode(text)) {
|
||||
h ^= byte;
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Python's floor division. `Math.floor(a / b)` is right for the positive
|
||||
* integers this generator deals in, but the subtraction form is exact for
|
||||
* every integer a double can hold and matches `//` on negatives too.
|
||||
*/
|
||||
export function floorDiv(a: number, b: number): number {
|
||||
return (a - pyMod(a, b)) / b;
|
||||
}
|
||||
|
||||
/** Python's `%`: the result takes the sign of the divisor. */
|
||||
export function pyMod(a: number, b: number): number {
|
||||
const r = a % b;
|
||||
return r !== 0 && r < 0 !== b < 0 ? r + b : r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marsaglia xorshift32. Seven lines, and Python has the same seven.
|
||||
*
|
||||
* `below(n)` is a plain modulo. It is biased for large `n`, which does not
|
||||
* matter here (every `n` is tiny) and keeps the port a one-liner.
|
||||
*/
|
||||
export class XorShift32 {
|
||||
state: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
const s = seed >>> 0;
|
||||
this.state = s === 0 ? 0x9e3779b9 : s;
|
||||
}
|
||||
|
||||
next(): number {
|
||||
let x = this.state;
|
||||
x = (x ^ (x << 13)) >>> 0;
|
||||
x = (x ^ (x >>> 17)) >>> 0;
|
||||
x = (x ^ (x << 5)) >>> 0;
|
||||
this.state = x;
|
||||
return x;
|
||||
}
|
||||
|
||||
below(n: number): number {
|
||||
return this.next() % n;
|
||||
}
|
||||
|
||||
/** Inclusive on both ends. */
|
||||
between(lo: number, hi: number): number {
|
||||
return lo + this.below(hi - lo + 1);
|
||||
}
|
||||
|
||||
chance(percent: number): boolean {
|
||||
return this.below(100) < percent;
|
||||
}
|
||||
|
||||
pick<T>(items: readonly T[]): T {
|
||||
return items[this.below(items.length)]!;
|
||||
}
|
||||
|
||||
/** k distinct items, Fisher-Yates over a copy, first k taken. */
|
||||
sample<T>(items: readonly T[], k: number): T[] {
|
||||
const pool = [...items];
|
||||
const out: T[] = [];
|
||||
const n = Math.min(k, pool.length);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const j = i + this.below(pool.length - i);
|
||||
const tmp = pool[i]!;
|
||||
pool[i] = pool[j]!;
|
||||
pool[j] = tmp;
|
||||
out.push(pool[i]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
hex4(): string {
|
||||
return this.below(65536).toString(16).padStart(4, '0');
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ dates */
|
||||
// Howard Hinnant's civil-from-days / days-from-civil. Integer arithmetic only.
|
||||
// Day 0 is 1970-01-01.
|
||||
|
||||
export function daysFromCivil(y: number, m: number, d: number): number {
|
||||
y -= m <= 2 ? 1 : 0;
|
||||
const era = floorDiv(y >= 0 ? y : y - 399, 400);
|
||||
const yoe = y - era * 400;
|
||||
const mp = pyMod(m + 9, 12);
|
||||
const doy = floorDiv(153 * mp + 2, 5) + d - 1;
|
||||
const doe = yoe * 365 + floorDiv(yoe, 4) - floorDiv(yoe, 100) + doy;
|
||||
return era * 146097 + doe - 719468;
|
||||
}
|
||||
|
||||
export function civilFromDays(z: number): [number, number, number] {
|
||||
z += 719468;
|
||||
const era = floorDiv(z >= 0 ? z : z - 146096, 146097);
|
||||
const doe = z - era * 146097;
|
||||
const yoe = floorDiv(doe - floorDiv(doe, 1460) + floorDiv(doe, 36524) - floorDiv(doe, 146096), 365);
|
||||
const y = yoe + era * 400;
|
||||
const doy = doe - (365 * yoe + floorDiv(yoe, 4) - floorDiv(yoe, 100));
|
||||
const mp = floorDiv(5 * doy + 2, 153);
|
||||
const d = doy - floorDiv(153 * mp + 2, 5) + 1;
|
||||
const m = mp < 10 ? mp + 3 : mp - 9;
|
||||
return [y + (m <= 2 ? 1 : 0), m, d];
|
||||
}
|
||||
|
||||
export function daysInMonth(y: number, m: number): number {
|
||||
if (m === 12) return daysFromCivil(y + 1, 1, 1) - daysFromCivil(y, 12, 1);
|
||||
return daysFromCivil(y, m + 1, 1) - daysFromCivil(y, m, 1);
|
||||
}
|
||||
|
||||
const pad = (n: number, width: number) => String(n).padStart(width, '0');
|
||||
|
||||
export function isoDate(day: number): string {
|
||||
const [y, m, d] = civilFromDays(day);
|
||||
return `${pad(y, 4)}-${pad(m, 2)}-${pad(d, 2)}`;
|
||||
}
|
||||
|
||||
export function monthKey(y: number, m: number): string {
|
||||
return `${pad(y, 4)}-${pad(m, 2)}`;
|
||||
}
|
||||
|
||||
export function monthOfDay(day: number): string {
|
||||
const [y, m] = civilFromDays(day);
|
||||
return monthKey(y, m);
|
||||
}
|
||||
|
||||
export function addMonths(y: number, m: number, n: number): [number, number] {
|
||||
const index = y * 12 + (m - 1) + n;
|
||||
return [floorDiv(index, 12), pyMod(index, 12) + 1];
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* The reward, in the browser.
|
||||
*
|
||||
* A port of `envs/alert_triage/alert_triage/reward.py`. The page quotes the
|
||||
* Python — its `# region: pig-demo/reward` block is the receipt — and this
|
||||
* file exists so the browser can re-derive what the environment recorded. CI
|
||||
* hashes `scoreExact` over a fixed grid on both sides (digest `scorer`).
|
||||
*
|
||||
* Three components, weights summing to 1.0. Two pull toward clearing the
|
||||
* alert quickly with the right citations. The third — the heaviest — pulls
|
||||
* the other way: it pays for not letting a suspicious customer through.
|
||||
*
|
||||
* Hours are integers in MINUTES so that the world, the grader and the browser
|
||||
* agree to the last digit; the page shows them as hours.
|
||||
*/
|
||||
|
||||
// The generator's config, NOT industry measurements. Nobody timed an analyst.
|
||||
export const SCREEN_MINUTES = 30; // reading the free screen; charged to everyone
|
||||
export const TURN_MINUTES = 3; // every reply, accepted or rejected
|
||||
export const LOOKUP_MINUTES = 24; // history, counterparty, prior alerts
|
||||
export const DOC_MINUTES = 6; // documents on file
|
||||
|
||||
export const CAUGHT_WEIGHT = 0.55;
|
||||
export const HOURS_WEIGHT = 0.25;
|
||||
export const EVIDENCE_WEIGHT = 0.2;
|
||||
|
||||
/**
|
||||
* What `caught` pays for filing on a clean customer. Not zero: a defensive
|
||||
* escalation is a wasted filing, not a missed crime. Not one: otherwise a
|
||||
* policy that escalates everything takes the counterweight for free.
|
||||
*/
|
||||
export const FALSE_ESCALATION_CREDIT = 0.5;
|
||||
|
||||
export type ComponentKey = 'caught' | 'hours' | 'evidence';
|
||||
|
||||
/** Everything the reward needs from one played alert. */
|
||||
export interface Episode {
|
||||
/** What the generator planted. */
|
||||
label: 'benign' | 'suspicious';
|
||||
/** Alternates; the best one is scored. */
|
||||
planted: string[][];
|
||||
disposition: 'close' | 'escalate' | null;
|
||||
cites: string[];
|
||||
minutes_spent: number;
|
||||
/** Cheapest shipped policy that got this seed right. */
|
||||
reference_minutes: number | null;
|
||||
turns_spent: number;
|
||||
rejected: number;
|
||||
/** Ended early with no disposition: not scored. */
|
||||
truncated: boolean;
|
||||
/** What the agent wrote; diagnostic only. */
|
||||
typology?: string | null;
|
||||
/** What the generator planted; diagnostic only. */
|
||||
true_typology?: string | null;
|
||||
lookups?: number;
|
||||
}
|
||||
|
||||
/** A close on a benign alert, or an escalation on a suspicious one. */
|
||||
export function correct(ep: Episode): boolean {
|
||||
return (ep.label === 'benign' && ep.disposition === 'close') || (ep.label === 'suspicious' && ep.disposition === 'escalate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing slipped through. The counterweight, and over half the reward.
|
||||
*
|
||||
* 1.0 on any correct call. 0.0 if a suspicious alert was closed, or nothing
|
||||
* was decided. A false escalation earns FALSE_ESCALATION_CREDIT.
|
||||
*/
|
||||
export function caught(ep: Episode): number {
|
||||
if (ep.disposition === null) return 0.0;
|
||||
if (correct(ep)) return 1.0;
|
||||
if (ep.label === 'benign') return FALSE_ESCALATION_CREDIT;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleared it in the hours budgeted, as a ratio against a reference.
|
||||
*
|
||||
* Capped at 1.0. A fast wrong answer saves nothing. If no shipped policy got
|
||||
* this seed right there is no reference and this is null — not scored.
|
||||
*/
|
||||
export function hours(ep: Episode): number | null {
|
||||
if (!correct(ep)) return 0.0;
|
||||
if (ep.reference_minutes === null) return null;
|
||||
return Math.min(1.0, ep.reference_minutes / Math.max(1, ep.minutes_spent));
|
||||
}
|
||||
|
||||
/** F1 as an exact fraction [numerator, denominator], so both runtimes agree bit for bit. */
|
||||
export function f1Exact(cites: readonly string[], planted: readonly string[]): [number, number] {
|
||||
const c = new Set(cites);
|
||||
const p = new Set(planted);
|
||||
let common = 0;
|
||||
for (const id of c) if (p.has(id)) common += 1;
|
||||
return [2 * common, c.size + p.size];
|
||||
}
|
||||
|
||||
function bestF1(ep: Episode): [number, number] {
|
||||
let best: [number, number] = [0, 1];
|
||||
for (const alt of ep.planted) {
|
||||
const [num, den] = f1Exact(ep.cites, alt);
|
||||
if (num * best[1] > best[0] * den) best = [num, den];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named the facts that decide it: F1 between the cites and the planted set.
|
||||
* Where a template admits two reasonable rationales the best alternate is
|
||||
* scored. Zero if the disposition was wrong. The narrative prose is never
|
||||
* read here.
|
||||
*/
|
||||
export function evidence(ep: Episode): number {
|
||||
if (!correct(ep) || !ep.cites.length) return 0.0;
|
||||
const [num, den] = bestF1(ep);
|
||||
return num / den;
|
||||
}
|
||||
|
||||
export type Components = Record<ComponentKey, number | null>;
|
||||
|
||||
/** The components, unweighted. null means "not scored", never zero. */
|
||||
export function score(ep: Episode): Components {
|
||||
if (ep.truncated) return { caught: null, hours: null, evidence: null };
|
||||
return { caught: caught(ep), hours: hours(ep), evidence: evidence(ep) };
|
||||
}
|
||||
|
||||
export const WEIGHTS: Record<ComponentKey, number> = { caught: CAUGHT_WEIGHT, hours: HOURS_WEIGHT, evidence: EVIDENCE_WEIGHT };
|
||||
export const ROLES: Record<ComponentKey, 'counterweight' | 'objective'> = { caught: 'counterweight', hours: 'objective', evidence: 'objective' };
|
||||
|
||||
export function total(ep: Episode, weights: Record<ComponentKey, number> = WEIGHTS): number | null {
|
||||
const parts = score(ep);
|
||||
if (parts.caught === null || parts.hours === null || parts.evidence === null) return null;
|
||||
return parts.caught * weights.caught + parts.hours * weights.hours + parts.evidence * weights.evidence;
|
||||
}
|
||||
|
||||
/** P(benign) below which a blind escalation out-scores a well-cited close. */
|
||||
export function escalateBreakEven(evidenceIfClose = 1.0): number {
|
||||
const closePerBenign = CAUGHT_WEIGHT + HOURS_WEIGHT + EVIDENCE_WEIGHT * evidenceIfClose;
|
||||
const escPerBenign = CAUGHT_WEIGHT * FALSE_ESCALATION_CREDIT;
|
||||
const escPerSuspicious = CAUGHT_WEIGHT + HOURS_WEIGHT;
|
||||
return escPerSuspicious / (closePerBenign - escPerBenign + escPerSuspicious);
|
||||
}
|
||||
|
||||
export type ExactComponents = Record<ComponentKey, [number, number] | null>;
|
||||
|
||||
/** The components as exact fractions, for the cross-language digest. */
|
||||
export function scoreExact(ep: Episode): ExactComponents {
|
||||
if (ep.truncated) return { caught: null, hours: null, evidence: null };
|
||||
let c: [number, number];
|
||||
if (ep.disposition === null) c = [0, 1];
|
||||
else if (correct(ep)) c = [1, 1];
|
||||
else if (ep.label === 'benign') c = [1, 2];
|
||||
else c = [0, 1];
|
||||
|
||||
let h: [number, number] | null;
|
||||
if (!correct(ep)) h = [0, 1];
|
||||
else if (ep.reference_minutes === null) h = null;
|
||||
else {
|
||||
const spent = Math.max(1, ep.minutes_spent);
|
||||
h = ep.reference_minutes >= spent ? [1, 1] : [ep.reference_minutes, spent];
|
||||
}
|
||||
|
||||
const e: [number, number] = !correct(ep) || !ep.cites.length ? [0, 1] : bestF1(ep);
|
||||
return { caught: c, hours: h, evidence: e };
|
||||
}
|
||||
|
||||
/** Diagnostics. Weight zero — reported, never summed into the reward. */
|
||||
export function metrics(ep: Episode): Record<string, number | null> {
|
||||
return {
|
||||
hours_spent: ep.minutes_spent / 60,
|
||||
reference_hours: ep.reference_minutes === null ? null : ep.reference_minutes / 60,
|
||||
lookups: ep.lookups ?? 0,
|
||||
turns_used: ep.turns_spent,
|
||||
rejected_replies: ep.rejected,
|
||||
false_escalation: ep.label === 'benign' && ep.disposition === 'escalate' ? 1.0 : 0.0,
|
||||
missed: ep.label === 'suspicious' && ep.disposition === 'close' ? 1.0 : 0.0,
|
||||
typology_match: ep.disposition !== 'escalate' ? null : (ep.typology ?? null) === (ep.true_typology ?? null) ? 1.0 : 0.0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { memo, type ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { channelLabel, enumLabel, hours, kycEntries, money, wholeUnits } from './format';
|
||||
import { bestAlternate } from './grade';
|
||||
import type { Lookup, Reveal, Screen, Transaction, TriageState } from './types';
|
||||
|
||||
/**
|
||||
* The analyst's screen. One component for all three jobs — the visitor
|
||||
* playing, the recorded replay, and the timeline thumbnail — because three
|
||||
* near-identical boards is how they drift.
|
||||
*
|
||||
* Two things this board must do that a game board does not:
|
||||
*
|
||||
* · Say, on the board itself, that the data is synthetic. Not in a footnote.
|
||||
* The label is the first row of every render.
|
||||
* · Show the answer only once the episode is over. `state.reveal` is null
|
||||
* until then, so the surface cannot leak the label even by accident.
|
||||
*
|
||||
* Nothing here interprets the data for the visitor. A counterparty shared with
|
||||
* nine other alerted accounts is rendered as the number nine, the way the
|
||||
* model saw it; whether nine is a lot is the analyst's call to make.
|
||||
*/
|
||||
|
||||
const MAX_TURNS = 8;
|
||||
|
||||
/* ---------------------------------------------------------------- pieces */
|
||||
|
||||
function SyntheticLabel({ compact }: { compact?: boolean }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'rounded-md border border-dashed border-warning/60 bg-warning/5 font-semibold uppercase tracking-wide text-warning',
|
||||
compact ? 'px-1 py-0.5 text-[7px] leading-tight' : 'px-2 py-1 text-[0.65rem]',
|
||||
)}
|
||||
>
|
||||
Synthetic data — no real customer, account or transaction
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Id({ children, tone = 'muted' }: { children: ReactNode; tone?: 'muted' | 'fg' }) {
|
||||
return (
|
||||
<code className={cn('nums font-mono text-[0.8em]', tone === 'muted' ? 'text-muted' : 'text-fg')}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<header className="mb-2 flex items-baseline justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</h4>
|
||||
{aside ? <div className="text-xs text-muted">{aside}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `contain: inline-size` is load-bearing. The board lives inside a
|
||||
* shrink-to-fit card, so a table's min-content width would otherwise become
|
||||
* the board's minimum width and push a 390px phone into horizontal scroll.
|
||||
* Containment makes the wrapper contribute nothing and scroll instead.
|
||||
*/
|
||||
function Scroll({ children }: { children: ReactNode }) {
|
||||
return <div className="overflow-x-auto [contain:inline-size]">{children}</div>;
|
||||
}
|
||||
|
||||
function TransactionTable({ rows, currency }: { rows: Transaction[]; currency: string }) {
|
||||
if (rows.length === 0) return <p className="text-xs text-muted">No transactions.</p>;
|
||||
return (
|
||||
<Scroll>
|
||||
<table className="w-full min-w-[26rem] text-xs">
|
||||
<thead className="text-left text-muted">
|
||||
<tr>
|
||||
<th className="pb-1 pr-2 font-medium">id</th>
|
||||
<th className="pb-1 pr-2 font-medium">date</th>
|
||||
<th className="pb-1 pr-2 font-medium">channel</th>
|
||||
<th className="pb-1 pr-2 text-right font-medium">amount</th>
|
||||
<th className="pb-1 font-medium">where</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.id} className="border-t border-border/60">
|
||||
<td className="py-0.5 pr-2">
|
||||
<Id tone="fg">{t.id}</Id>
|
||||
</td>
|
||||
<td className="nums py-0.5 pr-2 whitespace-nowrap">{t.date}</td>
|
||||
<td className="py-0.5 pr-2 whitespace-nowrap">{channelLabel(t.channel)}</td>
|
||||
<td className="nums py-0.5 pr-2 text-right whitespace-nowrap">{money(t.amount, currency)}</td>
|
||||
<td className="py-0.5 whitespace-nowrap">
|
||||
{t.branch ? `branch ${t.branch}` : t.cp ? <Id>{t.cp}</Id> : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Scroll>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertBlock({ screen }: { screen: Screen }) {
|
||||
const a = screen.alert;
|
||||
return (
|
||||
<Block title="Alert" aside={<span className="nums">fired {a.fired}</span>}>
|
||||
<p className="text-sm">
|
||||
<Id tone="fg">{a.id}</Id>
|
||||
<span className="mx-1.5 text-muted">·</span>
|
||||
<span className="font-mono text-xs font-semibold">{a.rule}</span>
|
||||
<span className="mx-1.5 text-muted">—</span>
|
||||
{a.rule_text}
|
||||
</p>
|
||||
<p className="mb-1 mt-2 text-xs text-muted">Triggering transactions</p>
|
||||
<TransactionTable rows={screen.triggering_transactions} currency={screen.currency} />
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function kycValue(key: string, value: unknown, currency: string): string {
|
||||
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
||||
if (typeof value === 'number') {
|
||||
return key === 'stated_income' || key === 'expected_monthly_cash' ? money(value, currency) : String(value);
|
||||
}
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
function KycBlock({ screen }: { screen: Screen }) {
|
||||
const c = screen.customer;
|
||||
return (
|
||||
<Block title="KYC file" aside={<Id>{c.id}</Id>}>
|
||||
<p className="mb-2 text-sm font-medium">{c.name}</p>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
|
||||
{kycEntries(c as unknown as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex min-w-0 flex-col">
|
||||
<dt className="text-muted">
|
||||
<Id>kyc.{key}</Id>
|
||||
</dt>
|
||||
<dd className="nums break-words">{kycValue(key, value, screen.currency)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryBlock({ screen }: { screen: Screen }) {
|
||||
const cols: [Exclude<keyof Screen['summary'][number], 'month'>, string][] = [
|
||||
['cash_in', 'cash in'],
|
||||
['cash_out', 'cash out'],
|
||||
['wires_in', 'wires in'],
|
||||
['wires_out', 'wires out'],
|
||||
['ach_in', 'ACH in'],
|
||||
['ach_out', 'ACH out'],
|
||||
];
|
||||
return (
|
||||
<Block
|
||||
title="Twelve-month summary"
|
||||
aside={
|
||||
<span>
|
||||
whole {screen.currency}
|
||||
{screen.screen_order === 'kyc_first' ? ' · newest first' : ''}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Scroll>
|
||||
<table className="w-full min-w-[30rem] text-xs">
|
||||
<thead className="text-left text-muted">
|
||||
<tr>
|
||||
<th className="pb-1 pr-2 font-medium">month</th>
|
||||
{cols.map(([key, label]) => (
|
||||
<th key={key} className="pb-1 pr-2 text-right font-medium">
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
<th className="pb-1 text-right font-medium">txns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{screen.summary.map((row) => (
|
||||
<tr key={row.month} className="border-t border-border/60">
|
||||
<td className="nums py-0.5 pr-2 whitespace-nowrap">{row.month}</td>
|
||||
{cols.map(([key]) => (
|
||||
<td key={key} className="nums py-0.5 pr-2 text-right">
|
||||
{wholeUnits(row[key])}
|
||||
</td>
|
||||
))}
|
||||
<td className="nums py-0.5 text-right">{row.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Scroll>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterpartyBlock({ screen }: { screen: Screen }) {
|
||||
return (
|
||||
<Block title="Counterparties on screen" aside="top by twelve-month volume">
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
{screen.screen_counterparties.map((cp) => (
|
||||
<li key={cp.id} className="flex flex-wrap items-baseline gap-x-2">
|
||||
<Id tone="fg">{cp.id}</Id>
|
||||
<span className="text-muted">{cp.direction === 'in' ? 'inbound' : 'outbound'}</span>
|
||||
<span className="nums">{cp.count} txns</span>
|
||||
<span className="nums">{money(cp.total, screen.currency)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Reporting threshold in this jurisdiction:{' '}
|
||||
<span className="nums text-fg">{money(screen.threshold, screen.currency)}</span>
|
||||
</p>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string }) {
|
||||
switch (lookup.what) {
|
||||
case 'history':
|
||||
return (
|
||||
<Block
|
||||
title={`History ${lookup.result.month}`}
|
||||
aside={<span className="nums">{lookup.result.transactions.length} transactions</span>}
|
||||
>
|
||||
<TransactionTable rows={lookup.result.transactions} currency={currency} />
|
||||
</Block>
|
||||
);
|
||||
case 'counterparty': {
|
||||
const r = lookup.result;
|
||||
return (
|
||||
<Block title={`Counterparty ${r.id}`} aside={<span className="nums">first seen {r.first_seen}</span>}>
|
||||
<p className="text-sm font-medium">{r.name}</p>
|
||||
<p className="text-xs text-muted">
|
||||
{r.type.replace(/_/g, ' ')} · {r.jurisdiction}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Other alerted accounts sharing this counterparty in twelve months:{' '}
|
||||
<span className="nums font-semibold">{r.other_alerted_accounts}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
transactions:{' '}
|
||||
{r.transactions.map((id) => (
|
||||
<span key={id} className="mr-1">
|
||||
<Id>{id}</Id>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
case 'prior_alerts':
|
||||
return (
|
||||
<Block title="Prior alerts" aside={<span className="nums">{lookup.result.alerts.length} on file</span>}>
|
||||
{lookup.result.alerts.length === 0 ? (
|
||||
<p className="text-xs text-muted">None on file.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 text-xs">
|
||||
{lookup.result.alerts.map((p) => (
|
||||
<li key={p.id}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<Id tone="fg">{p.id}</Id>
|
||||
<span className="font-mono">{p.rule}</span>
|
||||
<span className="nums text-muted">fired {p.fired}</span>
|
||||
<span>
|
||||
{p.disposition} after {p.elapsed_days} {p.elapsed_days === 1 ? 'day' : 'days'} by {p.analyst}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted">{p.rationale}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Block>
|
||||
);
|
||||
case 'documents':
|
||||
return (
|
||||
<Block title="Documents on file" aside={<span className="nums">{lookup.result.documents.length}</span>}>
|
||||
<ul className="space-y-1 text-xs">
|
||||
{lookup.result.documents.map((d) => (
|
||||
<li key={d.id} className="flex flex-col gap-0.5 sm:flex-row sm:gap-2">
|
||||
<span className="shrink-0">
|
||||
<Id tone="fg">{d.id}</Id>{' '}
|
||||
<span className="text-muted">[{d.kind.replace(/_/g, ' ')}]</span>
|
||||
</span>
|
||||
<span>{d.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function CiteChip({ id, hit }: { id: string; hit?: boolean | null }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[0.7rem]',
|
||||
hit === true && 'border-positive/50 bg-positive/10 text-fg',
|
||||
hit === false && 'border-border text-muted line-through decoration-muted/60',
|
||||
(hit === null || hit === undefined) && 'border-border text-fg',
|
||||
)}
|
||||
>
|
||||
{hit === true ? <span aria-hidden="true">✓</span> : null}
|
||||
{id}
|
||||
{hit === true ? <span className="sr-only">, planted</span> : null}
|
||||
{hit === false ? <span className="sr-only">, not planted</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DispositionCard({ state }: { state: TriageState }) {
|
||||
const d = state.disposition;
|
||||
if (!d) return null;
|
||||
const planted = state.reveal ? bestAlternate(d.cites, state.reveal.planted) : null;
|
||||
const scored = state.reveal !== null && state.outcome === 'solved';
|
||||
return (
|
||||
<Block
|
||||
title={d.action === 'close' ? 'Closed' : 'Escalated'}
|
||||
aside={
|
||||
<span className="font-mono">{d.action === 'close' ? enumLabel(d.reason) : d.typology}</span>
|
||||
}
|
||||
>
|
||||
<p className="mb-1 text-xs text-muted">Cites</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{d.cites.map((id) => (
|
||||
<CiteChip key={id} id={id} hit={planted && scored ? planted.includes(id) : null} />
|
||||
))}
|
||||
</div>
|
||||
{(d.action === 'close' ? d.note : d.narrative).trim() ? (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 flex items-baseline gap-2 text-xs text-muted">
|
||||
<span>{d.action === 'close' ? 'Note' : 'Narrative'}</span>
|
||||
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
|
||||
displayed, never scored
|
||||
</span>
|
||||
</p>
|
||||
<blockquote className="border-l-2 border-border pl-2 text-xs leading-relaxed">
|
||||
{d.action === 'close' ? d.note : d.narrative}
|
||||
</blockquote>
|
||||
</div>
|
||||
) : null}
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone: 'positive' | 'danger' | 'warning' } {
|
||||
if (state.status === 'aborted') {
|
||||
return {
|
||||
text: 'No disposition in eight replies. The alert stays in the queue and the episode scores 0.000 on every component.',
|
||||
tone: 'danger',
|
||||
};
|
||||
}
|
||||
if (state.outcome === 'solved') {
|
||||
return {
|
||||
text: reveal.label === 'benign' ? 'Right call: the alert was benign and it was closed.' : 'Right call: the alert was suspicious and it was escalated.',
|
||||
tone: 'positive',
|
||||
};
|
||||
}
|
||||
if (reveal.label === 'suspicious') {
|
||||
return {
|
||||
text: 'Missed: a suspicious alert was closed. The counterweight and both objectives are zero — the episode scores 0.000.',
|
||||
tone: 'danger',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: 'A clean customer was escalated. Nothing slipped, but a report was filed on them: the counterweight pays half and the two objectives pay nothing.',
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
function RevealCard({ state }: { state: TriageState }) {
|
||||
const reveal = state.reveal;
|
||||
if (!reveal) return null;
|
||||
const cites = state.disposition?.cites ?? [];
|
||||
const best = bestAlternate(cites, reveal.planted);
|
||||
const verdict = verdictLine(state, reveal);
|
||||
const TONE = { positive: 'text-positive', danger: 'text-danger', warning: 'text-warning' } as const;
|
||||
return (
|
||||
<Block
|
||||
title="What the generator planted"
|
||||
aside={
|
||||
<span className="font-mono">
|
||||
{reveal.template}
|
||||
{reveal.overlay ? ` + ${reveal.overlay.replace(/_/g, ' ')}` : ''}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<span className={cn('font-semibold', reveal.label === 'benign' ? 'text-positive' : 'text-danger')}>
|
||||
{reveal.label}
|
||||
</span>
|
||||
{reveal.typology ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted">·</span>
|
||||
<span className="font-mono text-xs">{reveal.typology}</span>
|
||||
</>
|
||||
) : null}
|
||||
{reveal.tier === 'hidden' ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted">·</span>
|
||||
<span className="text-xs text-muted">hidden tier: the screen is a benign case; the signal was only in a lookup</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className={cn('mt-1 text-xs font-medium', TONE[verdict.tone])} role="status">
|
||||
{verdict.text}
|
||||
</p>
|
||||
<p className="mb-1 mt-2 text-xs text-muted">
|
||||
The facts that decide it{reveal.planted.length > 1 ? ' (best of the alternates)' : ''}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{best.map((id) => (
|
||||
<CiteChip key={id} id={id} hit={cites.includes(id) ? true : null} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Hours spent <span className="nums text-fg">{hours(state.minutesSpent)}</span>
|
||||
{reveal.referenceMinutes !== null ? (
|
||||
<>
|
||||
{' '}
|
||||
· cheapest shipped analyst that got it right{' '}
|
||||
<span className="nums text-fg">{hours(reveal.referenceMinutes)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function Meter({ state }: { state: TriageState }) {
|
||||
const STATUS: Record<TriageState['status'], string> = {
|
||||
playing: 'in the queue',
|
||||
closed: 'closed',
|
||||
escalated: 'escalated',
|
||||
aborted: 'left in the queue',
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
<span>
|
||||
Hours <span className="nums font-semibold text-fg">{hours(state.minutesSpent)}</span>
|
||||
</span>
|
||||
<span>
|
||||
Replies{' '}
|
||||
<span className="nums font-semibold text-fg">
|
||||
{state.turnsSpent} of {MAX_TURNS}
|
||||
</span>
|
||||
</span>
|
||||
{state.rejected > 0 ? (
|
||||
<span>
|
||||
refused <span className="nums font-semibold text-fg">{state.rejected}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-1.5 py-px font-medium',
|
||||
state.status === 'playing' && 'border-border',
|
||||
state.status === 'closed' && 'border-positive/50 text-positive',
|
||||
state.status === 'escalated' && 'border-warning/60 text-warning',
|
||||
state.status === 'aborted' && 'border-danger/50 text-danger',
|
||||
)}
|
||||
>
|
||||
{STATUS[state.status]}
|
||||
</span>
|
||||
{state.screen.held_out ? (
|
||||
<span className="rounded-md border border-border px-1.5 py-px" title="fnv1a(seed) % 8 == 7: second currency, renamed rules, KYC before the alert">
|
||||
held-out seed
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- compact */
|
||||
|
||||
/**
|
||||
* The thumbnail: what the timeline chip and the gallery card show. A whole
|
||||
* screen at 7rem is noise, so this is the shape of the run instead — the rule,
|
||||
* the twelve months of cash as bars, and what has happened so far.
|
||||
*/
|
||||
function CompactBoard({ state }: { state: TriageState }) {
|
||||
const rows = state.screen.summary;
|
||||
const peak = Math.max(1, ...rows.map((r) => r.cash_in));
|
||||
const d = state.disposition;
|
||||
const label =
|
||||
`${state.screen.alert.rule}, ${hours(state.minutesSpent)} hours, ${state.lookups.length} lookups` +
|
||||
(d ? `, ${d.action === 'close' ? 'closed' : 'escalated'}` : state.status === 'aborted' ? ', aborted' : '');
|
||||
return (
|
||||
<div className="w-[7.5rem] text-[9px] leading-tight" role="img" aria-label={label}>
|
||||
<SyntheticLabel compact />
|
||||
<div className="mt-1 flex items-baseline justify-between gap-1">
|
||||
<span className="truncate font-mono font-semibold">{state.screen.alert.rule}</span>
|
||||
<span className="nums shrink-0 text-muted">{hours(state.minutesSpent)} h</span>
|
||||
</div>
|
||||
<div className="mt-1 flex h-4 items-end gap-px" aria-hidden="true">
|
||||
{rows.map((r) => (
|
||||
<span
|
||||
key={r.month}
|
||||
className="flex-1 rounded-sm bg-muted/50"
|
||||
style={{ height: `${Math.max(6, Math.round((r.cash_in / peak) * 100))}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-0.5">
|
||||
{state.lookups.map((l, i) => (
|
||||
<span key={i} className="rounded border border-border px-0.5 text-muted">
|
||||
{l.what === 'history' ? l.key.slice(5) : l.what === 'counterparty' ? l.key : l.what === 'prior_alerts' ? 'priors' : 'docs'}
|
||||
</span>
|
||||
))}
|
||||
{state.rejected > 0 ? (
|
||||
<span className="rounded border border-warning/60 px-0.5 text-warning">!{state.rejected}</span>
|
||||
) : null}
|
||||
{d ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded border px-0.5 font-semibold uppercase',
|
||||
d.action === 'close' ? 'border-positive/50 text-positive' : 'border-warning/60 text-warning',
|
||||
)}
|
||||
>
|
||||
{d.action === 'close' ? 'close' : 'escalate'}
|
||||
</span>
|
||||
) : state.status === 'aborted' ? (
|
||||
<span className="rounded border border-danger/50 px-0.5 font-semibold uppercase text-danger">aborted</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- board */
|
||||
|
||||
export const Board = memo(function Board({ state, compact }: { state: TriageState; compact?: boolean }) {
|
||||
if (compact) return <CompactBoard state={state} />;
|
||||
|
||||
const { screen } = state;
|
||||
const blocks =
|
||||
screen.screen_order === 'kyc_first'
|
||||
? [<KycBlock key="kyc" screen={screen} />, <AlertBlock key="alert" screen={screen} />]
|
||||
: [<AlertBlock key="alert" screen={screen} />, <KycBlock key="kyc" screen={screen} />];
|
||||
|
||||
return (
|
||||
<div
|
||||
// An explicit width, capped by the VIEWPORT. The board sits in a
|
||||
// shrink-to-fit card on every tab, so `w-full` resolves to nothing and
|
||||
// `max-w-full` resolves against a parent that sized itself to this
|
||||
// board — measured: 640px on a 390px phone, 304px of page overflow.
|
||||
// 5rem is the page gutters plus the card padding at the phone breakpoint.
|
||||
// `max-w-full` is for the one place the parent IS definite: a blind
|
||||
// comparison's grid cell, which is narrower than 40rem at 1280px and
|
||||
// measured 31px of page overflow without it.
|
||||
className="w-[min(40rem,calc(100vw-5rem))] max-w-full min-w-0 space-y-3 text-sm"
|
||||
role="group"
|
||||
aria-label={`Alert ${screen.alert.id} on customer ${screen.customer.id}. ${state.lookups.length} lookups made, ${hours(state.minutesSpent)} hours spent.`}
|
||||
>
|
||||
<SyntheticLabel />
|
||||
<Meter state={state} />
|
||||
{blocks}
|
||||
<SummaryBlock screen={screen} />
|
||||
<CounterpartyBlock screen={screen} />
|
||||
{state.lookups.map((lookup, i) => (
|
||||
<LookupCard key={`${lookup.what}-${lookup.key ?? i}`} lookup={lookup} currency={screen.currency} />
|
||||
))}
|
||||
{state.lastRejection ? (
|
||||
<p role="status" className="rounded-md border border-warning/60 bg-warning/5 px-3 py-2 text-xs text-warning">
|
||||
Reply refused: {state.lastRejection}. It still cost a turn.
|
||||
</p>
|
||||
) : null}
|
||||
<DispositionCard state={state} />
|
||||
<RevealCard state={state} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Board;
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The board, as the surface renders it.
|
||||
*
|
||||
* The pieces of the world — transactions, the KYC block, the alert, the
|
||||
* summary — are the port's own types, re-exported from `./generator` so the
|
||||
* surface is typed against the shapes CI hashes (digest `world` in
|
||||
* `envs/alert_triage/CONFORMANCE.txt`) rather than a restatement of them.
|
||||
* Field names are snake_case because they are the canonical JSON's keys.
|
||||
*
|
||||
* Amounts are integers in minor units, never floats; dates are `YYYY-MM-DD`;
|
||||
* months are `YYYY-MM`. Everything is synthetic.
|
||||
*
|
||||
* Types only, plus the two enums the controls offer as choices.
|
||||
*/
|
||||
|
||||
export type {
|
||||
Alert,
|
||||
Counterparty,
|
||||
Customer,
|
||||
Document,
|
||||
Label,
|
||||
Overlay,
|
||||
PriorAlert,
|
||||
Screen,
|
||||
ScreenCounterparty,
|
||||
SummaryRow,
|
||||
Template,
|
||||
Tier,
|
||||
Txn as Transaction,
|
||||
} from './generator';
|
||||
|
||||
import type { Counterparty, Document, Label, Overlay, PriorAlert, Screen, Template, Tier, Txn } from './generator';
|
||||
|
||||
/** One accepted lookup and what it returned, in the order they were made. */
|
||||
export type Lookup =
|
||||
| { what: 'history'; key: string; result: { month: string; transactions: Txn[] } }
|
||||
| { what: 'counterparty'; key: string; result: Counterparty }
|
||||
| { what: 'prior_alerts'; key: null; result: { alerts: PriorAlert[] } }
|
||||
| { what: 'documents'; key: null; result: { documents: Document[] } };
|
||||
|
||||
export type LookupKind = Lookup['what'];
|
||||
|
||||
export type CloseReason =
|
||||
| 'CONSISTENT_WITH_PROFILE'
|
||||
| 'DOCUMENTED_SOURCE_OF_FUNDS'
|
||||
| 'PREVIOUSLY_REVIEWED'
|
||||
| 'RULE_ARTEFACT';
|
||||
|
||||
export type Typology = 'STRUCTURING' | 'FUNNEL_ACCOUNT' | 'MONEY_MULE' | 'RAPID_MOVEMENT' | 'UNKNOWN';
|
||||
|
||||
export const CLOSE_REASONS: readonly CloseReason[] = [
|
||||
'CONSISTENT_WITH_PROFILE',
|
||||
'DOCUMENTED_SOURCE_OF_FUNDS',
|
||||
'PREVIOUSLY_REVIEWED',
|
||||
'RULE_ARTEFACT',
|
||||
];
|
||||
|
||||
export const TYPOLOGIES: readonly Typology[] = [
|
||||
'STRUCTURING',
|
||||
'FUNNEL_ACCOUNT',
|
||||
'MONEY_MULE',
|
||||
'RAPID_MOVEMENT',
|
||||
'UNKNOWN',
|
||||
];
|
||||
|
||||
/** An accepted disposition, in the engine's canonical form. Ends the episode. */
|
||||
export type Disposition =
|
||||
| { action: 'close'; reason: CloseReason; cites: string[]; note: string }
|
||||
| { action: 'escalate'; typology: Typology; cites: string[]; narrative: string };
|
||||
|
||||
/**
|
||||
* What the generator planted, shown only once the episode is over. On the
|
||||
* board before then it would be the answer key taped to the exam.
|
||||
*/
|
||||
export interface Reveal {
|
||||
label: Label;
|
||||
typology: string | null;
|
||||
/** Alternates; the grader scores the best one. */
|
||||
planted: string[][];
|
||||
template: Template;
|
||||
overlay: Overlay | null;
|
||||
tier: Tier | null;
|
||||
/**
|
||||
* Minutes the cheapest shipped analyst needed on this seed. Null when the
|
||||
* board does not know — a recorded run carries it as a metric; a fresh
|
||||
* board in Play does only if the port ran the reference policies.
|
||||
*/
|
||||
referenceMinutes: number | null;
|
||||
}
|
||||
|
||||
export type Status = 'playing' | 'closed' | 'escalated' | 'aborted';
|
||||
|
||||
/**
|
||||
* The board after a step. Snapshots, never deltas: the scrubber jumps to any
|
||||
* step, and every field the surface reads is here in full.
|
||||
*/
|
||||
export interface TriageState {
|
||||
seed: number;
|
||||
screen: Screen;
|
||||
lookups: Lookup[];
|
||||
/** Replies spent, accepted or not. Eight ends the episode. */
|
||||
turnsSpent: number;
|
||||
rejected: number;
|
||||
/** The hour meter, in minutes, so it agrees with the grader to the digit. */
|
||||
minutesSpent: number;
|
||||
/** Why the most recent reply was refused, or null if it was accepted. */
|
||||
lastRejection: string | null;
|
||||
disposition: Disposition | null;
|
||||
status: Status;
|
||||
/** The grader's verdict on the disposition, once there is one. */
|
||||
outcome: 'solved' | 'failed' | 'aborted' | null;
|
||||
/** Every id the engine has rendered so far — the only ids a disposition may cite. */
|
||||
shown: string[];
|
||||
/** The replies played so far. The seam replays these to take the next step. */
|
||||
replies: (string | null)[];
|
||||
reveal: Reveal | null;
|
||||
}
|
||||
Reference in New Issue
Block a user