Turnover projection rethink — before / after

As of 2026-07-30 · 52-scenario replay corpus · both arms at one commit, one env var

The projection used to ask one model call to do two jobs: read the walk, and decide the money. It now does one LLM extraction with verbatim quotes and no money fields, decides every money question in pure TypeScript, and escalates only what the rules cannot settle to a judge with a 3-draw unanimity panel behind it.

Headline

BEFORE
single-pass v14, opus-5
AFTER
extract → decide → judge
charge_correctness (52 scenarios, LLM judge, median of 3)7898
per-scenario mean75.392.3
scenarios scoring 093
scenarios scoring 1003344
vendor dispatch billed to the tenant ("call Cardenas for trash removal")2 of 3 draws0 of 3 draws
that scenario's score090
money decisions made by pure code0 of 181171 of 181 (94.5%)
decisions left unresolved for the PM0
scenarios needing any judge call4 of 52 (24 calls)
fabricated quotes caught by code0 (no check existed)2
hard failures0 / 520 / 52
projection latency, mean / median9.5 s / 9.2 s29.9 s / 23.7 s

15 improved · 33 unchanged · 4 regressed. The ladder is 3.1× slower per projection at the mean, 2.6× at the median — verbatim quotes cost output tokens and strict decoding is not free, and both are what the fabrication check validates against. Flagged, not optimized away.

The four regressions, named

scorewhat it is
100 → 0Locked policy working. A walk documents a wrecked carpet with no charge instruction anywhere. BEFORE attributed it to the tenant on damage language alone; AFTER refuses. The grader rewards inventing the charge; the rule forbids it.
100 → 50A real remaining defect. The proposer's item axis never checks attribution, so a standalone damage item still mints a line at the KB typical with nobody behind it — the same defect class the scope axis no longer has. Outside this PR.
100 → 83Missing category mapping. A correctly attributed, correctly priced $250 pest-control charge files under damage because the taskType→category table has no pest_control row. Wrong label, right money.
92 → 88Grader noise. One partial verdict on an adversarial transcript; both arms mint the same charge and the graders disagree with each other more than the arms do.

The first row exposed a gap that #5002 did not fix: an unattributable finding produced a silent zero — no charge line and no question. That follow-up has since shipped (#5034, merged): an unattributable finding now mints a pending who-pays question that no bare "yes" can turn into a deduction. Measured across the same 52 walks: 65 questions total, median 1 per walk, p90 2, max 4.

What the replay corpus caught that unit tests did not

Five money bugs found by scoring an arm, none by a test. Each is fixed and pinned, and the AFTER arm was re-run from scratch rather than reporting a number for code that isn't shipping.

Determinism

Neither arm is byte-stable — 0 of 6 probe cells — and no Claude 5 model can restore the temperature-0 contract, so byte-stability was the wrong target. What changed is where the variance lives. Across three draws on the dispatch scenario, BEFORE went 0, 2, 2 tenant tasks: two charges appearing from nothing but a resample. AFTER structurally cannot add one — the decide stage is pure and byte-identical by construction, pinned by a repeated-call equality test. The remaining variance is extraction recall and wording, not money.

Status

PropFlow Docs