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>
This commit is contained in:
parent
dfaf3cc134
commit
ff79cfa19b
7 changed files with 989 additions and 17 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -258,11 +258,19 @@ and fixtures, never code.
|
|||
recursively, and two same-named documents in different folders must not
|
||||
collide); `--ingested-at` and `--proposed-at` default to one shared epoch
|
||||
constant rather than the clock, because a wall-clock default takes
|
||||
rebuild-equals-incremental away from anyone who omits them. Arm C, Arm D,
|
||||
Arm E and Arm F are off and not exposed here -- which is measured, not
|
||||
incidental: the build path calls the proposer with no arm flag at all, so a
|
||||
tender PDF that Arm D splits into nine concepts lands as one
|
||||
(`docs/2026-09-08-k3-arm-f-mot-enhetsarket.md`, the `okf build` section).
|
||||
rebuild-equals-incremental away from anyone who omits them.
|
||||
**Four segmentation rules are REACHABLE here and all four are OFF by
|
||||
default** (2026-09-08): `--outline-run N` (Arm D), `--table-grid` (Arm E),
|
||||
`--unit-fold` (Arm F) and `--keep-table-heading` (D1), each passed to the
|
||||
proposer unchanged. Until that day the build path called the proposer with no
|
||||
arm flag at all, so a tender PDF that Arm D splits into nine concepts landed
|
||||
as one -- a build path a full arm behind the proposer. Exposing them is not
|
||||
the same decision as moving one: **which arm should ship as the default is
|
||||
the operator's, and a no-flag build stays byte-identical** (measured by
|
||||
digest before and after, and by Arm E over all 43 corpus documents). Arm C
|
||||
(`--max-segment-chars`) stays unexposed: no reference has ever been measured
|
||||
for its cap. Reports: `docs/2026-09-08-k3-arm-f-mot-enhetsarket.md` and
|
||||
`docs/2026-09-08-k3-runde2-per-filtype.md`.
|
||||
- Consume a bundle: `python3 tools/okf_consume.py <bundle> --question "<q>"
|
||||
[--k N] [--limit N] [--out PATH] [--ref IDENTITY]` — the **pre-pass**
|
||||
`docs/consumption-contract.md` § 1 defines, and the only reading direction
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -80,6 +80,39 @@ than the clock, so two builds of the same folder are byte-identical — a
|
|||
wall-clock default would break rebuild-equals-incremental for every caller who
|
||||
did not pass them.
|
||||
|
||||
### The segmentation flags
|
||||
|
||||
Four rules are reachable from `okf build` and **all four are off by default**;
|
||||
a build with none of them is byte-identical to every bundle published here. Each
|
||||
line below carries the number it was measured at, and nothing beyond it — none
|
||||
of them is a recommendation, because which one should become the default has
|
||||
not been decided.
|
||||
|
||||
| flag | what it does | measured |
|
||||
|---|---|---|
|
||||
| `--outline-run N` | also propose a boundary where the document's own bare-integer numbering sustains an ascending run of at least `N` | a tender PDF whose headings are bare integers: **no boundary** at `0`, **9 concepts** at `3`, against a reference of 9 |
|
||||
| `--table-grid` | a pandoc grid-table rule line no longer closes an open table block, so one grid table is one concept | a `.docx` experience list: **21 → 6** concepts |
|
||||
| `--unit-fold` | discard a contents-list run, fold a deeper heading into its parent, fold a table into the shorter heading that introduces it. Adds no boundary, so it can only reduce a plan | on a 12-document sample scored against an operator's unit worksheet: **5 of 12** match, against **2** for the default, no cell worse. Whole corpus: 679 → 485 proposed segments over 39 readable documents |
|
||||
| `--keep-table-heading` | keep a heading whose body is empty only because a table opens under it, and absorb that table into its span | the two spreadsheets in that corpus, and **0 of 32 `pdf` and 0 of 5 `docx`**: the concept count does not move (1 → 1), its first byte does — the concept gains the heading line it was missing |
|
||||
|
||||
They compose, and the order above is the order they apply in. Measured on a
|
||||
five-document tender folder (2 `pdf`, 2 `docx`, 1 `xlsx`), concepts per
|
||||
document:
|
||||
|
||||
| document | default | `--outline-run 3` | `+ --table-grid` | `+ --unit-fold` | `+ --keep-table-heading` |
|
||||
|---|---|---|---|---|---|
|
||||
| tender PDF, technical requirements | 1 (no boundary) | 9 | 9 | 9 | 9 |
|
||||
| tender PDF, technical layout | 1 (no boundary) | 1 | 1 | 1 | 1 |
|
||||
| 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 |
|
||||
|
||||
Every column merged 5 of 5 with 0 rejections. The reference for the first row
|
||||
is 9, so the default is a full arm behind what the proposer can do on that
|
||||
document — which is a statement about the default, not a licence to change it
|
||||
here.
|
||||
|
||||
Measured 2026-09-08 on a 43-file corpus (33 `pdf`, 5 `docx`, 2 `xlsx`, and
|
||||
three files no reader accepts), one
|
||||
`okf build` invocation replacing the shell loop over `tools/` that produced the
|
||||
|
|
|
|||
388
docs/2026-09-08-k3-runde2-per-filtype.md
Normal file
388
docs/2026-09-08-k3-runde2-per-filtype.md
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# 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 3–103). The eleven numbered cost groups are eleven ROWS inside that
|
||||
block (`01`, `11+12`, `02` … `92`, lines 10–20). 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.
|
||||
|
|
@ -38,11 +38,21 @@ the replay dated differently passes both explicitly.
|
|||
|
||||
## What it does not decide
|
||||
|
||||
Arm C (`--max-segment-chars`), Arm D (`--outline-run`) and Arm E
|
||||
(`--table-grid`) are OFF here and are not exposed: they are measurement arms,
|
||||
all off by default by operator decision, and a build command is not where an
|
||||
unadjudicated segmentation heuristic should become one flag away. `tools/`
|
||||
still reaches them.
|
||||
**Every arm is OFF unless the caller asks, and this command does not move a
|
||||
default.** Which arm should ship as the default is the operator's decision and
|
||||
is not taken here.
|
||||
|
||||
The arms are, however, REACHABLE from here, and that is a change of 2026-09-08.
|
||||
Until then `_propose_plans` called the proposer with no arm flag at all, so
|
||||
`okf build` ran Arm B while `tools/okf_propose_segments.py` could run Arm D, E
|
||||
and F -- a build path a full arm behind the proposer, reachable only by
|
||||
retyping the loop the packaging removed. Measured on a five-document folder:
|
||||
one tender PDF lands as ONE concept from the build path and as NINE with
|
||||
`--outline-run 3`, and nine is what the operator's unit worksheet asks for.
|
||||
|
||||
Arm C (`--max-segment-chars`) stays unexposed: it is a character cap whose
|
||||
value nobody has measured against a reference, so it has no number to offer a
|
||||
caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -71,7 +81,16 @@ DEFAULT_STAMP = "1970-01-01T00:00:00Z"
|
|||
|
||||
|
||||
def _propose_plans(
|
||||
inbox: Path, bundle: Path, plans_dir: Path, *, proposed_at: str, okf_type: str
|
||||
inbox: Path,
|
||||
bundle: Path,
|
||||
plans_dir: Path,
|
||||
*,
|
||||
proposed_at: str,
|
||||
okf_type: str,
|
||||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
keep_table_heading: bool = False,
|
||||
) -> tuple[int, int, int]:
|
||||
"""Propose a plan per dropped file. Returns (written, nothing, failed).
|
||||
|
||||
|
|
@ -96,6 +115,10 @@ def _propose_plans(
|
|||
okf_type=okf_type,
|
||||
proposed_at=proposed_at,
|
||||
path_prefix=relative.with_suffix("").as_posix(),
|
||||
outline_run=outline_run,
|
||||
table_grid=table_grid,
|
||||
unit_fold=unit_fold,
|
||||
keep_table_heading=keep_table_heading,
|
||||
)
|
||||
except ProposerError as exc:
|
||||
print(f"{CLI_ID}: {relative.as_posix()}: {exc}", file=sys.stderr)
|
||||
|
|
@ -119,6 +142,10 @@ def build(
|
|||
segments: bool = True,
|
||||
plans_dir: Path | None = None,
|
||||
okf_type: str = "reference",
|
||||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
keep_table_heading: bool = False,
|
||||
) -> CorpusReport:
|
||||
"""Folder in, bundle out. The whole command, minus argument parsing.
|
||||
|
||||
|
|
@ -157,7 +184,15 @@ def build(
|
|||
target = plans_dir if plans_dir is not None else Path(scratch)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
written, nothing, failed = _propose_plans(
|
||||
inbox, bundle, target, proposed_at=proposed_at, okf_type=okf_type
|
||||
inbox,
|
||||
bundle,
|
||||
target,
|
||||
proposed_at=proposed_at,
|
||||
okf_type=okf_type,
|
||||
outline_run=outline_run,
|
||||
table_grid=table_grid,
|
||||
unit_fold=unit_fold,
|
||||
keep_table_heading=keep_table_heading,
|
||||
)
|
||||
print(
|
||||
f"{CLI_ID}: proposed {written} plan(s); {nothing} document(s) with no boundary; "
|
||||
|
|
@ -257,6 +292,53 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
"the run replayed"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--outline-run",
|
||||
type=int,
|
||||
default=0,
|
||||
metavar="N",
|
||||
help=(
|
||||
"Arm D, passed to the proposer unchanged: also propose a boundary at "
|
||||
"each line of the document's own numbered outline, where the integers "
|
||||
"sustain an ascending run of at least N. 0 (the default) is OFF and "
|
||||
"leaves the bundle byte-identical. Measured on a tender PDF whose "
|
||||
"headings are bare integers: no boundary at 0, nine at 3"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--table-grid",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Arm E, passed to the proposer unchanged: a pandoc grid-table rule "
|
||||
"line no longer closes an open table block, so one grid table is one "
|
||||
"concept instead of one per row group. Absent (the default) is OFF. "
|
||||
"Measured on the K3 sample: it changes a `.docx` experience list from "
|
||||
"21 concepts to 6"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--unit-fold",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Arm F, passed to the proposer unchanged: discard a contents-list run, "
|
||||
"fold a deeper heading into its parent, fold a table back into the "
|
||||
"shorter heading that introduces it. It adds no boundary, so it can "
|
||||
"only reduce a plan. Absent (the default) is OFF. Measured on the K3 "
|
||||
"sample: 5 of 12 documents match the operator's unit worksheet, "
|
||||
"against 2 for the shipped default, with no cell worse"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--keep-table-heading",
|
||||
action="store_true",
|
||||
help=(
|
||||
"D1, passed to the proposer unchanged: keep a heading whose body is "
|
||||
"empty only because a table opens under it, and absorb that table "
|
||||
"into its span. Absent (the default) is OFF. Measured on a tender "
|
||||
"price sheet: the concept count does not move (1 -> 1) and the "
|
||||
"concept gains the heading line it was missing"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument("--report", type=Path, default=None, help="also write the report")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
|
@ -277,6 +359,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||
segments=args.segments == "on",
|
||||
plans_dir=args.plans_dir,
|
||||
okf_type=args.okf_type,
|
||||
outline_run=args.outline_run,
|
||||
table_grid=args.table_grid,
|
||||
unit_fold=args.unit_fold,
|
||||
keep_table_heading=args.keep_table_heading,
|
||||
)
|
||||
except (IngestError, OSError, ValueError) as exc:
|
||||
print(f"{CLI_ID}: FAILED - {exc}", file=sys.stderr)
|
||||
|
|
|
|||
|
|
@ -239,6 +239,15 @@ class Candidate:
|
|||
#: a rule line -- a single-row grid table joins nothing and stays
|
||||
#: byte-identical to Arm D.
|
||||
grid: bool = False
|
||||
#: True when this candidate belongs to a run of page-numbered siblings that
|
||||
#: was a contents list BEFORE the orphan check thinned it. Set only when
|
||||
#: Arm F is on, because it exists only for Arm F's clause 1 to read: a
|
||||
#: contents list without dot leaders is a run of bodiless headings, so the
|
||||
#: orphan check deletes all but the last and the run clause 1 looks for is
|
||||
#: gone by the time `fold_units` sees the list. Computed where the whole
|
||||
#: pre-orphan list is still in hand, and nowhere else -- no candidate
|
||||
#: carrying it survives clause 1, so it never reaches an artifact.
|
||||
contents: bool = False
|
||||
|
||||
|
||||
def _is_stop_word_only(title: str) -> bool:
|
||||
|
|
@ -312,6 +321,7 @@ def find_candidates(
|
|||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
keep_table_heading: bool = False,
|
||||
) -> list[Candidate]:
|
||||
"""Every boundary the mechanical rules propose, in document order.
|
||||
|
||||
|
|
@ -338,6 +348,14 @@ def find_candidates(
|
|||
table block, so one grid table proposes one candidate instead of one per row
|
||||
group. It only ever REMOVES marks, which is what keeps every surviving
|
||||
candidate's `start` fixed and the orphan check monotone.
|
||||
|
||||
`keep_table_heading` is D1's gate and it is OFF at False. On, a heading
|
||||
whose body is empty ONLY because a table block opens under it keeps that
|
||||
table instead of being dropped: the table is absorbed into the heading's
|
||||
span rather than emitted, so the concept starts at the heading line. The
|
||||
count does not move -- one candidate either way -- and the first byte does.
|
||||
It is its own flag and not part of an arm because the orphan check is
|
||||
reached by every file type, and moving it is a decision about all of them.
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
offsets: list[int] = []
|
||||
|
|
@ -479,8 +497,24 @@ def find_candidates(
|
|||
# is still carried by a live candidate, so copying its title onto the table
|
||||
# as well would put one name on two concepts and rescue none.
|
||||
orphaned_name: tuple[str, str | None] | None = None
|
||||
# D1. Which table blocks a heading ABSORBS, decided before the pass that
|
||||
# consumes it: once a table is absorbed the heading's body is no longer
|
||||
# empty, so the orphan check below stops firing on it by itself and no
|
||||
# branch is needed there. Computed only when the caller asked, so every
|
||||
# other arm's `marked` -> `candidates` mapping is untouched code.
|
||||
absorbed = _absorbed_tables(text, marked, offsets, end_of_text) if keep_table_heading else set()
|
||||
# Arm F clause 1's input, and it must be read HERE: the orphan pass below
|
||||
# deletes every bodiless heading, which is every entry of a contents list
|
||||
# but the last, and a run of one is below `CONTENTS_RUN`.
|
||||
contents_run = _contents_run_positions(marked) if unit_fold else set()
|
||||
for position_in_list, (_, candidate) in enumerate(marked):
|
||||
following = marked[position_in_list + 1 :]
|
||||
if position_in_list in absorbed:
|
||||
continue
|
||||
following = [
|
||||
entry
|
||||
for position, entry in enumerate(marked)
|
||||
if position > position_in_list and position not in absorbed
|
||||
]
|
||||
end = offsets[following[0][0]] if following else end_of_text
|
||||
body = text[candidate.start : end]
|
||||
# The orphan check: everything after the heading line itself.
|
||||
|
|
@ -503,11 +537,77 @@ def find_candidates(
|
|||
start=candidate.start,
|
||||
end=end,
|
||||
grid=position_in_list in joined,
|
||||
contents=position_in_list in contents_run,
|
||||
)
|
||||
)
|
||||
return fold_units(candidates) if unit_fold else candidates
|
||||
|
||||
|
||||
def _absorbed_tables(
|
||||
text: str,
|
||||
marked: list[tuple[int, Candidate]],
|
||||
offsets: list[int],
|
||||
end_of_text: int,
|
||||
) -> set[int]:
|
||||
"""D1: positions in `marked` of table blocks a bodiless heading keeps.
|
||||
|
||||
The condition is the orphan check's own, evaluated on the UNABSORBED
|
||||
neighbour distance -- a heading is eligible only when the very next mark is
|
||||
a table block and there is nothing between them but the heading line. That
|
||||
is what keeps the variant from merging a section into a table it merely
|
||||
contains: a heading with a paragraph of its own is not orphaned, so it
|
||||
absorbs nothing.
|
||||
"""
|
||||
absorbed: set[int] = set()
|
||||
for position, (_, candidate) in enumerate(marked):
|
||||
if candidate.rule == RULE_TABLE_BLOCK or position + 1 >= len(marked):
|
||||
continue
|
||||
line_index, following = marked[position + 1]
|
||||
if following.rule != RULE_TABLE_BLOCK:
|
||||
continue
|
||||
body = text[candidate.start : offsets[line_index]]
|
||||
if body.splitlines()[1:] and "".join(body.splitlines()[1:]).strip():
|
||||
continue
|
||||
absorbed.add(position + 1)
|
||||
return absorbed
|
||||
|
||||
|
||||
def _contents_run_positions(marked: list[tuple[int, Candidate]]) -> set[int]:
|
||||
"""Arm F clause 1, measured on the PRE-orphan list. Same predicate as there.
|
||||
|
||||
A run of at least `CONTENTS_RUN` consecutive page-numbered headings, table
|
||||
blocks excluded and never a single line. Written once here and read by
|
||||
`fold_units` through `Candidate.contents`, so the two cannot drift into two
|
||||
definitions of what a contents list is.
|
||||
|
||||
LEVEL IS NOT PART OF THE PREDICATE, and that is the second round-2 change.
|
||||
A numbered report's contents list interleaves `1`, `1.1`, `1.1.1`, `2`, so
|
||||
requiring the members to be siblings breaks the run at every level change:
|
||||
on K3 position 7 the level-2 entries formed runs long enough to discard and
|
||||
`6.2.2 Tverrfaglig kontroll` did not, so one contents line was emitted as a
|
||||
concept while its neighbours were not. What still bounds the rule is the run
|
||||
LENGTH, which is what the `CONTENTS_RUN` sweep bought and is unchanged.
|
||||
"""
|
||||
inside: set[int] = set()
|
||||
index = 0
|
||||
while index < len(marked):
|
||||
candidate = marked[index][1]
|
||||
if candidate.rule == RULE_TABLE_BLOCK or not _TRAILING_PAGE_NUMBER.search(candidate.title):
|
||||
index += 1
|
||||
continue
|
||||
end = index
|
||||
while (
|
||||
end < len(marked)
|
||||
and marked[end][1].rule != RULE_TABLE_BLOCK
|
||||
and _TRAILING_PAGE_NUMBER.search(marked[end][1].title)
|
||||
):
|
||||
end += 1
|
||||
if end - index >= CONTENTS_RUN:
|
||||
inside.update(range(index, end))
|
||||
index = end
|
||||
return inside
|
||||
|
||||
|
||||
#: Arm F clause 1. How many CONSECUTIVE same-level page-numbered headings make
|
||||
#: a contents list. Three, and it is swept rather than guessed: at 1 and 2 the
|
||||
#: rule deletes body chapters (a heading like `... i henhold til TEK 17` ends
|
||||
|
|
@ -527,6 +627,9 @@ def fold_units(candidates: list[Candidate]) -> list[Candidate]:
|
|||
`CONTENTS_RUN` consecutive same-level headings each ending in a page
|
||||
number is a contents list and is discarded. A run of siblings, never a
|
||||
single line: one body heading ending in a number is not a contents list.
|
||||
The run is measured on this list AND on the list before the orphan check
|
||||
(`Candidate.contents`), because a contents list whose entries carry no
|
||||
dot leaders is bodiless and reaches here as one surviving line.
|
||||
2. "hvert h2-kapittel med sine h3" -- the unit level is the SHALLOWEST
|
||||
heading level occurring more than once; anything deeper folds into the
|
||||
preceding candidate at or above that level, which EXTENDS the parent's
|
||||
|
|
@ -543,8 +646,13 @@ def fold_units(candidates: list[Candidate]) -> list[Candidate]:
|
|||
if not candidates:
|
||||
return candidates
|
||||
|
||||
# Clause 1.
|
||||
drop: set[int] = set()
|
||||
# Clause 1. Two inputs, one predicate. `contents` carries the run measured
|
||||
# on the list BEFORE the orphan check thinned it -- a contents list with no
|
||||
# dot leaders is a run of bodiless headings, so only its last entry reaches
|
||||
# here and a run of one is below `CONTENTS_RUN`. The scan below still has
|
||||
# work to do: a contents list WITH dot leaders keeps every entry, and that
|
||||
# run exists only in this list.
|
||||
drop: set[int] = {position for position, c in enumerate(candidates) if c.contents}
|
||||
index = 0
|
||||
while index < len(candidates):
|
||||
candidate = candidates[index]
|
||||
|
|
@ -760,13 +868,18 @@ def build_plan(
|
|||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
keep_table_heading: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||
taken: set[str] = set()
|
||||
extractor_id = source.suffix.lower().lstrip(".") or "none"
|
||||
entries: list[dict[str, Any]] = []
|
||||
candidates = find_candidates(
|
||||
text, outline_run=outline_run, table_grid=table_grid, unit_fold=unit_fold
|
||||
text,
|
||||
outline_run=outline_run,
|
||||
table_grid=table_grid,
|
||||
unit_fold=unit_fold,
|
||||
keep_table_heading=keep_table_heading,
|
||||
)
|
||||
for candidate in subdivide(text, candidates, max_segment_chars):
|
||||
entries.append(
|
||||
|
|
@ -837,6 +950,7 @@ def run(
|
|||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
keep_table_heading: bool = False,
|
||||
) -> int:
|
||||
if max_segment_chars < 0:
|
||||
raise ProposerError(
|
||||
|
|
@ -889,6 +1003,7 @@ def run(
|
|||
outline_run=outline_run,
|
||||
table_grid=table_grid,
|
||||
unit_fold=unit_fold,
|
||||
keep_table_heading=keep_table_heading,
|
||||
)
|
||||
# Nothing to propose is an OUTCOME, and it is not an artifact. An empty
|
||||
# plan cannot be replayed -- `process_inbox` refuses one, because a plan
|
||||
|
|
@ -997,6 +1112,21 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
"either"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-table-heading",
|
||||
action="store_true",
|
||||
help=(
|
||||
"D1: keep a heading whose body is empty ONLY because a table block "
|
||||
"opens under it, and absorb that table into the heading's span "
|
||||
"instead of emitting it. Without this the orphan check drops the "
|
||||
"heading, the table inherits its NAME, and the concept starts at "
|
||||
"the first table row -- so the heading line is in no concept's "
|
||||
"body. Measured on a spreadsheet: the concept count does not move "
|
||||
"(1 -> 1), the first byte does. Absent (the default) is OFF and "
|
||||
"leaves every artifact byte-identical. Its own flag rather than "
|
||||
"part of an arm: the orphan check is reached by every file type"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--proposed-at",
|
||||
default="1970-01-01T00:00:00Z",
|
||||
|
|
@ -1018,6 +1148,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
outline_run=args.outline_run,
|
||||
table_grid=args.table_grid,
|
||||
unit_fold=args.unit_fold,
|
||||
keep_table_heading=args.keep_table_heading,
|
||||
)
|
||||
except ProposerError as exc:
|
||||
print(f"{PROPOSER_ID}: FAILED - {exc}", file=sys.stderr)
|
||||
|
|
|
|||
|
|
@ -469,3 +469,115 @@ def test_the_root_index_does_not_link_the_run_log(tmp_path: Path) -> None:
|
|||
}
|
||||
assert concepts, "the fixture must produce concepts for this control to mean anything"
|
||||
assert len(okf_consume.enumerate_concepts(bundle)) == len(concepts)
|
||||
|
||||
|
||||
# --- the arms are reachable, and still off ---------------------------------
|
||||
#
|
||||
# Round 2, 2026-09-08. `_propose_plans` called the proposer with no arm flag at
|
||||
# all, so `okf build` ran Arm B while `tools/okf_propose_segments.py` could run
|
||||
# Arm D, E and F. Measured on a five-document folder: one tender PDF landed as
|
||||
# ONE concept from the build path and as NINE from the proposer with
|
||||
# `--outline-run 3`, and nine is what the operator's unit worksheet asks for.
|
||||
# The default is unchanged -- that is the operator's decision, not this
|
||||
# layer's -- but the flags now exist here.
|
||||
|
||||
# Two headings the mechanical rules match at two levels, plus a table opening
|
||||
# directly under a heading: enough for Arm F's clause 2 and D1 to have
|
||||
# something to do, so "the flag reached the proposer" is measured on the
|
||||
# ARTIFACT and not on the parser.
|
||||
ARM_DOCUMENTS = {
|
||||
"delta.md": (
|
||||
"## 4 Grunnforhold\n\nGrunnen er morene over berg i hele omraadet.\n\n"
|
||||
"### 4.1 Loesmasser\n\nLoesmassene er telefarlige og maa skiftes ut.\n"
|
||||
),
|
||||
"epsilon.md": ("## 5 Prissammenstilling\n\n| Post | Sum |\n|------|-----|\n| 01 | 100 |\n"),
|
||||
}
|
||||
|
||||
|
||||
def inbox_for_arms(root: Path) -> Path:
|
||||
inbox = root / "arm-inbox"
|
||||
inbox.mkdir(parents=True, exist_ok=True)
|
||||
for name, body in ARM_DOCUMENTS.items():
|
||||
(inbox / name).write_text(body, encoding="utf-8", newline="")
|
||||
return inbox
|
||||
|
||||
|
||||
def plan_titles(plans: Path) -> list[str]:
|
||||
import json
|
||||
|
||||
titles: list[str] = []
|
||||
for path in sorted(plans.glob("*.json")):
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
titles.extend(str(entry["title"]) for entry in payload["entries"])
|
||||
return titles
|
||||
|
||||
|
||||
def test_the_arms_reach_the_proposer_from_the_build_command(tmp_path: Path) -> None:
|
||||
"""The red test for round 2: the flags exist here and they change the plan.
|
||||
|
||||
Asserted on the PLANS, which is where an arm's effect is visible, and on
|
||||
titles rather than a count -- a fold that kept the right number of concepts
|
||||
by discarding the wrong ones would pass a count assertion.
|
||||
|
||||
`--unit-fold` folds `4.1 Loesmasser` into `4 Grunnforhold`;
|
||||
`--keep-table-heading` keeps `5 Prissammenstilling` as the heading it is
|
||||
instead of letting the table block carry the name.
|
||||
"""
|
||||
inbox = inbox_for_arms(tmp_path)
|
||||
plans = tmp_path / "plans-armed"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
tmp_path / "bundle-armed",
|
||||
"--plans-dir",
|
||||
str(plans),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--outline-run",
|
||||
"3",
|
||||
"--table-grid",
|
||||
"--unit-fold",
|
||||
"--keep-table-heading",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert plan_titles(plans) == ["4 Grunnforhold", "5 Prissammenstilling"]
|
||||
|
||||
|
||||
def test_the_build_default_is_unchanged_by_the_flags_existing(tmp_path: Path) -> None:
|
||||
"""The control the test above rests on: same inbox, no flags, Arm B.
|
||||
|
||||
Both halves over the same fixture. Without this, a change that turned an
|
||||
arm ON by default would leave the assertion above green and move every
|
||||
bundle every consumer builds.
|
||||
"""
|
||||
inbox = inbox_for_arms(tmp_path)
|
||||
plans = tmp_path / "plans-plain"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
tmp_path / "bundle-plain",
|
||||
"--plans-dir",
|
||||
str(plans),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert plan_titles(plans) == ["4 Grunnforhold", "4.1 Loesmasser", "5 Prissammenstilling"]
|
||||
|
||||
|
||||
def test_a_bundle_built_with_no_flags_is_byte_identical_to_the_shipped_one(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The byte control, on the tree the other build tests use.
|
||||
|
||||
The proposer gained a parameter and the CLI gained four. A default that
|
||||
moved by a byte would break rebuild-equals-incremental for every consumer
|
||||
who never passes a flag, and a title assertion cannot see that.
|
||||
"""
|
||||
inbox = inbox_with_subdirectories(tmp_path)
|
||||
reference = two_script_bundle(inbox, tmp_path / "reference-2")
|
||||
bundle = tmp_path / "cli-bundle-2"
|
||||
assert build(inbox, bundle, "--ingested-at", INGESTED_AT, "--proposed-at", PROPOSED_AT) == 0
|
||||
assert tree(bundle) == tree(reference)
|
||||
|
|
|
|||
|
|
@ -1709,6 +1709,17 @@ def test_a_contents_run_is_discarded_and_the_body_survives() -> None:
|
|||
that `_TRAILING_PAGE_NUMBER` reads as a contents line, and discarding it
|
||||
would delete a chapter. Measured on the K3 sample: one such heading at
|
||||
position 1.
|
||||
|
||||
ROUND 2 MOVED ONE EXPECTATION HERE, and it is stated rather than quietly
|
||||
updated: `Innhold 1` used to survive because the run required its members
|
||||
to share a LEVEL, and it is a level-1 heading in front of three level-2
|
||||
ones. The level condition is gone (a numbered report's contents list
|
||||
interleaves `1`, `1.1`, `1.1.1`), so the run is now four and the contents
|
||||
HEADING goes with the contents list -- which is what "innholdsfortegnelsen
|
||||
er ikke konsepter" says. The cost is real and bounded: this heading has a
|
||||
body of its own in the fixture, and discarding the candidate leaves that
|
||||
body in no segment. Measured on the corpus, the relaxation changes ONE
|
||||
document of 39, and there it removes exactly the leftover contents line.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(CONTENTS_THEN_BODY)
|
||||
assert [c.title for c in off] == [
|
||||
|
|
@ -1721,7 +1732,7 @@ def test_a_contents_run_is_discarded_and_the_body_survives() -> None:
|
|||
"Vurdering",
|
||||
]
|
||||
on = okf_propose_segments.find_candidates(CONTENTS_THEN_BODY, unit_fold=True)
|
||||
assert [c.title for c in on] == ["Innhold 1", "Innledning", "Grunnforhold", "Vurdering"]
|
||||
assert [c.title for c in on] == ["Innledning", "Grunnforhold", "Vurdering"]
|
||||
|
||||
|
||||
def test_a_deeper_heading_folds_into_its_parent() -> None:
|
||||
|
|
@ -1844,3 +1855,206 @@ def test_a_recovered_outline_level_does_not_decide_the_unit() -> None:
|
|||
"Loesmasser",
|
||||
"Berggrunn",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round 2, 2026-09-08: two mechanisms the K3 per-file-type row named.
|
||||
#
|
||||
# Both stay behind flags that are off by default. The first refines Arm F's
|
||||
# clause 1 (`--unit-fold`); the second is its own gate, because it changes the
|
||||
# orphan check, which every file type reaches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A contents list as a PDF converter usually leaves one: no dot leaders, so
|
||||
# every entry but the last is bodiless and the orphan check removes it. What
|
||||
# reaches Arm F is a run of ONE, which is below `CONTENTS_RUN`, so clause 1
|
||||
# cannot fire on the line that survived. This is the K3 position 9 shape.
|
||||
BODILESS_CONTENTS = """## Formaalet med planen 4
|
||||
## Orientering om prosjektet 4
|
||||
## Maalsetting for SHA 5
|
||||
|
||||
SHA-plan for Stange skole, revisjon A.
|
||||
|
||||
## Formaalet med planen
|
||||
|
||||
Planen skal sikre arbeidet.
|
||||
|
||||
## Orientering om prosjektet
|
||||
|
||||
Prosjektet gjelder en utvidelse.
|
||||
|
||||
## Maalsetting for SHA
|
||||
|
||||
Null skader er maalet.
|
||||
"""
|
||||
|
||||
# The known-negative for the same refinement: ONE bodiless page-numbered
|
||||
# heading, no run at all. A rule that discarded it would delete a chapter whose
|
||||
# title happens to end in a number.
|
||||
SINGLE_BODILESS_PAGE_NUMBER = """## Sikkerhet i henhold til TEK 17
|
||||
|
||||
Kravene staar i forskriften.
|
||||
|
||||
## Grunnforhold
|
||||
|
||||
Loesmasser og berg.
|
||||
"""
|
||||
|
||||
# The D1 shape: a sheet heading with a table opening on the line below it, so
|
||||
# the heading's own body is empty. This is what a spreadsheet extracts to.
|
||||
HEADING_THEN_TABLE = """## Prissammenstilling
|
||||
|
||||
| Post | Sum |
|
||||
|------|-----|
|
||||
| 01 | 100 |
|
||||
| 02 | 200 |
|
||||
"""
|
||||
|
||||
# The known-negative for D1: the heading has a body of its own, so it is not
|
||||
# orphaned and nothing may be absorbed.
|
||||
HEADING_WITH_BODY_THEN_TABLE = """## Prissammenstilling
|
||||
|
||||
Tabellen under viser postene i skjemaet.
|
||||
|
||||
| Post | Sum |
|
||||
|------|-----|
|
||||
| 01 | 100 |
|
||||
"""
|
||||
|
||||
|
||||
def test_a_bodiless_contents_run_is_discarded_too() -> None:
|
||||
"""Clause 1 counts the run BEFORE the orphan check, not after.
|
||||
|
||||
The mechanism, measured: the orphan check (`find_candidates`) drops every
|
||||
heading with an empty body, and a contents list without dot leaders is
|
||||
exactly a sequence of such headings. All but the LAST entry are gone before
|
||||
`fold_units` sees the list, so the run clause 1 looks for has length one and
|
||||
the surviving contents line is emitted as a concept. Measured on the K3
|
||||
sample: position 9 emits `Informasjon om og oppdatering av SHA-planen 5`
|
||||
and position 7 emits `Tverrfaglig kontroll ....`, one leftover each.
|
||||
|
||||
Both halves over the same text: the flag-on assertion alone would stay
|
||||
green if the default moved with it.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(BODILESS_CONTENTS)
|
||||
assert [c.title for c in off] == [
|
||||
"Maalsetting for SHA 5",
|
||||
"Formaalet med planen",
|
||||
"Orientering om prosjektet",
|
||||
"Maalsetting for SHA",
|
||||
]
|
||||
on = okf_propose_segments.find_candidates(BODILESS_CONTENTS, unit_fold=True)
|
||||
assert [c.title for c in on] == [
|
||||
"Formaalet med planen",
|
||||
"Orientering om prosjektet",
|
||||
"Maalsetting for SHA",
|
||||
]
|
||||
|
||||
|
||||
def test_a_single_bodiless_page_numbered_heading_survives() -> None:
|
||||
"""The known-negative for the refinement above, and it is load-bearing.
|
||||
|
||||
Counting the run before the orphan check makes MORE candidates eligible for
|
||||
clause 1, so the guard the sweep bought -- a run of siblings, never a single
|
||||
line -- has to be measured again on the pre-orphan list. `TEK 17` is the
|
||||
real heading that guard exists for.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(SINGLE_BODILESS_PAGE_NUMBER)
|
||||
on = okf_propose_segments.find_candidates(SINGLE_BODILESS_PAGE_NUMBER, unit_fold=True)
|
||||
assert [c.title for c in off] == ["Sikkerhet i henhold til TEK 17", "Grunnforhold"]
|
||||
assert off == on
|
||||
|
||||
|
||||
def test_a_heading_keeps_the_table_that_opens_under_it() -> None:
|
||||
"""D1, behind `--keep-table-heading`, off by default.
|
||||
|
||||
Default: the heading is bodiless, so the orphan check drops it and the
|
||||
table block inherits its NAME -- the concept is named right but starts at
|
||||
the table, so the heading line itself is not in the body any consumer reads.
|
||||
On: the heading survives and its span extends over the table, which is
|
||||
absorbed rather than emitted. The count does not move; the concept's first
|
||||
byte does.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(HEADING_THEN_TABLE)
|
||||
assert [(c.title, c.rule) for c in off] == [
|
||||
("Prissammenstilling", okf_propose_segments.RULE_TABLE_BLOCK)
|
||||
]
|
||||
assert off[0].start == HEADING_THEN_TABLE.index("| Post")
|
||||
|
||||
on = okf_propose_segments.find_candidates(HEADING_THEN_TABLE, keep_table_heading=True)
|
||||
assert [(c.title, c.rule) for c in on] == [
|
||||
("Prissammenstilling", okf_propose_segments.RULE_HEADING)
|
||||
]
|
||||
assert on[0].start == 0
|
||||
assert on[0].end == off[0].end
|
||||
|
||||
|
||||
def test_a_heading_with_its_own_body_absorbs_nothing() -> None:
|
||||
"""The known-negative for D1: identical objects, not merely an equal count.
|
||||
|
||||
The gate is CONDITIONED on the drop. A heading that keeps its own body is
|
||||
already carried by a live candidate, so absorbing the table as well would
|
||||
silently merge two concepts on every document that has a table under a
|
||||
section -- a default-shaped change wearing a flag.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(HEADING_WITH_BODY_THEN_TABLE)
|
||||
on = okf_propose_segments.find_candidates(HEADING_WITH_BODY_THEN_TABLE, keep_table_heading=True)
|
||||
assert len(off) == 2
|
||||
assert off == on
|
||||
|
||||
|
||||
def test_keep_table_heading_takes_no_argument(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A boolean at the CLI: the variant has no number to sweep."""
|
||||
source = write(tmp_path, HEADING_THEN_TABLE, "noarg-d1.md")
|
||||
out = tmp_path / "noarg-d1.json"
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
okf_propose_segments.main([str(source), "--out", str(out), "--keep-table-heading", "3"])
|
||||
assert exit_info.value.code == 2
|
||||
assert "usage:" in capsys.readouterr().err
|
||||
|
||||
|
||||
# A contents list as a numbered report leaves one: the dotted entries carry
|
||||
# their own depth, so `1.1`, `1.1.1`, `2.1` INTERLEAVE levels and no three
|
||||
# consecutive entries are siblings. This is the K3 position 7 shape, where the
|
||||
# level-2 entries fell in same-level runs and `6.2.2` did not.
|
||||
INTERLEAVED_CONTENTS = """1.1 Om dette dokumentet ......... 4
|
||||
|
||||
1.1.1 Bakgrunn ......... 4
|
||||
|
||||
2.1 Versjoner ......... 5
|
||||
|
||||
Revisjon 04, for bruk til entreprise.
|
||||
|
||||
## Innledning
|
||||
|
||||
Bakgrunnen for arbeidet.
|
||||
|
||||
## Programvare
|
||||
|
||||
Programvaren er angitt her.
|
||||
"""
|
||||
|
||||
|
||||
def test_a_contents_run_crosses_heading_levels() -> None:
|
||||
"""Clause 1 counts consecutive page-numbered headings, not siblings.
|
||||
|
||||
The mechanism, measured on K3 position 7: a numbered report's contents list
|
||||
interleaves `1.1`, `1.1.1`, `2.1`, so requiring the run's members to share
|
||||
a LEVEL breaks it at every level change. There the 34 contents entries are
|
||||
one continuous block of page-numbered headings at levels 2 and 3, and the
|
||||
level condition cut it into runs of 9, 1, 1, 1, 5, 2, 10, 2 and 3 -- so the
|
||||
two-long runs survived and `6.2.2 Tverrfaglig kontroll ....` was emitted as
|
||||
a concept while its neighbours were discarded.
|
||||
|
||||
The guard the sweep bought is unchanged and is still what makes this safe:
|
||||
a run of at least `CONTENTS_RUN`, never a single line. One body heading
|
||||
ending in a number is not a contents list, and that case has its own test.
|
||||
Measured on the corpus, dropping the level condition changes ONE document
|
||||
of 39, and there it removes exactly that leftover line.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(INTERLEAVED_CONTENTS)
|
||||
assert [c.title for c in off] == ["Versjoner ......... 5", "Innledning", "Programvare"]
|
||||
on = okf_propose_segments.find_candidates(INTERLEAVED_CONTENTS, unit_fold=True)
|
||||
assert [c.title for c in on] == ["Innledning", "Programvare"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue