# K3 round 9: the rests that did not need the operator Four things stood in `STATE.md`'s NESTE that no operator decision blocked: clause 1 misclassifying numeric tables, the `{#…}` converter attribute leaking into concept titles, the first end-to-end measurement of `odt`/`rtf`/`pptx`, and the prefix overmatch. This round is those four, plus two documentation findings PM's re-measurement of O6 produced. Baseline: HEAD `fbad5b0`, tree clean, tag `v0.7.0` on `1260fac` (two commits back, deliberately — O5/O6's install measurement is of those bytes), remote `main` at `b01492b`, so five commits unpushed plus the tag. All four reproduced before any code was touched. **Every fraction below carries its denominator. Where a published number did not reproduce, this report says so before it says anything else.** ## 0 What did not reproduce **Round 8's decomposition of clause 1 splits the eleven documents into "four misclassified numeric tables and seven real contents listings". Two of the eleven are BOTH.** Re-measured with round 8's own instrument (`/tmp/k3r8/clause1.py`, unchanged), the reach reproduces exactly — **68 candidates over 11 of 39 readable documents** — but reading all 68 dropped titles rather than the three-title sample changes the split: | | documents | candidates | |---|---|---| | carry at least one misclassified data row | **5** of 11 | **19** of 68 | | carry at least one real contents entry | **8** of 11 | **49** of 68 | Documents 21 and 22 appear in both rows. The 4 + 7 = 11 reading is only available if each document is assigned to one side, and two of them cannot be. The four purely-misclassified documents are a drawing's dimension chain (`477 3 025 1`), a P&ID's schematic labels (`40.23`), a door schedule (`3000 V 1`) and a borehole log's coordinate column (`619649.097`); document 22 additionally carries three soil-layer rows (`- 8.225m Fjell 41`) inside an otherwise real contents listing. **The README published 596 tests, measured 2026-08-21 and never updated.** The suite is 1515. A count is a measurement with a date on it. **The README published "5 documents in, 15 concepts out" for `okf project`.** 15 was the O6 defect, not the result; the same command writes 26. Corrected. ## 1 Clause 1: a title that ends in a number is not thereby a contents entry `_TRAILING_PAGE_NUMBER` admits a candidate into a contents run by asking whether the title ENDS in an integer. That is a question about the number, not about the title. A drawing's dimension chain, a schematic's labels, a door schedule and a coordinate column all end in integers and name nothing. The damage was already gone: round 8's `--close-span-gaps` carries a removed mark's text on the mark above, so the corpus coverage gap is 0 either way (re-measured: **0 characters of 1 739 511**, both before and after this round). What remained was classification. ### 1.1 The rule, and where the threshold sits A title is admitted only if a NAME survives stripping the page number. What counts as a name is **swept, not chosen** — an alphabetic run of at least *n* characters, over the 43-document corpus: | n | candidates dropped | documents | data rows rescued | REAL entries wrongly rescued | |---|---|---|---|---| | — (shipped) | 68 | 11 | 0 of 19 | 0 of 49 | | 1 | 55 | 9 | **13** of 19 | 0 of 49 | | **2** | **52** | **8** | **16** of 19 | **0** of 49 | | 3 | 48 | 8 | 16 of 19 | **4** of 49 | It collapses at both ends. At n = 1 a door schedule keeps a stray single letter (`3000 V`) and stays misclassified. At n = 3 the two-letter section name `VA` stops being a name, falls out of run membership, and the run around it drops below `CONTENTS_RUN` — so `RIB`, `MMI` and `Tittelfelt` are emitted as concepts too. One two-letter acronym costs four real entries. **n = 2.** **The three it does not rescue are named rather than rounded off.** Document 22's `- 8.225m Fjell 41` rows carry a real word, so this predicate cannot separate them from a contents entry. 16 of 19, not 19 of 19. ### 1.2 What it costs Corpus, extracted text, default flags: **429 → 447 candidates**, documents with a plan **32 → 33** (one document had all three of its candidates discarded and so had no plan at all), characters in no segment **0 → 0**. On the operator's five-document folder the flag changes **nothing** — `diff -r` between a build with `--no-contents-name` and one without is empty. Those five documents contain no run of data rows. A flag whose measured effect on one folder is zero is still the right default; it is measured where the shape occurs. ### 1.3 The acceptance criteria, all four **(i) The red test is green on both sides.** A run of five coordinate rows is no longer discarded; a contents listing whose shortest entry is the two-letter `VA` still is. `tests/test_contents_name_part.py`. **(ii) The twelve-position reference loses no cell, in EITHER reading.** Scored with round 8's instrument imported unchanged (which imports round 5's criterion unchanged), round 8's shipped default against round 9's: | reading | `pdf` | `docx` | `xlsx` | sheet | labels identical | |---|---|---|---|---|---| | fasit as authored, `|F|`[3] = 11 | 7 of 8 | 3 of 3 | 0 of 1 | 10 of 12 | **yes** | | `xlsx` fasit read as 12 (PM's working answer) | 7 of 8 | 3 of 3 | 1 of 1 | 11 of 12 | **yes** | Not one of the 12 positions changes label. Position 0 stays `for grovt` with an undefined `|F|`, which is the operator decision this round does not touch. **(iii) No fasit row loses rank 1, on either bundle.** One K2 bundle built carrying both of this round's shipped changes (`--bundle-id k2-trinn1-20260903 --okf-version 0.2`, default flags, default epoch stamp), 825 s wall time, `merged + coded rejections = 43`, 39 of 43 substantive, 4 coded rejections (1 `extractor_empty_pdf`, 3 `extractor_unknown`): | bundle | concepts | md | `tie_shared_rank` ON | OFF (known-negative) | |---|---|---|---|---| | round 9 (new default) | **453** | 865 | **`[1,1,1,1,1,None]`** | `[2,1,1,1,1,–]` | | previous default `…-20260911` | 436 | 832 | `[1,1,1,1,1,None]` | `[2,1,1,1,1,–]` | | Arm B `…-20260903` | 629 | 1108 | `[1,1,1,1,1,None]` | `[1,1,1,1,1,–]` | hit@8 is **5 of 6** on all three. The known-negative still reproduces on the NEW bytes, so the pin continues to name its own cause rather than being green for an unstated reason. **(iv) `okf project` is still byte-equal to `okf build`.** `diff -r` over the operator's five-document folder after every change: **0 differences**. The new flag's `cli.build` signature default and its argparse default are both `True`, which is the check O6's defect made mandatory. ### 1.4 What it costs a consumer A re-run, and it is not small. The reference corpus goes 629 concepts / 1108 files (2026-09-03) → 492 / 944 → 425 / 810 → 436 / 832 → **453 / 865**. Digest, from inside the bundle, `find . -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256`: **`21af4a1aa98315cf…`** (the previous default was `8c93e5e3222577a2…`). On the operator's five-document folder the count is unchanged at 26 concepts / 52 md. The pinned artefact is `~/corpora/okf-telling-20260829/K2-bundle-default-20260912`. It was **copied** from the build scratch rather than rebuilt a second time: the pin's own comment records that two independent builds differ in nothing, including `log.md`, which carries the corpus path and never the bundle's own. `--contents-name`, ON by default since 2026-09-09, opt-out `--no-contents-name`. ## 2 The converter attribute in concept titles Pandoc writes a spreadsheet's sheet as `## {#sheet-}` and a slide with a title placeholder as `## {#slide-<n>}`. The brace block is pandoc's own anchor. It reached the artifact twice: into `title`, and from there into the concept id, because a filename is reduced FROM the title. **Exposure, measured 2026-09-09 with a known-positive control run first:** | bundle / folder | concepts | concepts carrying the attribute | |---|---|---| | `K2-bundle-default-20260911` | 436 | **2** | | `K2-bundle-20260903` (Arm B) | 629 | **2** | | `~/okf-test/dokumenter` | 26 | **1** | The "2 of 52" figure carried into this round counted FILES containing `{#` anywhere, one of which was an `index.md`. At concept level it is 1 of 26. **The renamed ids, before and after, in full:** | before | after | |---|---| | `del-ii-bilag-7-prisskjema/prissammenstilling-sheet-1` | `del-ii-bilag-7-prisskjema/prissammenstilling` | | `del-ii-bilag-0-dokumentliste-del-ii/ark1-sheet-1` | `del-ii-bilag-0-dokumentliste-del-ii/ark1` | **The first of those two is the id `portfolio-optimiser` has cited in writing.** This round does not send that message: the rename is reported here so PM can. The strip lives in one function (`extract.strip_converter_attribute`) read by both title-forming sites — `propose` names a segment from an ATX heading, `structure` derives a document title from its leading heading — because a rule in only one of them would leave the id and the title naming the same concept differently. **Provenance holds, measured rather than assumed.** The renamed concept keeps `source_file`, `source_sha256`, `sources`, `source_sheet: Prissammenstilling` (the extractor's unit table still reads the anchor, which is why the strip is applied to a title downstream and never to the extracted text), `references`, and the parent index entry points at the new filename. `okf check`: **conformant, 15 rules over 8 excerpts and 18 withheld entries, 0 findings.** The known-negative is the point: `Mal for {kundenavn}` and `Feltet {"id": 4} i nyttelasten` are titles an author wrote and are untouched. ## 3 `odt`, `rtf` and `pptx`, measured end to end for the first time The corpus denominator is genuinely zero, re-counted: `K2/trinn1` holds 86 files — **66 `pdf`, 10 `docx`, 4 `xlsx`, 2 `zip`, 2 `smc`, 2 `doc`** — and not one `odt`, `rtf` or `pptx`. `K1` is empty. So these rows are measured on hand-built documents, and the report says so in every row. | type | declared headings recovered | concepts | chars in no segment | N | |---|---|---|---|---| | `odt` | **1 of 1** | 1 | 0 of 1366 | 1 document | | `pptx` (deck declaring slide titles) | **2 of 2** | 2 | 0 | 1 deck | | `pptx` (deck declaring none) | **0 of 2** | 2 | 0 of 1285 | 1 deck | | `rtf` | 0 of **0 declared** | **0** | 1368 of 1368 | 1 document | End to end through Door B, all three containers, **0 of 3 rejected**. **`pptx` is not broken, and round 7's reading of it was a fixture property.** The committed fixture's title shapes carry no `<p:ph type="title"/>` placeholder, so the converter has no title to name a slide with and writes `Slide 1` / `Slide 2`. Given a deck that declares one, it writes the authored title plus `{#slide-N}` — and § 2's strip removes the anchor, so the concept is named by the title the author wrote. Pinned in `tests/test_converter_attribute_titles.py`, with the anchor's presence asserted first so the test cannot be green over an empty set. **`rtf` is the one open finding.** The container has no heading style; the author's title is bold text. The proposer proposes nothing, and the document reaches the bundle inbox as one whole concept — content preserved, structure zero. That is not data loss and it is not segmentation either. `extract._EVIDENCE` gains a third class rather than stretching an existing one. `measured` means real corpus files and a hand-counted fasit; `unmeasured` means no document has ever been through the row; **`constructed`** means the row has met a document end to end, but not one anyone wrote for their own purposes. All three office rows move `unmeasured` → `constructed`. The suite asserts the new class, and asserts the `unmeasured` set is now empty rather than dropping it. ## 4 The prefix overmatch, re-measured Round 5's `df 159 of 270` was measured on the N500 bundle and has not been re-measured since. It is not reproduced here — that bundle is a different one — but the mechanism is, on both K2 bundles, **with a known-positive control run first because a query that cannot find would report zero either way**: | bundle | N | `df(undersjøisk)` prefix rule | `df(undersjøisk)` equality | control `df(under)` | |---|---|---|---|---| | `K2-bundle-default-20260911` | 436 | **171** | 0 | 79 | | `K2-bundle-20260903` | 629 | **174** | 0 | 76 | A token that occurs **zero** times in either bundle matches 39.2 % and 27.7 % of their concepts through `MIN_SHARED_PREFIX = 4`. The first attempt at this measurement returned 0 for the control as well, which is how the broken query was caught rather than published. Not fixed this round, and not because it is small: the acceptance bar is zero regression on hit@8 across six rows and two bundles plus the twelve-position reference under both readings, and `MIN_SHARED_PREFIX` has a known-positive in the suite that Norwegian compounding is the reason for. It stands, with this number. ## 5 Two documentation findings from PM's O6 re-measurement **(a) The print-mode method as published does not reproduce.** O6 § 6 gave the method as `cd <project> && claude -p "<the question>"`. Run that way — including with `--permission-mode acceptEdits` — the `okf consume` call is refused, the model answers without opening the bundle, and a hypothesis run marks both premises `undecidable-from-bundle`. The four runs reported in fact used `claude -p --allowedTools=Bash,Read,Grep,Glob "<question>"` (plus `Write,Edit` for the document row). The numbers are unaffected; the recipe for getting them was wrong. Corrected in § 6 of that report and added to the README as its own short paragraph for agents running non-interactively. **(b) Green lint hangs on the lockfile.** `uv sync --frozen` resolves ruff 0.15.22 and the tree is clean. A loose install resolves ruff 0.16.6, under which the same tree reports 144 lint errors and 2 files that would be reformatted — new rules, not new defects. Not upgraded here. One sentence added to the README's developer section. **One thing to add to PM's finding:** `uv sync --frozen` alone also drops the `[extract]` extra, after which `mypy src` fails with `Cannot find implementation or library stub for module named "pdfplumber"`. The green command is `uv sync --frozen --extra extract`. ## 6 A finding reported, not fixed: the `tools/okf_consume.py` shim `vegnormal-okf` reported that the v0.7.0 shim broke a caller using `importlib.util.spec_from_file_location`. Reproduced here, with numbers: such a caller holds a module object carrying **4** public names against the `sys.modules` entry's **90**, and `build_payload` is absent. `sys.modules[__name__] = _impl` replaces the registry entry; it cannot reach a module object the caller already holds. A backward-compatible repair is one line placed before the alias: ```python globals().update({k: v for k, v in vars(_impl).items() if not k.startswith("__")}) ``` Verified to restore all 90 names on the caller-held object. **The dunder filter is load-bearing**: an unfiltered `vars(_impl)` overwrites `__name__` with `llm_ingestion_okf.consume` before the next line reads it, aliasing the module under the wrong key. Note the repair restores attribute ACCESS and not patch-through — a caller monkeypatching the copy patches a binding the implementation does not read, which is the reason the alias exists. **Not applied.** This round's order fences `tools/okf_consume*.py` as run, not edited. The mechanism and the line are here so a session whose order authorises it can apply them in one step. ## Honesty limits - **The twelve-position reference is n = 12, of which 8 are `pdf`, 3 `docx` and 1 `xlsx`.** Every per-type fraction is a fraction of 8, 3 or 1. A single `xlsx` position means that row moves between 0/1 and 1/1 and nothing else. - **Both sheet readings are reported, never one.** The sheet is 11 of 12 under `|F|` = 12 and 10 of 12 under `|F|` = 11; the difference is entirely the `xlsx` cell. - **The retrieval side is n = 6 questions, one rater, one fasit**, and row 6 misses on every bundle measured so far. `hit@8 5 of 6` is that. - **"General" rests on ONE corpus of 43 documents**, plus the operator's five. `K2/trinn2` is byte-identical to `K2/trinn1` and `K1` is empty, so the 86 files are 43 documents. - **Each new file-type row rests on ONE hand-built document** (`pptx` on two decks). They are constructed documents, not corpus files, and the evidence class says so. - **`delivered` is not `answered`.** Every count here is of concepts and excerpts a payload carries, never of correct answers. - **Position 0 is blocked on an operator decision, not on code.** So are the `xlsx` fasit's `|F|` = 12, the `pptx` fasit, and whether routing between bundles may rest on `--id`. None of the four moved this round. - **The renamed concept ids break a citation `portfolio-optimiser` made in writing.** Both ids are in § 2 so that message can be sent; it was not sent from here. - **The threshold sweep in § 1.1 is over one corpus.** n = 2 is where it collapses on these 43 documents; a corpus with one-letter section names would put it elsewhere.