`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>
18 KiB
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
/Outlinestree of 2 763 nodes. All 2 763 of 2 763 resolve to a page index, and all 2 763 of 2 763 carry an/XYZdestination with a non-nulltop. Afterre.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 99–181), 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 atopper line. It requires the line splitting to be the onepage.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 topsits 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 frozengit archiveexport of8e19179, a build on this tree with the arm off, and a build on this tree with the arm on --diff -ris 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 projectis still byte-equal tookf build(diff -rempty), 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
pdf7 of 8,docx3 of 3, in both readings of thexlsxfasit (|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
/Outlinesis not an error.PDFNoOutlinesyields no marks, no count, and identical behaviour. - An unresolvable
/Destis 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-okfwas 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, 3docx, 1xlsx, so every per-type fraction is a fraction of 8, 3 or 1. Both readings of thexlsxfasit 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|, thexlsxfasit's|F|, thepptxfasit, re-rating the reference positions and routing between bundles on--idremain blocked on an operator decision, not on code. This round moved none of them.