llm-ingestion-okf/docs/2026-09-09-k3-runde9-restene.md
Kjell Tore Guttormsen 6ff18fd703 feat(propose,extract,cli): a title that ends in a number, and a converter's own anchor in a concept id
Round 9: the four rests in STATE's NESTE that needed no operator decision.

CLAUSE 1 CLASSIFIED BY THE NUMBER, NOT THE TITLE. `_TRAILING_PAGE_NUMBER`
admitted a candidate into a contents run by asking whether the title ended in
an integer -- a question about the number. A drawing's dimension chain, a
schematic's labels, a door schedule, a coordinate column and a soil-layer
table all end in integers and name nothing. Measured over the 43-document
corpus: 68 candidates discarded over 11 of 39 readable documents, of which
19 over 5 documents are data rows.

That corrects round 8's own decomposition. Its "four misclassified numeric
tables and seven real contents listings" needs each document on one side, and
two of the eleven are both. Read across all 68 titles rather than the
three-title sample: 5 documents carry a data row, 8 carry a real entry.

`--contents-name` requires a NAME to survive stripping the page number. The
threshold is SWEPT, not chosen, and collapses at both ends: at an alphabetic
run of 1 a door schedule keeps a stray `V` and 13 of 19 are rescued; at 3 the
two-letter section name `VA` stops being a name, falls out of run membership,
and takes `RIB`, `MMI` and `Tittelfelt` below `CONTENTS_RUN` with it -- one
acronym costing four REAL entries. At 2: 16 of 19 rescued, 0 of 49 regressed.
The three not rescued carry a real word and are named rather than rounded off.

THE CONVERTER'S ANCHOR WAS IN THE CONCEPT ID. Pandoc writes a sheet as
`## <name> {#sheet-N}` and a titled slide as `## <title> {#slide-N}`. Because
a filename is reduced FROM the title, the anchor reached both. Operator
authorised the strip 2026-09-09 after the exposure was counted: 2 of 810
concepts on the previous default bundle, 2 of 1108 on Arm B, 1 of 26 on the
operator's folder. Two ids renamed, one of which `portfolio-optimiser` has
cited in writing; both are in the report so that message can be sent.

One rule in one function, read by BOTH title-forming sites -- a rule in only
one would leave the id and the title naming the same concept differently. The
known-negative is the point: `Mal for {kundenavn}` is a title an author wrote.

odt/rtf/pptx MEASURED END TO END FOR THE FIRST TIME, on hand-built documents,
because the corpus denominator is genuinely zero (86 files: 66 pdf, 10 docx,
4 xlsx, 2 zip, 2 smc, 2 doc). `_EVIDENCE` gains a third class rather than
stretching an existing one: `constructed` means the row has met a document,
but not one anyone wrote for their own purposes. odt 1 of 1 declared headings;
pptx 2 of 2 on a deck that declares slide titles and 0 of 2 on one that does
not -- round 7's reading of pptx was a fixture property, not the format; rtf
0 segments, because the container has no heading style and the author's title
is bold text. rtf is the one open finding.

ACCEPTANCE, all four. The 12-position reference is label-identical in BOTH
readings (pdf 7/8, docx 3/3, xlsx 0/1 or 1/1, sheet 10/12 or 11/12). One K2
bundle carrying both changes: 453 concepts / 865 md, hit@8 [1,1,1,1,1,None]
on it AND on Arm B, with the known-negative still reproducing on the new
bytes. `okf project` byte-equal to `okf build`, `diff -r` empty. Consumer
cost is a re-run: 436/832 -> 453/865, digest 21af4a1aa98315cf.

Three published numbers corrected: README's 596 tests (1515), README's "15
concepts out" for `okf project` (that was the O6 defect; it is 26), and O6's
print-mode method, which does not reproduce without --allowedTools.

Report: docs/2026-09-09-k3-runde9-restene.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:45:08 +02:00

17 KiB
Raw Blame History

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 nothingdiff -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
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 ## <name> {#sheet-<n>} and a slide with a title placeholder as ## <title> {#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 unmeasuredconstructed. 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:

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.