# Changelog Notable, user-visible changes. Newest first. ## 0.123.0 — 2026-11-03 **A table-backed array field can now be read by cell address instead of reconstructed by a model.** Declare `grid:` on an array field and Koji finds the table, names its columns from your sub-field names, names its rows from your row-key vocabulary, and then *looks each value up* at the resulting (row, column). No model runs at the read, so no value can be fabricated, and each one's provenance is the cell's own bounding box — known when the value is read rather than matched back to the document afterwards. The problem this addresses is structural, not model quality. The router scores chunks, flattens their text to markdown, and asks a model to rebuild a nested object from it; row<->column identity is already gone by the time the prompt is written. `oss-536` shipped the geometry to avoid that — `PositionalStructuredProvider` emits one unit per table cell with `{tableId, row, col}` and a per-cell box from any digital PDF, credential-free — but nothing consumed those coordinates except post-hoc provenance. This release makes them the extraction surface. ```yaml fields: coverages: type: array grid: true # the whole opt-in hints: element_key: coverage items: type: object properties: coverage: type: mapping mappings: # the row vocabulary — this is what transfers general_liability: ["Commercial General Liability", "CGL"] auto: ["Business Auto", "Automobile Liability"] premium: { type: number } limit: { type: number } ``` **Why not just write a `forms:` grammar.** `forms:` works — a hand-authored regex hits the table it was written for almost every time — but it does not transfer: it is written against one issuer's spacing and column order, and the next issuer's version of the same table needs a new grammar. `grid:` reads the table from its *shape* and takes the names from your schema, so the same declaration reads a layout it has never seen. Where a header or a row label does not match literally, the `select` capability (`oss-526`, LLM-choice over your own key by default) answers it as one enumerated choice with abstention allowed — so a conventional layout costs **zero** model calls and an unfamiliar one costs a handful of single-token choices rather than a table reconstruction. **It is opt-in per field, and that is load-bearing.** A great deal already indexes into an array field's rows — `source_texts`, `provenance.items[]`, `element_key` collapse, `skip_row_when`, the `forms:` overlay. Switching the producer of those rows on by default would change all of it for documents nobody re-measured. A schema that does not declare `grid:` executes no new code at all. **The grid is authoritative only for the sub-fields it named.** A column it could not name is not a claim that the column is empty, so the extraction's value for it stands. With a row key the two row sets join by key; with no row key they join by position, but *only* when they agree on a mapped column for at least half the rows — equal row counts alone are not evidence of the same rows in the same order, and an array whose schema asks for a reconciled list has no reason to arrive in one table's print order. With neither join available the grid's rows stand alone only if it named every sub-field. Every one of those decisions is reported in `normalization.warnings`. `exclude:` names sub-fields the grid must never claim, for the case where **the cell is not the answer**: a column printing a short reference the schema asks you to resolve elsewhere on the page, or a raw form you want normalized. A deterministic read beats a model only where the value really is printed in the cell; where it is not, reading the cell is confidently wrong. **What the corpus says: no improvement, and no regression.** Measured paired on 50 documents with `@koji/score` — the pipeline runs once per document and the same result is scored with the stage off and on, so the LLM half is byte-identical between arms and the only variance is the stage itself. `grid` is deterministic matching only; `grid+s` additionally lets the selector resolve headers and row labels that did not match literally. `grid: true` was applied blanket to every array field, which is *not* how it would be used. | category | n | base | grid | grid+s | |---|---|---|---|---| | invoices (`items`) | 25 | 72.0% | **72.0%** | **72.0%** | | certificates (`policies`) | 25 | 24.0% | 20.0% | 16.0% | | all array fields | 50 | 48.0% | 46.0% | 44.0% | On invoice line items the stage is *exactly* neutral — same binary accuracy, same partial score (87.2%), with a grid accepted on 30 of 50 documents. On certificates it costs 4–8 points, and the cause is a schema that is the opposite of a one-table read: `policies` is explicitly a **reconciled** field ("collate all information per unique policy_number", "dereference the letter code against the insurer list"), so the coverage grid's `INSR LTR` column prints `A` where the schema wants the name `A` refers to. Declaring `exclude: [carrier_name]` removes that and the stage is exactly neutral there too (28.0% / 28.0% / 28.0%, n=25). **No corpus evidence that it helps, and that is a statement about the corpus.** The validation corpus contains **zero PDFs** — every document is markdown — so there is no cell geometry anywhere in it, and the half of this track that the geometry exists for is unexercised. What the corpus can show is that the stage does not cost accuracy, and getting there found two real defects worth naming: - The read wrote **raw cell text past the declared type**: `09/06/2025` into a `date` sub-field whose correct value is `2025-09-06`, overwriting a value the extraction had resolved with locale context a cell read does not have. The grid now runs the sub-field's declared `normalize:` transforms and then requires `validateField` to accept the result; a cell it cannot represent is left unread. This was the single largest cause of the stage scoring below plain extraction. - The positional join paired rows on a **majority** agreement. The rows that did *not* agree were pairings nobody verified, and they were exactly the rows the join then overwrote with another row's values. It now requires a mapped column to agree on **every** row. On a real digital PDF, where the geometry exists, the capability does what it says. A four-column schedule with a hole in one row — the shape that breaks a reading-order flatten, because the surviving value slides left into the missing column — reads correctly through the shipped `extractFields` entry point, zero model calls spent on identity, every value carrying `resolution: "chunk"` and its own box: ``` row=4 col=1 bbox=(0.0817, 0.1869, 0.0517, 0.0126) item="Charlie" row=4 col=3 bbox=(0.2941, 0.1869, 0.0263, 0.0126) code="C-3" row=4 col=5 bbox=none count=null row=4 col=6 bbox=(0.7190, 0.1869, 0.0182, 0.0126) amount=90 ``` **Also fixed: cell provenance boxes were inflated across the next column.** pdfjs interleaves synthetic whitespace runs between text runs, and in a wide grid those are as wide as the gap they span. `reconstructTable` was including them in each cell's box union, so a one-glyph `3` in a 612pt-wide page carried a 130pt box reaching most of the way into the next column — a highlight pointing at empty paper, on the one path whose whole value is that its geometry is exact. The cell's *text* already dropped them; now the box agrees with the text. Measured on the same PDF, that cell's box went from 0.2124 of the page width to 0.0091. New docs: [Table grids](docs/schema-guide.md#table-grids-grid) in the schema guide, and Phase 3.5 in [the architecture overview](docs/architecture.md). ## 0.122.2 — 2026-11-03 **Fix: CI's end-to-end job pinned to Ubuntu 24.04.** GitHub rolled the `ubuntu-latest` label to Ubuntu 26.04, and Playwright's pinned version has no chromium build for it, so `Install Playwright browsers` exited 1 before a single test ran — on every pull request in the repository, not just the one that surfaced it. Nothing in the product changed. This is the same failure shape as the Node pin in 0.122.1: an external label moving underneath us, silently, with no release of ours involved. Unpin when Playwright supports 26.04, as its own change rather than a browser-stack move riding along inside an unrelated PR. ## 0.122.1 — 2026-10-26 **Node.js pinned to 22.23.4 for the October 2026 Node.js security release.** The API and dashboard images were built `FROM node:22-alpine`, a floating tag, so whether an image carried the fix depended on when it was last built. Both now pin `node:22.23.4-alpine`, and CI pins `node-version: 22.23.4`. 22.23.4 fixes CVE-2026-111497 (Brotli decoder out-of-bounds write, reachable through `fetch()` response decompression) and the TLS hostname-validation bypasses CVE-2026-105483 and CVE-2026-106069. If you self-host, rebuild your images. ## 0.122.0 — 2026-10-21 **Array accuracy in the TypeScript bench was overstated by 22.6 points.** `api/src/extract/bench-ts.ts` carried its own comparator, and it scored an array field as correct whenever extraction returned *any non-empty array*, whatever it contained — so line items, coverages and schedules were effectively unscored. Its scalar path was no better: the "fuzzy" branch compared character **multisets** rather than edit distance, so any anagram matched at the default 0.85 threshold, including `12/31/2026` against `13/21/2026` — a different date. Re-scoring the same 7,121-field extraction set with both comparators: | | old bench comparator | `@koji/score` | |---|---|---| | all fields | 84.4% | 84.5% | | **array fields only** | **80.8%** | **58.2%** | The headline number barely moves. The array number falls 22.6 points, and that is the honest one. If you have been reading TS bench output to judge how well Koji extracts line items, it was telling you roughly 81% for work that scores 58%. `bench-ts.ts` now delegates to `@koji/score` like every other caller, with per-field specs from the schema so arrays match elements by their declared `element_key`. A guard test fails the build if a comparator reappears there. **Numeric equivalence no longer reads a prefix.** The shared guard admitted any string of digits and punctuation and let `parseFloat` read the front of it, so a truncation compared equal to the value it came from: ``` "45" vs "45-5108603" (a bare number against a TIN) → matched "704" vs "704-376-9896" (against a phone number) → matched "2026" vs "2026-05-01" (against a full date) → matched ``` A quantity must now be a complete numeric literal. Measured across the same 7,121 fields, this changes exactly **one** verdict — a malformed ground-truth date (`2017-28-12` vs `2017-12-28`) that was passing only because both sides read as the quantity 2017. Genuine quantities, currency, thousands separators and formatting-only punctuation differences are unaffected. `@koji/score` goes to 0.4.0, so `scorer_version` marks results either side of the change as not comparable. ## 0.121.0 — 2026-10-20 **Feature: classifier classes can require a signal — `require_keywords` / `require_patterns`.** The mirror of `exclude_keywords`: a class that declares required signals stays a candidate only when at least one of them appears in the window text. If none does, the class is ruled out of every tier — it can't win the keyword tier and is not offered to the LLM or vision model. This closes a gap the exclude rules can't: a model choosing between look-alike classes sometimes picks one the document never names. In a production backtest of a classifier that added a class for one such look-alike, the LLM tier gave that label to 7 of 90 documents of a neighbouring class, none of which contained any of the class's words. A required signal makes that answer impossible without touching the prompt. A window with no text (a scanned PDF headed for vision) enforces nothing, as with exclusions. When a class's exclude and require signals both match, the exclusion wins. See [Classifiers](docs/classifiers.md#required-signals). ## 0.120.1 — 2026-10-20 **Fix: a tenant's Doc AI or Textract endpoint stopped receiving scanned documents in 0.118.0, and that traffic silently moved to the deployment's default parse backend.** 0.118.0 corrected a real misclassification — the cell-emitting drivers were labelled `markdown`, so a configured Doc AI endpoint sat in the heavy slot and digital PDFs never reached it. Reclassifying them as `structured` fixed that and introduced a worse problem: it moved the endpoint *out* of the heavy slot, and an empty heavy slot falls back to the deployment default. On the hosted platform that default is Modal. Scanned, image and non-PDF documents a tenant had been sending to the vendor they configured started being parsed — and billed — through the platform backend instead, with nothing in the product indicating a change. These drivers are full document parsers, not table-only add-ons, so a structured tenant endpoint now fills **both** slots: it serves scanned input as it did before 0.118.0, and table-heavy digital PDFs still get the cell path 0.118.0 added. That is strictly better than either side of the regression. A deployment-level structured backend (`KOJI_PARSE_STRUCTURED=positional`) deliberately does not do this — it is pdfjs geometry and cannot parse a scan. If you run the hosted platform or set a default parse backend, check your backend's usage for the window between 0.118.0 and this release. ## 0.120.0 — 2026-10-19 **`koji test`, `koji bench` and `koji pipeline-bench` now score with `@koji/score`.** They were the last callers with their own comparison engine — `cli/test_runner.py` carried its own Levenshtein string similarity, date parser, number coercion, emptiness rule and array-of-dicts structural similarity — and it disagreed with the scorer the server uses *by construction*. Same corpus, same extraction, two different numbers depending on which command you ran. That engine is deleted; the module now shapes a payload and calls the one scorer, the same way `koji score` does. **This changes the numbers. Do not compare a new run against an old one.** Re-run the baseline. Every report now carries `scorer_version`, which moved `0.2.0` → `0.3.0`, so the mismatch is visible rather than silent. ### What changed, concretely **Fuzzy string matching is gone.** A schema-level `compare: fuzzy_threshold` accepted any string within *N*% Levenshtein similarity, across every field at once. The scorer has no fuzzy mode: a one-character content difference is a content difference. ``` expected "TEO HENG STATIONERY" actual "TED HENG STATIONERY" before (fuzzy_threshold: 0.85) pass — "fuzzy match (95% similar)" after fail — expected 'TEO HENG STATIONERY', got 'TED HENG STATIONERY' ``` The nine corpus schemas that still declare `compare.fuzzy_threshold` now get a one-line notice on stderr saying the key is ignored. The replacement is per-field and explicit — `match: contains` for values the document wraps in boilerplate, `match: {mode: numeric, tolerance: …}` for rounding, `match: enum` plus `mappings:` for synonyms. See "Replacing a legacy `compare: fuzzy_threshold`" in the schema guide. **Fuzzy *array* matching is gone too, and this is where the real movement is.** The old engine had two tolerant array paths that could pass an array with a fabricated element in it: ``` parties expected ["Genworth Financial, Inc."] actual ["Genworth Financial, Inc.", "[Optionee]"] before pass — "fuzzy array match (1/1 items, 100%)" ← the extra is invisible after fail — array length: expected 1, got 2 (67% element F1, P 50% / R 100%) ``` Recall was 100% and precision 50%; the old path only looked at recall, so a hallucinated element scored a clean pass. The same applies to the "fuzzy structural match (83% similarity)" path on arrays of objects. **Punctuation is now forgiven inside array elements**, the way it already was for scalars. The old engine's set-comparison keys were punctuation-sensitive, so a trailing period on every element zeroed the field: ``` procedures expected ["Chest x-ray", "Wound consult", …] actual ["Chest x-ray.", "Wound consult.", …] before fail — array items differ (0% element F1) after pass ``` **`[]` and `{}` count as absent, on either side.** Ground truth writes a field with no value as `null`; extraction returns `[]` for an empty collection. The scorer scored those against each other as a mismatch, which invented a failure out of a representation difference and broke the adversarial case where a fixture asserts a field must NOT be extracted and the model correctly declines with an empty array. This is a fix in `@koji/score` itself, so `koji validate` and the dashboard get it as well. **Element matching, declared policies and informational sub-fields now apply.** `koji test` and the benches had no concept of `hints.element_key`, `hints.informational`, or the `match:` policy added in 0.115.0 — the Python engine predated all three. They now work everywhere, which is most of the point of having one scorer. **A declared `mappings:` block now applies without `match: enum`.** The Python engine resolved schema synonyms on every string field; the scorer only did it under `match: enum`, so migrating would have dropped a declared schema feature on the floor: ``` form_type expected "Employer's First Report" actual "Employer's First Report of Injury" ← a declared alias before pass (mappings resolved) after pass — fixed here; without this it would have failed ``` Extraction already folds values through `mappings` (`foldVocabValue`), so scoring that ignored them marked a correct extraction wrong. This is a fix in `@koji/score`, so `koji validate` and the dashboard get it too. It is purely additive — it can only turn a mismatch into a match, and only for pairs the schema names — and it does not loosen `match: exact`. **Every field carries a fractional score**, not only arrays. An object-valued field used to be pass/fail; a half-right address now scores 0.5, and that credit is what `value_accuracy` averages. Each field's `score` appears in the `--json` output, and `koji test`'s human summary now prints the four metrics and the scorer stamp that `koji bench` and `koji score` already printed. ### Measured on the corpus 1,302 fields over 198 documents (10 categories, gpt-4o-mini), scored by both engines on *identical* extraction output, so every difference is a scoring difference and not model variance: | | old engine | `@koji/score` | |---|---|---| | exact-match verdicts | 1127/1302 (86.6%) | 1107/1302 (85.0%) | | field credit | 1129.12 (86.7%) | 1126.05 (86.5%) | **24 of 1,302 field verdicts changed (1.8%)**, and every one traces to a mechanism the old engine had and the scorer does not: | | count | old mechanism | fields | |---|---|---|---| | now fails | 12 | `fuzzy structural match` | `policies` | | now fails | 4 | `fuzzy array match` | `parties`, `policies` | | now fails | 6 | `fuzzy match` (scalar Levenshtein) | `employer_name` ×5, `producer_phone` | | now passes | 1 | punctuation inside array elements | `procedures` | | now passes | 1 | malformed ground-truth date | `date` | The five `employer_name` cases are the clearest argument for the change: those fixtures differ only by a trailing index digit, and the old engine passed `Structural Test Corp` against `Structural Test Corp 4` at 91% similarity — it would have scored employer 4's name as correct while extraction returned employer 1's. One case is a genuine loss: `producer_phone` expected `(316) 555-0155` against `3165550155`, which is formatting-only. The scorer collapses punctuation to a space rather than removing it, so the two do not match. That is pre-existing scorer behaviour shared with `koji validate`, tracked as oss-555. The `date` case that now passes is the mirror image — a malformed ground-truth value (`2017-28-12`) read as a quantity rather than rejected as a date, tracked as oss-556. A 36-document `koji bench` run before and after produces identical totals and identical per-field verdicts (164/176 exact fields, and the same four metrics); the only output differences are the scorer stamp and precision/recall now appearing alongside element F1 in a failing array's detail line. ### Operational notes - Scoring runs out of process, so these commands need `node` on PATH. In a checkout they work with nothing built (the scorer's TypeScript source runs through the workspace `tsx`); `pnpm --filter @koji/score build` just makes them start faster. - A document or fixture whose scoring fails is recorded as *that document's* error with a message saying how to fix it. A 1000-document bench does not die on its first field. - The scorer is now located from the CLI's own checkout as well as the working directory, so `koji bench --corpus ~/corpus` run from the corpus directory finds it instead of reaching for `npx`. - Scoring costs one scorer invocation per document, not per field: about 140ms of process startup per document, measured over a 223-document bench. Against seconds of extraction per document that is noise, but it is visible on a run whose extraction is cached or replayed. ## 0.119.0 — 2026-10-19 **`select`, `adjudicate` and `review` are now pluggable capabilities, and the OSS default for each runs on nothing but your own key.** This is the architectural commitment made mechanical: everything available in commercial Koji is available in OSS, so any component needing an LLM, a local model or a hosted service sits behind an interface that ships a BYO-credential implementation. `select(state, options, instructions) → { choice, probabilities, confidence }` picks a field's value out of a candidate set instead of generating it. Three properties come from that *shape*, not from which implementation answers: - **Fabrication is impossible** — the answer is always a candidate you supplied, or the explicit "none of these". - **Provenance is free** — the chosen candidate comes back with its offset, page and bounding box intact. No post-hoc string matching. - **Confidence is intrinsic** — the distribution over candidates *is* the field's confidence. Three interchangeable implementations: **LLM-choice** over your own chat key (the default, zero configuration), **laya** (a local open-source decision model run in-process through ONNX — optional install), and **Jev** (TypeSafe's hosted System-1 model). They differ in calibration quality, latency and unit cost, not in capability. **Your own key now gets calibrated confidence too.** Where an endpoint reports token logprobs (OpenAI, Azure OpenAI), selection takes its probabilities from the model's own output distribution over the option labels rather than asking the model how sure it is — self-reported confidence is conservatively calibrated noise. `SelectResult.calibrated` tells you which you are holding. `adjudicate(expected, got, field) → { equivalent, confidence }` decides whether two values mean the same thing for a field — whether `Delaware` counts as `State of Delaware`. It is **off by default and offline only**: never in the extraction request path, where it would put a second inference in front of every field and make a hosted dependency load-bearing for an OSS install. Arm it with `KOJI_ADJUDICATE=1` in a CI or corpus job. `review(items) → verdicts` is the low-confidence queue. The default parks items for a person; the agent tier first-passes them, and only decides items that carry a candidate set — so an agent correction is also a span that exists in the document. **A parity suite enforces all of this.** The same inputs run through every implementation asserting *invariants, not accuracy*: no value outside the candidate set however hard the model is pushed, provenance round-trips, confidence in [0,1]. They pass or fail identically for every provider. Also in this release: a model configured for one capability can no longer shadow a chat model when extraction asks for one — chat rows are now preferred in provider resolution. `select` joins `chat`/`vision`/`ocr` in the model catalog, and `typesafe` and `laya` join the credential providers. ## 0.118.0 — 2026-10-19 **Table-heavy documents can be parsed into cells instead of flattened markdown.** Set `KOJI_PARSE_STRUCTURED=positional`: ```bash KOJI_PARSE_STRUCTURED=positional ``` A document whose layout reads as a grid is then parsed into **table cells** — each one carrying its 1-based `(row, col)`, its table id, and its own bounding box — and the markdown the model reads is rebuilt from those cells. Previously every parser produced markdown by flattening the page in reading order, which loses column association: a row with a blank cell slides its remaining values left, under the wrong header. It needs no credentials and no extra service — it reads the geometry the PDF reader already extracts. It applies to PDFs with a text layer, and only to documents whose layout is actually grid-like (a geometric measure, never a document-type rule). Prose documents keep the existing path. Off by default, and while it is off the layout check never runs. **Fixed: a configured Google Document AI or AWS Textract parse endpoint was never used for PDFs with a text layer.** Both drivers return cell structure, but neither was classified as a structured provider, so both were placed in the slot that only ever sees scanned/image/non-PDF input. Document AI and Textract endpoints now serve table-heavy documents of any kind, including digital PDFs. **Fixed: cell and word geometry was dropped from documents large enough to be parsed in shards** (over `KOJI_CHUNK_PARSE_THRESHOLD` pages, 40 by default). The shard merge rebuilt the markdown but discarded the per-unit geometry entirely, so provenance boxes and cell coordinates silently vanished on exactly the long documents they matter most on. The merge now carries every unit over with corrected page numbers, markdown offsets and table ids. ## 0.116.0 — 2026-10-19 **One accuracy number is gone. Four are reported instead.** A single number folded four different things into one average, and one of them was "correctly absent" — a field ground truth says has no value, which the extraction also left empty. Counting that as a pass gave an extraction of **nothing at all** a non-zero floor: on the reference corpus run (1,100 documents, 7,121 fields), 1,365 of the 5,978 "passed" fields were correctly-absent ones, so an extractor that returned `{}` for every document scored 19.2%. Worse, the four things move in opposite directions. Tighten a prompt until the model declines more often and "accuracy" can rise while the extraction gets less useful, with nothing in the number to say so. So four are reported, over two disjoint populations of fields: | metric | population | question | |---|---|---| | **presence recall** | ground truth HAS a value | did we produce a value at all? | | **value accuracy** | …of those, the ones we produced | is it right? (partial credit) | | **fabrication rate** | ground truth has NO value | how often did we invent one? | | **null agreement** | …the same population | how often did we correctly decline? | A document that extracts nothing now scores **0 presence recall**. Null agreement is reported separately and is never folded into any other number — folding it in is what produced the floor. What that reveals on the same corpus run whose headline was 83.95%: presence recall 88.1%, value accuracy 92.2%, fabrication rate 5.4%, null agreement 94.6%. And per category, things one number could not show — `medical_records` finds a value for only 47.4% of the fields that have one, while `legal_filings`, at 97.1% presence recall, invents a value for half the fields ground truth leaves empty. A metric whose population is empty reports `null` (`n/a`), not 0 or 1. A run with no empty-ground-truth fields has no fabrication rate, and reporting 0 would read as "it never fabricates" — a claim the run did not test. Where you see them: - **`koji score`** prints the four, labels each failing field `[missing]` or `[fabricated]` so the three different fixes behind one ✗ are distinguishable, and in `--json` carries per-field `bucket` / `expected_present` / `got_present` so an external harness can recompute the metrics over its own slices. There is deliberately no single `score` key any more. - **`koji bench`** and **`koji test`** artifacts carry a `metrics` block at every level — run, category, document. The legacy `accuracy` field is kept for continuity and is **deprecated**: it still has the floor. - **`koji validate`** reports them per run and per field (`metrics` in `--json`). Its `overall` figure is unchanged and keeps working — it never counted a correctly-absent field as a pass, so it had no floor — but it still cannot say whether a move came from finding fewer values or getting more of them wrong, and it reports nothing about fabrication, because it skips every field ground truth leaves empty. That gap is now measured. **Every run artifact records which scorer produced it.** A score is only meaningful relative to that code: the difference between two runs scored by different versions includes whatever the scorer changed, and the two parts cannot be separated afterwards. So `scorer_version` is stamped on every result and artifact, and a comparison across versions is refused (`assertComparableScorerVersion`, `require_comparable_scorer`) or loudly flagged — `koji validate` prints a warning when the server hands back a stored run that a different scorer version produced. Comparability is string equality, not semver-aware: a patch-level date-parsing fix moves numbers, which is exactly the case the guard exists to catch. `@koji/score` goes to 0.2.0 for the output change. ## 0.115.0 — 2026-10-19 **Schemas can now declare how a field's values are compared.** Whether `Delaware` counts as `State of Delaware` is a judgement call with no universal answer — it depends what the field is for — so the schema makes it instead of the scorer: ```yaml fields: governing_law: type: string match: contains # the document's wrapper is not the value policy_number: type: string match: exact # one wrong character is a wrong policy number premium: type: number match: { mode: numeric, tolerance: 0.5 } ``` Modes are `normalized`, `exact`, `contains`, `numeric` (with `tolerance`), `date` and `enum`. `koji validate` and `koji score` share one scorer, so a field scores the same whichever you run. **`normalized` is the default for every type**, and it is exactly the behaviour that was already there — case, whitespace and punctuation forgiven, dates and quantities compared by value. Verified across all 7,121 fields of the validation corpus: the new scorer and the old one return an identical verdict on every one. Declaring `match:` on one field cannot move the numbers on any other, and adopting this changes nothing until you opt a field in. Worth knowing: `numeric` and `date` are *stricter* than the default, not looser — they fail a value that will not parse, where the default compared it as text. That is the point of declaring them. And an unrecognised mode fails the run rather than reverting to the default, because a misspelled policy that looks configured while scoring differently is the problem this replaces. For scale, on this corpus `match: contains` recovers 30 of the 44 failing `governing_law` fields (56/100 to 86/100). It is permissive by design — it will accept `Dela` for `Delaware` — so it belongs on fields where the document wraps a value in boilerplate, not as a general loosener. ## 0.114.0 — 2026-10-19 **`koji start` works again.** MinIO withdrew their public container images — the Docker Hub repository 404s and quay.io/minio/minio now requires authentication — so `koji start` could not pull an image on any machine without a warm cache. New installs failed outright; existing ones kept working only until someone pruned their image cache. Local clusters now run SeaweedFS in place of MinIO. **Your existing local documents are not carried over.** The old `koji--minio-data` volume is left untouched on disk, but MinIO's on-disk format cannot be read by anything else, and MinIO can no longer be started to export it. Anything stored in a local cluster has to be re-ingested. Nothing in a hosted or BYO-S3 deployment is affected — this is the local dev cluster only. **The MinIO console is gone.** SeaweedFS has no equivalent browser UI, so the console port (`base_port + 16`) is no longer mapped. It is left unallocated rather than reassigned, so the rest of an existing cluster's port map does not shift. The S3 endpoint stays on `base_port + 15`. Alongside it, a compatibility fix that matters to anyone pointing Koji at their own object storage: AWS SDK v3.729+ adds a checksum header to `PutObject` by default. Real S3 understands it; most S3-compatible stores do not, and a presigned PUT then fails with `BadDigest: The Content-Md5 you specified did not match what we received` — the browser never sends a header the signature assumed was there. Koji now emits checksums only where the operation requires them, which is the SDK's pre-3.729 behaviour and correct against real S3. If dashboard uploads were failing against R2, Wasabi, self-hosted MinIO or similar, this is why. CI uses a different S3 double (s3mock) because it wants the opposite trade: a one-second start and no state at all. `koji stop` runs `docker compose down`, which destroys containers, so a local cluster needs a store that survives that — and s3mock ignores every persistence setting it documents, writing to a random temporary directory instead. ## 0.113.3 — 2026-10-18 **Digital parses cached before 0.113.1 get their blank pages back.** The digital-PDF path used to drop pages with no text, so a cached parse's piece k was not always page k — a 2-page notice whose second page is blank came back as one piece, and a 34-page document as 22. Cached digital parses are now served with each piece on its page and empty slots for blank pages, placed by the parse's own word map (its offsets move with their pieces). No re-parse; used only when the result carries exactly the cached text. ## 0.113.2 — 2026-10-18 **The OCR fallback never replaces a digital read with less text.** When the digital-PDF read looks corrupt, Koji re-parses with the OCR provider. It used to take whatever the OCR provider returned, even an empty answer: a 46-page, encrypted-but-readable policy came back from Document AI with no text while pdf.js had read 109k characters, and Koji stored the empty parse. The fallback now replaces the pdf.js read only when it carries at least as many letters and digits; otherwise the pdf.js read is kept and the reason is logged. ## 0.113.1 — 2026-10-17 **Every multi-page parse comes back split into pages.** Parse markdown had two shapes. The in-process digital-PDF path joined pages with a page break (`\n\n---\n\n`); every OCR path — Google Document AI, AWS Textract, Mistral OCR, Azure Document Intelligence, Docling (Modal and Docker), the vision-OCR re-read, and the chunked large-document merge — joined pages with a blank line. A scanned or OCR-routed multi-page document therefore came back as one block of text: every citation landed on page 1 and a bundle could not be split by page. All of them now return one shape: N pages are N pieces joined by the page break, piece *k* being page *k*. A page with no text keeps its slot as an empty piece (the digital path used to drop blank pages, shifting every later page number). No parsed text is dropped: where a provider's per-page view would not carry exactly the text of its whole-document view, the whole-document text is kept. Parses already cached from structured providers (Document AI, Textract) are served paged with no re-parse: their cached parse spine records each unit's page, so the markdown is re-projected from it — on the next run of the document and from `GET /api/jobs/:slug/documents/:docId/markdown` — and used only when it carries exactly the cached text. ## 0.113.0 — 2026-09-29 **One scorer, reachable from the command line.** Comparison of an extracted value against ground truth had drifted into several independent implementations — the server's structural comparator, a Python comparison engine in the CLI, and separate copies in the benchmark and paper harnesses. They disagreed by construction: one scored pass/fail with set-equality arrays, another scored a continuous [0,1] with F1-weighted arrays. Numbers from different callers were not comparable, and there was no way to tell whether a scoring change was an improvement. The scorer now lives in one place, `@koji/score`, and is consumed by everything that scores. The server imports it directly; the new `koji score` command shells out to it; external harnesses can install the package rather than reimplement it. ```bash koji score --expected truth.json --actual extracted.json koji score --expected truth.json --actual extracted.json --schema invoice.yaml --json ``` The command is offline by design — no server, no credentials — so a benchmark or paper script can score a run in CI. Results carry `scorer_version`, so a run artifact records which scorer produced it; numbers from different versions are not comparable and should not be compared. Scoring gained date normalization in the move. `2026-03-26`, `03/26/2026`, `March 26, 2026`, `26 March 2026` and `3/26/26` now compare equal, which the Python scorers already did and the server's did not. Ambiguous numeric dates are read month-first; two-digit years use the POSIX pivot (00-68 → 2000s). Impossible dates like `02/31/2026` are rejected rather than rolling over into March. Existing behaviour is otherwise unchanged — case, whitespace, currency and thousands separators, a 0.01 numeric tolerance, and punctuation-only differences were already forgiven and still are. `koji score` needs `node` on PATH. Inside a checkout it works with nothing built, running the scorer's source through the workspace's `tsx`; building it (`pnpm --filter @koji/score build`) only makes startup faster. Outside a checkout it falls back to `npx @koji/score`. `KOJI_SCORE_BIN` pins a specific build. ## 0.112.1 — 2026-09-17 **Scanned pages no longer render blank in the document viewer.** A PDF whose pages are JPEG 2000 scans — a large share of carrier-issued policy and declaration-page documents — showed as empty white boxes everywhere Koji renders a document: the embed viewer, the review queue, the trace page, the form annotator. Extraction was unaffected, so a reviewer checking a highlight against the page saw the highlight floating on nothing. PDF.js 5 moved JPEG 2000 decoding into a WASM module it fetches at render time from the `wasmUrl` option. That option defaults to `null` and we never set it, so the decoder could not initialize — and the pure-JS fallback could not rescue it either, because it builds its module specifier by string concatenation and a null base asks for the literal `nullopenjpeg_nowasm_fallback.js`. A page whose only content was such an image painted nothing, while neighbouring vector-text pages rendered fine because they need no decoder. The failure was silent: no error surfaced in the UI. The viewer now serves PDF.js's runtime assets out of `public/pdfjs/` and passes all four base URLs (`wasmUrl`, `cMapUrl`, `standardFontDataUrl`, `iccUrl`), which also restores non-Latin character maps, the 14 standard fonts when a document does not embed them, and ICC colour. The assets are copied out of `pdfjs-dist` by a build step so they can no longer drift from the installed version. ## 0.112.0 — 2026-08-24 **Review items now record whether a human corrected the value or accepted it.** Accepting an extraction and correcting one both resolved as `approved`, so the two were indistinguishable in the data — the only way to tell them apart was diffing the final value against the proposed one in JSON, which is unreliable for lists and objects and impossible to aggregate. That made "how often does Koji's output need fixing?" unanswerable, which is the single most useful thing a review queue can tell you. Review items carry a new `edited` field, set by the endpoint that knows the answer, and returned by the review list and detail APIs. Existing resolved items are backfilled from the value comparison, which reconstructs the distinction exactly for them. The review queue now shows *corrected* rather than *approved* for those items. `resolution` deliberately still reads `approved` for both. Promotion to corpus gates on it, so splitting it would have quietly stopped corrected items — the most valuable ground truth there is — from ever reaching the corpus. ## 0.111.3 — 2026-08-24 **Stuck validate runs are now cleaned up — and the stuck-job sweeper actually runs.** A validate run whose fan-out died sat in `running` forever: nothing watched `schema_runs`. That left the run showing as never-finishing in `koji validate` and the Validate UI, and — because an unchanged schema reuses its persisted run rather than starting a new one — could make a schema look permanently un-validatable. Runs that pass a generous ceiling (one hour) are now failed with a reason, keeping any error the run had already recorded. Queued runs that were never picked up are swept the same way. While adding it we found the existing stuck-job sweeper had never worked: every sweep threw before touching a row, because its timestamps were bound in a form the database driver rejects. Its tests replaced the database with a stub, so nothing caught it. Both sweepers now run against a real database in the test suite. ## 0.111.2 — 2026-08-24 **API keys now record when they were last used.** The keys list and the dashboard have always shown a "used