docs(extract): measure one Vegnormalene PDF page against the extraction registry

Order 20260821T170054Z-486638087-from-.claude (gap G2). Measurement only: no
parser implemented, no version bump, no pin move.

Measured on Handbok N200 Vegbygging (juli 2018), 308 pages, page index 150:
the registry rejects .pdf with extractor_extra_missing while .md/.csv controls
pass in the same call, and process_inbox reports the file as failed without
aborting the run. pdfplumber recovers Tabell 524.1 as 4/4 correctly paired text
lines where pypdf, pdfminer.six and pymupdf all score 0/4; both structural
extractors return the same wrong 2x6 grid, so table structure is the document's
geometry rather than a library defect. Whole book: 308/308 pages yield text,
45 of 196 detected tables are clean enough for render_table.

Verdict: text extraction is a small, bounded job; structured table recovery is a
separate project that nothing currently waits on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xTq1nbpz9x34udpDDExWM
This commit is contained in:
Kjell Tore Guttormsen 2026-08-21 19:16:15 +02:00
commit b73dd9d6a4

View file

@ -0,0 +1,274 @@
# G2 measurement: one Vegnormalene PDF page through the extraction registry
**Date:** 2026-08-21 · **Order:** `20260821T170054Z-486638087-from-.claude` (from `.claude`)
**Question this document answers:** is a PDF parser behind the `[extract]` extra a small,
bounded job, or is it a project?
This is a measurement, not an implementation. No production code was written, no version
bumped, no tag cut, no guard pin moved. Filename is English per this repo's docs convention;
the order suggested a Norwegian one. Grep anchor for the gap register: **G2**.
## Verdict
**Two answers, and the fork is the finding — but only one of the two branches is needed
to unblock the trial stone.**
1. **Text extraction is small.** One extractor function plus an import probe, at a seam that
is two source lines wide. One library (`pdfplumber`, MIT) reads 308 of 308 pages of the
measured handbook, is deterministic across runs, and recovers the requirement table on the
sample page as correctly paired text lines (4 of 4 rows). This is a bounded job.
2. **Recovering tables as *structured* markdown is a project, and it is not a parser choice.**
`pdfplumber.extract_tables()` and `PyMuPDF.find_tables()` — two independent
implementations — return the *same* wrong structure for the sample table: one 2x6 grid
where four logical rows collapse into a single cell with embedded newlines. The breakage
is in the document's ruling geometry, not in either parser. Fixing it means layout
heuristics this repo would own, specify and test. Across the whole handbook, only
**45 of 196** detected table objects are structurally clean enough to hand to
`render_table` unchanged.
**Recommendation: take branch 1, decline branch 2 for now.** Vegnormalene enter the chain as
prose with correctly paired table lines, which is what the trial stone needs to reach leg 3.
Branch 2 is a separate decision that nothing currently waits on.
**The choice of library is forced by branch 1, not free.** Cheaper candidates parse the page
but destroy the requirement table's row-to-value pairing — in a `krav` document that is a
correctness hazard, not a cosmetic one. See [Table pairing](#table-pairing-the-decisive-number).
## What was measured
| Item | Value |
|---|---|
| Source | Håndbok N200 Vegbygging (juli 2018), Statens vegvesen, public PDF |
| URL | `https://www.vegvesen.no/globalassets/fag/handboker/hb-n200-vegbygging-juli-2018.pdf` |
| Book bytes | 9 879 066 · sha256 `aec0825c305cc830f4e542062c51ec8e4499769495b7d39cb36e48079fe0fdb4` |
| Book pages | 308 |
| Sample page | page index 150 (printed page 151), ch. 5 "Dimensjonering av vegoverbygning" |
| Sample bytes | 316 356 · sha256 `95e1d28b42ec3f514a79e0ad568d91a7122530fba82828c05b859e324e0884ac` |
| Why this page | running prose + one numbered requirement table (`Tabell 524.1 Krav til helning på utkilingen`) + one real figure (`Figur 524.1`) + `skal`-requirements |
The PDF lives under a scratch path, not in this repo — it is test material, not content.
Everything below ran on Python 3.14.0 in throwaway virtualenvs; nothing was added to this
repo's environment or its dependency set.
## Probe 1 — the registry as it stands today
`extract_text` called on the page, with two known-positive controls **in the same call**, so
the negative result is measured rather than assumed:
```
n200-page.pdf: RAISED ExtractionError code='extractor_extra_missing' msg=extracting '.pdf'
requires the optional 'extract' extra (pip install 'llm-ingestion-okf[extract]');
it is not installed
control.md: OK 48 chars -> '# Kontroll\n\nDette er en kjent-positiv kontroll.\n'
control.csv: OK 54 chars -> '| krav | verdi |\n| --- | --- |\n| N200 5.4.1 | fritt |\n'
```
The probe can succeed; the `.pdf` rejection is real and typed, not a broken probe.
## Probe 2 — the full Door B path
The same page dropped in an inbox next to a `.md` control, through `process_inbox` with the
real guard adapter:
```
persisted : [PersistedFile(source_file='kontroll.md',
path=.../bundle/inbox-kontroll.md, reasons=('clean: no findings',))]
failed : [FailedFile(source_file='n200-page.pdf',
error=ExtractionError("extracting '.pdf' requires the optional 'extract' extra ..."))]
rejected : () quarantined: ()
```
The behaviour is exactly what the contract promises: one bad file does not abort the run, the
control persists and is indexed, and the PDF is reported per-file. Nothing here is a defect —
leg 2 stops because the extra ships no parser, by design.
## Candidates
License, release date and required dependencies read from PyPI metadata; package count and
disk size measured by installing each into a clean virtualenv and listing it.
| Candidate | Version | Released | License | Packages installed | site-packages |
|---|---|---|---|---|---|
| `pypdf` | 6.16.1 | 2026-08-14 | BSD-3-Clause | 1 | 16 MB |
| `pdfminer.six` | 20260107 | 2026-01-07 | MIT | 5 | 32 MB |
| `pdfplumber` | 0.11.10 | 2026-06-15 | MIT | 8 | 56 MB |
| `pymupdf` | 1.28.2 | 2026-08-06 | AGPL-3.0 **or** Artifex commercial | 1 | 71 MB |
Three facts that matter more than the table:
- **`pymupdf` is a licensing decision, not a technical one.** AGPL-3.0 propagates to whoever
installs the extra. This repo is MIT; shipping an AGPL default in an optional extra hands a
consumer a copyleft obligation they did not choose. It is out on that ground alone, and it
performs no better on the sample page than the MIT alternatives.
- **`pdfplumber` pins `pdfminer.six==20260107` exactly.** A hard `==` in a transitive
dependency is a co-installation hazard for any consumer that already uses `pdfminer.six`.
- **`pdfplumber` pulls binary wheels** (`pillow`, `pypdfium2`). Acceptable behind an opt-in
extra; it would not be acceptable in core, and this repo's "exactly one runtime dependency"
rule is untouched either way.
For completeness, the other two types the extra covers: `python-docx` 1.2.0 (MIT, 3 packages,
26 MB, pulls `lxml`) and `openpyxl` 3.1.5 (MIT, 2 packages, 15 MB). Both are materially
simpler problems than PDF — the source formats are already structured XML — so they do not
change the verdict, but they do mean `[extract]` is three parsers, not one.
## What each candidate does to this page
`pdfplumber.extract_text()`, first ~30 lines, verbatim:
```
Kapittel 5 Dimensjonering av vegoverbygning
Utkiling
Utkiling i vegens lengderetning (overganger i undergrunn)
For ikke frostsikret veg skal det bygges en utkiling med ikke telefarlige materialer eller med
isolasjonsmaterialer for å unngå ujevne telehiv ved overgang mellom materialer med forskjellig
telefarlighet. Utkilingen går opp til underkant av forsterkningslaget. For frostsikret veg er det ikke
behov for utkilinger.
Isolasjonsmaterialene skal ligge drenert. Figur 524.1 viser prinsippskisser for utkiling med ikke
telefarlige materialer og isolasjonsplater av XPS. Også skumglass og lettklinker kan benyttes til
utkiling. Utkilingslengde L skal beregnes fra tabell 524.1 hvor frostsikringsdybden ved frostmengde
F danner utgangspunktet for beregning av utkilingslengden. Ved bruk av isolasjonsmaterialer skal
10
det benyttes et nedre frostsikringslag med tykkelse som gitt i tabell 521.2.
Utkilingslengder gjelder både for nyanlegg og utbedring av eksisterende veg.
Tabell 524.1 Krav til helning på utkilingen
Skiltet hastighet, km/t Helning på utkilingen, maks
≤ 50 og g/s veger 1:10
60 og 70 1:15
80 og 90 1: 25
≥ 100 Frostsikret veg forutsettes
Figur 524.1 Utkiling ved overgang bergskjæring/telefarlig grunn eller underbygning
For å redusere fuktopptak i isolasjonsplater av XPS skal tykkelsen være minimum 5 cm. For å unngå å
bruke tynnere isolasjonsplater i avslutningen av en utkiling bør det vurderes å legge isolasjonsplatene
med økende mellomrom for gradvis å redusere isoleringsevnen.
Ved overgang mellom skjæring og fylling i telefarlig jord, bør utkilingen utføres med det materialet
fyllingen er bygget opp av, se figur 524.2. Største utkilingsdybde skal beregnes etter reglene i kapittel
521.
Håndbok N200 Vegbygging (juli 2018) 151
```
That is readable Norwegian technical prose, in reading order, with the table's rows intact as
lines. Two fidelity defects are visible and neither is fatal: the subscript in `F10` breaks
across two lines, and the figure is gone — only its caption survives, because the figure is a
vector drawing, not text. A figure whose content is only in the drawing is lost at leg 2
regardless of parser, and that is worth knowing before anyone promises a complete bundle.
Character counts on the same page: `pypdf` 1819, `pdfminer.six` 1834, `pdfplumber` 1761,
`pymupdf` 1820. **Volume is not the discriminator — order is.**
### Table pairing: the decisive number
`Tabell 524.1` has four requirement rows (speed class → maximum slope). Counting how many
survive with label and value on the same line:
| Parser | Paired rows |
|---|---|
| `pdfplumber.extract_text()` | **4 / 4** |
| `pypdf.extract_text()` | 0 / 4 |
| `pdfminer.six.extract_text()` | 0 / 4 |
| `pymupdf.get_text()` | 0 / 4 |
The three zero-scorers emit all four labels, then all four values, as separate blocks. A
downstream reader — human or model — can only re-pair them by guessing. In a requirements
document, `80 og 90 → 1:25` becoming `80 og 90 → 1:10` is a wrong answer that looks right.
This is why the cheapest candidate is not the correct one.
### What happens to the table as *structure*
Feeding `render_table` needs rows and cells, not lines. Both structural extractors return the
same thing for this table:
```
pdfplumber.extract_tables() -> 1 table, 2 rows x 6 cols
['', 'Skiltet hastighet, km/t', '', '', 'Helning på utkilingen, maks', '']
['≤ 50 og g/s veger\n60 og 70\n80 og 90\n≥ 100', None, None,
'1:10\n1:15\n1: 25\nFrostsikret veg forutsettes', None, None]
pymupdf.find_tables() -> 1 table, 2 rows x 6 cols (byte-identical shape)
```
Four logical rows collapse into one cell each side; three of six columns are empty padding.
`render_table` would faithfully render a two-row table that misrepresents the requirement.
Two independent implementations agreeing on the same wrong shape is the evidence that this is
the document's geometry, not a library bug — N200 draws ruling lines around blocks, not rows.
**So: Vegnormalene are usable as prose, and are not usable as structured concept tables
without a layer this repo does not have.**
## Determinism
Two runs of each configuration, compared byte-for-byte:
```
pypdf identical=True
pdfminer.six identical=True
pdfplumber.text identical=True
pdfplumber.tables identical=True
pdfplumber slice[140:160] identical=True
```
Within one parser version, extraction is deterministic — the bit-exact promise survives.
**Across parser versions it is not guaranteed** (ASSUMED, not measured): `pdfminer.six` uses
date-stamped releases with no stability contract, so any golden fixture built on extracted
text would be pinned to an exact parser version, and a parser upgrade becomes a fixture
migration. That is a real, recurring maintenance cost and it belongs in the decision.
## Denominators
Whole-book run, all 308 pages:
| Measure | Count |
|---|---|
| Pages total | 308 |
| Pages yielding non-empty text | **308 / 308** |
| Lines matching `^Tabell <n>` (tables the document claims) | 149 |
| Table objects detected by `extract_tables()` | 196 |
| Detected tables clean enough for `render_table` unchanged | **45 / 196** |
"Clean enough" is defined in the measurement script, not by eye: at least two rows,
rectangular, no `None` cell, no cell containing a newline. Anything failing that would either
crash or silently misrepresent if handed to `render_table`.
Scope of these denominators: **one handbook, one edition (N200, July 2018)**. They are not a
claim about the N-series as a whole. Nothing here was measured on a scanned or image-only
PDF; every page of this book carries a text layer, and a scanned normal would score 0 and
need OCR — a different project entirely.
## Size of the wiring
The seam is small and already shaped for this. `grep` finds the gate at exactly two source
lines — `extract.py:30` (`_OPTIONAL_EXTENSIONS`) and `extract.py:135` (the raise) — plus two
test files asserting the current rejection (`tests/test_extract.py`,
`tests/test_error_codes.py`). Adding a type means one `_extract_pdf(data)` function in the
same shape as `_extract_csv`, an import probe replacing the frozenset membership test, and
the same error code kept for the still-uninstalled case. The module docstring already
describes this exact transition.
**The wiring is hours. The cost is the choice above it**: which library, which fidelity
promise, which fixtures, and what happens to figures and tables that do not survive.
## What this order deliberately did not do
- No parser implemented, no `[extract]` populated, no error message changed.
- No version bump, no tag, no publish, no guard pin move.
- No writes in any other repo. The PDF stays in scratch.
## Verification log
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | The N200 PDF is public and downloadable from vegvesen.no | VERIFIED | `curl -L -w '%{http_code} %{content_type} %{size_download}'``200 application/pdf 9879066`; magic bytes `%PDF-1.6` |
| 2 | Book has 308 pages; sample is index 150 | VERIFIED | `pypdf.PdfReader(...).pages` length; `makepage.py` |
| 3 | `.pdf` fails with `extractor_extra_missing`, controls pass in the same call | VERIFIED | `probe_registry.py` output, quoted above |
| 4 | Full Door B path reports the PDF as `failed` and persists the control | VERIFIED | `probe_inbox.py` output, quoted above |
| 5 | `[extract]` is declared and empty | VERIFIED | `pyproject.toml` `[project.optional-dependencies] extract = []` |
| 6 | Candidate licenses and release dates | VERIFIED | PyPI JSON API per package (`pypi.org/pypi/<name>/json`) |
| 7 | Package counts and disk sizes | VERIFIED | one clean `python3 -m venv` per candidate; `pip list --format=freeze`, `du -sk` |
| 8 | `pdfplumber` pins `pdfminer.six==20260107` | VERIFIED | PyPI `requires_dist` |
| 9 | Table row pairing 4/4 vs 0/4 | VERIFIED | `pairing.py` / `pairing_mupdf.py`, ground truth read off the rendered page |
| 10 | `extract_tables()` and `find_tables()` return the same 2x6 shape | VERIFIED | `cand_pdfplumber.py`, `cand_pymupdf.py` |
| 11 | Determinism within a version, 5 configurations | VERIFIED | `determinism.py`, sha256 per run |
| 12 | 308/308 pages with text; 149 captions; 196 detected; 45 clean | VERIFIED | `denominator.py`, whole book, ~41 s |
| 13 | Gate seam is two source lines plus two test files | VERIFIED | `grep -rn '_OPTIONAL_EXTENSIONS\|extractor_extra_missing' src/ tests/` |
| 14 | Cross-version output stability is not guaranteed | ASSUMED | inferred from `pdfminer.six` date-stamped versioning; not measured across versions |
| 15 | Figures are lost because they are vector drawings | VERIFIED (this page) | caption present, no figure text in any parser's output; not generalised to the book |
| 16 | Denominators generalise beyond N200:2018 | NOT CLAIMED | one handbook, one edition measured |