llm-ingestion-okf/docs/2026-09-10-k3-runde12-pdf-outlines.md
Kjell Tore Guttormsen e1f4faa098 feat(propose): the PDF shipped a structure index and the build discarded it unopened
`okf build` recovers a PDF's boundaries from the shape of its page text and
never opens the file's own `/Outlines` bookmark tree. On a 701-page process
code whose publisher also ships a NISO-STS structure for it, measured outside
this repository and reproduced here exactly: the shipped default finds 1967 of
2761 titled sections, 0 of its 28 chapters, and 794 of 794 misses have their
heading text PRESENT in the extracted text. The line was read; the boundary
was never opened. The same file's bookmark tree matches 2761 of 2761 of those
titles exactly after normalisation.

`--pdf-outline`, OFF, cuts a PDF at the boundaries its tree declares.

  boundaries                 1967 of 2761  ->  2759 of 2761  (gate was 2700)
  depth 1                       0 of 28    ->     28 of 28
  titles identical to source        --     ->   2761 of 2761
  false positives             163 of 2182  ->      3 of 2762
  directories with two files  132 of 2050  ->      2 of 2738
  front-matter concepts        72 of 2182  ->      2 of 2762
  consumption fasit present       4 of 7   ->        7 of 7
  hit@1 / hit@8 / hit@50      1/6 2/6 4/6  ->   3/6 5/6 6/6

It is a SEGMENTATION arm, not a reader option: the extracted text is byte for
byte the same either way. A PDF with no tree builds byte-identically with the
flag on -- `diff -r` empty across the pre-change tree, the arm off and the arm
on. An unresolvable `/Dest` is dropped and COUNTED, never fabricated into a
boundary and never a refusal of the file.

The bridge from (page, y) to a line index is the whole risk, so both routes
are measured. `extract_text_lines` splits lines identically to `extract_text`
on 701 of 701 pages, and is CHECKED per page rather than assumed. The y route
and the title route disagree on 0 of 2762 nodes, flat from a 0pt tolerance to
8pt and collapsing at 12pt, so the rule ships with no tolerance constant. The
naive "nearest line" rule was wrong on 1840 of 2762, one line early every time.

The orphan check is not applied to a bookmark mark: it asks whether anything
stands under a candidate's first line, which is the right question for a
heuristic's guess and the wrong one for a publisher's declaration. 683 of 2762
marks are container sections; applying it scores 2079 instead of 2759.

No new dependency and no second parse of the pages: `pdfminer.six` already
ships under `pdfplumber` in `[extract]`. 119.22s -> 183.31s wall, peak RSS
3252 -> 3251 MiB. The default does not move; 1 of the 8 reference PDFs carries
a usable tree at all.

`.pdf` also gains its `_EVIDENCE` row, as `measured` -- it was the row with the
most measurement behind it and no entry in the table.

Report: docs/2026-09-10-k3-runde12-pdf-outlines.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 02:27:41 +02:00

18 KiB
Raw Blame History

K3 round 12: the PDF shipped a finished structure index and the build discarded it unopened

okf build reads a PDF's page text and recovers boundaries from the shape of that text. It never opens the file's /Outlines bookmark tree. On a document whose publisher wrote one, that is the difference between guessing the structure and being told it.

This round adds --pdf-outline, off by default, which cuts a PDF at the boundaries its own bookmark tree declares. It is a segmentation arm: the extracted text is byte for byte the same either way.

Every number below carries its denominator. The two that matter most are that the fasit is the publisher's own structure, and that it describes one document, one format, one publisher (§ 7).

0. Three divergences from the numbers this round was handed

The order and the incoming FYI both rest on vegnormal-okf's SK2 measurement. Its headline reproduces here exactly -- 1 967 of 2 761, depth 1 0 of 28, false positives 163 of 2 182, 794 of 794 misses present in the text -- both against SK2's own bundle and against one rebuilt on this repository's HEAD (§ 2). Three characterisations around it do not survive re-measurement.

(a) "No okf concept directory is a bare number" is a property of that BUNDLE, not of okf. The claim is what makes 81 of 2 761 fasit rows unreachable for SK2's pair key, and all 28 depth-1 rows sit in that group. It is true of the arm-off bundle: 0 of its directories is a bare integer. It is false of okf: with the arm on, 57 of 2 738 directories are, because _split_outline_title reads the same bare one-or-two-digit grammar Arm D already carries. The rows were unreachable because the boundaries were never opened, not because the id form forbids it.

(b) The corrected instrument does NOT lift the arm-off number. The correction -- accept a hit when the concept's own normalised title equals the fasit's, alongside the pair key -- was expected to show that "depth 1: 0 of 28" was partly an instrument artifact. Measured on the arm-off bundle it changes 1 967 to 1 967 and depth 1 stays 0 of 28 in both forms. So on the shipped default that row was a real segmentation hole measured with an instrument that could have reached it, and not only an artifact. The correction earns its keep on the arm-on bundle, where it moves 2 737 to 2 759 and depth 1 from 12 of 28 to 28 of 28 (§ 3).

(c) The 145 truncated titles and the 65 contents duplicates reproduce as described, and both are closed as a side effect rather than as separate work (§ 3). They are re-characterisations of SK2's own numbers, not new measurements on new material.

1. Where the gap is

  • 794 of 794 missed STS titles have their heading text present in the extracted text; 0 of 794 are absent. The gap is segmentation, not extraction.
  • The file carries an /Outlines tree of 2 763 nodes. All 2 763 of 2 763 resolve to a page index, and all 2 763 of 2 763 carry an /XYZ destination with a non-null top. After re.sub(r"\s+","",s).lower(), 2 761 of 2 761 STS titles match a node exactly; without the normalisation, 1 of 2 761.
  • Three holes, not one, and the tree closes all three: the boundary that is never opened (794 of 2 761); the title cut short at the PDF's line break (145 of the 163 "false positives" and 81 of the 794 "misses" are this -- a bookmark title does not come from the page, so it is complete); and the table of contents becoming a second set of concepts (65 directories carrying both a contents copy and a body section under one id -- a bookmark's destination page is the body).

2. Reproduced before anything was built on it

row SK2 (de9564d) this HEAD, arm off
boundaries 1 967 of 2 761 (71.2 %) 1 967 of 2 761 (71.2 %)
depth 1 … 7 0/28 · 59/118 · 410/500 · 913/1141 · 551/868 · 31/97 · 3/9 identical
false positives 163 of 2 182 163 of 2 182
misses present in the text 794 of 794 794 of 794
directories with two concept files 132 of 2 050 132 of 2 050
bare-integer directories 0

git log de9564d..HEAD -- extract.py propose.py names exactly one commit (732f84d, the HTML extractor), so the PDF path was unchanged between the bundle SK2 measured and this tree -- and the rebuild confirms it rather than resting on it.

3. The arm, measured

Built with okf build … --pdf-outline, same input, same epoch stamp.

row arm off arm on (SK2's instrument) arm on (corrected)
boundaries 1 967 of 2 761 2 737 of 2 761 (99.1 %) 2 759 of 2 761 (99.9 %)
depth 1 0 of 28 12 of 28 28 of 28
depth 2 59 of 118 116 of 118 118 of 118
depth 3 410 of 500 499 of 500 500 of 500
depth 4 913 of 1 141 1 140 of 1 141 1 141 of 1 141
depth 5 551 of 868 866 of 868 868 of 868
depth 6 31 of 97 95 of 97 95 of 97
depth 7 3 of 9 9 of 9 9 of 9
titles identical to the STS title after NORM 2 761 of 2 761
false positives 163 of 2 182 25 of 2 762 3 of 2 762
directories with two concept files 132 of 2 050 2 of 2 738 (same)
concepts in the front matter (page < 68) 72 of 2 182 2 of 2 762 (same)
concepts 2 182 2 762 (same)
K3-9: ends in a digit 16 of 99 99 of 99 99 of 99
K3-9: opens with a numbering token 1 967 of 2 739 2 737 of 2 739 2 737 of 2 739
K3-9: unnumbered 0 of 22 0 of 22 22 of 22

The gate was 2 700 of 2 761 with the corrected instrument; the result is 2 759, and the ceiling is 2 761.

The distance from the ceiling, named row by row rather than left as a remainder. Two fasit rows are missed, and they are the same two nodes the false-positive column counts from the other side: 36.7331 48V strømforsyning PoE and 36.7332 24V strømforsyning. The publisher's bookmark for each omits the numbering token the STS <title> glues on, so the concept lands with no number directory and the pair key cannot reach it. Both sections exist as concepts with the right body; only the address differs. The third false positive is the tree's own root node, R761 Prosesskoden -- one candidate, the document's own title.

The two remaining directories with two concept files are not contents duplicates. They are 11 and 12, and each holds two genuinely distinct declared sections that share a number: 11 ARBEIDSSTIKNING, TEKNISK KONTROLL (page 183) beside 11 Oversikt over prosesser (pages 99181), and the same shape at 12. The 65 contents-copy pairs are 0. The collision that remains is the publisher reusing a number in two chapters, which is a different mechanism and is not closed by anything here.

Depth 6 stays 95 of 97 in both instrument forms. The publisher's own tree and the publisher's own STS disagree about four nodes: the tree's levels 2..8 hold 28/118/500/1141/872/93/9 against the STS depths 1..7 at 28/118/500/1141/868/97/9. The mapping is level minus one on five rows and off by four on two of them, so the level is read from the tree and reported as declared rather than normalised (OutlineMark.level). Reporting a depth-1 row as 28 of 28 because a +1 shift was assumed would be the same failure 0 of 28 was.

3.1 The bridge, both routes, with denominators

A bookmark states a page and a y position; a candidate needs a line index. 2 706 of 2 761 bookmarks share a destination page with another bookmark, so the page alone is never a cut point.

  • Y route (primary). page.extract_text_lines() carries a top per line. It requires the line splitting to be the one page.extract_text() produced, which is an assumption and is therefore CHECKED per page: measured 701 of 701 pages identical, and the check ships, so a page where it fails uses the title route instead of guessing.
  • Title route (fallback). The normalised bookmark title, searched in the destination page's own lines: 2 762 of 2 763 resolved.
  • They disagree on 0 of 2 762. Swept over tolerances 0, 1, 2, 3, 5, 8 and 12 pt, the agreement is flat at 2 762 with 0 disagreements from 0 to 8 pt and collapses to 924 at 12 pt (the document's line spacing). Because 0 pt is already exact, the rule ships with no tolerance constant at all -- the first line at or below the destination.
  • The first naive rule -- nearest line -- was wrong on 1 840 of 2 762 nodes, one line early every time, because /XYZ top sits above the heading. It is recorded because it produced a plausible-looking bundle.

Provenance. For 2 762 of 2 762 concepts, the first page of source_pages equals the bookmark's own destination page. source_units still indexes exactly the string extract_text returned: the arm moves boundaries, never units. A PDF concept's address still names the page range its span covers -- now the range the publisher's own bookmark opened.

3.2 Consumption

Fasit presence was validated against the bundle before any rank was read.

arm off arm on
fasit present in the bundle 4 of 7 7 of 7
hit@1 · hit@8 · hit@50 (default k) 1/6 · 2/6 · 4/6 (SK2) 3/6 · 5/6 · 5/6
hit@1 · hit@8 · hit@50 (k = 50) 3/6 · 5/6 · 6/6
payload slots taken by the front matter 0 of 8 (SK2) 0 of 8, all eight questions

S3, S4 and the known-positive existed in no bundle before, because the section that answers them never became a boundary.

The known-positive is not delivered at default k, and that is stated rather than rounded. 1 Bruksområder for Prosesskoden is a real 1 827-byte concept now, reading as the answer to the question asked of it, and it ranks 13 -- delivered at k = 50, not at k = 8. SK2 reported it as null because the boundary did not exist. The segmentation half of that row is closed; the ranking half is not, and no part of this round touches ranking.

S5 (84.3 Armering) is the one row still missing at k = 8: rank 11 at k = 50, with Armering av betonghvelv at rank 1. Armering occurs nine times in this structure, and that collision is in the ranking, not the bridge.

3.3 Cost

arm off arm on
wall (/usr/bin/time -l real) 119.22 s 183.31 s (+53.8 %)
peak RSS 3 410 038 784 B = 3 252 MiB 3 409 002 496 B = 3 251 MiB
times the PDF's pages are parsed 1 1
times the file is opened 1 2

/usr/bin/time -l on macOS reports maximum resident set size in bytes, not KB; both figures above are the byte value converted. Neither is a 2× increase. SK2 measured the arm-off build at 180.50 s on their machine against 119.22 s here, so the honest comparison is the pair measured on one machine. The extra open reads the bookmark tree and the line geometry; the page text comes from the memoised extraction pass and is not re-rendered.

No new dependency. pdfminer.six 20260107 already ships under pdfplumber in the existing [extract] extra, and exposes PDFDocument.get_outlines(). uv.lock and [extract] are untouched; pypdf is not introduced.

4. Nothing else moved, and it is measured rather than argued

  • ~/okf-test/dokumenter, three ways: identical. A build from a frozen git archive export of 8e19179, a build on this tree with the arm off, and a build on this tree with the arm on -- diff -r is empty between all three. 52 md / 26 concepts, 0 of 5 rejected. Both PDFs there carry no /Outlines, which is what makes the third comparison a real control.
  • okf project is still byte-equal to okf build (diff -r empty), and the new flag's argparse default and Python signature default agree -- the test that compares every same-typed parameter covers it.
  • okf skill + okf consume + okf check --skill … --payload …: conformant, 15 rules over 8 excerpts and 18 withheld entries, 0 findings.
  • The pinned K2 bundle's concept count, per-row hit@8 ranks and all three known-negatives are unchanged: the arm is off by default and the arm-off code path is byte-identical.
  • The 12-position reference is untouched by the shipped default and stays pdf 7 of 8, docx 3 of 3, in both readings of the xlsx fasit (|F| = 11 → 10 of 12, |F| = 12 → 11 of 12). This is byte-identity, not a re-score.

Reach, and the number that decides whether the default should move. Of the eight pdf positions on the reference, 1 of 8 carries a usable bookmark tree (position 9, 11 nodes). One more (position 0) carries a tree whose two nodes both fail to resolve -- the drop-and-count branch firing on a real document rather than only on a fixture. The other six carry none.

With the arm on, position 9 goes from 4 concepts to 11, and the eleven are the document's own chapter names (Innledning, Organisering av prosjektet, Risikoforhold og spesifikke tiltak, Fremdriftsplan, Vedlegg 1: …) -- seven chapters the extracted text never showed as headings at all. The operator's cross for that position says a unit is a top-level chapter and the free text says "ville jeg hatt hvert kapittel". Whether 11 is nearer that than 4 depends on an |F| the sheet does not define, because the sheet's own "document's disposition" section could only list the four the text showed. That is an operator question and this round does not answer it.

5. What did NOT move

No HTML change, no OCR, no consumption change, no new fasit, no new corpus, no .xml/NISO-STS extractor, no change to Arm D (--outline-run/--outline-gate are a text heuristic over numbered lines and are a different mechanism entirely), no re-rating of the reference positions, no --bold-title move, no version bump, no tag, no push. uv.lock and [extract] untouched. The default did not move.

6. The tests, and what each one is for

tests/test_pdf_outline.py, over two hand-laid fixtures built by tests/fixtures/make_fixtures.py -- no library's output, objects laid out by hand, xref offsets computed from the emitted bytes (outlined-krav.pdf 2 073 B, outline-broken-dest.pdf 1 014 B).

  • Three levels, not two. A two-level tree cannot tell "the level the node declares" from "one below the root".
  • The line, not the page. The fixture's last page carries four lines and its second bookmark points at the third, so a bridge that resolved to the page and stopped would fail visibly instead of looking like it worked.
  • No /Outlines is not an error. PDFNoOutlines yields no marks, no count, and identical behaviour.
  • An unresolvable /Dest is dropped and counted, never fabricated and never a refusal.
  • An empty mark list leaves every other rule untouched -- "this file has no index" and "propose nothing" must not collapse into one answer.
  • A declared section with no prose of its own survives. The orphan check asks whether anything stands under a candidate's first line: the right question for a heuristic's guess, the wrong one for a publisher's declaration. Measured: 683 of 2 762 marks are container sections, and applying the check scores 2 079 instead of 2 759.

7. Honesty limits

  • The instrument was run in two forms and the two answer different questions. SK2's pair key (numbering token, normalised residual title) cannot reach 81 of 2 761 fasit rows -- 59 with a dotless token, 22 unnumbered -- and all 28 depth-1 rows are among them. Every percentage above says which form it rests on, and the gate was set at 2 700, not at the ceiling 2 761, precisely because of those 81. The corrected form lives in a scratch copy of SK2's script; vegnormal-okf was read and never written, and the correction is offered to them, not applied on their behalf.
  • The fasit is the publisher's own STS structure. "The right boundary" is therefore defined by the publisher, not by what is useful to a reader. A reader might well want 2 761 sections merged into far fewer.
  • The denominator 2 761 is CHOSEN. The document has 7 715 <sec>; 4 954 of them carry a letter-point label (a), c)) inside a process description and 0 of those 4 954 is a process number. Measuring against 7 715 would answer a different question.
  • Matching is exact after normalisation. A boundary set one word away from the source title counts as a miss. That is what makes 2 759 credible and also what makes it a floor rather than a satisfaction score.
  • One document, one format, one publisher. R761 is a guideline with 2 739 of 2 761 titles numbered in a strict hierarchy. A running prose document would measure something else, and this round has no such document with a fasit.
  • A bookmark tree is the publisher's CLAIM about its own structure, not a measurement of the document. A PDF with a stale, incomplete or wrongly pointing tree carries that error straight into the segmentation, and R761 says nothing about how common that is. One of the eight reference PDFs here already carries a tree whose destinations do not resolve.
  • SK2's own figures (1 967, 163, 794, 180.50 s, 3 253 MiB, 1/6 · 2/6 · 4/6) are vegnormal-okf's documentation and stand as premises wherever they were not re-measured here. The rows in § 2 were re-measured.
  • The 12-position reference is n = 12, stratified 8 pdf, 3 docx, 1 xlsx, so every per-type fraction is a fraction of 8, 3 or 1. Both readings of the xlsx fasit are reported, never one.
  • "Delivered" is not "answered." hit@k asks whether the gold concept reached the payload, and nothing here evaluates an answer.
  • Position 0's |F|, the xlsx fasit's |F|, the pptx fasit, re-rating the reference positions and routing between bundles on --id remain blocked on an operator decision, not on code. This round moved none of them.