A PDF carries no notion of a heading -- a heading in a PDF is a typographic
fact -- so the text stream `pdfplumber` hands the segment proposer has already
thrown away the only evidence there was. The `docx` path never had that problem:
the converter emits ATX headings and `_ATX` cuts on them. Two readers close the
gap, and both are OFF.
`--pdf-headings font` infers a heading from the conjunction this repository
already measured (size above the document's character-weighted body median AND
a bold font name, recall 1.000 / precision 0.846) and emits it as ATX in the
SAME markdown the office path produces, so `_ATX` applies unchanged and no
PDF-only heading grammar exists.
It stays off BY MEASUREMENT, and the measurement is the point of the round:
against the operator's unit worksheet it takes `pdf` from 2 of 8 to 0 of 8,
losing two exact matches. The mechanism of the loss is stated rather than
guessed -- on those documents the outline rule already recovers the document's
own numbered chapters, so a second heading source can only add. Whole-corpus
screen: 25 of 32 `pdf` change, 0 of 5 `docx`, 0 of 2 `xlsx`. The default bundle
is byte-identical before and after this commit (`diff -r`, exit 0).
`--ocr` reads a page as an image when its own text never arrived: empty, or
`(cid:N)` placeholder codes at or above a threshold READ OFF a measured
distribution -- 834 pages over 32 files, 818 at exactly 0.0 and 16 at 0.93 or
above, nothing in between. On the one corpus document with the failure: 95.07 %
cid to 0 %, 44 to 2561 words of four or more letters, 17 to 18 pages with text.
Its engine is an optional dependency group and never a runtime dependency; a
packaging test pins both halves, and without the group every affected file is a
coded rejection (`extractor_ocr_group_missing`) rather than a crash.
Also corrects two stale published facts found while measuring: the README still
said two segmentation rules were on by default after `f6fea13` made it three,
and CLAUDE.md's K2 digest named the round-3 default. The current default is
492 concepts / 944 files, `bdefa679...`.
Report: docs/2026-09-08-k3-runde4-pdf-skrift-og-ocr.md
Co-Authored-By: Claude <claude-opus-5>
24 KiB
K3 round 4, pdf: typography as a heading source, and OCR for the pages whose text never came
Fourth iteration of the per-file-type directive of 2026-09-08 13:05Z. Order
20260908T195728Z-8160520552-from-.claude. HEAD at the start and at the end of
the session is O5's f6fea13 plus this session's commit; the tag on f6fea13
is v0.6.0.
Everything below is measured on the same 43-document corpus and the same
12-document sample as docs/2026-09-08-k3-runde3-per-filtype.md, against the
same reference: the operator's filled unit worksheet, which is local-only and
gitignored. No document title or extracted text from it is reproduced here.
Section 0: what was measured and what was not
Measured. The three reproduction numbers the order names, before any edit.
Two new readers, each red-first, each with a known-negative. The per-page
(cid:N) distribution over every K2 PDF, with both denominators, and the
threshold read off it rather than chosen. What OCR recovers on the one document
that has the failure, through the shipped code path and the shipped instrument.
The 12-document sample re-scored with session 109's criterion under seven
configurations. The whole 43-document corpus under both renderings from one
cached extraction. Two K2 bundles differing only in the new flag.
Not measured. Whether a live model answers better with any of this. Whether
OCR helps a corpus that is not this one -- the (cid:N) denominator that
matters is 1 document of 39. Whether the font reader helps a corpus whose
typography is unlike this one. No o200k token counts (no encoder is installed
and the single-dependency rule does not admit one).
1. The reproduction, before any edit
git rev-parse HEAD -> f6fea13299d92b959f497652ff19a7fd79546281,
git status --short empty, tag v0.6.0 on that commit.
| # | control | expected | result |
|---|---|---|---|
| a | pytest -q |
1427 collected | 1427 passed in 38 s |
| b | okf build ~/okf-test/dokumenter, no flags |
30 | 30 markdown files |
| c | the same folder, --no-table-grid |
43 | 43 markdown files |
All three reproduce. (a) differs in FORM from the PM's figure and not in substance: on a frozen export the same suite is 1424 passed, 1 skipped and 2 export artefacts; in a working tree all 1427 run and pass.
2. The root cause, verified rather than inherited
The order states a diagnosis. Each half was checked against the tree before any code was written, because a premise is an input like any other:
| claim | check | result |
|---|---|---|
| PDF reaches the proposer as a plain text stream | _pdf_pages called page.extract_text() and nothing else |
confirmed |
the docx path emits ATX the proposer already cuts on |
_PANDOC_WRITER = "markdown", never plain |
confirmed |
| the measured best deterministic heading rule is not on the run path | RULE_POPPLER_SIZE_AND_BOLD occurs twice in src/, both in propose.py -- its definition and its entry in RULE_NAMES -- and zero times in tools/ or tests/ |
confirmed: no candidate has ever carried it |
A PDF has no notion of a heading. A heading in a PDF is a typographic fact, and the text stream throws that fact away before anything downstream can read it.
3. Deliverable A: the font reader, behind --pdf-headings font
The mechanism
pdfplumber -- already a dependency -- reports size and fontname per
character. Per line, the reader takes the dominant size and dominant font name
over the line's non-blank characters. A line is a heading when its dominant
size is above the document's character-weighted median size AND its
dominant font name contains bold. The heading sizes of the whole document are
ranked largest-first and that rank becomes the ATX level, clamped at six.
Three choices, each with a reason rather than a taste:
- A conjunction, not a disjunction.
propose.pyalready records the measurement:size AND boldscored recall 1.000 / precision 0.846, and adding weight as a disjunct took precision from 0.786 to 0.524. - The body size is the document's, not the page's. A title page is 100 % heading, so a per-page median compares it with itself and marks nothing.
- The output is ATX in the same markdown the office path produces, so
_ATXreads it unchanged and no PDF-only heading grammar exists. A second grammar would be a second definition of "heading" free to drift from the one five office formats already use. The cost is stated in Honesty limits.
The controls
| control | result |
|---|---|
| a PDF with one font at one size, flag ON | byte-identical to flag OFF (test) |
.docx / .xlsx, flag ON, whole corpus |
0 of 5 and 0 of 2 changed |
| every readable corpus document: same line count, and every differing line is the cached line with a hash run prefixed | 39 of 39 |
That last control is the load-bearing one and it states three things at once:
the round-3 cache is still valid (extract.py is byte-identical between
c3b645b and f6fea13), joining extract_text_lines() reproduces
extract_text() on real documents rather than on the one it was spot-checked
on, and the reader adds markers and changes nothing else.
The first version of that control was wrong and said so. It stripped
^#{1,6} from the marked text and compared: 38 of 39. The one failure is a
corpus document whose own extracted text carries lines beginning with # , and
the stripper removed those too. The corrected control compares line by line.
The reach
Whole corpus, 39 readable documents, one cached extraction per rendering:
| figure | value | denominator |
|---|---|---|
| documents changed | 25 | 32 pdf |
| documents changed | 0 | 5 docx |
| documents changed | 0 | 2 xlsx |
| ATX headings added | 1279 | — |
pdf documents where the reader finds nothing |
7 | 32 |
On the order's spike document the reader reproduces the PM's numbers exactly: 9 of 9 numbered chapters found on 11 pages in 1.1 s, with 4 extra candidates -- two 20 pt title lines and two 16 pt contents lines.
Per pdf position, against the unit worksheet
N is the shipped default (--outline-run 3 --table-grid --unit-fold); G is
that default plus --pdf-headings font. Concepts per document, reference from
the operator's worksheet:
| pos | reference | N | G | verdict |
|---|---|---|---|---|
| 0 | undefined | 1 | 1 | unchanged (the reader finds nothing in (cid:N)) |
| 1 | 15 | 17 | 5 | worse, distance 2 -> 10 |
| 2 | 9 | 9 treffer |
12 | hit lost |
| 4 | 1 | 4 | 11 | worse, distance 3 -> 10 |
| 6 | 6 | 7 | 11 | worse, distance 1 -> 5 |
| 7 | 30 | 33 | 33 | unchanged |
| 8 | 8 | 8 treffer |
16 | hit lost |
| 9 | 4 | 10 | 12 | worse, distance 6 -> 8 |
pdf goes 2 of 8 to 0 of 8. docx (3 of 3) and xlsx (0 of 1) do not
move, which is what the whole-corpus screen predicts: the reader is PDF-only.
The mechanism of the loss, so the row is not just a number. At positions 2 and 8 the outline rule already recovers the document's own numbered chapters, and the font reader ADDS the title page and the contents lines on top of them -- the four extras above. It is not that typography is wrong; it is that on these documents the document's own numbering is already right, and a second heading source can only add.
The variant that asks the question the other way round
If typography competes with numbering rather than adding to it, the comparison
is the font reader with --outline-run 0. Measured, same cache, same criterion:
| configuration | pdf |
docx |
xlsx |
whole sample |
|---|---|---|---|---|
| N, the shipped default | 2 of 8 | 3 of 3 | 0 of 1 | 5 of 12 |
| G = N + font | 0 of 8 | 3 of 3 | 0 of 1 | 3 of 12 |
X = font instead of Arm D (--outline-run 0) |
1 of 8 | 3 of 3 | 0 of 1 | 4 of 12 |
| F = the three round-3 rules on top of N | 3 of 8 | 3 of 3 | 0 of 1 | 6 of 12 |
| H = F + font | 0 of 8 | 3 of 3 | 0 of 1 | 3 of 12 |
X is the one result worth reading twice. It is worse overall, and it is the
first configuration in four rounds to land position 7 exactly on its reference
of 30 -- a document no arm has ever reached. Position 7's worksheet answer
(c) is I, "no arm is closest", so the criterion's LITERAL gate declares that
any treffer there is an instrument failure. Both readings are stated
because the criterion's author never faced this case: the gate's clause at
position 7 is a statement about the three arms the operator rated, and a
configuration that did not exist then reaching the reference count is new
information rather than evidence the instrument broke. It is not enough to make
X a default -- X loses positions 2, 6 and 8 to buy it.
4. Deliverable B: OCR behind --ocr, as an optional group
The threshold, read off a measured distribution
Every page of every K2 PDF, (cid:N) characters as a share of the page's
extracted characters. Denominators: 32 pdf files, 834 pages that produced
text (a page that produced none is not in the table; it is caught by the
other half of the trigger).
| share | pages |
|---|---|
| exactly 0.0 | 818 |
| 0 < s < 1 % | 0 |
| 1 % <= s < 10 % | 0 |
| 10 % <= s < 50 % | 0 |
| 50 % <= s < 90 % | 0 |
| s >= 90 % | 16 |
The distribution is bimodal with nothing between 0.0 and 0.93. The lowest positive share measured is 0.9300 and the highest zero is 0.0.
OCR_CID_SHARE = 0.10, and what happens at plus or minus ten points is not
symmetric. At 0.20 the rule selects the identical 16 pages -- the gap is 93
percentage points wide, so every threshold inside it is the same rule. At 0.00
the rule stops being a rule: share >= 0.0 is true of every page, and all 834
would be read as images. The threshold is therefore bounded by an empty region
on one side and by zero on the other, and 0.10 is the middle of the useful
range rather than a calibrated value. This number is bounded by the corpus,
not by a property of the format, and a corpus with intermediate pages would
have to set it again.
The trigger has two conditions because there are two failures
A page is read as an image when its extracted text is empty or when its
(cid:N) share reaches the threshold. A rule written for one of them leaves
the other exactly where it was: a page with no text layer extracts as the empty
string and has no cid codes at all.
What it recovers
The one document in the corpus with the failure, through the shipped code path
(extract_text(..., ocr=True)) and measured with the shipped instrument
(tools/okf_cid_measure.measure, imported rather than re-implemented):
| before | after | |
|---|---|---|
| characters | 217 470 | 34 534 |
(cid:N) characters |
206 758 | 0 |
| cid share | 95.07 % | 0.00 % |
| words of 4+ letters | 44 | 2561 |
| pages that produced text | 17 of 18 | 18 of 18 |
| wall time | — | 65.3 s, 3.6 s/page |
The before column reproduces the published known-positive of
docs/2026-09-04-cid-andel-k2.md exactly on both figures (95.07 %, 44 words).
The trigger fired on 16 of the 17 pages that had text, and on the one page that had none. The page it did not fire on carries 16 characters and no cid codes -- text arrived, just very little of it. A length rule would catch it and there is no measurement for one, so it is reported rather than repaired.
Position 0 moves from "the text never came" to segmented
| configuration | concepts |
|---|---|
| position 0, shipped default, no OCR | 1 (no plan at all; the proposer finds no boundary) |
position 0, shipped default, --ocr |
4 |
position 0, the three round-3 rules, --ocr |
3 |
The cell still cannot cross, and that is the reference's doing rather than
the reader's. The worksheet's answer for position 0 is that no unit can be
decided, so the criterion makes treffer unreachable there by construction and
records the dominant error instead. The label moves from for grovt to for fint; the pdf row is 8 positions either way.
Why this engine, and what it costs
rapidocr on onnxruntime, in an optional group named ocr and never in
project.dependencies -- a packaging test asserts both halves. Install
measured at 31 s into an empty venv on this machine.
Docling was measured first and is out on a platform fact rather than a
preference: it needs torch, and torch stopped publishing macOS x86_64 wheels
after 2.2.2, with no transformers version inside Docling's own window that
works against that one.
The models ship inside the rapidocr wheel -- the run log names files under
site-packages/rapidocr/models/ and no download step runs -- which matters more
here than it usually would: this library's network access is an explicit
per-run opt-in, and an engine that fetched a model on first use would walk
straight through that gate.
Without the group installed, --ocr is a typed refusal with its own code,
extractor_ocr_group_missing, distinct from extractor_extra_missing because
the [extract] extra can be fully installed with only the engine absent. In a
corpus run it is a coded rejection per file, so merged + coded rejections == N
still holds; a test pins that.
5. The default question, answered on numbers
--pdf-headings font does not become the default. The order's condition is
that it be at least as good on EVERY pdf position and regress nothing else.
It is worse on five positions, it loses two treffer cells outright, and the
label row goes 2 of 8 to 0 of 8. DEFAULT_PDF_HEADINGS = False, with a test.
--ocr can never become a default, and that is by construction rather than
by measurement: the engine is an optional dependency, so a default that needed
it would make an ordinary install fail on the first scanned page.
6. What round 3 left standing
6.1 pdf position 1 (17 against 15). The font axis is the first one not
tried here, and it is worse: 17 -> 5, distance 2 -> 10. Declined for a
third round, now on three axes.
6.2 positions 6, 7, 9. Position 6 unchanged (7). Position 7 unchanged at 33 under the default, and reached exactly at 30 under X -- see § 3. Position 9 unchanged at 10 under the default, 8 under X. None of them crosses under a configuration that does not lose more elsewhere.
6.3 position 0. Measured in § 4: 1 -> 4 concepts with --ocr, and the cell
still cannot cross because the reference says no unit can be decided.
6.4 xlsx position 3. Untouched: the font reader changes 0 of 2
spreadsheets. The reading used here is the criterion as written -- a
rule:sheet-section concept is neither Tabell linje N nor
rule:table-block/rule:table-grid, so the eleven cost groups count as
ordinary units and the distance is 1, for fint. Under the opposite
reading the same plan scores distance 11. Round 3 stated both; this round
changes neither and picks the same one, out loud.
6.5 the docx cell, re-measured on the current default. Round 3's docx
0 of 3 was measured on a default that no longer exists -- --table-grid
entered the default in f6fea13. On this HEAD the shipped default scores
docx 3 of 3, as expected. Reported, not reversed.
6.6 the order's premise here is false, and it is reported rather than acted
on. The order says K3-3 § 0 promises "a third K2 bundle on the new default"
and that the report "contains the result nowhere". It contains it:
docs/2026-09-08-k3-runde3-per-filtype.md has a section titled "The K2 bundle
on the new default" whose table carries the row 517 concepts / 969 files /
42c8646d…. Nothing was struck. What IS true, and is the useful half, is
that 42c8646d… names the round-3 default (Arm D + Arm F) and the default
moved again in f6fea13, so the digest is stale. Section 8 supersedes it.
7. Per file type, after round 4
The 12-document sample, concepts per configuration, from one cached extraction per rendering. The instrument was verified before it was read: re-run on the archive plans the operator rated, session 109's criterion returns LITERAL 7/12, DOMINANCE 11/12 -- its published gates.
| pos | type | reference | N (default) | G (+font) | F (round 3) | H (F+font) |
|---|---|---|---|---|---|---|
| 0 | — | 1 | 1 | 1 | 1 | |
| 1 | 15 | 17 | 5 | 17 | 5 | |
| 2 | 9 | 9 | 12 | 9 | 11 | |
| 3 | xlsx | 11 | 1 | 1 | 12 | 12 |
| 4 | 1 | 4 | 11 | 1 | 8 | |
| 5 | docx | 3 | 3 | 3 | 3 | 3 |
| 6 | 6 | 7 | 11 | 7 | 11 | |
| 7 | 30 | 33 | 33 | 33 | 33 | |
| 8 | 8 | 8 | 16 | 8 | 16 | |
| 9 | 4 | 10 | 12 | 6 | 8 | |
| 10 | docx | 2 | 2 | 2 | 2 | 2 |
| 11 | docx | 1 | 1 | 1 | 1 | 1 |
The label row:
| type | n | Arm B | N (default) | G (+font) | F (round 3) | H (F+font) |
|---|---|---|---|---|---|---|
pdf |
8 | 2 | 2 | 0 | 3 | 0 |
docx |
3 | 0 | 3 | 3 | 3 | 3 |
xlsx |
1 | 0 | 0 | 0 | 0 | 0 |
| whole sample | 12 | 2 | 5 | 3 | 6 | 3 |
One divergence between the rebuilt arms and the archive, named rather than
smoothed. Arms B, D and E were rebuilt on this tree rather than read from the
archive, and they reproduce the archive's cells on 11 of 12 positions. Position
3 differs, and the reason is a known extraction change rather than an arm: the
spreadsheet writer moved in 56ae274, after the archive plans were written. It
does not touch the N-versus-G comparison, which reads one cache with one code.
8. The K2 control
Two K2 bundles from the same 43-document corpus, same --bundle-id k2-trinn1-20260903 --okf-version 0.2, the default epoch stamp, differing only
in the new flag. Both exit 0 and both report the conservation identity.
| default | --pdf-headings font |
|
|---|---|---|
merged + coded rejections |
39 + 4 = 43 = N |
idem |
| rejection codes | extractor_empty_pdf 1, extractor_unknown 3 |
idem |
| concepts | 492 | 359 |
| markdown files | 944 | 595 |
| content digest | bdefa679957edd7d… |
a751b48f517f3ed4… |
| wall time | 819.9 s | 814.7 s |
| concepts reachable from the index tree | 492 of 492 | 359 of 359 |
The digest a consumer should compare against from today is
bdefa679957edd7d…, at 492 concepts in 944 files. It supersedes round 3's
42c8646d… (517 / 969), which named the round-3 default before --table-grid
joined it in f6fea13. The command is the one above with no flags.
And the ranking, on the S7 control question, --k 12:
| default | --pdf-headings font |
|
|---|---|---|
| priced concept present | yes, title Prissammenstilling {#sheet-1} |
yes, same title |
| candidate rank, no vocabulary | 434 of 492 | 298 of 359 |
candidate rank, --cost-vocabulary |
159 of 492 | 151 of 359 |
| delivered | no | no |
| payload bytes, vocabulary on | 123 681 | 95 446 |
Nothing gets worse and nothing gets better: the flag moves the priced concept from rank 159 of 492 to 151 of 359 and it is delivered in neither. The bundle it produces is 133 concepts smaller, which is a large change to buy nothing.
One thing in that table is not about this flag and the operator should read
it. Round 3 published the same question against a 629-concept Arm B bundle:
candidate rank 96 of 629 with the vocabulary on, delivered at rank 10.
On the shipped default it is rank 159 of 492 and not delivered at all. That
is the default move of 47f6619/f6fea13 showing up on the retrieval side for
the first time, not this round's flag -- the two columns above are the
controlled comparison, and they differ only in the flag. It is one question and
one bundle, and it is written down rather than left for the next round to
rediscover.
9. Tests
1427 -> 1441. Fourteen new, and which of them were red first is stated rather
than counted -- the first run of the new file was 8 failed, 1 passed, and
the second, after the flags reached okf build, 3 failed, 9 passed:
- Red before the implementation (11): the ATX emission and its level
mapping; the proposer segmenting it with no new rule; the known-negative (a
PDF with no font variation is byte-identical with the flag on); the locator
indexing the marked text; the OCR refusal without the group; OCR recovering a
page with no text; OCR leaving a page that has text alone; the cid threshold
and the two-condition trigger; the flag reaching
okf build; the build-layer default control; the coded-rejection accounting. - Green by construction, and named as such (3): the "off by default"
extraction control, the new code's entry in the error-code registry suite, and
the
ocrgroup's pin. All three were written after the code they describe.
One fixture is new -- tests/fixtures/font-heading-krav.pdf, a hand-laid
two-font PDF regenerated by tests/fixtures/make_fixtures.py. It gets its own
builder rather than a parameter on the existing one: a second font object
changes the object numbering, and every other PDF fixture's value is that its
bytes have not moved. Regenerating writes all nine fixtures and git status
shows only the new one.
No shipped expectation moved. The README's segmentation section did move, and
that is a correction rather than a change: it still said two rules were on
by default after f6fea13 made it three.
Reproducing
# 1. The three reproduction numbers.
pytest -q
okf build ~/okf-test/dokumenter --bundle /tmp/b --bundle-id t --okf-version 0.2
okf build ~/okf-test/dokumenter --bundle /tmp/b2 --bundle-id t --okf-version 0.2 \
--no-table-grid
# 2. The per-page cid distribution, both denominators.
python3 /tmp/k3r4/cidpages.py # 32 pdf files, 834 pages with text
# 3. The two renderings of the whole corpus, one cached extraction each.
python3 /tmp/k3r4/font43.py # writes /tmp/k3r4/font43/
# the control is line-by-line: same line count, every differing line is the
# cached line with a hash run prefixed -> 39 of 39
# 4. The 12-sample, scored with session 109's own criterion.
python3 /tmp/k3r4/plans4.py && python3 /tmp/k3r4/score4.py
python3 /tmp/k3r4/score4b.py # the X and Y variants
# 5. What OCR recovers. Needs the optional group in a venv of its own:
# uv pip install rapidocr onnxruntime pypdfium2 pdfplumber
python3 /tmp/k3r4/ocr91.py
# 6. The K2 pair. Each build is a foreground nohup with an rc file and a
# bounded poll -- the Bash tool caps at 600 s and a build takes ~15-25 min.
/tmp/k3r4/k2.sh default
/tmp/k3r4/k2.sh font --pdf-headings font
Honesty limits
- A font-inferred heading is indistinguishable from a declared one in the
artifact. Emitting ATX is what makes
_ATXapply unchanged, and it is also what makes the plan sayrule:headingfor a heading no document ever declared.RULE_POPPLER_SIZE_AND_BOLDwas deliberately NOT assigned to it: that name records a poppler measurement on a path that cannot ship, and putting it on a pdfplumber inference would make the artifact claim a provenance it does not have. The cost is real and it is the strongest argument against this design; it is affordable only because the flag is off. - n = 12, one rater, one reference. Per file type the denominators are 8, 3
and 1. The
xlsxrow is a0/1, which is not a rate. - The OCR denominator is 1. One document of 39 has the
(cid:N)failure and one has no text layer at all. Every claim about what OCR buys is a claim about those two documents. - OCR output is not deterministic across versions. It is a model's reading
of an image at a rendering resolution. It is reproducible against the model
version and
OCR_DPIit was produced with, and no dependency pin can promise more. Theocrgroup therefore uses ranges and says why. --ocrand--pdf-headings fontdo not compose on an OCR'd page. The engine reports text, not fonts, so a recovered page carries no typography and can contribute no heading. Both flags together are valid and each does its own work on its own pages.- The cid threshold is bounded by a corpus, not by the format. Every value between 0.01 and 0.93 selects the same 16 pages here. A corpus with pages in between would need the number set again, from its own distribution.
- The 43-document corpus and the 12-document sample are the same corpus. The sample is drawn from it, so the whole-corpus screen is not an independent check of the sample result.