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>
This commit is contained in:
parent
8e1917946a
commit
e1f4faa098
12 changed files with 1261 additions and 15 deletions
317
docs/2026-09-10-k3-runde12-pdf-outlines.md
Normal file
317
docs/2026-09-10-k3-runde12-pdf-outlines.md
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
# 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 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 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue