feat(propose,cli): typography as a reserve, and the two of our own numbers it took to measure it

K3 round 5. Three questions, three answers, and two of them correct a figure
this repository published.

RETRIEVAL FIRST, because it could have reversed a default. hit@8 over the six
questions on BOTH K2 bundles -- Arm B at 629 concepts and the shipped default
at 492 -- is 5 of 6 with ranks 1,1,1,1,1,- on each, so 0 of 6 rows lost. The
order's rule reverses `--unit-fold` at >= 2 of 6; it does not fire, and the
default stands. The gold sets shrink (49->26, 20->17, 43->36, 11->18) while
every rank holds at 1, which is the fold merging concepts rather than removing
a document from the top.

TWO PUBLISHED NUMBERS CORRECTED, both ours. The S7 candidate ranks 96 of 629
and 159 of 492 were measured with the cost vocabulary passed to
`concept_scores` and NOT to `document_scores`, while `build_payload` passes it
to both; scored the way the shipped payload scores it, the same concept is 10
of 629 and 19 of 492. And round 4 attributed its non-delivery to the default
move -- measured here, it is not delivered on the Arm B bundle either, for a
different reason (knapsack eviction at 68 046 bytes of a 120 000 budget, versus
`below_k`). That column had been inherited from round 3's own build, never
re-measured.

`--pdf-headings font-reserve`, OFF, and the hypothesis behind it is falsified
by its own condition rather than by a score: position 7, the one position the
flag exists for, has THREE outline runs, so the reserve is silent there at
every minimum. It changes 0 of 12 cells on the reference and reaches 4 of 39
corpus documents, none of them rated. Built anyway because it was authorised
and because the condition is now measured rather than assumed. The predicate
lives in one place (`propose.heading_reserve_applies`) and the door receives it
as a callable, like `gate`: a plan indexes the exact string it was proposed
against, so a reserve firing on one side only would make every document it
touches a coded rejection.

The `xlsx` re-reading is confirmed on the artifact -- 11 `rule:sheet-section`
units plus 1 `rule:table-block` ingress -- but the number alone makes the cell
worse (distance 1 -> 2), because the criterion counts that ingress as a table
that should have been merged. A hit needs both halves ratified, and the
reference is the operator's.

`--sheet-section-rows` as a default: three cells better and none worse on the
twelve positions, but the K2 control moves -- row 1's gold document splits 1 ->
12 concepts and its best concept ranks 2 instead of 1. Condition not met,
default not moved.

Default build byte-identical before and after (`diff -r`, 30 md files).
Suite 1441 -> 1449; three of the eight were red first.

Report: docs/2026-09-08-k3-runde5-hitat8-og-skriftakse.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-09 00:25:51 +02:00
commit b01492b7f5
10 changed files with 743 additions and 7 deletions

View file

@ -309,7 +309,7 @@ and fixtures, never code.
`docs/2026-09-08-k3-arm-f-mot-enhetsarket.md`,
`docs/2026-09-08-k3-runde2-per-filtype.md` and
`docs/2026-09-08-k3-runde3-per-filtype.md`.
- **Two PDF READER flags, both off, and they sit BEFORE every segmentation
- **Three PDF READER flags, all off, and they sit BEFORE every segmentation
flag** -- an arm changes how the proposer cuts a text, these change what the
text says. `--pdf-headings font` infers a heading from typography (dominant
font size above the document's character-weighted body median AND a bold font
@ -335,6 +335,31 @@ and fixtures, never code.
become a default -- an optional dependency in the default path would make an
ordinary install fail on the first scanned page. Report:
`docs/2026-09-08-k3-runde4-pdf-skrift-og-ocr.md`.
**`--pdf-headings font-reserve`** is the third value on that same option
(`none`, `font`, `font-reserve` — three answers to one question, so no caller
can ask for two at once): the same typographic rule applied ONLY where Arm D's
outline gate admits no run at all, typography as a second heading source where
there is no first one. The condition lives in ONE function
(`propose.heading_reserve_applies`) that the proposer and the door both
consult, the door receiving it as a callable the way it already receives
`gate` — a plan indexes the exact string it was proposed against, so a reserve
firing on one side only would turn every document it touches into a coded
rejection. It reads the gate AS CONFIGURED, so at `--outline-run 0` it is
unconditional and equals round 4's "font instead of Arm D". **Off, and the
measurement is that it changes nothing measurable:** on the twelve-position
reference it alters **not one cell** — the five positions where it fires are
one PDF whose glyphs carry no ToUnicode mapping and four office documents the
PDF reader never touches — and the position it was built for has **three**
outline runs, so the reserve is silent there by construction. Its reach is
real but unrated: **4 of 39** readable corpus documents, none in the sample.
Report: `docs/2026-09-08-k3-runde5-hitat8-og-skriftakse.md`, which also
corrects two of this repository's own published figures — the S7 candidate
ranks (96 of 629 / 159 of 492 were measured with the cost vocabulary reaching
only half the ranker; consistently scored they are **10 of 629** and **19 of
492**) and round 4's attribution of that concept's non-delivery to the default
move (it is not delivered on the Arm B bundle either, by a different
mechanism). hit@8 over the six published questions holds at **5 of 6 on both
K2 bundles**, so the default move cost the retrieval side nothing.
- Consume a bundle: `okf consume <bundle> --question "<q>"
[--k N] [--limit N] [--out PATH] [--ref IDENTITY]` — the **pre-pass**
`docs/consumption-contract.md` § 1 defines, and the only reading direction

View file

@ -132,11 +132,12 @@ here.
Separate from the six above, and they sit before every one of them: a
segmentation flag changes how the proposer cuts a text, these change what the
text says. **Both are off by default.**
text says. **All three are off by default.**
| flag | what it does | measured |
|---|---|---|
| `--pdf-headings font` | a PDF carries no heading markup, so one is inferred from typography — a line whose dominant font size is above the document's character-weighted median AND whose dominant font name says bold — and emitted as an ATX heading in the same markdown the office path produces, so the existing heading rule reads it | on a tender PDF: **9 of 9** numbered chapters found, plus 4 extra candidates. Whole corpus: **25 of 32 `pdf`** change, **0 of 5 `docx`**, **0 of 2 `xlsx`**. **Off by measurement:** against the operator's unit worksheet it takes `pdf` from **2 of 8 to 0 of 8**, losing two exact matches, because on those documents the outline rule already found the chapters and a second heading source can only add |
| `--pdf-headings font-reserve` | the same typographic rule, applied ONLY to a document whose own numbering the outline arm finds no run of — typography as a second heading source where there is no first one, never on top of one. Three values of one option (`none`, `font`, `font-reserve`), so no caller can ask for two at once | reaches **4 of 39** readable corpus documents (10 of 32 `pdf` admit no outline run; 4 of those render differently at all). **Off by measurement, and the measurement is that it changes nothing measurable:** on the operator's twelve-position unit worksheet it alters **not one cell** — the five positions where it fires are one PDF whose glyphs carry no ToUnicode mapping and four office documents the PDF reader never touches. The position it was built for numbers its own chapters, so the reserve is silent there by construction |
| `--ocr` | read a PDF page as an image when its own text never arrived: the page extracts empty, or as `(cid:N)` placeholder codes at or above 10 % of its characters. Needs the optional `ocr` group | on the one corpus document with the failure: **95.07 % → 0 %** cid, **44 → 2561** words of four or more letters, 17 → **18** pages with text, 3.6 s/page. Whole corpus: **16 of 834** pages qualify, in **1 of 32** files |
```

View file

@ -0,0 +1,388 @@
# K3 round 5: hit@8 on both K2 bundles, and typography as a reserve
**Date:** 2026-09-08 · **HEAD at start:** `53d5c74` · **Suite at start:** 1441
Round 4 shipped two PDF readers and left three questions behind it. This round
answers the retrieval one first, because it is the one that could have reversed
a default, and then measures the segmentation candidate the round-4 report
pointed at. Two of the three answers are negative, and the negative ones are the
informative ones.
## 0. What this round found, in one paragraph
The default move did **not** cost anything on the retrieval side: hit@8 is 5 of
6 on both K2 bundles, with the same five ranks at 1 and the same row missing on
both. The decision rule therefore does not fire and `--unit-fold` stays in the
default. The font **reserve** — typography read only where the outline arm finds
no run — was built, is off, and must stay off: it cannot reach the position it
was designed for, because that position numbers its own chapters. And the
`xlsx` re-reading works, but only if BOTH halves of it move together; the number
alone makes the cell worse.
Two published figures are corrected here rather than left standing. Both are
this repository's own.
---
## 1. Reproduction of the starting state
Every figure below was measured on `53d5c74` with a clean tree.
| what | expected | measured |
|---|---|---|
| `git rev-parse HEAD` | `53d5c74` | `53d5c74c96b4316dcb3380227d72be908e1be270` |
| `git status --short` | empty | empty |
| `pytest -q` | 1441 | **1441 passed** |
| `ruff check src tests tools` | clean | `All checks passed!` |
| `mypy src` | clean | `Success: no issues found in 21 source files` |
| `okf build <folder>`, no flags | 30 md | **30**, exit 0, 0/5 rejected |
| same, `--keep-table-heading --sheet-section-rows --drop-wrapped-outline` | 52 md | **52**, exit 0, 0/5 rejected |
| unpushed commits | 4 | **4** (`47f6619`, `efa92ce`, `f6fea13`, `53d5c74`) |
| tag `v0.6.0` | on `f6fea13` | **on `f6fea13`**, one commit behind HEAD |
Both cached bundles were counted before use rather than trusted: the Arm B tree
holds **1108** md files and round 4's default artifact **944**, so neither was
rebuilt.
---
## 2. hit@8 on both K2 bundles
One instrument (`tools/okf_consume_measure.py`), one gold set, one `--k 8`, run
against both bundles. The gold set is local-only and neither the questions nor
the gold documents appear here; the row number carries the identity, as in
`docs/2026-09-07-okf-konsumskill-maaling.md`.
**Bundle 1 (Arm B):** 629 concepts, 1108 files, every arm flag off.
**Bundle 2 (default):** 492 concepts, 944 files, digest `bdefa679957edd7d…`,
default flags and default epoch stamp.
Both bundles are the same 39-document readable set, so `documents` is 39 in
both and the chance baselines are directly comparable.
| row | gold concepts (B) | hit (B) | rank (B) | considered (B) | spent (B) | chance (B) | gold concepts (D) | hit (D) | rank (D) | considered (D) | spent (D) | chance (D) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 1 | yes | **1** | 629 | 76 897 | 0.013 | 1 | yes | **1** | 492 | 43 288 | 0.016 |
| 2 | 49 | yes | **1** | 629 | 21 403 | 0.479 | 26 | yes | **1** | 492 | 18 458 | 0.354 |
| 3 | 20 | yes | **1** | 629 | 26 282 | 0.229 | 17 | yes | **1** | 492 | 28 347 | 0.247 |
| 4 | 43 | yes | **1** | 629 | 19 818 | 0.434 | 36 | yes | **1** | 492 | 21 125 | 0.458 |
| 5 | 11 | yes | **1** | 629 | 22 459 | 0.132 | 18 | yes | **1** | 492 | 17 866 | 0.259 |
| 6 | 5 | **no** | | 629 | 20 282 | 0.062 | 5 | **no** | | 492 | 33 113 | 0.079 |
| **total** | | **5 of 6** | | | | 1.35 expected by chance | | **5 of 6** | | | | 1.41 expected by chance |
**0 of 6 rows lost.** The decision rule in the order fires at `>= 2 of 6`, so it
does not fire: `--unit-fold` stays in the default, and no `DEFAULT_UNIT_FOLD`
was touched. This reproduces the PM measurement of 21:20Z exactly, including
the ranks.
**The gold sets change size while the rank does not.** 49→26, 20→17, 43→36 and
11→18 concepts between the two bundles, and every one of those rows still ranks
1. That is the fold merging concepts rather than removing a document from the
top — which is the property the fold was defended on, measured here on the
retrieval side for the first time.
Both known-negative controls behave identically on both bundles: 2 of 6 tokens
reaching nothing on the planned negative, 4 of 6 on the lexically disjoint one,
8 excerpts delivered in each case. The negatives are a control on the question,
not on the ranker, and they did not move.
### 2.1 The S7 control row, and a correction to how it was measured
A separate question with a separate answer, measured with
`--cost-vocabulary --k 12` on both bundles:
| | Arm B (629) | default (492) |
|---|---|---|
| priced concept present | yes | yes |
| **candidate rank (whole ranking)** | **10 of 629** | **19 of 492** |
| rank among concepts that survive the gate | 10 of 280 | 19 of 280 |
| delivered at `--k 12` | **no** | **no** |
| why not | `over_budget_after_knapsack` — inside the shortlist, evicted; the excerpt weighs **68 046** of a 120 000 budget | `below_k` — outside the shortlist at k = 12 |
| excerpts delivered | 11 | 12 |
| budget spent | 84 953 | 51 810 |
**Two published numbers are corrected by this row.**
**First, the ranks.** Round 3 and round 4 published this concept at *96 of 629*
and *159 of 492*. Both are reproducible and both name a mixed configuration:
the measuring script (`/tmp/k3r4/rank4.py`) passes `cost_vocabulary=True` to
`concept_scores` but calls `document_scores(root, question)` with the vocabulary
left off, while `build_payload` passes it to both. Scored the way the shipped
payload scores it, the same concept is **10 of 629** and **19 of 492**. The
published figures are not wrong about their own arithmetic; they are answers
about a ranking the library never emits.
**Second, the attribution.** Round 4 wrote that the non-delivery "is the default
move of `47f6619`/`f6fea13` showing up on the retrieval side for the first
time". Measured here on the Arm B bundle with today's code, the concept is **not
delivered there either**. The default move is not the cause; the two bundles
fail to deliver it for two *different* reasons, and only one of them is about
bundle size. The knapsack eviction on Arm B is the case `--reserve-top-rank`
was built for and cannot fix, because the reservation is for rank one and this
is rank ten — which is exactly what the flag's own documentation already says.
What round 4 could not have known is that the Arm B column was never measured;
it was carried over from round 3's own build. Re-running it was the whole
correction.
---
## 3. The font reserve
Round 4 measured the font reader two ways and shipped neither: on TOP of Arm D
it took `pdf` from 2 of 8 to 0 of 8, and INSTEAD of Arm D (`--outline-run 0`) it
scored 1 of 8 while landing position 7 on 30 = its reference count. The
hypothesis this round was asked to test: use typography only where Arm D's
outline gate admits no run, and position 7 is bought without paying positions 2
and 8.
**The hypothesis is falsified, and it is falsified by the condition itself
rather than by a score.**
### 3.1 Where the reserve can fire, on the 12-position reference
Measured before any code was written, on the cached round-3 rendering:
| position | type | outline runs at minimum 3 | reserve |
|---|---|---|---|
| 0 | pdf | 0 | **fires** |
| 1 | pdf | 2 (last run 7 long) | silent |
| 2 | pdf | 2 (last run 9) | silent |
| 3 | xlsx | 0 | fires (no effect: the font reader is PDF-only) |
| 4 | pdf | 1 (last run 5) | silent |
| 5 | docx | 0 | fires (no effect: PDF-only) |
| 6 | pdf | 1 (last run 3) | silent |
| **7** | **pdf** | **3 (last run 10)** | **silent** |
| 8 | pdf | 2 (last run 8) | silent |
| 9 | pdf | 3 (last run 6) | silent |
| 10 | docx | 0 | fires (no effect: PDF-only) |
| 11 | docx | 0 | fires (no effect: PDF-only) |
Position 7 — the one position the flag exists for — has **three** outline runs,
the last of them ten entries long. No reading of "no run at all" reaches it: it
is a run at minimum 3, at minimum 1, and at every minimum up to 10. The 1 of 8
that round 4 measured under `--outline-run 0` came from *removing* Arm D on that
document, not from adding a second source where there was none.
### 3.2 What it scores
| pos | type | ref \|F\| | default | **+ reserve** | + font everywhere |
|---|---|---|---|---|---|
| 0 | pdf | undefined | for grovt (U1) | for grovt (U1) | for grovt (U1) |
| 1 | pdf | 15 | for fint (U17, d2) | for fint (U17, d2) | for grovt (U5, d10) |
| 2 | pdf | 9 | **treffer** (d0) | **treffer** (d0) | for fint (U12, d3) |
| 3 | xlsx | 11 | for grovt (U0, S1, d12) | for grovt (U0, S1, d12) | for grovt (U0, S1, d12) |
| 4 | pdf | 1 | for fint (U4, d3) | for fint (U4, d3) | for fint (U11, d10) |
| 5 | docx | 3 | **treffer** (d0) | **treffer** (d0) | **treffer** (d0) |
| 6 | pdf | 6 | for fint (U7, d1) | for fint (U7, d1) | for fint (U11, d5) |
| 7 | pdf | 30 | for fint (U33, d3) | for fint (U33, d3) | for fint (U33, d3) |
| 8 | pdf | 8 | **treffer** (d0) | **treffer** (d0) | for fint (U16, d8) |
| 9 | pdf | 4 | for fint (U10, d6) | for fint (U10, d6) | for fint (U12, d8) |
| 10 | docx | 2 | **treffer** (d0) | **treffer** (d0) | **treffer** (d0) |
| 11 | docx | 1 | **treffer** (d0) | **treffer** (d0) | **treffer** (d0) |
| | | | **5 of 12** · pdf 2 of 8 · docx 3 of 3 · xlsx 0 of 1 | **5 of 12**, identical cell for cell | 4 of 12 · pdf 1 of 8 |
**The reserve changes not one cell of the twelve.** It fires on five positions
and alters none of them: position 0's glyphs carry no ToUnicode mapping, so its
font rendering is byte-identical to its plain one, and the other four are office
documents the PDF reader never touches. The default column reproduces round 4
exactly (pdf 2 of 8, docx 3 of 3, xlsx 0 of 1), which is what makes the
instrument calibrated rather than merely re-run.
The acceptance criterion the order set — *position 7 hits 30 without losing 2, 6
and 8* — is **unreachable by construction**, not merely unmet. And position 7's
`(c)` in the reference is `I`, so the criterion's literal gate forbids a hit
there in any case; both readings stood in round 4 § 3 and both stand here.
### 3.3 What the reserve reaches on the whole corpus
The twelve positions are a sample of 43. Over the full cached corpus:
| | count |
|---|---|
| readable documents | 39 |
| of which pdf | 32 |
| pdf admitting no outline run at minimum 3 | **10** |
| of those, whose font rendering differs at all | **4** |
| of those, whose plan changes | **4** (1→4, 1→2, 0→2 and 5→6 entries) |
So the flag is not inert: it acts on **4 of 39** readable documents. But **none
of those four is in the rated sample**, so there is no reference count for any
of them, and this round cannot say whether the four changes are improvements.
A flag with a reach and no measurable quality signal is exactly a flag that
stays off.
### 3.4 What was built anyway, and why
`--pdf-headings font-reserve`: a third value on the option round 4 introduced,
so `none`, `font` and `font-reserve` are three answers to one question and no
caller can ask for two at once.
The condition lives in one function, `propose.heading_reserve_applies(text, *,
outline_run)`, and the proposer and the door both call it. That is not tidiness:
a plan indexes the exact string it was proposed against (`text_sha256`), so a
reserve that fired on one side and not the other would turn every document it
touches into a coded rejection. The door receives it as a **predicate**
(`heading_reserve: Callable[[str], bool] | None`), the same shape `gate` already
has, because the condition is the proposer's outline grammar and Door B does not
own that grammar — a direct import would point the dependency against the
layering.
The reserve reads the gate **as configured**. At `--outline-run 0` the gate
admits nothing at all, so the reserve becomes unconditional; that combination is
round 4's "font instead of Arm D" at 1 of 8, and a caller reaching it does so
deliberately. This is stated in the code and pinned by a test.
**Control:** the default build of the operator's folder is **byte-identical**
before and after the change (`diff -r`, 30 md files either way), and the suite
went 1441 → **1449** with eight new tests, three of which were red first for
three different reasons (missing predicate, wrong expected segmentation, wrong
report field).
---
## 4. The `xlsx` reference count, re-read as 12
The operator's re-reading is that the priced worksheet holds **11 numbered cost
groups plus one leading block of real prose that has to live somewhere**, so
11 + 1 = 12. Measured on the extraction, under `--sheet-section-rows`:
| unit | rule | count |
|---|---|---|
| the leading block | `rule:table-block` | 1 |
| the cost groups | `rule:sheet-section` | 11 |
| **total** | | **12** |
The rule produces exactly 12 entries, and the split is 11 + 1 in the shape the
re-reading describes. **The re-reading is confirmed on the artifact.**
**But the number alone does not deliver the hit, and this is the finding.** The
criterion as written treats a table-derived concept under `b = M` as *S* — a
table that should have been merged into a heading — not as a unit. So:
| reading of the reference | U | T | S | distance | label |
|---|---|---|---|---|---|
| \|F\| = 11, leading block counted as S (rounds 3 and 4) | 11 | 0 | 1 | **1** | for fint |
| \|F\| = 12, leading block still counted as S | 11 | 0 | 1 | **2** | for grovt |
| \|F\| = 12, leading block counted as the 12th unit | **12** | 0 | 0 | **0** | **treffer** |
Changing the number without changing what the criterion counts makes the cell
**worse**, from distance 1 to distance 2. `xlsx` reaches **1 of 1** only when
both halves move together.
**This is the operator's reference, not mine.** What is measured here is that
the artifact contains 11 + 1 units in exactly the shape the re-reading names,
and that a hit requires ratifying the second half too. Rounds 3 and 4 both used
the reading "11" and both reported distance 1, so nothing published so far
depends on the outcome either way.
---
## 5. `--sheet-section-rows` as a default: what the 12 positions say
Because § 4 raised it, the D1/D3 configuration was scored against the shipped
default on all twelve positions:
| pos | default | + `--keep-table-heading --sheet-section-rows --drop-wrapped-outline` |
|---|---|---|
| 3 (xlsx) | for grovt, d12 | **for fint, d1** (treffer, d0 under the re-read reference) |
| 4 (pdf) | for fint, d3 | **treffer, d0** |
| 9 (pdf) | for fint, d6 | **for fint, d2** |
| all other nine | — | identical, cell for cell |
**Three positions improve, none regresses**, taking `pdf` from 2 of 8 to 3 of 8
and leaving `docx` at 3 of 3. That is the strongest segmentation result this
round has, and it is **not acted on here**: the order makes a default move
conditional on the K2 control as well, and it is a fourth change to every
consumer's bytes in two days. The K2 control was run for exactly that
reason and it does not hold (§ 7), so the default stays where it is.
---
## 6. What still stands, unchanged
- **The prefix over-matches the other way** (round 3 finding, untouched since):
a compound word matches a four-character prefix, df 159 of 270. It belongs to
the rarity weight and was not this round's work.
- **`pdf` position 1** (17 against a reference of 15) is now refused on four
axes: number, outline, typography, and the reserve — the reserve is silent
there, because that document has two outline runs.
- **vegnormal-okf FUNN 1 and FUNN 2** are still not investigated in this repo.
- **`tests/test_okf_consume.py:940` pins hit@8 against the Arm B bundle alone.**
The default bundle is pinned nowhere. That is a gap, and it is named in
STATE's next block rather than closed here: O4/O5 fence that file, and this
round only ran the instrument.
---
## 7. The K2 control for the D1/D3 configuration: it moves, so nothing moves
The whole 43-document corpus rebuilt with
`--keep-table-heading --sheet-section-rows --drop-wrapped-outline` on top of the
shipped default. Exit 0, 2305 s wall (773 s inside the door), 39/43 persisted,
4/43 coded rejections -- the same conservation identity as the default build.
| | default | + D1/D3 |
|---|---|---|
| concepts reachable from the index tree | 492 | **491** |
| md files | 944 | **942** |
| hit@8 over the six questions | **5 of 6** | **5 of 6** |
| expected by chance | 1.41 | 1.57 |
| row | gold concepts (default) | rank | gold concepts (D1/D3) | rank |
|---|---|---|---|---|
| 1 | 1 | **1** | **12** | **2** |
| 2 | 26 | 1 | 26 | 1 |
| 3 | 17 | 1 | 17 | 1 |
| 4 | 36 | 1 | 36 | 1 |
| 5 | 18 | 1 | 17 | 1 |
| 6 | 5 | -- | 5 | -- |
**The count holds and the control does not.** Row 1's gold document is split
from one concept into twelve by the sheet-section rule, and its best concept
then ranks **2 instead of 1**. It is still a hit at 8, and it is still a change
to a published number on a corpus the operator reads.
The order's condition for a default move is "no regression on the 43-document
corpus **or** the K2 control". The 12-position reference improves three cells
and regresses none (§ 5); the K2 control regresses one row's rank. **The
condition is therefore not met and the default is not moved.** The flags stay
reachable and off, exactly as they were.
That is worth stating plainly rather than as a near miss: the segmentation side
and the retrieval side disagreed about this configuration, and the disagreement
is real -- splitting a worksheet into its rows is what the reference asks for
and what costs the ranker a rank position, because twelve small concepts each
carry less of the question than one whole sheet did.
---
## 8. Honesty limits
- **n = 6 questions** on the retrieval side, and **n = 12 positions** on the
segmentation side, of which 8 pdf, 3 docx and 1 xlsx. Every per-type figure
in this report is a fraction of 8, 3 or 1. A "3 of 8" is three documents.
- **One rater, one reference.** The twelve-cell reference and the criterion that
scores it were authored by the same operator, and neither has an independent
second rating. Where the criterion and the reference disagree (§ 4), that is
visible only because the disagreement was arithmetic.
- **Both bundles are the same 43-document corpus**, so "hit@8 held on both" is a
statement about one corpus read two ways, not about two corpora.
- **"Delivered" is not "answered".** hit@8 says a gold document appeared among
eight excerpts. Nothing here measures whether a reader could answer the
question from those excerpts; the one measurement that ever tried
(`docs/2026-09-07-okf-konsumskill-maaling.md`) got the correct answer on 1 of
3 bundles.
- **The reserve's corpus reach (§ 3.3) was measured by simulation**, not by
running `okf build --pdf-headings font-reserve` over all 43 documents: the
two cached renderings were combined by the same predicate the shipped code
calls. The predicate is identical; the extraction path around it was exercised
only on the fixtures and on the 12-document sample.
- **The four documents the reserve changes have no reference count.** They are
outside the rated sample, so "4 of 39 change" is a reach, not a quality claim.
- **The corrected S7 ranks (§ 2.1) were measured on today's code.** Round 3's
own build is not available, so this report cannot separate "the instrument was
mixed" from "the code also moved" for the *delivery* outcome; it can and does
separate it for the *rank*, because the rank reproduces exactly under the
published instrument and changes only when the vocabulary is passed
consistently.

View file

@ -70,6 +70,7 @@ from __future__ import annotations
import argparse
import sys
import tempfile
from functools import partial
from pathlib import Path
from .corpus import LOG_NAME, CorpusReport, load_plans, measure
@ -77,6 +78,7 @@ from .errors import IngestError
from .inbox import walk_inbox
from .profiles import SEGMENTED_OKF_V0_2, STRUCTURED_V1, BundleProfile
from .propose import ProposerError
from .propose import heading_reserve_applies
from .propose import run as propose_run
__all__ = ["DEFAULT_STAMP", "build", "main", "measure"]
@ -150,6 +152,18 @@ DEFAULT_UNIT_FOLD = True
DEFAULT_PDF_HEADINGS = False
DEFAULT_OCR = False
#: Round 5's third value on the SAME axis, and off for a third reason: not
#: caution, not construction, but a reachable set with no reference in it.
#: The reserve reads typography only where the outline gate admits no run, so
#: over the 43-document corpus it can act on 4 of 39 readable documents -- and
#: on the twelve-position reference it changes NOTHING, because the only
#: positions where it fires are one PDF whose glyphs carry no ToUnicode
#: mapping and four office documents the PDF reader never touches. The
#: position it was built for numbers its own chapters, so the reserve is
#: silent there by construction. Measured in
#: `docs/2026-09-08-k3-runde5-hitat8-og-skriftakse.md`.
DEFAULT_PDF_HEADINGS_RESERVE = False
#: The timestamp written when the caller passes none, for the ingest stamp and
#: the proposal stamp alike. ONE constant: two independently-defaulted literals
#: drift, and the drift shows up only as two bundles differing in a field
@ -171,6 +185,7 @@ def _propose_plans(
sheet_section_rows: bool = False,
drop_wrapped_outline: bool = False,
pdf_headings: bool = False,
pdf_headings_reserve: bool = False,
ocr: bool = False,
) -> tuple[int, int, int]:
"""Propose a plan per dropped file. Returns (written, nothing, failed).
@ -203,6 +218,7 @@ def _propose_plans(
sheet_section_rows=sheet_section_rows,
drop_wrapped_outline=drop_wrapped_outline,
pdf_headings=pdf_headings,
pdf_headings_reserve=pdf_headings_reserve,
ocr=ocr,
)
except ProposerError as exc:
@ -234,6 +250,7 @@ def build(
sheet_section_rows: bool = False,
drop_wrapped_outline: bool = False,
pdf_headings: bool = DEFAULT_PDF_HEADINGS,
pdf_headings_reserve: bool = DEFAULT_PDF_HEADINGS_RESERVE,
ocr: bool = DEFAULT_OCR,
) -> CorpusReport:
"""Folder in, bundle out. The whole command, minus argument parsing.
@ -252,6 +269,13 @@ def build(
"""
if proposed_at is None:
proposed_at = ingested_at
# Bound to THIS run's outline minimum, once, so the proposer and the door
# cannot be handed two different thresholds for the same question.
reserve = (
partial(heading_reserve_applies, outline_run=outline_run)
if pdf_headings_reserve and not pdf_headings
else None
)
if not segments:
report = measure(
inbox,
@ -259,6 +283,7 @@ def build(
ingested_at=ingested_at,
profile=STRUCTURED_V1,
pdf_headings=pdf_headings,
heading_reserve=reserve,
ocr=ocr,
)
_write_log(bundle, report, profile=STRUCTURED_V1)
@ -292,6 +317,7 @@ def build(
sheet_section_rows=sheet_section_rows,
drop_wrapped_outline=drop_wrapped_outline,
pdf_headings=pdf_headings,
pdf_headings_reserve=pdf_headings_reserve,
ocr=ocr,
)
print(
@ -308,6 +334,7 @@ def build(
profile=SEGMENTED_OKF_V0_2,
root_frontmatter_values={"okf_version": okf_version, "bundle_id": bundle_id},
pdf_headings=pdf_headings,
heading_reserve=reserve,
ocr=ocr,
)
_write_log(bundle, report, profile=SEGMENTED_OKF_V0_2)
@ -507,8 +534,14 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
)
build_parser.add_argument(
"--pdf-headings",
choices=("none", "font"),
default="font" if DEFAULT_PDF_HEADINGS else "none",
choices=("none", "font", "font-reserve"),
default=(
"font"
if DEFAULT_PDF_HEADINGS
else "font-reserve"
if DEFAULT_PDF_HEADINGS_RESERVE
else "none"
),
help=(
"how a PDF's headings are recovered before any arm reads the text. "
"none (the default) is today's extraction: a PDF carries no heading "
@ -518,7 +551,11 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
"heading in the same markdown the office path produces, so the "
"existing heading rule reads it and no PDF-only grammar exists. A "
"CONJUNCTION, measured: adding weight as a disjunct took precision "
"from 0.786 to 0.524"
"from 0.786 to 0.524. font-reserve is font applied ONLY to a "
"document whose own numbering the outline arm finds nothing of -- "
"typography as a second source where there is no first one, never "
"on top of one. Three values of one option, so no caller can ask "
"for two at once"
),
)
build_parser.add_argument(
@ -566,6 +603,7 @@ def main(argv: list[str] | None = None) -> int:
sheet_section_rows=args.sheet_section_rows,
drop_wrapped_outline=args.drop_wrapped_outline,
pdf_headings=args.pdf_headings == "font",
pdf_headings_reserve=args.pdf_headings == "font-reserve",
ocr=args.ocr,
)
except (IngestError, OSError, ValueError) as exc:

View file

@ -36,7 +36,7 @@ import argparse
import json
import sys
import time
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import dataclass, replace
from pathlib import Path
@ -281,6 +281,7 @@ def measure(
profile: BundleProfile = STRUCTURED_V1,
root_frontmatter_values: Mapping[str, str] | None = None,
pdf_headings: bool = False,
heading_reserve: Callable[[str], bool] | None = None,
ocr: bool = False,
) -> CorpusReport:
"""Run the corpus through the door and count what happened.
@ -304,6 +305,7 @@ def measure(
root_frontmatter_values=root_frontmatter_values,
segmentations=plans,
pdf_headings=pdf_headings,
heading_reserve=heading_reserve,
ocr=ocr,
)
elapsed = time.monotonic() - started

View file

@ -743,6 +743,7 @@ def process_inbox(
segmentation: SegmentationPlan | None = None,
segmentations: Mapping[str, SegmentationPlan] | None = None,
pdf_headings: bool = False,
heading_reserve: Callable[[str], bool] | None = None,
ocr: bool = False,
) -> InboxResult:
"""Convert every file dropped in `inbox_dir` into an OKF concept.
@ -962,6 +963,23 @@ def process_inbox(
pdf_headings=pdf_headings,
ocr=ocr,
)
# The heading RESERVE, supplied as a predicate rather than decided
# here: the condition is the proposer's outline grammar, and the
# door does not own that grammar. A callable keeps the dependency
# pointing the way the layers do -- the same shape `gate` already
# has -- while guaranteeing the two sides ask ONE question. Both
# `text` and `units` move together, because a locator built from
# one rendering cannot address the other.
reading_fonts = pdf_headings
if heading_reserve is not None and not pdf_headings and heading_reserve(text):
reading_fonts = True
text = extract_text(
source_name(path),
source_bytes,
renderer=_resolve_renderer(profile, path.name),
pdf_headings=True,
ocr=ocr,
)
# Computed from the SAME text the plan's offsets index, so the
# locator and the offset can never disagree about which rendering
# they describe. `None` when the profile names no provenance:
@ -972,7 +990,7 @@ def process_inbox(
source_name(path),
source_bytes,
text,
pdf_headings=pdf_headings,
pdf_headings=reading_fonts,
ocr=ocr,
)
if profile.provenance is not None

View file

@ -310,6 +310,26 @@ def outline_lines(text: str) -> list[tuple[int, int, str]]:
return found
def heading_reserve_applies(text: str, *, outline_run: int) -> bool:
"""Whether this text needs a SECOND heading source, having no run of its own.
The font reader's reserve condition, and the only place it is decided. The
proposer and the door both call this, because a plan indexes the exact
string it was proposed against: a reserve that fired on one side and not
the other would make every document it touched a coded rejection.
It reads the gate AS CONFIGURED rather than a fixed minimum -- raising the
arm's threshold widens the reserve, which is the same document property
seen through the same threshold. At `outline_run` 0 the gate admits nothing
at all, so the reserve is unconditional; that combination is round 4's
"font instead of Arm D", measured at 1 of 8, and a caller reaching it gets
it deliberately.
"""
if outline_run <= 0:
return True
return not outline_runs(outline_lines(text), outline_run)
def outline_runs(
entries: list[tuple[int, int, str]], minimum: int
) -> list[list[tuple[int, int, str]]]:
@ -1119,6 +1139,7 @@ def run(
sheet_section_rows: bool = False,
drop_wrapped_outline: bool = False,
pdf_headings: bool = False,
pdf_headings_reserve: bool = False,
ocr: bool = False,
) -> int:
if max_segment_chars < 0:
@ -1164,6 +1185,14 @@ def run(
# against another is refused by `assert_plan_applies`, which is the
# right outcome and a confusing one to debug.
text = extract_text(source.name, source_bytes, pdf_headings=pdf_headings, ocr=ocr)
# The reserve, and the reason it re-extracts rather than post-processes:
# the font reader works on the PDF's glyph geometry, which the joined
# text no longer carries. Skipped outright when the font reader is
# already on -- `font` and `font-reserve` are two values of one option,
# never a pair to combine.
if pdf_headings_reserve and not pdf_headings:
if heading_reserve_applies(text, outline_run=outline_run):
text = extract_text(source.name, source_bytes, pdf_headings=True, ocr=ocr)
except IngestError as exc:
raise ProposerError(f"cannot extract text from {source.name}: {exc}") from exc

View file

@ -65,6 +65,23 @@ FONT_HEADING_CONTENT = (
)
# The SAME typography as `FONT_HEADING_CONTENT`, over a document that numbers
# its own chapters. It exists for the heading RESERVE and for nothing else: the
# reserve reads typography only where Arm D's outline gate admits no run, so
# proving it stays silent needs a document where both signals are present and
# only one of them may be used. Three ascending integers at line start, which
# is a run at the build default's minimum of 3.
NUMBERED_FONT_CONTENT = (
b"BT /F2 20 Tf 50 700 Td (Forord) Tj ET\n"
b"BT /F1 10 Tf 50 670 Td (1 Generelle krav) Tj ET\n"
b"BT /F1 10 Tf 50 640 Td (Utkilingen skal ha helning 1:15.) Tj ET\n"
b"BT /F1 10 Tf 50 610 Td (2 Merking) Tj ET\n"
b"BT /F1 10 Tf 50 580 Td (Kravet gjelder alle veiklasser.) Tj ET\n"
b"BT /F1 10 Tf 50 550 Td (3 Vedlegg) Tj ET\n"
b"BT /F1 10 Tf 50 520 Td (Vedlegget er eget oppslag.) Tj ET\n"
)
def build_two_font_pdf(content: bytes) -> bytes:
"""A one-page PDF whose resources declare BOTH a regular and a bold font.
@ -419,6 +436,9 @@ if __name__ == "__main__":
(HERE / "font-heading-krav.pdf").write_bytes(build_two_font_pdf(FONT_HEADING_CONTENT))
print("wrote font-heading-krav.pdf")
(HERE / "numbered-font-krav.pdf").write_bytes(build_two_font_pdf(NUMBERED_FONT_CONTENT))
print("wrote numbered-font-krav.pdf")
for name, parts in (
("two-line-krav.docx", _DOCX_PARTS),
("no-styles-krav.docx", _DOCX_NO_STYLES_PARTS),

42
tests/fixtures/numbered-font-krav.pdf vendored Normal file
View file

@ -0,0 +1,42 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>
endobj
4 0 obj
<< /Length 353 >>
stream
BT /F2 20 Tf 50 700 Td (Forord) Tj ET
BT /F1 10 Tf 50 670 Td (1 Generelle krav) Tj ET
BT /F1 10 Tf 50 640 Td (Utkilingen skal ha helning 1:15.) Tj ET
BT /F1 10 Tf 50 610 Td (2 Merking) Tj ET
BT /F1 10 Tf 50 580 Td (Kravet gjelder alle veiklasser.) Tj ET
BT /F1 10 Tf 50 550 Td (3 Vedlegg) Tj ET
BT /F1 10 Tf 50 520 Td (Vedlegget er eget oppslag.) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
6 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
endobj
xref
0 7
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000251 00000 n
0000000654 00000 n
0000000751 00000 n
trailer
<< /Size 7 /Root 1 0 R >>
startxref
853
%%EOF

View file

@ -0,0 +1,173 @@
"""The font reader as a RESERVE: typography only where the outline gate is empty.
Round 4 measured the font reader two ways and shipped neither as a default. On
TOP of Arm D it took `pdf` from 2 of 8 to 0 of 8, because the documents it broke
already numbered their own chapters and the second heading source could only
ADD a title page and a contents listing. INSTEAD of Arm D it scored 1 of 8 but
landed one position exactly on its reference count, which is the observation
this flag comes from: use typography only where the document does not number
itself.
The mechanism is one condition, and these tests pin the condition rather than
the outcome: the reserve reads a rendering it may not have asked for only when
`outline_runs` admits nothing at the configured minimum. Everything else --
which arm ships, whether the reserve helps -- is a measurement, and the
measurement is in `docs/2026-09-08-k3-runde5-hitat8-og-skriftakse.md`.
**The reserve is OFF by default and the measurement says it must stay off**: on
the twelve-position reference it changes not one cell, because the only
positions where it fires are a PDF whose glyphs carry no ToUnicode mapping and
four office documents the PDF reader never touches.
"""
from __future__ import annotations
import warnings
from pathlib import Path
from llm_ingestion_okf import cli
from llm_ingestion_okf.extract import extract_text
from llm_ingestion_okf.propose import build_plan, heading_reserve_applies
FIXTURES = Path(__file__).parent / "fixtures"
NUMBERED_PDF = FIXTURES / "numbered-font-krav.pdf"
FONT_PDF = FIXTURES / "font-heading-krav.pdf"
def _extract(path: Path, **kwargs: object) -> str:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return extract_text(path.name, path.read_bytes(), **kwargs) # type: ignore[arg-type]
# --- the condition ----------------------------------------------------------
def test_the_reserve_is_silent_where_the_document_numbers_itself() -> None:
"""The whole point: a document with its own chapter run keeps its own run."""
assert heading_reserve_applies(_extract(NUMBERED_PDF), outline_run=3) is False
def test_the_reserve_fires_where_the_outline_gate_admits_nothing() -> None:
assert heading_reserve_applies(_extract(FONT_PDF), outline_run=3) is True
def test_a_run_shorter_than_the_minimum_is_not_a_run() -> None:
"""The condition reads the gate as CONFIGURED, not a fixed grammar.
The same three-chapter document is a run at 3 and not one at 4, so raising
the arm's minimum widens the reserve rather than leaving it behind.
"""
text = _extract(NUMBERED_PDF)
assert heading_reserve_applies(text, outline_run=4) is True
def test_with_arm_d_off_the_reserve_fires_everywhere() -> None:
"""`--outline-run 0` admits no run at all, so the reserve is unconditional.
Stated rather than left to the reader: that configuration IS round 4's
"font instead of Arm D", which measured 1 of 8. A caller combining the two
flags gets that, and gets it deliberately.
"""
assert heading_reserve_applies(_extract(NUMBERED_PDF), outline_run=0) is True
# --- the effect on a plan ---------------------------------------------------
def _entries(text: str) -> list[str]:
plan = build_plan(
Path("x.pdf"),
text,
b"",
okf_type="reference",
proposed_at="1970-01-01T00:00:00Z",
outline_run=3,
table_grid=True,
unit_fold=True,
)
return [str(entry["title"]) for entry in plan["entries"]]
def test_the_numbered_document_is_segmented_by_its_own_numbering() -> None:
"""The reserve buys nothing here, and that is the guarantee, not a defect.
Measured rather than assumed, and the measurement is stronger than the
guarantee: at the build default this fixture lands on the same three units
from BOTH renderings, because the outline arm's run wins the document and
the fold puts the font-inferred `Forord` inside the first unit rather than
beside it. So the reserve's silence here has two independent reasons, and
only one of them -- the condition -- is what this flag controls. The
condition itself is pinned above, on the extraction, where it is decided.
"""
assert _entries(_extract(NUMBERED_PDF)) == ["Generelle krav", "Merking", "Vedlegg"]
assert _entries(_extract(NUMBERED_PDF, pdf_headings=True)) == [
"Generelle krav",
"Merking",
"Vedlegg",
]
# --- the flag ---------------------------------------------------------------
def test_the_build_default_does_not_reach_the_reserve() -> None:
"""Every byte-pinned golden depends on this, so it is a test and not a note."""
assert cli.DEFAULT_PDF_HEADINGS is False
assert cli.DEFAULT_PDF_HEADINGS_RESERVE is False
def test_the_reserve_is_reachable_from_the_build_command(tmp_path: Path) -> None:
"""`--pdf-headings font-reserve` is a third value on the same axis.
One axis, three values, because `none`, `font` and `font-reserve` are three
answers to one question -- how a PDF's headings are recovered -- and a
separate boolean flag would let a caller ask for two of them at once.
"""
base = [
"build",
str(tmp_path),
"--bundle",
str(tmp_path / "b"),
"--bundle-id",
"x",
"--okf-version",
"0.2",
]
assert cli.parse_args([*base, "--pdf-headings", "font-reserve"]).pdf_headings == "font-reserve"
assert cli.parse_args([*base, "--pdf-headings", "font"]).pdf_headings == "font"
assert cli.parse_args(base).pdf_headings == "none"
def test_the_reserve_reaches_the_proposer_and_the_door_alike(tmp_path: Path) -> None:
"""Propose and apply must choose the SAME rendering, or nothing applies.
A plan indexes the exact string it was proposed against (`text_sha256`), so
a reserve that fired on one side and not the other would turn every
document it touches into a coded rejection. The build is the test: the
unnumbered PDF must come back segmented by its typography, and the numbered
one by its own chapters, in ONE run with ONE flag.
"""
inbox = tmp_path / "in"
inbox.mkdir()
(inbox / "numbered.pdf").write_bytes(NUMBERED_PDF.read_bytes())
(inbox / "unnumbered.pdf").write_bytes(FONT_PDF.read_bytes())
with warnings.catch_warnings():
warnings.simplefilter("ignore")
report = cli.build(
inbox,
tmp_path / "bundle",
bundle_id="reserve-fixture",
okf_version="0.2",
pdf_headings_reserve=True,
)
assert report.rejected == 0
names = sorted(path.name for path in (tmp_path / "bundle").rglob("*.md"))
# The unnumbered PDF: the default leaves it one flat concept (round 4 pins
# that), and the reserve gives it the unit its typography names.
assert "generelle-tekniske-krav.md" in names, names
# The numbered PDF: its own chapters, and no `Forord` the reserve added.
assert "generelle-krav.md" in names, names
assert "merking.md" in names, names
assert "vedlegg.md" in names, names
assert "forord.md" not in names, names