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
|
recursively, and two same-named documents in different folders must not
|
||||||
collide); `--ingested-at` and `--proposed-at` default to one shared epoch
|
collide); `--ingested-at` and `--proposed-at` default to one shared epoch
|
||||||
constant rather than the clock, because a wall-clock default takes
|
constant rather than the clock, because a wall-clock default takes
|
||||||
rebuild-equals-incremental away from anyone who omits them. Arm C, Arm D,
|
rebuild-equals-incremental away from anyone who omits them.
|
||||||
Arm E and Arm F are off and not exposed here -- which is measured, not
|
**Four segmentation rules are REACHABLE here and all four are OFF by
|
||||||
incidental: the build path calls the proposer with no arm flag at all, so a
|
default** (2026-09-08): `--outline-run N` (Arm D), `--table-grid` (Arm E),
|
||||||
tender PDF that Arm D splits into nine concepts lands as one
|
`--unit-fold` (Arm F) and `--keep-table-heading` (D1), each passed to the
|
||||||
(`docs/2026-09-08-k3-arm-f-mot-enhetsarket.md`, the `okf build` section).
|
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>"
|
- Consume a bundle: `python3 tools/okf_consume.py <bundle> --question "<q>"
|
||||||
[--k N] [--limit N] [--out PATH] [--ref IDENTITY]` — the **pre-pass**
|
[--k N] [--limit N] [--out PATH] [--ref IDENTITY]` — the **pre-pass**
|
||||||
`docs/consumption-contract.md` § 1 defines, and the only reading direction
|
`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
|
wall-clock default would break rebuild-equals-incremental for every caller who
|
||||||
did not pass them.
|
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
|
Measured 2026-09-08 on a 43-file corpus (33 `pdf`, 5 `docx`, 2 `xlsx`, and
|
||||||
three files no reader accepts), one
|
three files no reader accepts), one
|
||||||
`okf build` invocation replacing the shell loop over `tools/` that produced the
|
`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
|
## What it does not decide
|
||||||
|
|
||||||
Arm C (`--max-segment-chars`), Arm D (`--outline-run`) and Arm E
|
**Every arm is OFF unless the caller asks, and this command does not move a
|
||||||
(`--table-grid`) are OFF here and are not exposed: they are measurement arms,
|
default.** Which arm should ship as the default is the operator's decision and
|
||||||
all off by default by operator decision, and a build command is not where an
|
is not taken here.
|
||||||
unadjudicated segmentation heuristic should become one flag away. `tools/`
|
|
||||||
still reaches them.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -71,7 +81,16 @@ DEFAULT_STAMP = "1970-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
|
||||||
def _propose_plans(
|
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]:
|
) -> tuple[int, int, int]:
|
||||||
"""Propose a plan per dropped file. Returns (written, nothing, failed).
|
"""Propose a plan per dropped file. Returns (written, nothing, failed).
|
||||||
|
|
||||||
|
|
@ -96,6 +115,10 @@ def _propose_plans(
|
||||||
okf_type=okf_type,
|
okf_type=okf_type,
|
||||||
proposed_at=proposed_at,
|
proposed_at=proposed_at,
|
||||||
path_prefix=relative.with_suffix("").as_posix(),
|
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:
|
except ProposerError as exc:
|
||||||
print(f"{CLI_ID}: {relative.as_posix()}: {exc}", file=sys.stderr)
|
print(f"{CLI_ID}: {relative.as_posix()}: {exc}", file=sys.stderr)
|
||||||
|
|
@ -119,6 +142,10 @@ def build(
|
||||||
segments: bool = True,
|
segments: bool = True,
|
||||||
plans_dir: Path | None = None,
|
plans_dir: Path | None = None,
|
||||||
okf_type: str = "reference",
|
okf_type: str = "reference",
|
||||||
|
outline_run: int = 0,
|
||||||
|
table_grid: bool = False,
|
||||||
|
unit_fold: bool = False,
|
||||||
|
keep_table_heading: bool = False,
|
||||||
) -> CorpusReport:
|
) -> CorpusReport:
|
||||||
"""Folder in, bundle out. The whole command, minus argument parsing.
|
"""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 = plans_dir if plans_dir is not None else Path(scratch)
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
written, nothing, failed = _propose_plans(
|
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(
|
print(
|
||||||
f"{CLI_ID}: proposed {written} plan(s); {nothing} document(s) with no boundary; "
|
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"
|
"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")
|
build_parser.add_argument("--report", type=Path, default=None, help="also write the report")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
@ -277,6 +359,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
segments=args.segments == "on",
|
segments=args.segments == "on",
|
||||||
plans_dir=args.plans_dir,
|
plans_dir=args.plans_dir,
|
||||||
okf_type=args.okf_type,
|
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:
|
except (IngestError, OSError, ValueError) as exc:
|
||||||
print(f"{CLI_ID}: FAILED - {exc}", file=sys.stderr)
|
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
|
#: a rule line -- a single-row grid table joins nothing and stays
|
||||||
#: byte-identical to Arm D.
|
#: byte-identical to Arm D.
|
||||||
grid: bool = False
|
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:
|
def _is_stop_word_only(title: str) -> bool:
|
||||||
|
|
@ -312,6 +321,7 @@ def find_candidates(
|
||||||
outline_run: int = 0,
|
outline_run: int = 0,
|
||||||
table_grid: bool = False,
|
table_grid: bool = False,
|
||||||
unit_fold: bool = False,
|
unit_fold: bool = False,
|
||||||
|
keep_table_heading: bool = False,
|
||||||
) -> list[Candidate]:
|
) -> list[Candidate]:
|
||||||
"""Every boundary the mechanical rules propose, in document order.
|
"""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
|
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
|
group. It only ever REMOVES marks, which is what keeps every surviving
|
||||||
candidate's `start` fixed and the orphan check monotone.
|
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)
|
lines = text.splitlines(keepends=True)
|
||||||
offsets: list[int] = []
|
offsets: list[int] = []
|
||||||
|
|
@ -479,8 +497,24 @@ def find_candidates(
|
||||||
# is still carried by a live candidate, so copying its title onto the table
|
# 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.
|
# as well would put one name on two concepts and rescue none.
|
||||||
orphaned_name: tuple[str, str | None] | None = 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):
|
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
|
end = offsets[following[0][0]] if following else end_of_text
|
||||||
body = text[candidate.start : end]
|
body = text[candidate.start : end]
|
||||||
# The orphan check: everything after the heading line itself.
|
# The orphan check: everything after the heading line itself.
|
||||||
|
|
@ -503,11 +537,77 @@ def find_candidates(
|
||||||
start=candidate.start,
|
start=candidate.start,
|
||||||
end=end,
|
end=end,
|
||||||
grid=position_in_list in joined,
|
grid=position_in_list in joined,
|
||||||
|
contents=position_in_list in contents_run,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return fold_units(candidates) if unit_fold else candidates
|
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
|
#: 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
|
#: 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
|
#: 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
|
`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
|
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.
|
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
|
2. "hvert h2-kapittel med sine h3" -- the unit level is the SHALLOWEST
|
||||||
heading level occurring more than once; anything deeper folds into the
|
heading level occurring more than once; anything deeper folds into the
|
||||||
preceding candidate at or above that level, which EXTENDS the parent's
|
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:
|
if not candidates:
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
# Clause 1.
|
# Clause 1. Two inputs, one predicate. `contents` carries the run measured
|
||||||
drop: set[int] = set()
|
# 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
|
index = 0
|
||||||
while index < len(candidates):
|
while index < len(candidates):
|
||||||
candidate = candidates[index]
|
candidate = candidates[index]
|
||||||
|
|
@ -760,13 +868,18 @@ def build_plan(
|
||||||
outline_run: int = 0,
|
outline_run: int = 0,
|
||||||
table_grid: bool = False,
|
table_grid: bool = False,
|
||||||
unit_fold: bool = False,
|
unit_fold: bool = False,
|
||||||
|
keep_table_heading: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||||
taken: set[str] = set()
|
taken: set[str] = set()
|
||||||
extractor_id = source.suffix.lower().lstrip(".") or "none"
|
extractor_id = source.suffix.lower().lstrip(".") or "none"
|
||||||
entries: list[dict[str, Any]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
candidates = find_candidates(
|
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):
|
for candidate in subdivide(text, candidates, max_segment_chars):
|
||||||
entries.append(
|
entries.append(
|
||||||
|
|
@ -837,6 +950,7 @@ def run(
|
||||||
outline_run: int = 0,
|
outline_run: int = 0,
|
||||||
table_grid: bool = False,
|
table_grid: bool = False,
|
||||||
unit_fold: bool = False,
|
unit_fold: bool = False,
|
||||||
|
keep_table_heading: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
if max_segment_chars < 0:
|
if max_segment_chars < 0:
|
||||||
raise ProposerError(
|
raise ProposerError(
|
||||||
|
|
@ -889,6 +1003,7 @@ def run(
|
||||||
outline_run=outline_run,
|
outline_run=outline_run,
|
||||||
table_grid=table_grid,
|
table_grid=table_grid,
|
||||||
unit_fold=unit_fold,
|
unit_fold=unit_fold,
|
||||||
|
keep_table_heading=keep_table_heading,
|
||||||
)
|
)
|
||||||
# Nothing to propose is an OUTCOME, and it is not an artifact. An empty
|
# 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
|
# 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"
|
"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(
|
parser.add_argument(
|
||||||
"--proposed-at",
|
"--proposed-at",
|
||||||
default="1970-01-01T00:00:00Z",
|
default="1970-01-01T00:00:00Z",
|
||||||
|
|
@ -1018,6 +1148,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
outline_run=args.outline_run,
|
outline_run=args.outline_run,
|
||||||
table_grid=args.table_grid,
|
table_grid=args.table_grid,
|
||||||
unit_fold=args.unit_fold,
|
unit_fold=args.unit_fold,
|
||||||
|
keep_table_heading=args.keep_table_heading,
|
||||||
)
|
)
|
||||||
except ProposerError as exc:
|
except ProposerError as exc:
|
||||||
print(f"{PROPOSER_ID}: FAILED - {exc}", file=sys.stderr)
|
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 concepts, "the fixture must produce concepts for this control to mean anything"
|
||||||
assert len(okf_consume.enumerate_concepts(bundle)) == len(concepts)
|
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
|
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
|
would delete a chapter. Measured on the K3 sample: one such heading at
|
||||||
position 1.
|
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)
|
off = okf_propose_segments.find_candidates(CONTENTS_THEN_BODY)
|
||||||
assert [c.title for c in off] == [
|
assert [c.title for c in off] == [
|
||||||
|
|
@ -1721,7 +1732,7 @@ def test_a_contents_run_is_discarded_and_the_body_survives() -> None:
|
||||||
"Vurdering",
|
"Vurdering",
|
||||||
]
|
]
|
||||||
on = okf_propose_segments.find_candidates(CONTENTS_THEN_BODY, unit_fold=True)
|
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:
|
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",
|
"Loesmasser",
|
||||||
"Berggrunn",
|
"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