llm-ingestion-okf/docs/2026-09-08-k3-runde2-per-filtype.md
Kjell Tore Guttormsen ff79cfa19b feat(cli,propose): reach the arms from okf build, keep a sheet heading behind a flag, fix two PDF contents mechanisms
K3 round 2, per file type. Order 20260908T143513Z-6327528123-from-.claude, carrying two operator decisions taken beforehand: D1 the orphan-gate variant goes behind a flag, D2 the arms become reachable from `okf build`. No default moved. Report: docs/2026-09-08-k3-runde2-per-filtype.md.

THE REPRODUCTION HELD, all three numbers, before any edit: `okf build` on the five-document tender folder gives 31 markdown files with both PDFs flat and 5/5 merged; the tender PDF gives no boundary without a flag and 9 with `--outline-run 3` (reference 9); the price sheet gives 1 on HEAD. Both proposer runs had to go through `bash -c` -- zsh does not word-split an unquoted `$flags`, so a sweep hands `--outline-run 3` to argparse as one token and every row comes back exit 2.

D2 -- `cli.py:_propose_plans` called the proposer with no arm argument, so the build path ran Arm B while `tools/okf_propose_segments.py` could run D, E and F. It now passes `--outline-run`, `--table-grid`, `--unit-fold` and `--keep-table-heading` through unchanged. THE DEFAULT DOES NOT MOVE and that is measured, not asserted: same folder, no flags, before and after the change, digest 3af10770...8fbbe2 both times and `diff -rq` clean. The "before" bundle was built before the first edit, because the editable install reads src/ live. Red test on the PLANS and on titles rather than a count, with the same fixture and no flags as its control. Per-document table for B/D/E/F/F2 is in the README and the report; the tender PDF is 1 under the default and 9 under every arm above it, and the reference is 9.

D1 -- a sheet heading with a table opening under it has an empty body, so the orphan check drops it: the NAME survives (carried onto the table block), the LINE does not. `--keep-table-heading` lets the heading survive and absorb the table instead. Price sheet 1 -> 1 concepts, `source_offset` [34, 11048] -> [0, 11048], body now starting at the heading. ELEVEN IS NOT REACHABLE THIS WAY and the number says why: the sheet is one heading and one continuous pipe-table block, and the eleven cost groups are eleven ROWS inside it (lines 10-20 of 103). What is missing is a section-row rule inside a sheet -- the opposite of `--table-grid`. Corpus: the flag changes 2 of 39 documents, both `.xlsx`, under arms B, E and F alike; known-negative 0 of 32 `pdf` and 0 of 5 `docx`. With it off, Arm E over all 43 is byte-identical to session 109's tree (33 plans, 43 `.err`, 4 FAILED, diff exit 0, counts asserted first).

THE PDF REMAINDER, one at a time. Position 9: clause 1 read the list AFTER the orphan check, and a contents list without dot leaders is a run of bodiless headings, so all but the last entry were already gone and the run was one. The run is now measured on the pre-orphan list, predicate written once and read in both places. 11 -> 10. Position 7: the same clause required siblings, and a numbered report's contents list interleaves 1.1/1.1.1/2.1 -- its 34 entries are one block that the level condition cut into runs of 9, 1, 1, 1, 5, 2, 10, 2 and 3, so the short runs survived. The level condition is dropped; the run LENGTH, which is what the CONTENTS_RUN sweep bought, is unchanged. Measured outward: the relaxation changes 1 document of 39 and removes exactly the leftover line. 34 -> 33.

TWO REMAINDERS ARE DECLINED WITH NUMBERS RATHER THAN FIXED. Position 1: the three level-1 candidates are 3 of 3 `rule:outline`, same level, same grammar, and the operator keeps one of them by prose alone -- there is no property to read. Position 4: a title-length rule was measured on paper and falsified -- a real chapter is 56 characters and a real heading in a document the arms already score correct is 88, sitting between position 4's 86 and 91, so no threshold separates the classes. Position 0 stays an extraction failure.

ONE SHIPPED EXPECTATION MOVED and is stated rather than quietly updated: `Innhold 1` is now discarded with the contents list it heads. Its body is in no segment afterwards, which is a real cost on a fixture where that heading has one.

Nine new tests: five red before the implementation, four green by construction and named as such. Three mutations, three red, unmutated control green each time -- restoring the level condition, computing the run post-orphan, absorbing a table unconditionally. 1379 -> 1388 tests. ruff clean, mypy --strict clean on 17 files. K2 bundle untouched (1108 files, 9cd74519...). The K2 ranking control is NOT measured: no bundle was rebuilt with the flag, so the rank is a prediction.

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

19 KiB
Raw Blame History

K3 round 2, per file type: the build command, the spreadsheet, the PDF remainder

Second iteration of the per-file-type directive of 2026-09-08 13:05Z. Order 20260908T143513Z-6327528123-from-.claude, which carries two decisions already taken by the operator and asks for neither to be widened: D1 — the orphan gate variant goes behind a flag, off by default; D2 — the measurement arms become reachable from okf build, with the default unchanged.

Everything below is measured on the same 43-document corpus and the same 12-document sample as docs/2026-09-08-k3-arm-f-mot-enhetsarket.md, against the same reference: the operator's filled unit worksheet, which is local-only and gitignored. No document title or extracted text from it is reproduced here.

Section 0: what was measured and what was not

Measured. The three reproduction numbers the order names, before any edit. Four flags reaching the proposer from okf build, on the artifact. A byte-identical no-flag bundle, by digest and by diff -r. Two rule adjustments, each red-first, each with a known-negative and a mutation control. The whole corpus under six configurations, from ONE cached extraction so every variant reads the same bytes. The four PDF remainders, each decomposed to a named mechanism.

Not measured. No default moved. No K2 bundle was rebuilt, so the K2 ranking control (the priced concept at rank ≤ 12 with --cost-vocabulary --k 12) is not measured this round — it is a property of a bundle built WITH the flag, and no such bundle exists. The K2 bundle on disk is unchanged, which proves nothing was overwritten and nothing more. Two of the four PDF remainders are diagnosed and declined rather than fixed, with the numbers that decline them.

1. The reproduction, before any edit

# control command result
a okf build on the five-document tender folder okf build ~/okf-test/dokumenter --bundle … --bundle-id … --okf-version 0.2 31 markdown files, both PDFs as one flat inbox-*.md each, merged 5/5, 0 rejections
b the tender PDF through the proposer tools/okf_propose_segments.py "<tender>.pdf" --out … no boundary (exit 1); with --outline-run 3: 9 entries
c the price sheet through the proposer same, on the .xlsx 1 entry on HEAD, against 3 in the 2026-09-07 archive; the reference is 11 cost groups

All three reproduce. (b) and (c) had to be run under bash -c: zsh does not word-split an unquoted $flags, so a sweep loop hands --outline-run 3 to argparse as ONE token and every row comes back exit 2. That is the trap docs/2026-09-08-k3-arm-f-mot-enhetsarket.md recorded, hit again.

2. D2: okf build reaches the arms, and the default does not move

cli.py's _propose_plans called propose.run with no arm argument, so the build path ran Arm B while tools/okf_propose_segments.py could run D, E and F. It now passes --outline-run, --table-grid, --unit-fold and --keep-table-heading through unchanged, same names and same semantics.

The red test came first and asserts on the PLANS, on titles rather than on a count: a fold that kept the right number of concepts by discarding the wrong ones passes a count assertion. Its control is the same fixture with no flags.

The byte control. Same folder, no flags, before and after the change:

BEFORE 3af107707b66090e7ab4e4372c5976ca2c448ac0d771c7e9a9d74746d08fbbe2
AFTER  3af107707b66090e7ab4e4372c5976ca2c448ac0d771c7e9a9d74746d08fbbe2
diff -rq: identical, 31 markdown files either way

The "before" bundle was built at the start of the session, from the editable install, before the first edit — a fresh build afterwards would have read the changed src/ and compared a tree against itself.

Concepts per document, per configuration

Five documents, 2 pdf / 2 docx / 1 xlsx. Every column merged 5 of 5 with 0 coded rejections.

document B (default) D E F F2
tender PDF, technical requirements no boundary → 1 9 9 9 9
tender PDF, technical layout no boundary → 1 no boundary no boundary no boundary no boundary
price sheet .xlsx 1 1 1 1 1
experience list .docx 21 21 6 3 3
agreement .docx 2 2 1 1 1
markdown files in the bundle 31 49 33 30 30

B = no flags. D = --outline-run 3. E = + --table-grid. F = + --unit-fold. F2 = + --keep-table-heading.

The reference for the first row is 9. What the measurement says about moving the default is therefore one sentence: on this folder the default is a full arm behind the proposer on the one document with a reference. It is five documents, one folder, and the decision is the operator's.

3. D1: the spreadsheet, behind --keep-table-heading

The mechanism, named. propose.py's orphan check drops a heading whose body is empty, and a sheet heading with a pipe table opening two lines below it has an empty body. The heading's NAME survives (it is carried onto the table block), its LINE does not: the concept starts at the first table row.

The variant. When a heading is orphaned only because the very next mark is a table block, the heading survives and the table is absorbed into its span rather than emitted. The count does not move; the first byte does. Measured on the price sheet:

default --keep-table-heading
concepts 1 1
source_offset [34, 11048] [0, 11048]
first body line | Prisskjema | … ## Prissammenstilling {#sheet-1}

The reference wants eleven, and eleven is not reachable this way. The sheet extracts to 103 lines: ONE heading (line 1) and one continuous pipe-table block (lines 3103). The eleven numbered cost groups are eleven ROWS inside that block (01, 11+12, 0292, lines 1020). Reaching them needs a rule that splits a table on its section rows — the exact opposite of --table-grid, which exists to stop a table becoming one concept per row group. What is missing is a section-row rule inside a sheet, and no heading-and-table rule can supply it.

The corpus numbers for D1

From one cached extraction, so every configuration reads identical bytes. 39 of 43 documents are extractable (4 FAILED, unchanged).

control result
documents changed by --keep-table-heading, arm B 2 of 39 — both .xlsx
documents changed, arm E 2 of 39 — the same two
documents changed, arm F 2 of 39 — the same two
known-negative: pdf changed 0 of 32
known-negative: docx changed 0 of 5
both spreadsheets in the corpus 2 of 2 changed, [20,1921] → [0,1921] and [34,11048] → [0,11048]

With the flag off, nothing moves. Arm E re-run over all 43 documents on the round-2 code, against session 109's Arm E tree: 33 plans either way, 43 .err either way, 4 FAILED either way, diff -rq … -x '*.err' -x '_index.txt' exit 0. Counts asserted before the diff, because a diff over two trees where every document failed compares nothing and exits 0.

The K2 ranking control is not measured. 56ae274 fixed a ranking regression by restoring the concept's title, and this flag restores the title's LINE to the body — which is the same direction, and a prediction, not a measurement. No K2 bundle was rebuilt with the flag, so nothing here says where the priced concept ranks in one. It is round 3's.

4. The PDF remainder, one at a time

The reference row was pdf 2 of 8. Each of the four is decomposed below. Two get a rule adjustment behind the SAME flag as Arm F (--unit-fold); two are declined, with the numbers that decline them.

Position 9 — one leftover contents line. FIXED.

Mechanism. Clause 1 discards a RUN of at least CONTENTS_RUN page-numbered headings. It reads the list fold_units is given, which is the list AFTER the orphan check — and a contents list without dot leaders is precisely a run of bodiless headings, so the orphan check deletes all of it but the last entry. The run clause 1 looks for is gone before clause 1 runs; what is left is a run of one, which is below the threshold by design.

Adjustment (one). The run is measured on the PRE-orphan list and carried forward on the candidate. The predicate is written once and read in both places, so there cannot be two definitions of a contents list.

Result. Position 9: 11 → 10. Still too fine — the reference is 4, and the remaining 6 are Arm D recovering a risk table's rows as an integer outline, which the operator ruled out in free text ("radene er ikke overskrifter"). That is Arm D's recovery, not the fold's, and --unit-fold adds no boundary it could withdraw.

Position 7 — a contents list that interleaves levels. FIXED.

Mechanism. The same clause also required the run's members to be SIBLINGS. A numbered report's contents list interleaves 1.1, 1.1.1, 2.1, so the level condition cuts one block into many runs. Measured on the document: its 34 contents entries are one continuous block of page-numbered headings at levels 2 and 3, and the level condition split it into runs of 9, 1, 1, 1, 5, 2, 10, 2 and 3. The runs of one and two survived, and one of them (6.2.2 Tverrfaglig kontroll ....) had a body, so it was emitted as a concept while its neighbours were discarded.

Adjustment (one). The level condition is dropped. What still bounds the rule is the run LENGTH — the number CONTENTS_RUN's sweep bought — and that is unchanged.

Generalisation, measured outward. Across all 39 readable documents the relaxation changes 1 document, and there it removes exactly that one line:

value
documents whose plan changes 1 of 39
entries removed 1 (position 7: 34 → 33)
other documents touched 0

Result. Position 7: 34 → 33. Still too fine against a reference of 30, and the operator had already marked that document "none". The remaining 3 are the heading rule proposing units the operator does not want; that is not the fold's to withdraw either.

One shipped expectation moved with it, and it is stated rather than quietly updated. In test_a_contents_run_is_discarded_and_the_body_survives the level-1 heading Innhold 1 used to survive, because it is not a sibling of the three level-2 contents lines after it. It is now part of the run and is discarded — which is what "innholdsfortegnelsen er ikke konsepter" says about a contents heading. The cost is real and bounded: in that fixture the heading has a body of its own, and a discarded candidate leaves its body in no segment.

Position 1 — two recovered chapters. DECLINED, with numbers.

Mechanism. 17 concepts against a reference of 15. The three level-1 candidates are Innledning (§1), Referanser (§6) and Vedlegg (§7), and all three are rule:outline — Arm D's recovery of the document's bare-integer numbering. The operator's free text keeps ONE of them ("vedlegget er eget oppslag") and the criterion's reference set keeps only that one.

Why no rule. 3 of 3 are the same rule, the same level, the same grammar and the same shape; the worksheet showed the operator dotted headings only, so the reference set contains Vedlegg because the operator named it in prose, not because anything in the document distinguishes it. A rule that dropped two of the three would have to read the title, and there is no property here to read. One document is not a rule, and this one does not even offer a property.

Position 4 — quoted regulation text. DECLINED, with numbers.

Mechanism. The reference is 1 (the whole note as one argument); Arm B gives exactly that and every arm above it gives 4. The four are rule:outline candidates recovered from numbered legal paragraphs — full sentences, truncated at the line break, which is why the operator wrote "sitert forskriftstekst er ikke overskrifter".

The rule that was tried on paper and falsified. A heading is a heading and a sentence is not, so: discard an outline candidate whose TITLE is longer than some threshold. Every outline title in the 12-document sample, sorted:

class lengths
real headings 7, 7, 7, 7, 8, 8, 9, 9, 10, 10, 10, 10, 12, 12, 13, 14, 18, 18, 20, 24, 24, 24, 31, 45, 56, 88
position 4's sentences 86, 91, 92, 100

The classes overlap and no threshold separates them. A real chapter at position 9 is 56 characters (Rutiner for behandling av endringer av betydning for SHA) and a real heading at position 2 — a document the arms already score correct — is 88. A threshold at 90 leaves one of position 4's four and costs nothing; a threshold low enough to take all four deletes a chapter at a document the arms currently get right. Declined.

Position 0 — an extraction failure. REPORTED, not repaired.

95.1 % (cid:N) glyphs. No segmentation rule can repair a document whose text never arrived. Unchanged, and it is the one position where Arm F proposes nothing at all because there is no candidate to fold.

5. Per file type, before and after

The 12-document sample, concepts per arm. Arm F is session 109's number; F2 is this round's code with all four flags on.

pos type reference Arm B Arm D Arm E Arm F (109) F2
0 pdf 0 0 0 0 0
1 pdf 15 20 23 23 17 17
2 pdf 9 0 9 9 9 9
3 xlsx 11 1 1 1 1 1
4 pdf 1 0 4 4 4 4
5 docx 3 21 21 6 3 3
6 pdf 6 6 7 7 7 7
7 pdf 30 45 48 48 34 33
8 pdf 8 0 8 8 8 8
9 pdf 4 5 11 11 11 10
10 docx 2 15 15 3 2 2
11 docx 1 2 2 1 1 1

The label row does not move. Using session 109's criterion under its dominance gate — which is the reading that survived, because column (c) is relative and (a)/(b) are absolute:

type n Arm B Arm D Arm E Arm F F2
pdf 8 2 2 2 2 2
docx 3 0 0 1 3 3
xlsx 1 0 0 0 0 0

Two cells moved closer and neither crossed. Position 7 loses a concept the reference does not want and is still 3 too many; position 9 loses one and is still 6 too many. pdf is 2 of 8 for the third round running, and this round says why with a mechanism per position rather than with one number: two of the four are fixed as far as the fold can reach, and two are declined on measurements that are written down above.

Nothing regressed. No count moved away from its reference in any cell.

6. Whole corpus, 43 documents

figure Arm E Arm F (109) F2 denominator
plans written 33 32 32 43
proposed entries 679 491 485
.err files 43 43 43 43
.err recording FAILED 4 4 4 so extractable 39/43
plans differing from Arm F 6 of 32

The six: two .xlsx where only the span start moves (D1), three .pdf where a leftover contents line goes (the pre-orphan run), and one .pdf where the level relaxation removes one more (position 7). Cross-checked: the entry count of every one of the 39 written plans equals the count computed from the cached extraction, 39 of 39.

K2-bundle-20260903 is unchanged: 1108 files, 9cd745194346cda0c70eab9c7136fa44506203bbe85bc17d7eff2766c6e9b4d1. That is a statement that nothing overwrote it, not a rebuild.

7. Tests

Nine new tests, plus one shipped expectation moved and stated. Five were red before the implementation (the bodiless run, the level-crossing run, the two halves of D1, and the arms reaching okf build). Four were green by construction and are named as such rather than counted as evidence: two known-negatives (a single page-numbered heading survives; a heading with its own body absorbs nothing — the latter was red only as a TypeError before the parameter existed, and its value is the mutation below), a default control, and a byte control. --keep-table-heading taking no argument is green either way, because argparse rejects an unknown flag with the same exit code; it is a guard, not a measurement.

Three mutations, three red, with the unmutated control green each time:

mutation expected to kill result
restore the same-level condition in the contents run the level test red (2 tests)
compute the contents run after the orphan check the bodiless-run test red (3 tests)
absorb a table unconditionally the D1 known-negative red (1 test)
(control) unmutated green, 93/93

1379 → 1388 tests. ruff check src tests tools clean, ruff format --check clean, mypy --strict src/ clean on 17 files.

Reproducing

# 1. The three numbers, in bash -- zsh does not split an unquoted $flags.
okf build ~/okf-test/dokumenter --bundle /tmp/b --bundle-id r --okf-version 0.2
find /tmp/b -name '*.md' | wc -l                                     # 31
python3 tools/okf_propose_segments.py "<tender>.pdf" --out /tmp/t.json
python3 tools/okf_propose_segments.py "<tender>.pdf" --out /tmp/t.json --outline-run 3

# 2. The corpus, once, cached -- every variant then reads identical bytes.
#    Ascending chunks, each a foreground call under 600 s.
C=~/corpora/okf-telling-20260829
for lo_hi in "1 12" "13 18" "19 26" "27 43"; do
  set -- $lo_hi
  arm_run "$C/K2-plans-r2-armFoff-20260908" 2026-09-07T00:00:00Z "$1" "$2" \
    --outline-run 3 --table-grid
  arm_run "$C/K2-plans-r2-armF2-20260908"   2026-09-08T00:00:00Z "$1" "$2" \
    --outline-run 3 --table-grid --unit-fold --keep-table-heading
done

# 3. Counts BEFORE the diff.
ls "$C"/K2-plans-r2-armFoff-20260908/*.json | wc -l               # 33
ls "$C"/K2-plans-r2-armF2-20260908/*.json | wc -l                 # 32
grep -l FAILED "$C"/K2-plans-r2-armF2-20260908/*.err | wc -l      # 4 -> 39/43

# 4. The flag-off identity, and the flag-on delta.
diff -rq "$C/K2-plans-armE-check-20260908" "$C/K2-plans-r2-armFoff-20260908" \
  -x '*.err' -x '_index.txt'                                      # exit 0
diff -rq "$C/K2-plans-armF-20260908" "$C/K2-plans-r2-armF2-20260908" \
  -x '*.err' -x '_index.txt'                                      # 6 files

# 5. The consumer bundle, untouched.
cd "$C" && LC_ALL=C find K2-bundle-20260903 -type f | LC_ALL=C sort \
  | xargs shasum -a 256 | shasum -a 256
# 1108 files, 9cd745194346cda0c70eab9c7136fa44506203bbe85bc17d7eff2766c6e9b4d1

arm_run is the function published in docs/2026-09-07-k3-arm-e.md § Reproducing, unchanged.

Honesty limits

  • n = 12, one rater, one reference. Per file type the denominators are 8, 3 and 1. A 1/1 is not a rate, and the xlsx row is that.
  • Two of the four PDF remainders are declined, not solved. The declines rest on measurements written above; they are still declines.
  • The two adjustments were derived from the two documents they move, and screened outward on 39. The outward screen is what bounds the fitting; it does not remove it.
  • The rules were measured on plans, never on a bundle. No bundle was built with --unit-fold or --keep-table-heading beyond the five-document folder, so nothing here says what these concepts look like after materialisation at corpus scale.
  • The K2 ranking control is not measured, for the reason § 3 gives.
  • No default moved, and no threshold is set. Which arm should ship as the default is the operator's decision and the measurement above is one folder, one corpus and one worksheet wide.