CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Code style
- Code must be easy for a human to understand first: plain naming, straightforward control flow, standard idioms for the language and framework. No cleverness that needs a comment to decode.
- If less code solves the problem equally well, prefer less code — but never buy brevity at the cost of readability.
## Repository Layout
This is a polyglot monorepo with **four independent backend services** that share PostgreSQL/Redis/Kafka infrastructure, plus a separate frontend SPA. Each service has its own dependencies and build:
- **Root (`src/`)** — RDA (Real-Time Detection Agent), TypeScript/Fastify HTTP API. Owns the `package.json` at the repo root, the Knex migrations under `src/database/migrations/`, and the shared ONNX model under `models/`.
- **`paa-service/`** — PAA (Pattern Analysis Agent), TypeScript Kafka consumer worker. Has its own `package.json`, `tsconfig.json`, and `node_modules`. Not invoked from the root scripts.
- **`mla-service/`** — MLA (Model Learning Agent), Python 3.11 service. Has its own `requirements.txt` and `venv`. Trains XGBoost → ONNX models that are deployed by copying into `models/fraud_model.onnx` for RDA.
- **`fia-service/`** — FIA (Fraud Investigation Agent), Python 3.11 service. Consumes the `transactions.blocked` Kafka topic, runs a fine-tuned Phi-3-mini-4k-instruct LLM, and writes structured reports to PostgreSQL `investigationReports`. Strictly async — never on the RDA authorization path.
- **`frontend/`** — Sentinel operator dashboard, Vite + React 18 SPA. Has its own `package.json` and `node_modules`. Not invoked from the root scripts. Talks to RDA `/v1/admin/*` and FIA `/v1/reports*`; when those services are unreachable every read returns an empty fallback and the SPA shows a persistent OFFLINE banner — no synthetic data. The old `mock.js` seed and `sentinel.useMock` override were removed in May 2026.
RDA is the only producer. PAA and MLA consume `transactions.completed`; FIA consumes `transactions.blocked` (published by RDA only when the decision is `DECLINE`). All four services share the same Postgres `fraud_db`.
## Common Commands
### RDA (root)
```bash
npm run start:dev # nodemon hot-reload
npm run build # tsc; postbuild copies *.yaml into dist/
npm run lint # eslint over .ts
npm run test # jest --runInBand --passWithNoTests
npx jest path/to/file.test.ts # single test file
npx jest -t "test name" # single test by name
npm run db:migrate # knex migrate:latest (uses dotenv)
npm run db:migrate:make -- name_of_migration
npm run db:migrate:rollback
```
### PAA (`paa-service/`)
PAA has its own deps and tsconfig — `cd paa-service` first. Same `start:dev` / `build` / `lint` / `test` script names. The root `npm install` does **not** install PAA deps.
### FIA (`fia-service/`)
```bash
cd fia-service && source .venv/bin/activate # NOTE: .venv (FIA), not venv (MLA)
python -m src.main # consume + generate reports
```
Health: `:9094/livez`, `:9094/readyz`, `:9094/stats`. The first run downloads ~7.6 GB of Phi-3-mini-4k-instruct weights to `~/.cache/huggingface`. Set `FIA_FALLBACK_ON_LLM_FAILURE=true` (default) to degrade gracefully to a deterministic rule-based report when the LLM cannot load — the pipeline still produces parseable rows. Device selection: `LLM_DEVICE=auto` picks CUDA → MPS → CPU; on Apple Silicon expect ~45 s model load and a one-time ~6–10 min MPS kernel compilation on the first generation, then ~40–90 s per report steady-state.
### MLA (`mla-service/`)
```bash
cd mla-service && source venv/bin/activate
python -m src.main # start drift-monitoring loop
python -m src.main --train # force retrain on startup
python scripts/train_initial_model.py # cold-start training
python scripts/train_initial_model.py --samples 10000 --skip-registry
pytest # all tests
pytest tests/test_onnx_conversion.py -v # single file
```
After training, deploy the model to RDA with `cp models/fraud_model_v1.0.onnx ../models/fraud_model.onnx`.
### Frontend (`frontend/`)
The Sentinel dashboard has its own deps and build — `cd frontend` first. The root `npm install` does **not** install frontend deps.
```bash
cd frontend && npm install
npm run dev # vite dev server on :5173
npm run build # production bundle into dist/
npm test # vitest run (jsdom + Testing Library)
npm test -- tests/sidebar.test.jsx # single file
```
The dev server proxies `/v1/*` → `VITE_RDA_URL` (default `http://localhost:3000`) and `/fia/*` → `VITE_FIA_URL` (default `http://localhost:9094`, prefix stripped). Auth is read from `localStorage` at request time — `sentinel.jwt` becomes `Authorization: Bearer …` (the user JWT from `POST /v1/auth/login`), `sentinel.apiKey` becomes `X-Api-Key`. See [`docs/FRONTEND.md`](docs/FRONTEND.md) for the full reference.
### Infrastructure
```bash
docker compose up -d redis postgres kafka zookeeper # infra only
docker compose -f docker-compose.yml -f docker-compose.dev.yml up rda-dev paa-dev --build # dev with hot-reload
docker compose up --build # full prod stack (N× RDA via RDA_REPLICAS (default 3), 1× PAA singleton, NGINX, Prometheus, Grafana)
```
Postgres in Docker listens on **5433** (not 5432) to avoid host conflicts.
**Dev module resolution (OJR-45, fixed).** Compiled builds register `module-alias`
(aliases → `dist/`); under ts-node, `src/register-aliases.ts` skips that registration so
`tsconfig-paths` resolves aliases to `src/*.ts` — one module universe either way (mixing
the two resolvers would duplicate every tsyringe singleton). Dev containers therefore run
the mounted source directly; no in-container `npx tsc` is needed after edits. nodemon
runs with `legacyWatch` (polling) so host-side edits through the Docker mount trigger
restarts on macOS. One remaining quirk: a fenced PAA exit reads as a crash to nodemon,
which waits for a file change instead of restarting.
## Architecture Notes That Aren't Obvious from One File
### Path aliases differ per service
The root and PAA both use TS path aliases but they map to different roots:
- Root: `@config/*` → `src/config/*`, `@shared/*` → `src/shared/*`, `@utils/*` → `src/shared/utils/*`
- PAA: `@config/*` → `paa-service/src/config/*`, `@services/*` → `paa-service/src/services/*`, `@utils/*` → `paa-service/src/utils/*`
Both rely on `module-alias` at runtime (resolving against `dist/`) and on the `paths` config in their respective `tsconfig.json` at compile time. Jest's `moduleNameMapper` mirrors this — when adding a new alias, update both tsconfig and jest.config.
### DI container
Both TS services use `tsyringe` with `reflect-metadata` imported as the very first line of the entry point. Services are `@injectable()` and resolved via `container.resolve(...)`. `KafkaProducer` is resolved at startup in `src/server.ts` and connection failure is non-fatal (logs warn, retries on first publish).
### Real-time path (RDA) vs async path (PAA)
RDA's `predict` flow is: Redis feature lookup → ONNX inference → Kafka publish. **Features in Redis are stale until PAA writes them.** On Redis cache miss, RDA falls back to default features and logs a degraded-accuracy warning — this is expected behavior, not a bug.
PAA is the writer: it consumes `transactions.completed`, updates an in-memory transaction graph (`graphology`) and velocity windows, then queues batched writes to Redis and Postgres. Graph metadata is snapshotted for both sender and receiver on every event into a Map keyed by `userId`, then bulk-upserted to Postgres on the standard batch flush (size 100 / 10 s) — the Map dedupes hot users so Postgres pressure tracks unique-user rate, not event rate. Redis writes feed the next RDA prediction.
**PAA is a singleton — do not scale it.** The graph and velocity state live in process memory. A second member in the `pattern-analysis` consumer group splits the partition assignment, so each replica runs PageRank/Louvain on a partial graph and rings whose members hash to different partitions become invisible. PAA takes a Redis leader lease (`ojuri:paa:leader`, `PAA_LEADER_LEASE_TTL_MS`, default 30 s) before it starts consuming; a second instance waits up to `PAA_LEADER_ACQUIRE_TIMEOUT_MS` for handover, then exits rather than joining the group. Two properties matter and are easy to get wrong:
- **Renewal fails closed.** An unreachable Redis is not "still the leader" — the key expires server-side regardless, so a partition outlasting the TTL means a challenger has already taken over. The lease surrenders on elapsed time, not only on a confirmed loss.
- **A fenced-out instance discards its buffers.** Its graph is by definition partial, and `redisUpdateService`/`postgresService` would otherwise flush that snapshot over what the new leader has already written. `stop({ discard: true })` drops them; the flushing path is for SIGTERM only.
This is a lease, not a fencing token: it cannot stop a process paused past the TTL from issuing one last write. `PAA_REQUIRE_LEADER_LEASE=false` disables the fence. The `paa_group_members` gauge and the ERROR log remain as backstop observability.
### Blocked-transaction investigation path
When `PredictService` returns `decision === "DECLINE"`, RDA publishes the same `TransactionEvent` to **two** topics fire-and-forget: the primary `transactions.completed` (consumed by PAA + MLA, partitioned by `sender_id` for per-user ordering) and `transactions.blocked` (consumed only by FIA, partitioned by `transaction_id` so a single high-fraud sender does not pin all FIA work to one partition). The dual publish is intentional — FIA runs at LLM-inference latencies (seconds) and must never share a queue with PAA's millisecond pipeline.
`KafkaProducer.publishAsync(event, topic?, partitionKey?)` is the entry point. The LevelDB disk buffer wraps each entry as `{ v: 2, topic, partitionKey, event }` so flushed events replay to the original topic with the original partition assignment. Legacy raw entries (pre-refactor) fall back to the primary topic with `sender_id` keying. `flushBuffer` continues past per-entry failures — a stuck entry on one topic must not block flush progress on others.
FIA-side idempotency: `investigationReports.transactionId` is UNIQUE and the writer uses `INSERT ... ON CONFLICT DO NOTHING`. The Kafka consumer commits offsets **per-partition** (never `consumer.commit()` with no args, which would advance offsets across partitions). LLM generation runs synchronously in the message handler, so the consumer is configured with `max_poll_records=1` and `max_poll_interval_ms=600000` to prevent rebalances during a slow LLM call. Poison messages are bounded by an in-memory retry counter (`MAX_RETRIES=3`); after that the offset is committed and the failure is logged loudly so a true bad message cannot wedge a partition forever.
### MLA closes the loop offline
MLA monitors windowed F1 and PSI (`DRIFT_PSI_THRESHOLD=0.25`; the F1 threshold is anchored to the deployed champion's validation F1 minus `DRIFT_F1_MARGIN`, falling back to `DRIFT_F1_THRESHOLD=0.4` when no champion metrics exist). Drift windows are fed from Postgres ground truth on the label poll — RDA never publishes labelled events. Retrains also fire on `LABEL_RETRAIN_THRESHOLD` (500) new verified labels; that watermark is anchored to the last `succeeded` row in `retrainRuns`, so labels that arrive while MLA is down still count after a restart. On retrain it applies SMOTE (calibration split carved pre-augmentation), runs McNemar's test against the current model, and only deploys if the improvement is statistically significant. Output is `.onnx` + `_scaler.npz` + `meta.json` (isotonic breakpoints + reason weights); the scaler must be loaded alongside the model. All triggers share `RETRAIN_COOLDOWN_SECONDS` (default 6 h).
### Resilience
RDA wraps Redis feature retrieval and ONNX inference in `opossum` circuit breakers (see `src/shared/circuit-breaker/`). When breakers open, predictions still succeed but use defaults — design for graceful degradation, not failure.
**The ONNX breaker fallback is not a DECLINE.** opossum fires the fallback on every failure *including a per-call timeout*, so `CB_ONNX_TIMEOUT` sitting below the service's own measured p95 under concurrency turned ordinary contention into customer-facing declines. The timeout now defaults to 750 ms (error threshold 25%), and the fallback returns `{ degraded: true }` rather than a bare `1.0`. `PredictService` maps that to `CB_ONNX_FALLBACK_DECISION` (default `REVIEW` — route the unscored transaction to a human, don't decline a customer on infrastructure failure) and stamps `decisionSource = BREAKER_FALLBACK`, so an audit row can distinguish "the model scored 1.0" from "inference never ran". Degraded declines are **not** published to `transactions.blocked`: there is no model signal for FIA to investigate, and during an outage every request would otherwise queue an LLM report.
### Health endpoints
- RDA: `GET /livez`, `GET /readyz`, predict at `POST /v1/predict`, metrics at `GET /v1/metrics` (route version is `/v1`, **not** `/api/v1`).
- PAA: standalone HTTP server on `METRICS_PORT` (default 9090) exposing `/livez`, `/readyz`, `/metrics`, `/stats`. Defined inline in `paa-service/src/worker.ts` — not a Fastify app.
- FIA: HTTP server on `METRICS_PORT` (default 9094) exposing `/livez`, `/readyz`, `/stats` (counters: processed, duplicates, failed, dropped_poison, in_flight_retries, llm_model). Defined inline in `fia-service/src/main.py` — not a Fastify/Flask app.
### Frontend dashboard
The Sentinel SPA under `frontend/` ports the Claude Design handoff into ES-module React. Two non-obvious patterns matter when editing it:
- **`safe()` fallback wrapper.** Every read in `frontend/src/api/client.js` is wrapped: `safe(() => fetch(...), () => [])`. The fallback is always an empty value (`[]`, `{ rows: [], total: 0 }`, `null`) — no `mock.js`, no synthetic rows. When the backend is offline or 401s, the call returns that empty fallback and the page renders an empty state; `app.jsx` flips a persistent `OFFLINE` banner. Write calls (issue key, save rule, …) do **not** use `safe` — they `try` the real call, catch failures locally, surface a toast, and leave the form in its previous state so the operator can retry. New endpoints should follow the same split: reads via `safe`+empty-fallback, writes try-locally-toast.
- **Auth lives in `localStorage`, read per-request.** `adminHeaders()` and `apiHeaders()` in `client.js` re-read `sentinel.jwt` / `sentinel.apiKey` on each call. The JWT is obtained via `POST /v1/auth/login` and authenticates identity only — the server resolves permissions from Postgres per request through a 30 s cache, so role changes and user deactivation apply to live sessions within that window (see `docs/AUTHZ.md`). `RDA_REQUIRE_API_KEY=true` on the backend will 401 all predict calls until `sentinel.apiKey` is set.
The dashboard never sits on the prediction hot path: it issues admin reads and rare writes only. Adding a new page only needs three touch points: a new file under `frontend/src/pages/`, the route id in `loadRoute()` in `frontend/src/app.jsx`, and a `Sidebar` entry in `frontend/src/components/shell.jsx`.
## ONNX Compatibility (MLA only)
XGBoost → ONNX conversion is broken in newer onnxmltools/onnx releases. `mla-service/requirements.txt` pins:
```
onnx==1.13.0
onnxmltools==1.10.0
onnxconverter-common==1.12.0
```
If you see `TypeError: Field onnx.AttributeProto.ints: Expected an int, got a boolean`, reinstall these pinned versions. Do not bump them without testing the full training → ONNX → RDA inference path end-to-end.
## Database
- Single Postgres database `fraud_db`, owned by RDA's Knex migrations under `src/database/migrations/`. PAA and MLA read/write the same tables but do **not** own migrations — schema changes go through the root.
- The `knexfile.js` exposes `primary` and `secondary` pool configs (read replica via `REPLICA_DB_*` env vars, falls back to primary if unset).
- Training requires non-null `fraudLabel` values in the `transactions` table. If absent, MLA's training script falls back to synthetic data (logged as a warning — fine for dev, not for production results).
## Adoption Features (added in 2026-05 revision)
These extend RDA without changing the real-time hot path. They all live
under `src/shared/` so they can be reused by PAA or future workers.
- **API-key auth (`src/shared/auth/`)** — `ApiKeyService` issues `fdk_<prefix>_<secret>`
tokens, persists only the SHA-256 hash, caches verification for 30 s. The
`apiKeyMiddleware` plugs into Fastify `preHandler`; `RDA_REQUIRE_API_KEY=true`
flips the predict endpoint from open to authenticated.
- **User auth + RBAC (`src/shared/authz/`)** — bcrypt-hashed users, roles
with permission arrays, JWT sessions. Permission catalogue is
**code-defined** in `permissions.ts` (no migration to add a new code).
Migration seeds `SUPER_ADMIN` + `admin/admin@fraudit`. The
`requireAuth(...perms)` middleware (`src/shared/middlewares/require-auth.middleware.ts`)
guards every `/v1/admin/*` route; the static `RDA_ADMIN_TOKEN` was
retired in 2026-05.
- **Rules engine (`src/shared/rules/`)** — JSON-Logic-style evaluator (no
arithmetic, just predicates / combinators / `in` / `var`). Rules are
hot-reloaded from Postgres every `RULES_RELOAD_INTERVAL_MS` (30 s default).
`stage` is `PRE` (short-circuits ML) or `POST` (overrides ML). Two seeds
ship by default: `01_demo_rules.ts` (PRE rules used by the demo dataset)
and `03_fatf_rule_pack.ts` (FATF: structuring, VPN+amount, high-risk
corridor TRANSFER, ATO signature, untrusted device + amount — NGN-tuned
defaults that adopters should review per-market).
- **Model registry (`src/shared/models/model-registry.service.ts`)** — stores
`modelVersions` and `segmentThresholds`; resolves `(segment) → (champion,
shadow, threshold)` for each request. Status transitions: CANDIDATE →
SHADOW → ACTIVE → RETIRED. The ONNX session itself is still loaded by
`OnnxService`; the registry is metadata + threshold routing only. Per-
transaction-type threshold defaults (CASH_OUT=0.70, TRANSFER=0.30,
PAYMENT=0.50, DEBIT=0.50, CASH_IN=0.50) are seeded via
`02_segment_thresholds.ts` once an ACTIVE model is registered. Lookup
uses `request.segment ?? request.transaction_type`. Activating a version
hot-reloads it: `OnnxService` loads the version artefact directly and
treats the copy into the canonical `MODEL_PATH` as best-effort (the
compose files mount `models/` read-only, so EROFS is normal), then
re-runs both health probes before serving. A champion that was already
ACTIVE before boot is applied at startup the same way — cold restarts
do not silently fall back to whatever the canonical file holds.
- **Decision audit log (`src/shared/audit/`)** — every `/v1/predict` writes a
row to `decisionAuditLog` with model versions, scores, threshold, rule hit,
reason codes, feature snapshot, and reviewer fields. Default pipeline is the
in-memory batch queue (row durable at flush; backpressure → 503 at 50k;
`AUDIT_SYNC_WRITE=true` for persist-before-respond). `AUDIT_PIPELINE=stream`
switches to the log-first prototype: the decision event carries the full
audit payload, is published with an awaited `acks=all` send before the
response, and `AuditStreamConsumer` materialises this table from the topic;
late values (shadow scores, early-PRE snapshots) follow as
`audit.enrichments` events applied as idempotent UPDATEs. Measurements and
adoption gaps: `docs/LOG_FIRST_AUDIT_PROTOTYPE.md`.
- **Reason codes (`src/shared/onnx/reason-codes.ts`)** — lightweight
feature-deviation explainer for the 12 named feature positions. Cheap
enough to compute on every prediction. Weight *magnitudes* come from
the deployed model's gain importances (`meta.json` → `reason_weights`,
emitted by MLA at registration); the *sign* stays with the hand-written
spec, since importances are unsigned. Each code carries a `basis` of
`MODEL_WEIGHTED` or `HEURISTIC` so an investigator knows which they are
reading. Neither is per-transaction attribution — for that, and for
narrative reasoning, use the FIA endpoints (`POST /v1/reports`,
`/messages`).
- **Webhooks (`src/shared/webhooks/`)** — HMAC-signed POST with exponential
backoff. `WebhookService.publish(event, payload, tenantId)` enqueues
rows in `webhookDeliveries`; the in-process worker (started from
`server.ts`) drains pending rows every `WEBHOOK_WORKER_INTERVAL_MS`.
- **Idempotency (`src/shared/idempotency/`)** — `Idempotency-Key` header on
`/v1/predict` is keyed against `(tenantId, key, requestHash)`. Replay
returns the cached response with `Idempotency-Replay: true`. Body
divergence on the same key returns 422.
- **Training-data ingest (`src/v1/modules/training/`)** — adopter CSVs
enter the system through either `POST /v1/admin/training/import`
(file:// source for ops-driven imports) or the Sentinel chunked-upload
protocol (`upload/init`, `chunk`, `complete`, `abandon`). Rows land in
`transactionsStaging` keyed by `jobId`. `POST /v1/admin/training/import/
:jobId/promote` upserts them into `transactions` with
`groundTruthSource = 'training_import'`, then `POST /mla/v1/admin/retrain`
kicks off a retrain. New tables: `trainingJobs`, `trainingUploads`,
`transactionsStaging`. See `docs/ADOPTER_TRAINING.md`.
- **Score calibration (MLA, `mla-service/src/training/calibration.py`)** —
XGBoost saturates near 0.0/1.0; we fit `sklearn.IsotonicRegression` on
a calibration split carved from the training block **before** SMOTE and
context-dropout augmentation (fitting it on oversampled rows targets a
~50% synthetic base rate, not the real one). The isotonic breakpoints
are written to `meta.json` as a `calibration` block — the paired
`calibrator.npz` is numpy-native and unreadable from Node, which is why
calibration previously affected only the reported Brier and never a
served score. RDA loads the breakpoints in `OnnxService` and applies
them after ONNX output.
**`ONNX_CALIBRATION_MODE` defaults to `observe`**: the calibrated score
is recorded in `decisionAuditLog.calibratedScore` while decisions still
use the raw score. Every threshold (0.65 default, 0.70 CASH_OUT, 0.30
TRANSFER) was tuned against the raw distribution, so flipping to
`enforce` before re-deriving them from calibrated audit data would move
every decision boundary at once.
- **Configurable training mode (`mlaSettings.trainingMode`)** — operators
pick `FRESH` (current behaviour, train from scratch) or `CONTINUED`
(seed from current production model via XGBoost `xgb_model=`, add
`continuedTreesPerRound` trees). Setting persists via
`PUT /mla/v1/admin/drift-config`. MLA reads it at retrain start;
in-flight runs keep their loaded mode.
FIA gained an HTTP API on `:9094`: `POST /v1/reports` (on-demand reports
for any transaction, idempotent by `transactionId`), `POST /v1/reports/:id/messages`
(conversational follow-ups persisted in `investigationConversations`),
`GET /v1/reports[/:id]` (list / read).
## Feature catalogue (replaces the legacy 434-dim prototype pipeline)
Features are now **declaratively contracted** in `models/feature-catalog.v1.json` — 64 base features across 9 categories (velocity / pair / graph / transaction / identity / receiver / geographic / device / calendar). Adopters add their own features via `models/feature-catalog.adopter.json` overlay using a small algebra of compute ops (`from_field`, `equals`, `is_one_of`, `ratio`, `lookup`, `numeric_bucket`, `bool_and/or`, `from_redis`). See `docs/FEATURES.md`.
Both RDA (TS — `src/shared/features/`) and MLA (Python — `mla-service/src/features/`) load the same JSON catalogue and validate identical invariants. `feature_schema_version` is baked into every model's `meta.json` and enforced at load time by `OnnxService` — a model trained against a different overlay is **refused** rather than silently misaligned. `MODEL_INPUT_DIMENSION` env enables pad-to-fit during the brief Phase-2/Phase-3 transition where RDA produces 64-dim but the deployed model still expects more.
The previous "PROTOTYPE MODE: USING PLACEHOLDER FEATURES" warnings and 411-zero-padding have been removed. Training and inference dimensions now match exactly.
## Reference Performance (single developer workstation, Apple Silicon)
Orientation values, not SLA targets. Re-measure on your own hardware.
**Before quoting any p99, read [Benchmarking traps to avoid](docs/ARCHITECTURE.md#8-performance-characteristics).** Two shipped defaults — the NGINX rate limit and the idempotency duplicate short-circuit — return fast responses without ever running the model, so a naive bench measures the reject path. That section is canonical; it is not restated here, because the last time it was, the two copies drifted and this file kept asserting a superseded p99.
**Measured values** (single RDA replica, direct port 3000 to bypass NGINX, unique `transaction_id` per request, all 200 OK):
- ONNX model only (deployed PaySim model, 122 KB, batch=1): p50=0.010 ms, p99=0.049 ms.
- RDA `/v1/predict`, single client, uncontended: p99 ≈ 6 ms (idempotency reservation + audit enqueue are now on the hot path; the earlier 4 ms figure predates those two stages).
- RDA `/v1/predict`, 16 concurrent, 2,000 trials, post-1.4.0 review fixes: p50=28.9 ms, p95=51.7 ms, **p99=84.5 ms**, ~516 RPS, zero breaker fallbacks. This supersedes the pre-fix 5,000-trial run (mean 35 ms, p50=43 ms, p95=140 ms, p99=295 ms, p999=3.3 s, ~237 RPS), whose per-stage means were `feature_load` 19 ms, `inference` 16 ms, and sub-1 ms for the non-ML stages (rules, reason codes, audit_enqueue) — the two ML stages an order of magnitude above their uncontended cost because Node's event loop was serialising async resolutions under contention. Per-stage means have not been re-measured since the fixes, so they describe the old regime only.
- IEEE-CIS XGBoost training: 683,852 train / 118,108 test in 27.67 s; held-out F1=0.554, AUC=0.911.
- FIA (Phi-3-mini, MPS, fp16): ~46 s LLM load, ~6–10 min one-time MPS warmup, then ~40–90 s per report. Idempotency guard verified end-to-end.
The 84.5 ms p99 number is the current honest baseline at meaningful concurrency on this hardware. Driving it down toward the uncontended 6 ms is open work — request admission control at the predict route, hot-path object-allocation cleanup, possibly cluster-mode workers per container, and a benchmark host separate from the server are the levers worth trying next.