/**
 * The world for a seed: one alert on one synthetic customer.
 *
 * A port of `envs/alert_triage/alert_triage/generator.py`. Everything here is
 * synthetic — no customer, account, transaction, counterparty, analyst or
 * institution exists behind any of it.
 *
 * Portability is the whole design. CI hashes the canonical JSON of every world
 * for seeds 0–4095 on both sides (digest `world` in
 * `envs/alert_triage/CONFORMANCE.txt`), so:
 *
 *   - every random draw goes through `XorShift32` seeded by FNV-1a over the
 *     decimal seed, in the order the Python makes it — the order IS the
 *     contract, and JavaScript's left-to-right argument evaluation is relied
 *     on exactly where Python's is;
 *   - every amount is an integer in minor units, never a float, and every
 *     `//` is `floorDiv`;
 *   - every date is a day ordinal turned into YYYY-MM-DD by our own civil-date
 *     function, never `Date`;
 *   - canonical JSON is sorted keys, no whitespace, `null` for absent.
 *
 * The hidden tier — a suspicious alert whose free screen is drawn from a
 * benign template — is produced by running the benign builder to completion,
 * freezing the screen, and only then overlaying the suspicious signal onto
 * data that a lookup returns.
 */

import { FIRST_NAMES, JURISDICTIONS, SURNAMES, WORDS } from './names';
import { XorShift32, addMonths, daysFromCivil, daysInMonth, floorDiv, fnv1a32, isoDate, monthKey, pyMod } from './rng';

/* -------------------------------------------------------------- constants */

export const MAX_TURNS = 8;
export const WINDOW_MONTHS = 12;
/** Top-N counterparties by volume shown on the free screen. */
export const SCREEN_COUNTERPARTIES = 2;

// Mix by seed. Deliberately not the real false-positive base rate.
const BENIGN_PCT = 55;
const VISIBLE_PCT = 30;
// hidden = the remaining 15

export type Family = 'STR' | 'VEL' | 'CASH' | 'WIRE' | 'DORM';
export const FAMILIES: readonly Family[] = ['STR', 'VEL', 'CASH', 'WIRE', 'DORM'];

export type Slice = 'main' | 'held_out';

/** Rule ids per slice. The held-out slice renames them. */
export const RULE_IDS: Record<Slice, Record<Family, string>> = {
  main: { STR: 'R-STR-01', VEL: 'R-VEL-02', CASH: 'R-CASH-03', WIRE: 'R-WIRE-04', DORM: 'R-DORM-05' },
  held_out: { STR: 'TM-A1', VEL: 'TM-A2', CASH: 'TM-A3', WIRE: 'TM-A4', DORM: 'TM-A5' },
};

export const RULE_TEXT: Record<Family, string> = {
  STR: 'Three or more cash deposits just under the reporting threshold within ten days',
  VEL: 'Funds received and moved out again within 48 hours',
  CASH: 'Monthly cash deposits materially above the trailing six-month average',
  WIRE: "Outbound wires to a jurisdiction on the institution's monitored list",
  DORM: 'Large credit to an account with no material activity for nine months',
};

/** The typology the rule nominally points at. Diagnostic only; never scored. */
export const NOMINAL_TYPOLOGY: Record<Family, string> = {
  STR: 'STRUCTURING',
  VEL: 'RAPID_MOVEMENT',
  CASH: 'STRUCTURING',
  WIRE: 'UNKNOWN',
  DORM: 'UNKNOWN',
};

const CURRENCIES: Record<Slice, { currency: string; threshold: number; scale: number }> = {
  main: { currency: 'USD', threshold: 1_000_000, scale: 1 },
  held_out: { currency: 'KRN', threshold: 2_000_000, scale: 2 },
};

export type Template = 'B1' | 'B2' | 'B3' | 'B4' | 'B5' | 'B6' | 'S1' | 'S3' | 'S6';
const BENIGN_TEMPLATES: readonly Template[] = ['B1', 'B2', 'B3', 'B4', 'B5', 'B6'];
const VISIBLE_TEMPLATES: readonly Template[] = ['S1', 'S3', 'S6'];
export type Overlay = 'funnel' | 'serial_closer' | 'doc_mismatch';
export const OVERLAYS: readonly Overlay[] = ['funnel', 'serial_closer', 'doc_mismatch'];
/** Where a document is the explaining fact. */
const DOC_TEMPLATES: readonly Template[] = ['B2', 'B3', 'B6'];

export const TEMPLATE_FAMILY: Record<Template, Family> = {
  B1: 'CASH', B2: 'VEL', B3: 'VEL', B4: 'STR', B5: 'WIRE', B6: 'DORM', S1: 'STR', S3: 'VEL', S6: 'CASH',
};

export const INBOUND: readonly string[] = ['cash_in', 'wire_in', 'ach_in', 'p2p_in'];

export const BOILERPLATE = 'Reviewed. Activity consistent with customer profile. No further action.';

const BENIGN_RATIONALE: Record<Family, string> = {
  STR: 'Closed: deposits are documented daily takings; amounts vary with trading days; within kyc.expected_monthly_cash.',
  VEL: 'Closed: pass-through matches the settlement terms in the agreement on file; counterparties verified.',
  CASH: 'Closed: seasonal peak consistent with kyc.expected_activity; volume within stated expectation.',
  WIRE: 'Closed: destination and amount match the remittance declared at onboarding; within stated income.',
  DORM: 'Closed: reactivation explained by a documented source of funds; document reviewed and retained.',
};

/* ------------------------------------------------------------------ types */

export interface Txn {
  id: string;
  date: string;
  month: string;
  channel: string;
  amount: number;
  branch: string | null;
  cp: string | null;
}

/**
 * The KYC block. Which optional fields are present depends on the template
 * (a business has `business_type`, an individual `occupation`), and canonical
 * JSON omits keys whose value is `undefined`, matching a dict without the key.
 */
export interface Customer {
  id: string;
  name: string;
  customer_type: string;
  country: string;
  pep: boolean;
  prior_alerts: number;
  prior_sars: number;
  remittance_declared: string;
  account_opened: string;
  kyc_refreshed: string;
  business_type?: string;
  occupation?: string;
  expected_monthly_cash?: number;
  expected_activity?: string;
  stated_income?: number;
}

export interface Counterparty {
  id: string;
  name: string;
  type: string;
  jurisdiction: string;
  first_seen: string;
  other_alerted_accounts: number;
  transactions: string[];
}

export interface ScreenCounterparty {
  id: string;
  direction: 'in' | 'out';
  count: number;
  total: number;
}

export interface Alert {
  id: string;
  rule: string;
  family: Family;
  rule_text: string;
  fired: string;
  triggering: string[];
}

export interface PriorAlert {
  id: string;
  rule: string;
  fired: string;
  disposition: string;
  analyst: string;
  rationale: string;
  elapsed_days: number;
}

export interface Document {
  id: string;
  kind: string;
  description: string;
  party: string | null;
}

export interface SummaryRow {
  month: string;
  cash_in: number;
  cash_out: number;
  wires_in: number;
  wires_out: number;
  ach_in: number;
  ach_out: number;
  count: number;
}

export type Label = 'benign' | 'suspicious';
export type Tier = 'benign' | 'visible' | 'hidden';

export interface World {
  seed: number;
  held_out: boolean;
  currency: string;
  threshold: number;
  screen_order: 'kyc_first' | 'alert_first';
  customer: Customer;
  months: string[];
  transactions: Txn[];
  counterparties: Counterparty[];
  screen_counterparties: ScreenCounterparty[];
  alert: Alert;
  prior_alerts: PriorAlert[];
  documents: Document[];
  summary: SummaryRow[];
  template: Template;
  overlay: Overlay | null;
  label: Label;
  typology: string | null;
  planted: string[][];
  tier: Tier;
  /** Attached by `worldForSeed`; absent from a bare `generate`. Part of the digest. */
  reference_minutes?: number | null;
  reference_policy?: string | null;
}

/** Exactly what turn 0 shows, including the triggering transactions in full. */
export interface Screen {
  seed: number;
  held_out: boolean;
  currency: string;
  threshold: number;
  screen_order: 'kyc_first' | 'alert_first';
  customer: Customer;
  months: string[];
  summary: SummaryRow[];
  screen_counterparties: ScreenCounterparty[];
  alert: Alert;
  triggering_transactions: Txn[];
}

/* -------------------------------------------------------------- utilities */

/**
 * Python's `json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False)`.
 *
 * Keys are sorted by code unit, which equals Python's code-point sort for the
 * ASCII keys every world has. `undefined` values are dropped, as a key a dict
 * never had. Floats are refused: nothing in a world is one, and a float that
 * slipped in would format differently on the two sides.
 */
export function canonicalJson(value: unknown): string {
  if (value === null || value === undefined) return 'null';
  if (typeof value === 'string') return JSON.stringify(value);
  if (typeof value === 'boolean') return value ? 'true' : 'false';
  if (typeof value === 'number') {
    if (!Number.isInteger(value)) throw new Error(`canonicalJson: non-integer number ${value}`);
    return String(value);
  }
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
  if (typeof value === 'object') {
    const record = value as Record<string, unknown>;
    const keys = Object.keys(record)
      .filter((k) => record[k] !== undefined)
      .sort();
    return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(record[k])}`).join(',')}}`;
  }
  throw new Error(`canonicalJson: unsupported value ${String(value)}`);
}

const roundTo = (cents: number, step: number) => floorDiv(cents, step) * step;

/** Python's `f"{n:,}"` for a non-negative integer. Locale-free on purpose. */
export function withCommas(n: number): string {
  const s = String(Math.abs(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 n < 0 ? `-${out}` : out;
}

interface LedgerRow {
  day: number;
  seq: number;
  channel: string;
  amount: number;
  branch: string | null;
  cp: string | null;
}

/**
 * Transactions are collected unordered, then ids are assigned by date.
 *
 * A stable sort on the day ordinal, with insertion order breaking ties, is
 * what makes T-ids identical across the two implementations.
 */
class Ledger {
  rows: LedgerRow[] = [];

  add(day: number, channel: string, amount: number, branch: string | null = null, cp: string | null = null): number {
    this.rows.push({ day, seq: this.rows.length, channel, amount, branch, cp });
    return this.rows.length - 1;
  }

  finish(): [Txn[], Map<number, string>] {
    const ordered = [...this.rows].sort((a, b) => a.day - b.day || a.seq - b.seq);
    const idOf = new Map<number, string>();
    const out: Txn[] = [];
    ordered.forEach((row, i) => {
      const tid = `T-${i + 1}`;
      idOf.set(row.seq, tid);
      const date = isoDate(row.day);
      out.push({ id: tid, date, month: date.slice(0, 7), channel: row.channel, amount: row.amount, branch: row.branch, cp: row.cp });
    });
    return [out, idOf];
  }
}

/** Everything a template builder needs, plus the accumulating world. */
class Ctx {
  readonly seed: number;
  readonly rng: XorShift32;
  readonly slice: Slice;
  readonly currency: string;
  readonly threshold: number;
  readonly scale: number;
  readonly ruleIds: Record<Family, string>;
  readonly home: string;
  readonly ledger = new Ledger();
  cps: { id: string; name: string; type: string; jurisdiction: string }[] = [];
  customer: Customer = null as unknown as Customer;
  months: [number, number][] = [];
  fireDay = 0;
  fireY = 0;
  fireM = 0;
  documents: Document[] = [];
  /** The CP a party-bearing document names. */
  docPartyCp: string | null = null;
  branches: string[];

  constructor(seed: number, rng: XorShift32, slice: Slice) {
    this.seed = seed;
    this.rng = rng;
    this.slice = slice;
    const cur = CURRENCIES[slice];
    this.currency = cur.currency;
    this.threshold = cur.threshold;
    this.scale = cur.scale;
    this.ruleIds = RULE_IDS[slice];
    this.home = JURISDICTIONS.home[this.currency]!;
    this.branches = rng.sample(WORDS.branches, 2);
  }

  /** n whole units of the slice currency, in minor units. */
  usd(n: number): number {
    return n * 100 * this.scale;
  }

  /** A random whole-unit amount in [lo, hi], rounded down to `step` units. */
  money(lo: number, hi: number, step = 10): number {
    return roundTo(this.usd(this.rng.between(lo, hi)), this.usd(step));
  }

  setFire(monthChoices: readonly number[], dayLo = 12, dayHi = 27): void {
    this.fireY = 2026;
    this.fireM = this.rng.pick(monthChoices);
    this.fireDay = daysFromCivil(this.fireY, this.fireM, this.rng.between(dayLo, dayHi));
    this.months = [];
    for (let i = 0; i < WINDOW_MONTHS; i += 1) this.months.push(addMonths(this.fireY, this.fireM, -(WINDOW_MONTHS - 1 - i)));
  }

  fireDom(): number {
    return this.fireDay - daysFromCivil(this.fireY, this.fireM, 1) + 1;
  }

  /** A day ordinal inside window month i. Nothing is ever dated on or after the fire date. */
  dayIn(i: number, lo = 1, hi: number | null = null): number {
    const [y, m] = this.months[i]!;
    let last = hi === null ? daysInMonth(y, m) : hi;
    if (i === WINDOW_MONTHS - 1) last = Math.min(last, this.fireDom() - 1);
    lo = Math.min(lo, last);
    return daysFromCivil(y, m, this.rng.between(lo, last));
  }

  /** k distinct days in month i, ascending. */
  daysIn(i: number, k: number, lo = 1, hi: number | null = null): number[] {
    const [y, m] = this.months[i]!;
    let last = hi === null ? daysInMonth(y, m) : hi;
    if (i === WINDOW_MONTHS - 1) last = Math.min(last, this.fireDom() - 1);
    lo = Math.min(lo, last);
    const first = daysFromCivil(y, m, 1);
    const range: number[] = [];
    for (let d = lo; d <= last; d += 1) range.push(d);
    return this.rng
      .sample(range, k)
      .map((d) => first + d - 1)
      .sort((a, b) => a - b);
  }

  person(): string {
    const first = this.rng.pick(FIRST_NAMES);
    return `${first} ${this.rng.pick(SURNAMES)}`;
  }

  business(noun: string | null = null): string {
    const adj = this.rng.pick(WORDS.adjectives);
    const n = noun ?? this.rng.pick(WORDS.nouns);
    return `${adj} ${n} ${this.rng.pick(WORDS.suffixes)}`;
  }

  cp(name: string, kind: string, jurisdiction: string | null = null): string {
    const cid = `CP-${this.cps.length + 1}`;
    this.cps.push({ id: cid, name, type: kind, jurisdiction: jurisdiction ?? this.home });
    return cid;
  }

  doc(kind: string, description: string, party: string | null = null): string {
    let did: string;
    do {
      did = `doc.D-${this.rng.hex4()}`;
    } while (this.documents.some((d) => d.id === did));
    this.documents.push({ id: did, kind, description, party });
    return did;
  }

  add(day: number, channel: string, amount: number, branch: string | null = null, cp: string | null = null): number {
    return this.ledger.add(day, channel, amount, branch, cp);
  }

  branch(): string {
    return this.rng.pick(this.branches);
  }

  /** The largest month of cash actually deposited so far — what the KYC expectation must cover. */
  maxMonthCash(): number {
    const totals = new Map<number, number>();
    for (const row of this.ledger.rows) {
      if (row.channel === 'cash_in') {
        const key = isoDate(row.day).slice(0, 7);
        const idx = Number(key.slice(0, 4)) * 12 + Number(key.slice(5));
        totals.set(idx, (totals.get(idx) ?? 0) + row.amount);
      }
    }
    return totals.size ? Math.max(...totals.values()) : 0;
  }
}

/**
 * The KYC draws every template makes, in the same order.
 *
 * Prior-alert and prior-SAR counts are drawn HERE, before any template or
 * label-specific code runs, from one distribution. The serial-closer overlay
 * rewrites what the prior alerts say; it can never change how many there are.
 */
function kycCommon(ctx: Ctx, customerType: string): void {
  const rng = ctx.rng;
  const customer: Customer = {
    id: `C-${rng.between(1000, 9999)}`,
    name: '',
    customer_type: customerType,
    country: ctx.home,
    pep: rng.chance(3),
    prior_alerts: 0,
    prior_sars: 0,
    remittance_declared: 'none declared',
    account_opened: '',
    kyc_refreshed: '',
  };
  ctx.customer = customer;
  const roll = rng.below(100);
  customer.prior_alerts = roll < 35 ? 0 : roll < 55 ? 1 : roll < 70 ? 2 : roll < 88 ? 3 : 4;
  // Python's `and` short-circuits: the 8 % roll is only drawn when there is a prior alert.
  customer.prior_sars = customer.prior_alerts >= 1 && rng.chance(8) ? 1 : 0;
  const openedYears = rng.between(2, 15);
  customer.account_opened = isoDate(ctx.fireDay - openedYears * 365 - rng.between(0, 300));
  customer.kyc_refreshed = isoDate(ctx.fireDay - rng.between(40, 500));
}

function salary(ctx: Ctx, monthly: number, employer: string): void {
  for (let i = 0; i < WINDOW_MONTHS; i += 1) {
    const amount = monthly + ctx.usd(ctx.rng.between(-60, 60));
    ctx.add(ctx.dayIn(i, 1, 5), 'ach_in', amount, null, employer);
  }
}

function smallCashOut(ctx: Ctx, lo: number, hi: number, perMonthLo: number, perMonthHi: number): void {
  for (let i = 0; i < WINDOW_MONTHS; i += 1) {
    const n = ctx.rng.between(perMonthLo, perMonthHi);
    for (const day of ctx.daysIn(i, n)) {
      ctx.add(day, 'cash_out', ctx.money(lo, hi), ctx.branch());
    }
  }
}

/** n cash deposits in month i summing roughly to `total`, each clamped. */
function cashDeposits(ctx: Ctx, i: number, total: number, n: number, loCap: number, hiCap: number, days: number[] | null = null): number[] {
  const picked = days ?? ctx.daysIn(i, n);
  const out: number[] = [];
  for (const day of picked) {
    let amount = floorDiv(floorDiv(total, n) * ctx.rng.between(75, 125), 100);
    amount = Math.max(loCap, Math.min(hiCap, roundTo(amount, ctx.usd(10))));
    out.push(ctx.add(day, 'cash_in', amount, ctx.branch()));
  }
  return out;
}

/* ----------------------------------------------------------------- specs */

interface Spec {
  family: Family;
  triggering: 'band_window' | 'fire_month_cash' | number[];
  planted: 'last_two_months' | (string | number)[][];
  typology?: string;
}

/* ---------------------------------------------------------------- benign */

const B1_KINDS: readonly [string, string, readonly number[], readonly number[], string][] = [
  ['landscaping services', 'Landscaping', [4, 5, 6, 7, 8, 9], [4, 5], 'April to September'],
  ['tax preparation', 'Tax Services', [2, 3, 4], [3], 'February to April'],
  ['pool maintenance', 'Pool Care', [5, 6, 7, 8], [5, 6], 'May to August'],
];

/** Seasonal cash business. CASH fires on the first peak month; the KYC file said it would. */
function buildB1(ctx: Ctx): Spec {
  const rng = ctx.rng;
  const [businessType, noun, peak, fireChoices, peakText] = rng.pick(B1_KINDS);
  ctx.setFire(fireChoices);
  kycCommon(ctx, 'business');
  ctx.customer.name = ctx.business(noun);
  ctx.customer.business_type = businessType;
  const off = ctx.money(3000, 7000, 100);
  const mult = rng.between(30, 40);
  const supplier = ctx.cp(ctx.business('Supply'), 'business');
  const equipment = ctx.cp(ctx.business('Equipment'), 'business');
  ctx.months.forEach(([, m], i) => {
    const inPeak = peak.includes(m);
    let total = inPeak ? floorDiv(off * mult, 10) : off;
    total = floorDiv(total * rng.between(85, 115), 100);
    const n = inPeak ? rng.between(8, 12) : rng.between(3, 5);
    cashDeposits(ctx, i, total, n, ctx.usd(200), floorDiv(ctx.threshold * 8, 10) - ctx.usd(100));
    ctx.add(ctx.dayIn(i, 20, 28), 'ach_out', floorDiv(total * rng.between(20, 35), 100), null, supplier);
    if (m % 3 === 0) {
      ctx.add(ctx.dayIn(i, 6, 12), 'wire_out', ctx.money(2500, 9000, 100), null, equipment);
    }
  });
  const expected = roundTo(floorDiv(ctx.maxMonthCash() * rng.between(110, 130), 100), ctx.usd(1000)) + ctx.usd(1000);
  ctx.customer.expected_monthly_cash = expected;
  ctx.customer.expected_activity = `Seasonal business; cash receipts peak ${peakText}.`;
  ctx.customer.stated_income = roundTo(expected * 7, ctx.usd(1000));
  ctx.doc('registration', `Business registration — ${ctx.customer.name}`);
  ctx.doc('licence', `Trade licence, ${businessType}, renewed annually`);
  return {
    family: 'CASH',
    triggering: 'fire_month_cash',
    planted: [['kyc.expected_activity', 'kyc.expected_monthly_cash'], ['kyc.expected_monthly_cash']],
  };
}

const B4_KINDS: readonly [string, string][] = [['restaurant', 'Bistro'], ['diner', 'Diner'], ['pizzeria', 'Kitchen'], ['cafe', 'Grill']];

/** Cash restaurant. Three of the month's deposits land in the band; the file expected more cash than this. */
function buildB4(ctx: Ctx): Spec {
  const rng = ctx.rng;
  const [businessType, noun] = rng.pick(B4_KINDS);
  ctx.setFire([3, 4, 5, 6], 14, 27);
  kycCommon(ctx, 'business');
  ctx.customer.name = ctx.business(noun);
  ctx.customer.business_type = businessType;
  const base = ctx.money(40000, 75000, 1000);
  const produce = ctx.cp(ctx.business('Produce'), 'business');
  const foods = ctx.cp(ctx.business('Foods'), 'business');
  const bandLo = floorDiv(ctx.threshold * 8, 10);
  for (let i = 0; i < WINDOW_MONTHS; i += 1) {
    const total = floorDiv(base * rng.between(90, 110), 100);
    let n = rng.between(11, 14);
    if (i === WINDOW_MONTHS - 1) {
      // Three window deposits in the band, on distinct days; the rest ordinary.
      const fireDom = ctx.fireDom();
      const windowDays = ctx.daysIn(i, 3, Math.max(1, fireDom - 10), fireDom - 1);
      for (const day of windowDays) {
        ctx.add(day, 'cash_in', ctx.money(floorDiv(bandLo, ctx.usd(1)) + 100, floorDiv(ctx.threshold, ctx.usd(1)) - 100), ctx.branch());
      }
      n -= 3;
    }
    cashDeposits(ctx, i, total, n, ctx.usd(1500), bandLo - ctx.usd(100));
    for (const day of ctx.daysIn(i, 4, 1, 28)) {
      ctx.add(day, 'ach_out', floorDiv(total * rng.between(4, 7), 100), null, produce);
    }
    ctx.add(ctx.dayIn(i, 1, 10), 'ach_out', floorDiv(total * rng.between(10, 16), 100), null, foods);
  }
  const expected = roundTo(floorDiv(ctx.maxMonthCash() * rng.between(110, 130), 100), ctx.usd(1000)) + ctx.usd(1000);
  ctx.customer.expected_monthly_cash = expected;
  ctx.customer.expected_activity = 'Cash-intensive food service; daily takings deposited most trading days.';
  ctx.customer.stated_income = roundTo(base * 12, ctx.usd(1000));
  ctx.doc('registration', `Business registration — ${ctx.customer.name}`);
  ctx.doc('licence', 'Food service licence, renewed annually');
  return {
    family: 'STR',
    triggering: 'band_window',
    planted: [['kyc.expected_monthly_cash'], ['kyc.expected_monthly_cash', 'kyc.business_type']],
  };
}

const B2_KINDS: readonly [string, string][] = [
  ['staffing agency', 'Staffing'],
  ['freight brokerage', 'Freight'],
  ['event production', 'Events'],
  ['general contractor', 'Contracting'],
];

/** Processor settlement. Wires in from a processor, contractors paid within two days; the agreement is on file. */
function buildB2(ctx: Ctx): Spec {
  const rng = ctx.rng;
  const [businessType, noun] = rng.pick(B2_KINDS);
  ctx.setFire([3, 4, 5, 6], 14, 27);
  kycCommon(ctx, 'business');
  ctx.customer.name = ctx.business(noun);
  ctx.customer.business_type = businessType;
  const processorName = `${rng.pick(WORDS.adjectives)} Payments LLC`;
  const processor = ctx.cp(processorName, 'processor');
  ctx.docPartyCp = processor;
  const contractors = [ctx.cp(ctx.business(), 'business'), ctx.cp(ctx.business(), 'business')];
  let revenue = 0;
  const window: number[] = [];
  for (let i = 0; i < WINDOW_MONTHS; i += 1) {
    const k = rng.between(3, 4);
    let days = ctx.daysIn(i, k, 1, 24);
    if (i === WINDOW_MONTHS - 1) {
      // Guarantee one settlement inside the 14-day window with its payouts before the fire date.
      const fireDom = ctx.fireDom();
      days = [...ctx.daysIn(i, k - 1, 1, Math.max(1, fireDom - 8)), ctx.fireDay - rng.between(3, 6)];
    }
    for (const day of days) {
      const amount = ctx.money(15000, 60000, 100);
      revenue += amount;
      const tIn = ctx.add(day, 'wire_in', amount, null, processor);
      const share = rng.between(70, 90);
      const first = floorDiv(floorDiv(amount * share, 100) * rng.between(50, 70), 100);
      const second = floorDiv(amount * share, 100) - first;
      const tA = ctx.add(day + 1, 'wire_out', roundTo(first, ctx.usd(10)), null, contractors[0]!);
      const tB = ctx.add(day + rng.between(1, 2), 'ach_out', roundTo(second, ctx.usd(10)), null, contractors[1]!);
      if (i === WINDOW_MONTHS - 1 && day >= ctx.fireDay - 14) window.push(tIn, tA, tB);
    }
  }
  ctx.customer.expected_monthly_cash = ctx.usd(0);
  ctx.customer.expected_activity = 'Card settlements from a payment processor; subcontractor payouts within days of settlement.';
  ctx.customer.stated_income = roundTo(revenue, ctx.usd(10000));
  ctx.doc('registration', `Business registration — ${ctx.customer.name}`);
  ctx.doc(
    'processing_agreement',
    `Merchant processing agreement between ${ctx.customer.name} and ${processorName}; settlement to this account on T+1.`,
    processorName,
  );
  return { family: 'VEL', triggering: window, planted: [['DOC'], ['DOC', 'kyc.business_type']] };
}

/** Property sale. One title-company wire in, moved to a brokerage; closing statement on file. */
function buildB3(ctx: Ctx): Spec {
  const rng = ctx.rng;
  ctx.setFire([3, 4, 5, 6], 14, 27);
  kycCommon(ctx, 'individual');
  ctx.customer.name = ctx.person();
  ctx.customer.occupation = rng.pick(['teacher', 'software engineer', 'pharmacist', 'electrician', 'accountant']);
  const pay = ctx.money(4000, 9000, 100);
  const employer = ctx.cp(ctx.business(), 'business');
  salary(ctx, pay, employer);
  smallCashOut(ctx, 100, 400, 2, 4);
  const titleName = `${rng.pick(SURNAMES)} Title & Escrow`;
  const title = ctx.cp(titleName, 'title_company');
  ctx.docPartyCp = title;
  const brokerage = ctx.cp(`${rng.pick(WORDS.adjectives)} Securities`, 'brokerage');
  const proceeds = ctx.money(180000, 650000, 1000);
  const dayIn = ctx.fireDay - rng.between(3, 6);
  const tIn = ctx.add(dayIn, 'wire_in', proceeds, null, title);
  const tOut = ctx.add(dayIn + rng.between(1, 2), 'wire_out', roundTo(floorDiv(proceeds * rng.between(85, 100), 100), ctx.usd(100)), null, brokerage);
  ctx.customer.expected_monthly_cash = ctx.usd(0);
  ctx.customer.expected_activity = 'Salaried individual; payroll deposits and routine spending.';
  ctx.customer.stated_income = roundTo(pay * 12, ctx.usd(1000));
  ctx.doc('identity', 'Government-issued identity document, verified at onboarding');
  const address = `${rng.between(12, 980)} ${rng.pick(WORDS.streets)}`;
  ctx.doc('closing_statement', `Closing statement — sale of ${address}; net proceeds to seller disbursed by ${titleName}.`, titleName);
  return { family: 'VEL', triggering: [tIn, tOut], planted: [['DOC'], ['DOC', 'kyc.occupation']] };
}

/** Family remittance. A monthly wire to one relative in a monitored jurisdiction, declared at onboarding. */
function buildB5(ctx: Ctx): Spec {
  const rng = ctx.rng;
  ctx.setFire([3, 4, 5, 6]);
  kycCommon(ctx, 'individual');
  const surname = rng.pick(SURNAMES);
  ctx.customer.name = `${rng.pick(FIRST_NAMES)} ${surname}`;
  ctx.customer.occupation = rng.pick(['registered nurse', 'civil engineer', 'teacher', 'pharmacist']);
  const pay = ctx.money(4500, 8500, 100);
  const employer = ctx.cp(ctx.business(), 'business');
  salary(ctx, pay, employer);
  smallCashOut(ctx, 100, 400, 2, 4);
  const jurisdiction = rng.pick(JURISDICTIONS.monitored);
  const relation = rng.pick(['mother', 'father', 'brother', 'sister']);
  const relative = ctx.cp(`${rng.pick(FIRST_NAMES)} ${surname}`, 'individual', jurisdiction);
  const monthly = ctx.money(800, 2500, 50);
  const wires: number[] = [];
  for (let i = 0; i < WINDOW_MONTHS; i += 1) {
    wires.push(ctx.add(ctx.dayIn(i, 1, 7), 'wire_out', monthly + ctx.usd(rng.between(-50, 50)), null, relative));
  }
  ctx.customer.expected_monthly_cash = ctx.usd(0);
  ctx.customer.expected_activity = 'Salaried individual; payroll deposits and routine spending.';
  const units = floorDiv(floorDiv(monthly, 100), ctx.scale) * ctx.scale;
  ctx.customer.remittance_declared = `Monthly support to ${relation} in ${jurisdiction}, about ${withCommas(units)} ${ctx.currency} per month.`;
  ctx.customer.stated_income = roundTo(pay * 12, ctx.usd(1000));
  ctx.doc('identity', 'Government-issued identity document, verified at onboarding');
  ctx.doc('address', 'Proof of address, utility statement');
  return { family: 'WIRE', triggering: wires.slice(-3), planted: [['kyc.remittance_declared'], ['kyc.remittance_declared', 'kyc.stated_income']] };
}

/** Inheritance. A dormant account receives an estate distribution; the executor's letter is on file. */
function buildB6(ctx: Ctx): Spec {
  const rng = ctx.rng;
  ctx.setFire([3, 4, 5, 6], 14, 27);
  kycCommon(ctx, 'individual');
  ctx.customer.name = ctx.person();
  ctx.customer.occupation = 'retired';
  const years = rng.between(8, 20);
  ctx.customer.account_opened = isoDate(ctx.fireDay - years * 365 - rng.between(0, 300));
  for (let i = 0; i < WINDOW_MONTHS - 1; i += 1) {
    if (rng.chance(35)) {
      ctx.add(ctx.dayIn(i), 'cash_out', ctx.money(40, 200), ctx.branch());
    }
  }
  const firmA = rng.pick(SURNAMES);
  const firm = `${firmA} & ${rng.pick(SURNAMES)} LLP, client trust account`;
  const trust = ctx.cp(firm, 'law_firm_trust');
  ctx.docPartyCp = trust;
  const brokerage = ctx.cp(`${rng.pick(WORDS.adjectives)} Securities`, 'brokerage');
  const amount = ctx.money(60000, 400000, 1000);
  const dayIn = ctx.fireDay - rng.between(4, 7);
  const tIn = ctx.add(dayIn, 'wire_in', amount, null, trust);
  const tOut = ctx.add(dayIn + rng.between(1, 3), 'wire_out', roundTo(floorDiv(amount * rng.between(50, 90), 100), ctx.usd(100)), null, brokerage);
  ctx.customer.expected_monthly_cash = ctx.usd(0);
  ctx.customer.expected_activity = 'Retired; low activity expected.';
  ctx.customer.stated_income = ctx.money(20000, 40000, 1000);
  ctx.doc('identity', 'Government-issued identity document, verified at onboarding');
  const executor = ctx.person();
  const decedent = ctx.person();
  ctx.doc('executor_letter', `Letter from ${executor}, executor, via ${firm}: distribution from the estate of ${decedent} to the named beneficiary.`, firm);
  return { family: 'DORM', triggering: [tIn, tOut], planted: [['DOC']] };
}

/* --------------------------------------------------------------- visible */

/** Structuring. Eight deposits just under the threshold in twelve days across three branches, on a modest income. */
function buildS1(ctx: Ctx): Spec {
  const rng = ctx.rng;
  ctx.setFire([3, 4, 5, 6], 14, 27);
  kycCommon(ctx, 'individual');
  ctx.customer.name = ctx.person();
  ctx.customer.occupation = rng.pick(['warehouse associate', 'delivery driver', 'retail supervisor', 'line cook']);
  ctx.branches = rng.sample(WORDS.branches, 3);
  const pay = ctx.money(2600, 3600, 100);
  const employer = ctx.cp(ctx.business(), 'business');
  salary(ctx, pay, employer);
  smallCashOut(ctx, 60, 300, 2, 4);
  const fireDom = ctx.fireDom();
  const days = ctx.daysIn(WINDOW_MONTHS - 1, 8, Math.max(1, fireDom - 12), fireDom - 2);
  const lo = floorDiv(floorDiv(ctx.threshold, ctx.usd(1)) * 92, 100);
  const hi = floorDiv(floorDiv(ctx.threshold, ctx.usd(1)) * 99, 100);
  const deposits: number[] = [];
  let total = 0;
  days.forEach((day, j) => {
    const amount = ctx.money(lo, hi);
    total += amount;
    deposits.push(ctx.add(day, 'cash_in', amount, ctx.branches[j % 3]!));
  });
  const holdings = ctx.cp(ctx.business('Holdings'), 'business');
  ctx.add(ctx.fireDay - 1, 'wire_out', roundTo(floorDiv(total * rng.between(85, 95), 100), ctx.usd(100)), null, holdings);
  ctx.customer.expected_monthly_cash = ctx.money(0, 500, 100);
  ctx.customer.expected_activity = 'Salaried individual; payroll deposits and routine spending.';
  ctx.customer.stated_income = roundTo(pay * 12 + ctx.usd(rng.between(0, 6000)), ctx.usd(1000));
  ctx.doc('identity', 'Government-issued identity document, verified at onboarding');
  return { family: 'STR', triggering: 'band_window', planted: [deposits], typology: 'STRUCTURING' };
}

/** Money mule. Four P2P credits from unrelated individuals, each wired to an exchange within a day. */
function buildS3(ctx: Ctx): Spec {
  const rng = ctx.rng;
  ctx.setFire([3, 4, 5, 6], 16, 27);
  kycCommon(ctx, 'individual');
  ctx.customer.name = ctx.person();
  ctx.customer.occupation = rng.pick(['student', 'retired', 'part-time cashier']);
  const stipend = ctx.money(900, 1800, 50);
  const source = ctx.cp(ctx.business(), 'business');
  salary(ctx, stipend, source);
  smallCashOut(ctx, 40, 200, 2, 4);
  const exchange = ctx.cp(`${rng.pick(WORDS.adjectives)} Digital Assets`, 'exchange');
  const fireDom = ctx.fireDom();
  const days = ctx.daysIn(WINDOW_MONTHS - 1, 4, Math.max(1, fireDom - 14), fireDom - 2);
  const pairs: number[] = [];
  for (const day of days) {
    const sender = ctx.cp(ctx.person(), 'individual');
    const amount = ctx.money(2000, 4900, 10);
    const tIn = ctx.add(day, 'p2p_in', amount, null, sender);
    const tOut = ctx.add(day + rng.between(0, 1), 'wire_out', roundTo(floorDiv(amount * rng.between(90, 97), 100), ctx.usd(10)), null, exchange);
    pairs.push(tIn, tOut);
  }
  ctx.customer.expected_monthly_cash = ctx.money(0, 300, 100);
  ctx.customer.expected_activity = 'Low-income individual; small regular credits and routine spending.';
  ctx.customer.stated_income = roundTo(stipend * 12, ctx.usd(1000));
  ctx.doc('identity', 'Government-issued identity document, verified at onboarding');
  return { family: 'VEL', triggering: pairs, planted: [pairs], typology: 'MONEY_MULE' };
}

const S6_KINDS: readonly [string, string][] = [['vending route', 'Vending'], ['car wash', 'Car Wash'], ['laundromat', 'Laundry'], ['convenience store', 'Market']];

/** Smurfing over time. Monthly cash triples across the year and no single deposit ever reaches the threshold. */
function buildS6(ctx: Ctx): Spec {
  const rng = ctx.rng;
  const [businessType, noun] = rng.pick(S6_KINDS);
  ctx.setFire([3, 4, 5, 6], 14, 27);
  kycCommon(ctx, 'business');
  ctx.customer.name = ctx.business(noun);
  ctx.customer.business_type = businessType;
  const c0 = ctx.money(9000, 14000, 500);
  const supplier = ctx.cp(ctx.business('Supply'), 'business');
  const cap = ctx.threshold - ctx.usd(100);
  for (let i = 0; i < WINDOW_MONTHS; i += 1) {
    const total = floorDiv(floorDiv(c0 * (11 + 2 * i), 11) * rng.between(97, 103), 100);
    const n = rng.between(6, 9);
    cashDeposits(ctx, i, total, n, ctx.usd(2000), cap);
    ctx.add(ctx.dayIn(i, 20, 28), 'ach_out', floorDiv(total * rng.between(15, 25), 100), null, supplier);
  }
  ctx.customer.expected_monthly_cash = roundTo(floorDiv(c0 * 12, 10), ctx.usd(500));
  ctx.customer.expected_activity = 'Cash-intensive retail; steady volume expected.';
  ctx.customer.stated_income = roundTo(c0 * 14, ctx.usd(1000));
  ctx.doc('registration', `Business registration — ${ctx.customer.name}`);
  return { family: 'CASH', triggering: 'fire_month_cash', planted: 'last_two_months', typology: 'STRUCTURING' };
}

const BUILDERS: Record<Template, (ctx: Ctx) => Spec> = {
  B1: buildB1, B2: buildB2, B3: buildB3, B4: buildB4, B5: buildB5, B6: buildB6, S1: buildS1, S3: buildS3, S6: buildS6,
};

/* -------------------------------------------------------------- assembly */

/** Benign prior alerts: mixed rules, specific rationales, a few days each. */
function priorAlerts(ctx: Ctx): PriorAlert[] {
  const rng = ctx.rng;
  const out: PriorAlert[] = [];
  const used = new Set<string>();
  for (let j = 0; j < ctx.customer.prior_alerts; j += 1) {
    let aid: string;
    do {
      aid = `A-${rng.between(1000, 4999)}`;
    } while (used.has(aid));
    used.add(aid);
    const fam = rng.pick(FAMILIES);
    const fired = ctx.fireDay - rng.between(30, 720);
    const escalated = j === 0 && ctx.customer.prior_sars >= 1;
    // Python builds the dict in key order, so the analyst is drawn before elapsed_days.
    const analyst = ctx.person();
    out.push({
      id: aid,
      rule: ctx.ruleIds[fam],
      fired: isoDate(fired),
      disposition: escalated ? 'escalated — report filed' : 'closed',
      analyst,
      rationale: escalated ? 'Escalated: pattern not explained by the file at the time.' : BENIGN_RATIONALE[fam],
      elapsed_days: rng.between(2, 9),
    });
  }
  // Stable, as Python's sort is: equal dates keep draw order.
  return out.sort((a, b) => (a.fired < b.fired ? -1 : a.fired > b.fired ? 1 : 0));
}

const SUMMARY_COLUMN: Record<string, keyof Omit<SummaryRow, 'month' | 'count'>> = {
  cash_in: 'cash_in', cash_out: 'cash_out', wire_in: 'wires_in', wire_out: 'wires_out',
  ach_in: 'ach_in', ach_out: 'ach_out', p2p_in: 'wires_in', p2p_out: 'wires_out',
};

const parseDay = (iso: string) => {
  const [y, m, d] = iso.split('-').map(Number);
  return daysFromCivil(y!, m!, d!);
};

function assemble(ctx: Ctx, template: Template, spec: Spec): World {
  const [transactions, idOf] = ctx.ledger.finish();
  const family = spec.family;

  // Triggering set, from the rule where the rule is mechanical.
  let triggering: string[];
  if (spec.triggering === 'band_window') {
    const lo = floorDiv(ctx.threshold * 8, 10);
    const hi = ctx.threshold - 1;
    triggering = transactions
      .filter((t) => {
        if (t.channel !== 'cash_in' || t.amount < lo || t.amount > hi) return false;
        const day = parseDay(t.date);
        return ctx.fireDay - 10 <= day && day <= ctx.fireDay - 1;
      })
      .map((t) => t.id);
  } else if (spec.triggering === 'fire_month_cash') {
    const fireMonth = monthKey(ctx.fireY, ctx.fireM);
    triggering = transactions.filter((t) => t.channel === 'cash_in' && t.month === fireMonth).map((t) => t.id);
  } else {
    triggering = spec.triggering.map((s) => idOf.get(s)!).sort((a, b) => Number(a.slice(2)) - Number(b.slice(2)));
  }

  // Counterparties: transactions and first-seen from the ledger, direction from the channel.
  const byCp = new Map<string, Txn[]>(ctx.cps.map((c) => [c.id, []]));
  for (const t of transactions) {
    if (t.cp) byCp.get(t.cp)!.push(t);
  }
  const counterparties: Counterparty[] = ctx.cps.map((c) => {
    const rows = byCp.get(c.id)!;
    return {
      ...c,
      first_seen: rows.length ? rows[0]!.date : isoDate(ctx.fireDay),
      other_alerted_accounts: ctx.rng.chance(15) ? 1 : 0,
      transactions: rows.map((t) => t.id),
    };
  });
  const volume = (id: string) => byCp.get(id)!.reduce((sum, t) => sum + t.amount, 0);
  const ranked = [...counterparties].sort((a, b) => volume(b.id) - volume(a.id) || Number(a.id.slice(3)) - Number(b.id.slice(3)));
  const screenCps: ScreenCounterparty[] = ranked.slice(0, SCREEN_COUNTERPARTIES).map((c) => {
    const rows = byCp.get(c.id)!;
    return {
      id: c.id,
      direction: rows.length && INBOUND.includes(rows[0]!.channel) ? 'in' : 'out',
      count: rows.length,
      total: volume(c.id),
    };
  });

  const summary: SummaryRow[] = ctx.months.map(([y, m]) => {
    const key = monthKey(y, m);
    const row: SummaryRow = { month: key, cash_in: 0, cash_out: 0, wires_in: 0, wires_out: 0, ach_in: 0, ach_out: 0, count: 0 };
    for (const t of transactions) {
      if (t.month !== key) continue;
      row.count += 1;
      row[SUMMARY_COLUMN[t.channel]!] += t.amount;
    }
    return row;
  });

  // Planted citation sets. "DOC" is the template's explaining document.
  const explaining = ctx.documents.find((d) => d.party)?.id ?? null;
  let planted: string[][];
  if (spec.planted === 'last_two_months') {
    const lastTwo = new Set([monthKey(...ctx.months[WINDOW_MONTHS - 1]!), monthKey(...ctx.months[WINDOW_MONTHS - 2]!)]);
    planted = [transactions.filter((t) => t.channel === 'cash_in' && lastTwo.has(t.month)).map((t) => t.id)];
  } else {
    planted = spec.planted.map((alt) =>
      alt.map((x) => (x === 'DOC' ? (explaining as string) : typeof x === 'number' ? idOf.get(x)! : x)),
    );
  }

  const alert: Alert = {
    id: `A-${ctx.rng.between(5000, 9999)}`,
    rule: ctx.ruleIds[family],
    family,
    rule_text: RULE_TEXT[family],
    fired: isoDate(ctx.fireDay),
    triggering,
  };

  return {
    seed: ctx.seed,
    held_out: ctx.slice === 'held_out',
    currency: ctx.currency,
    threshold: ctx.threshold,
    screen_order: ctx.slice === 'held_out' ? 'kyc_first' : 'alert_first',
    customer: ctx.customer,
    months: ctx.months.map(([y, m]) => monthKey(y, m)),
    transactions,
    counterparties,
    screen_counterparties: screenCps,
    alert,
    // Drawn after the alert id, as the Python's dict literal orders it.
    prior_alerts: priorAlerts(ctx),
    documents: ctx.documents,
    summary,
    template,
    overlay: null,
    label: template.startsWith('S') ? 'suspicious' : 'benign',
    typology: spec.typology ?? null,
    planted,
    tier: 'benign',
  };
}

/* --------------------------------------------------------------- overlay */

/** Which hidden signals this benign world can carry. Depends on the screen, never on the label. */
export function compatibleOverlays(world: World): Overlay[] {
  const kinds: Overlay[] = ['funnel'];
  if (world.customer.prior_alerts >= 3 && world.customer.prior_sars === 0) kinds.push('serial_closer');
  if (DOC_TEMPLATES.includes(world.template)) kinds.push('doc_mismatch');
  return kinds;
}

/** funnel is always available, so the other two are drawn twice as often where they can be. */
function overlayUrn(kinds: Overlay[]): Overlay[] {
  const urn: Overlay[] = [];
  for (const kind of kinds) {
    urn.push(kind);
    if (kind !== 'funnel') urn.push(kind);
  }
  return urn;
}

/** The highest-volume outbound counterparty on the screen. Every benign template has one. */
function outboundTarget(world: World): string {
  const hit = world.screen_counterparties.find((c) => c.direction === 'out');
  if (!hit) throw new Error(`template ${world.template} has no outbound counterparty on screen`);
  return hit.id;
}

/**
 * Overlay a suspicious signal onto lookup data only. Mutates `world`.
 *
 * The screen — alert, triggering set, KYC block, summary, screen
 * counterparties — is not touched. `docPartyCp` is the counterparty the
 * template's party-bearing document names; the Python carries it on the world
 * until the end of `generate` and deletes it, this port never adds it.
 */
export function applyOverlay(world: World, kind: Overlay, rng: XorShift32, docPartyCp: string | null): void {
  const family = world.alert.family;
  const triggering = world.alert.triggering;
  world.overlay = kind;
  world.label = 'suspicious';

  if (kind === 'funnel') {
    const target = outboundTarget(world);
    const cp = world.counterparties.find((c) => c.id === target)!;
    cp.other_alerted_accounts = rng.between(5, 14);
    world.typology = 'FUNNEL_ACCOUNT';
    world.planted = [[target, ...cp.transactions], [...cp.transactions]];
  } else if (kind === 'serial_closer') {
    const priors = world.prior_alerts;
    const analysts: string[] = [];
    while (analysts.length < priors.length) {
      const first = rng.pick(FIRST_NAMES);
      const name = `${first} ${rng.pick(SURNAMES)}`;
      if (!analysts.includes(name)) analysts.push(name);
    }
    const fireDay = parseDay(world.alert.fired);
    const range: number[] = [];
    for (let d = fireDay - 330; d < fireDay - 30; d += 1) range.push(d);
    const days = rng.sample(range, priors.length).sort((a, b) => a - b);
    priors.forEach((prior, j) => {
      prior.rule = world.alert.rule;
      prior.fired = isoDate(days[j]!);
      prior.disposition = 'closed';
      prior.analyst = analysts[j]!;
      prior.rationale = BOILERPLATE;
      prior.elapsed_days = rng.between(0, 1);
    });
    world.typology = NOMINAL_TYPOLOGY[family];
    const ids = priors.map((p) => p.id);
    world.planted = [[...ids, ...triggering], ids];
  } else if (kind === 'doc_mismatch') {
    const target = docPartyCp!;
    const cp = world.counterparties.find((c) => c.id === target)!;
    const adj = rng.pick(WORDS.adjectives);
    const noun = rng.pick(['Holdings', 'Ventures', 'Trading']);
    cp.name = `${adj} ${noun} ${rng.pick(WORDS.suffixes)}`;
    const doc = world.documents.find((d) => d.party)!.id;
    const inbound = world.transactions.filter((t) => triggering.includes(t.id) && t.cp === target).map((t) => t.id);
    world.typology = NOMINAL_TYPOLOGY[family];
    world.planted = [[doc, target, ...inbound], [doc, target]];
  } else {
    throw new Error(String(kind));
  }
}

/* ---------------------------------------------------------------- public */

export function isHeldOut(seed: number): boolean {
  return pyMod(fnv1a32(String(seed)), 8) === 7;
}

/**
 * The world before reference hours. `overlay` is for tests: `'auto'` follows
 * the seed; `null` forces the benign twin; a kind forces it on.
 */
export function generate(seed: number, overlay: 'auto' | Overlay | null = 'auto'): World {
  const h = fnv1a32(String(seed));
  const slice: Slice = h % 8 === 7 ? 'held_out' : 'main';
  const rng = new XorShift32(h);
  const ctx = new Ctx(seed, rng, slice);

  const roll = rng.below(100);
  const tier: Tier = roll < BENIGN_PCT ? 'benign' : roll < BENIGN_PCT + VISIBLE_PCT ? 'visible' : 'hidden';
  const template =
    tier === 'visible' ? VISIBLE_TEMPLATES[rng.below(VISIBLE_TEMPLATES.length)]! : BENIGN_TEMPLATES[rng.below(BENIGN_TEMPLATES.length)]!;

  const spec = BUILDERS[template](ctx);
  const world = assemble(ctx, template, spec);
  world.tier = tier;

  if (tier !== 'visible') {
    const kinds = compatibleOverlays(world);
    if (overlay === 'auto') {
      if (tier === 'hidden') applyOverlay(world, rng.pick(overlayUrn(kinds)), rng, ctx.docPartyCp);
    } else if (overlay !== null) {
      applyOverlay(world, overlay, rng, ctx.docPartyCp);
    }
  }
  return world;
}

const SCREEN_KEYS = ['seed', 'held_out', 'currency', 'threshold', 'screen_order', 'customer', 'months', 'summary', 'screen_counterparties', 'alert'] as const;

/** Exactly what turn 0 shows, including the triggering transactions in full. */
export function screenOf(world: World): Screen {
  const triggering = new Set(world.alert.triggering);
  const screen = {} as Record<string, unknown>;
  for (const k of SCREEN_KEYS) screen[k] = world[k];
  screen.triggering_transactions = world.transactions.filter((t) => triggering.has(t.id));
  return screen as unknown as Screen;
}
