feat(propose,cli): the coverage gap had one cause, and round 7's own decomposition did not reproduce

Round 7 named two open items: a table-block candidate displacing a declared
heading (26 041 characters between entries, `md` at 3 of 4 declared headings)
and 17 590 characters after the last entry, never examined. Measured on
`a364ef4`, the first premise does not reproduce and the second is not a
separate mechanism.

`md` recovers 4 of 4, not 3 of 4. D1 (`--keep-table-heading`) is the repair
for exactly the mechanism round 7 § 5 describes, and round 7 moved it into
the default in the same commit -- so § 5 is a pre-move measurement presented
as the post-move state. That is round 7's own trap, stated in its own report:
a number about a rule is a measurement of a configuration. Nothing held the
cell, which is why it could be wrong and stay wrong.

The remaining gap has ONE cause. Every rule closes a span against the NEXT
MARK; three steps then remove a mark after its neighbour's `end` was fixed
against it. The orphan check leaks 18 527 characters over 15 of 39 documents,
`fold_units` clause 1 leaks 7 514 between entries, and the same clause on the
last run leaks all 17 590 tail characters -- with `unit_fold=False` the corpus
tail gap is 0. Round 6 already established the principle (filter at admission,
let the mark above carry the text) and it was never applied post-filter.

`--close-span-gaps` states it once, after the fold, where every removal has
happened: a span runs to the next SURVIVOR, the last to the end of the text.
It adds no boundary. Measured: 43 631 characters (2.51 %) -> 0, entries
429 = 429, K2 concepts 436 = 436, the operator's folder 52 md = 52.

All four acceptance conditions hold at once. The 12-position reference is
label-identical -- 11 of 12 under |F|[3]=12 and 10 of 12 under |F|[3]=11,
both readings reported, not one position moved. hit@8 holds [1,1,1,1,1,-] on
the round 8 bundle (436), the round 7 default (436) and Arm B (629), and the
known-negative reproduces on the new bytes: `--no-tie-shared-rank` gives
[2,1,1,1,1,-].

The pin is rebuilt against the default that actually ships
(`K2-bundle-default-20260911`, 832 files, digest 8c93e5e3...); two independent
builds of it differ in nothing, `log.md` included.

Two measurement failures are recorded rather than hidden. The corpus identity
check first used `xargs` without `-print0`, every filename split on its
spaces, `shasum` read nothing, and both sides agreed on a digest of the
failure; a control against the digest of empty input separated them. And the
smoke build cannot prove the flag arrives -- the operator's folder has a
coverage gap of zero already, so `diff -rq` is 0 either way; the plumbing has
its own test on a document that has the defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-09 17:43:56 +02:00
commit 956714594d
8 changed files with 592 additions and 22 deletions

View file

@ -276,16 +276,17 @@ and fixtures, never code.
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. rebuild-equals-incremental away from anyone who omits them.
**Eight segmentation rules are REACHABLE here, and since 2026-09-10 ALL **Nine segmentation rules are REACHABLE here, and since 2026-09-11 ALL
EIGHT are ON by default** -- `--outline-run 3`, `--table-grid` and NINE are ON by default** -- `--outline-run 3`, `--table-grid` and
`--unit-fold` since 2026-09-08, `--drop-wrapped-outline` and `--unit-fold` since 2026-09-08, `--drop-wrapped-outline` and
`--outline-gate` since 2026-09-09, `--sheet-section-rows`, `--outline-gate` since 2026-09-09, `--sheet-section-rows`,
`--keep-table-heading` and `--first-span-from-zero` since 2026-09-10, each `--keep-table-heading` and `--first-span-from-zero` since 2026-09-10, and
`--close-span-gaps` since 2026-09-11, each
with an explicit opt-out (`--outline-run 0`, `--no-table-grid`, with an explicit opt-out (`--outline-run 0`, `--no-table-grid`,
`--no-unit-fold`, `--keep-wrapped-outline`, `--no-outline-gate`, `--no-unit-fold`, `--keep-wrapped-outline`, `--no-outline-gate`,
`--no-sheet-section-rows`, `--no-keep-table-heading`, `--no-sheet-section-rows`, `--no-keep-table-heading`,
`--no-first-span-from-zero`) that together reproduce the pre-move bytes -- `--no-first-span-from-zero`, `--no-close-span-gaps`) that together reproduce
measured, `diff -rq` 0 differences, not asserted. **The 2026-09-09 pair is one the pre-move bytes -- measured, `diff -rq` 0 differences, not asserted. **The 2026-09-09 pair is one
decision and cannot be split**: the gate takes `pdf` from 2 of 8 to 5 of 8 decision and cannot be split**: the gate takes `pdf` from 2 of 8 to 5 of 8
and the pair takes it to 7 of 8 (the sheet 5 of 12 -> 10 of 12, `docx` and the pair takes it to 7 of 8 (the sheet 5 of 12 -> 10 of 12, `docx`
unchanged at 3 of 3). **The gate is G1+G2:** Arm D's RECOVERED headings are unchanged at 3 of 3). **The gate is G1+G2:** Arm D's RECOVERED headings are
@ -336,10 +337,27 @@ and fixtures, never code.
decision cites belongs to another configuration:** Arm F's 5 of 12 was decision cites belongs to another configuration:** Arm F's 5 of 12 was
measured with `--table-grid` ON; without it the same sample scores 2 of 12 measured with `--table-grid` ON; without it the same sample scores 2 of 12
and `docx` 0 of 3, because the fold's table clause has no joined table to and `docx` 0 of 3, because the fold's table clause has no joined table to
fold. The eight: `--outline-run N` (Arm D), `--table-grid` (Arm E), fold. The nine: `--outline-run N` (Arm D), `--table-grid` (Arm E),
`--unit-fold` (Arm F), `--keep-table-heading` (D1), `--sheet-section-rows` `--unit-fold` (Arm F), `--keep-table-heading` (D1), `--sheet-section-rows`
and `--drop-wrapped-outline` (both D3), `--outline-gate` (G1+G2) and and `--drop-wrapped-outline` (both D3), `--outline-gate` (G1+G2),
`--first-span-from-zero`, each passed to the proposer unchanged. That last `--first-span-from-zero` and `--close-span-gaps`, each passed to the
proposer unchanged. **The last two are the same defect at two ends and
NEITHER is a segmentation rule**: a mark removed after its neighbour's span
was closed takes that text out of the plan. Round 8 measured the whole
remainder -- 43 631 characters, 2.51 %, over 8 of 32 documents with a plan --
down to **0**, with the concept count identical at 436 and every hit@8 row
holding rank 1 on both K2 bundles and the reference sheet label-identical at
11 of 12. Three steps leak: the orphan check (18 527 characters over 15 of
39 documents), `fold_units` clause 1 between entries (7 514) and the same
clause on the last run (all 17 590 tail characters; with `unit_fold=False`
the corpus tail gap is 0). **Round 7's own § 5 does not reproduce**: it
reports `md` at 3 of 4 declared headings and a `rule:table-block` displacing
`## 3 Prising`, but D1 -- the repair for exactly that -- became the default
in the same commit, so the number describes the configuration that existed
before the move. On `a364ef4` the default recovers **4 of 4**, and
`tests/test_md_declared_headings.py` now holds the cell with its cause as a
known-negative. Report:
`docs/2026-09-11-k3-runde8-tabellblokk-og-siste-spenn.md`. That last
one is ON since 2026-09-10 and is not a segmentation rule at all -- it adds one is ON since 2026-09-10 and is not a segmentation rule at all -- it adds
no boundary, and the K2 concept count is identical with and without it no boundary, and the K2 concept count is identical with and without it
(425 = 425 on the 2026-09-09 default). It repairs a measured loss: **32 of (425 = 425 on the 2026-09-09 default). It repairs a measured loss: **32 of

View file

@ -88,14 +88,16 @@ did not pass them.
### The segmentation flags ### The segmentation flags
Eight rules are reachable from `okf build`, and since 2026-09-10 **all eight Nine rules are reachable from `okf build`, and since 2026-09-11 **all nine
are ON by default** — `--outline-run 3`, `--table-grid` and `--unit-fold` since are ON by default** — `--outline-run 3`, `--table-grid` and `--unit-fold` since
2026-09-08, `--drop-wrapped-outline` and `--outline-gate` since 2026-09-09, and 2026-09-08, `--drop-wrapped-outline` and `--outline-gate` since 2026-09-09,
`--sheet-section-rows`, `--keep-table-heading` and `--first-span-from-zero` `--sheet-section-rows`, `--keep-table-heading` and `--first-span-from-zero`
since 2026-09-10 — each an operator decision, and each with an explicit since 2026-09-10, and `--close-span-gaps` since 2026-09-11 — each an operator
decision, and each with an explicit
opt-out: `--outline-run 0`, `--no-table-grid`, `--no-unit-fold`, opt-out: `--outline-run 0`, `--no-table-grid`, `--no-unit-fold`,
`--keep-wrapped-outline`, `--no-outline-gate`, `--no-sheet-section-rows`, `--keep-wrapped-outline`, `--no-outline-gate`, `--no-sheet-section-rows`,
`--no-keep-table-heading`, `--no-first-span-from-zero`. Passing all eight `--no-keep-table-heading`, `--no-first-span-from-zero`,
`--no-close-span-gaps`. Passing all nine
reproduces the pre-2026-09-08 bytes exactly, and the last three reproduce the reproduces the pre-2026-09-08 bytes exactly, and the last three reproduce the
pre-2026-09-10 bundle byte for byte — measured with `diff -rq`, 0 differences, pre-2026-09-10 bundle byte for byte — measured with `diff -rq`, 0 differences,
not asserted. Each line below carries the number it was measured at, and not asserted. Each line below carries the number it was measured at, and
@ -128,6 +130,7 @@ rank was measured in the same session and did NOT ship; see
| `--drop-wrapped-outline` (**on** by default since 2026-09-09; opt out with `--keep-wrapped-outline`) | do not admit an `--outline-run` candidate whose line continues onto the next one. Judges recovered candidates only, never a heading the document declares | quoted regulation text, whose numbered paragraphs match the outline grammar exactly: **4 → 1 concepts**, the reference. Whole corpus: **5 of 39**, all `pdf`; on the 12-document sample **8 of 34** outline candidates wrap, and none of the 26 the operator kept. On the reference it carries `pdf` from **5 of 8 to 6 of 8** together with the gate below, and neither reaches 7 of 8 without the other | | `--drop-wrapped-outline` (**on** by default since 2026-09-09; opt out with `--keep-wrapped-outline`) | do not admit an `--outline-run` candidate whose line continues onto the next one. Judges recovered candidates only, never a heading the document declares | quoted regulation text, whose numbered paragraphs match the outline grammar exactly: **4 → 1 concepts**, the reference. Whole corpus: **5 of 39**, all `pdf`; on the 12-document sample **8 of 34** outline candidates wrap, and none of the 26 the operator kept. On the reference it carries `pdf` from **5 of 8 to 6 of 8** together with the gate below, and neither reaches 7 of 8 without the other |
| `--outline-gate` (**on** by default since 2026-09-09; opt out with `--no-outline-gate`) | admit `--outline-run`'s RECOVERED headings only where the document declares none of its own, plus any one recovered heading whose span covers `OUTLINE_SHARE` (0.20) of the text. Applied at admission, before spans are closed, so the text a removed mark opened is carried by the mark above it rather than lost | on the 12-document sample: `pdf` **2 of 8 → 5 of 8** alone and **7 of 8** with the rule above, `docx` unchanged at **3 of 3**. Whole corpus: it fires on **25 of 39** readable documents, changes the plan in **15 of 39**, and removes **64 of 485** proposed entries. No plan disappears (32 → 32) | | `--outline-gate` (**on** by default since 2026-09-09; opt out with `--no-outline-gate`) | admit `--outline-run`'s RECOVERED headings only where the document declares none of its own, plus any one recovered heading whose span covers `OUTLINE_SHARE` (0.20) of the text. Applied at admission, before spans are closed, so the text a removed mark opened is carried by the mark above it rather than lost | on the 12-document sample: `pdf` **2 of 8 → 5 of 8** alone and **7 of 8** with the rule above, `docx` unchanged at **3 of 3**. Whole corpus: it fires on **25 of 39** readable documents, changes the plan in **15 of 39**, and removes **64 of 485** proposed entries. No plan disappears (32 → 32) |
| `--first-span-from-zero` (**on** by default since 2026-09-10; opt out with `--no-first-span-from-zero`) | start the first concept at character 0, so the text above it belongs to a segment instead of to none. Adds no boundary and removes none | Measured over the 39-document corpus, the default before this rule left **207 435 characters — 11.92 %** — in no segment at all: **163 804 above the first entry** (in **32 of the 32** documents that get a plan), 26 041 *between* entries and 17 590 after the last. This rule closes the first part entirely, 79 % of the whole, leaving **43 631 characters (2.51 %) over 8 of 32 documents** with two named mechanisms of their own. It adds no boundary and the K2 concept count is identical with and without it (**425 = 425**); on the 12-position reference it changes **not one cell**, and hit@8 on a K2 bundle built with it holds **5 of 6 at ranks 1,1,1,1,1,** under both tie-breaks | | `--first-span-from-zero` (**on** by default since 2026-09-10; opt out with `--no-first-span-from-zero`) | start the first concept at character 0, so the text above it belongs to a segment instead of to none. Adds no boundary and removes none | Measured over the 39-document corpus, the default before this rule left **207 435 characters — 11.92 %** — in no segment at all: **163 804 above the first entry** (in **32 of the 32** documents that get a plan), 26 041 *between* entries and 17 590 after the last. This rule closes the first part entirely, 79 % of the whole, leaving **43 631 characters (2.51 %) over 8 of 32 documents** with two named mechanisms of their own. It adds no boundary and the K2 concept count is identical with and without it (**425 = 425**); on the 12-position reference it changes **not one cell**, and hit@8 on a K2 bundle built with it holds **5 of 6 at ranks 1,1,1,1,1,** under both tie-breaks |
| `--close-span-gaps` (**on** by default since 2026-09-11; opt out with `--no-close-span-gaps`) | close a concept's span against the next SURVIVING concept, and the last against the end of the text. Three steps remove a candidate AFTER its neighbour's span was already closed against it — the orphan check, and `fold_units` clause 1 both between entries and on the last run — and the removed mark's text then belongs to no segment. Adds no boundary and removes none; only spans' ends move | It closes the whole remainder the rule above left: **43 631 characters, 2.51 % of the corpus over 8 of the 32 documents with a plan, to 0** — both the 26 041 between entries and the 17 590 after the last. Decomposed: orphan check **18 527** over 15 of 39 documents, clause 1 **7 514** between entries, clause 1 on the last run **all 17 590** of the tail (with `unit_fold=False` the corpus tail gap is 0). The entry count is identical (**429 = 429** on the corpus, **436 = 436** concepts on K2, **52 = 52** md on a five-document folder); on the 12-position reference it changes **not one cell** (11 of 12 under `|F|`[3]=12, 10 of 12 under `|F|`[3]=11), and hit@8 holds **5 of 6 at ranks 1,1,1,1,1,** on the new bundle, the previous default and Arm B alike |
They compose, and the order above is the order they apply in. Measured on a 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 five-document tender folder (2 `pdf`, 2 `docx`, 1 `xlsx`), concepts per

View file

@ -0,0 +1,224 @@
# K3 round 8: the coverage gap had one cause, and it was not the one named
Round 7 closed 79 % of the corpus coverage gap with `--first-span-from-zero`
and named the remaining **43 631 characters, 2.51 %** as two open items: a
table-block candidate displacing a declared heading (26 041 characters between
entries, and the reason `md` was reported at 3 of 4 declared headings), and
17 590 characters after the last entry that had never been examined.
Measured here, one of those two premises does not reproduce and the other is
not a separate mechanism. The gap has **one** cause, the whole of it is now
closed, and the reference sheet and every hit@8 row are unchanged.
Basis: `a364ef4` (round 7). Every number below was re-measured on that commit
before anything was changed.
## 0. Two divergences from the numbers this round was handed
**The order's headline defect does not reproduce.** Round 7 § 5 reports that
the default emits `rule:table-block` at 503 rather than `rule:heading` at 489
for `## 3 Prising`, and that `md` therefore recovers 3 of 4 declared headings.
On `a364ef4` — round 7's own commit, the document round 7 built, through the
same extraction path — the default recovers **4 of 4**, and `okf build`
emits all four as concepts.
The cause is round 7's own trap, stated in its own report and then walked
into: **a number about a rule is a measurement of a configuration.** D1
(`--keep-table-heading`) is precisely the repair for the mechanism § 5
describes, and round 7 moved it into the default in the same commit. § 5 is a
pre-move measurement presented as the post-move state. Isolated per flag on
that document:
| configuration | declared headings recovered |
|---|---|
| default | **4 of 4** |
| default, `--no-keep-table-heading` | 3 of 4 |
| every other flag turned off individually | 4 of 4 |
| all arms off (pre-arm baseline) | 3 of 4 |
Nothing in the tree held that cell, which is why it could be reported wrong
and stay wrong. `tests/test_md_declared_headings.py` pins it now, with the
known-negative in the same file: turn D1 off and `3 Prising` becomes a table
block carrying a name from a line below itself.
**The two remaining halves are one mechanism, not two.** Decomposed below.
## 1. The gap has one cause: marks removed after spans were closed
Every rule in `find_candidates` closes a span against the NEXT MARK. Three
steps then remove a mark after its neighbour's `end` was already fixed against
it, and the removed mark's text is in no segment. Measured over the
39-document corpus (1 739 511 characters, 32 documents with a plan):
| removing step | characters | where |
|---|---|---|
| orphan check (bodiless heading dropped) | **18 527** over 15 of 39 documents | between entries |
| `fold_units` clause 1, contents run discarded | **7 514** | between entries |
| `fold_units` clause 1, discarding the LAST run | **17 590** | after the last entry |
| | **43 631 (2.51 %)** | over 8 of 32 documents |
The decomposition that settles it: with `unit_fold=False` the corpus tail gap
is **0** and the between-gap is 18 527. So the entire tail is clause 1's, and
the between-gap is the orphan check plus clause 1. The 26 041 and the 17 590
are the same defect seen at two positions.
**This is round 6's own principle, not applied where it also holds.** The
outline gate filters at ADMISSION, "before spans close, so the text a removed
mark opened is carried by the mark above"; the post-filter form scored
identically and lost that text. The orphan check and clause 1 are post-filter
forms that were never converted.
### 1.1 What the between-gap is, and why the order's mechanism is not it
Clause 1's between-gap concentrates almost entirely in one document: of the
26 041 characters, **23 259 sit in a single document** and the remaining 2 782
are spread over seven. That document is a geotechnical borehole log whose data
rows end in an integer, so `_TRAILING_PAGE_NUMBER` reads a run of them as a
contents listing and discards it. Across the corpus, clause 1 drops **68
candidates over 11 documents**; most are genuine contents listings with prose
titles, and four documents are misclassified numeric tables (`40.23`,
`300 1`, `3000 V 1`, `619649.097`).
The misclassification is real and is NOT repaired here: a title predicate that
requires a name would rescue the three all-numeric cases and would still not
rescue the borehole log, whose rows do carry words. Carrying the text instead
of dropping it fixes all of them, at the level where the defect actually is.
### 1.2 What the tail is
The 17 590 characters fall on exactly **two** documents (11 016 and 6 574),
and both are drawing-heavy PDFs. Profiled against the SAME document's
segmented text as a known-positive — the control that matters, because the
question is not whether the tail is good prose but whether it is the kind of
text the bundle already carries:
| document | part | lines | % of lines with ≥3 words | % of characters in them |
|---|---|---|---|---|
| A | segmented | 1485 | 6.4 % | 34.1 % |
| A | tail | 448 | **8.5 %** | **33.6 %** |
| B | segmented | 883 | 50.3 % | 80.7 % |
| B | tail | 160 | **17.5 %** | 56.0 % |
On document A the tail is **indistinguishable** from what the bundle already
keeps; on B it is poorer but not empty. The honest reading is that this is
not a separate "is it content" question — the tail is the same kind of text,
arriving through the same defect, and the answer is the same rule.
## 2. The rule: `--close-span-gaps`
Each span is closed against the next SURVIVING candidate's start, and the last
against the end of the text. It runs LAST, after the fold, because that is the
one point at which every removing step has already run.
It **adds no boundary and removes none** — only `end` moves. Measured
consequences, both directions:
| | entries | lead | between | tail | gap |
|---|---|---|---|---|---|
| round 7 default | 429 | 0 | 26 041 | 17 590 | 43 631 (2.51 %) |
| + `--close-span-gaps` | **429** | 0 | **0** | **0** | **0 (0.00 %)** |
The entry count is identical, which is the same property `--first-span-from-zero`
has and the reason this can be measured by coverage rather than by count.
## 3. Acceptance: all four conditions at once
The criterion was cells up with no cell lost, and no gold row losing rank 1 on
either K2 bundle.
**The reference sheet is label-identical**, both readings reported:
| configuration | `pdf` | `docx` | `xlsx` | sheet |
|---|---|---|---|---|
| round 7 default, `|F|`[3] = 11 | 7/8 | 3/3 | 0/1 | 10/12 |
| round 8, `|F|`[3] = 11 | 7/8 | 3/3 | 0/1 | **10/12** |
| round 7 default, `|F|`[3] = 12 | 7/8 | 3/3 | 1/1 | 11/12 |
| round 8, `|F|`[3] = 12 | 7/8 | 3/3 | 1/1 | **11/12** |
Not one of the twelve positions changes label. Position 0 stays `for grovt`
(its `|F|` is undefined, an operator decision), position 3 stays `for fint` at
distance 1 under the authored fasit and `treffer` under the working answer.
**hit@8, six rows, three bundles:**
| bundle | concepts | ranks | hit@8 |
|---|---|---|---|
| round 8 default | **436** | `[1, 1, 1, 1, 1, ]` | 5 of 6 |
| round 7 default | 436 | `[1, 1, 1, 1, 1, ]` | 5 of 6 |
| Arm B | 629 | `[1, 1, 1, 1, 1, ]` | 5 of 6 |
**The known-negative reproduces on the new bytes**: read with
`--no-tie-shared-rank`, the round 8 bundle gives `[2, 1, 1, 1, 1, ]`, the
same fall round 7's pin was built to name. The pin still names its own cause.
The concept count is **436 = 436**. The rule adds no boundary on the real
corpus either, which the corpus measurement predicted and the build confirmed.
## 4. Cost to a consumer
A re-run, and this time a small one. The 43-document reference corpus:
* 2026-09-03 delivered tree: 629 concepts / 1108 files
* after 2026-09-08: 492 / 944
* after 2026-09-09: 425 / 810
* after 2026-09-10: 436 / 832
* **after 2026-09-11: 436 / 832** — unchanged in count; concept BODIES grow by
the 43 631 characters that were previously in no segment.
On the operator's five-document folder: **52 md, unchanged**, and byte-identical
with and without the flag — that folder has a coverage gap of **zero** under
round 7's default already, so there is nothing there to carry. That is the
rule behaving correctly on an input with nothing to do, and it is also why the
smoke build cannot prove the flag arrives; `tests/test_span_gaps.py` proves
that on a document that does have a gap.
The opt-out `--no-close-span-gaps` reproduces the previous spans.
## 5. What did NOT move
* **Clause 1's misclassification of numeric tables** (§ 1.1). Four documents,
named with their titles. Carrying the text removes the damage; it does not
remove the wrong classification, and a contents run is still discarded as a
concept. Whether a run of numeric rows should be a contents listing at all
is a separate question with its own measurement.
* **`MIN_SHARED_PREFIX`** (round 5's `undersjøisk`/`under`, df 159 of 270).
Not re-measured. It stands.
* **The `{#…}` leak, the `pptx` fasit, `|F|` for positions 0 and 3.** All four
are with the operator with their numbers delivered; none was re-opened here.
## 6. Honesty limits
* **n = 12 positions on the reference sheet**, stratified 8 `pdf`, 3 `docx`,
1 `xlsx`. Every per-type fraction here is a fraction of 8, 3 or 1, and a
single `xlsx` position carries the whole `xlsx` claim.
* **n = 6 questions on the retrieval side, one rater, one gold set.** Row 6
misses on every bundle and every configuration measured so far, so "5 of 6"
is "5 of 5 reachable rows plus one nothing has reached".
* **"General" rests on ONE corpus of 43 documents.** Re-measured
independently this round: `K2/trinn2` is byte-identical to `K2/trinn1`
both `5131f367...` over `find . -type f -print0 | sort -z | xargs -0 shasum
-a 256 | shasum -a 256`, 43 files each — and `K1` is empty (0 entries). The
null-separated form is not decoration: the first attempt used `xargs`
without it, every filename split on its spaces, `shasum` read nothing, and
the two sides agreed on a digest of the failure. A control against the
digest of empty input (`e3b0c442...`) is what separated the two runs. Both K2 bundles, arms AF, the
whole 12-position reference and all six gold rows are the same 43 documents
read again. This is the mandate's fourth acceptance criterion and it is not
a weakness in the method — it is a weakness in the basis. See the operator
request that accompanies this report.
* **`pptx` and `md` are measured on two hand-built documents** and hold **zero**
positions on the reference sheet. The `md` cell in § 0 is one document.
* **`odt` and `rtf` are unmeasured**, with no document behind them at all.
* **"Delivered" is not "answered".** hit@8 measures whether a concept from the
gold document reaches the payload, not whether the payload answers. The last
measurement of that was rank 1 of 8 on 3 of 3 bundles and a correct answer
on 1 of 3.
* **Position 0 is blocked on an operator decision, not on code.** Its `|F|` is
undefined, so `for grovt` is a label the sheet cannot currently improve.
* **The tail profile in § 1.2 is a two-document measurement** with a
hand-chosen metric (lines carrying at least three four-letter words). It
distinguishes A's tail from nothing, because A's segmented text scores the
same. That is the finding, and it is not a general prose detector.
* **The between-gap decomposition rests on one dominant document.** 23 259 of
26 041 characters are one borehole log. The mechanism generalises; the
magnitude is one document's.

View file

@ -163,6 +163,13 @@ DEFAULT_DROP_WRAPPED_OUTLINE = True
#: `docx`, 10 of 12) AND hit@8 holds rank 1 on every row it held, measured end #: `docx`, 10 of 12) AND hit@8 holds rank 1 on every row it held, measured end
#: to end on a K2 bundle built with it. Opt-out `--no-first-span-from-zero`. #: to end on a K2 bundle built with it. Opt-out `--no-first-span-from-zero`.
DEFAULT_FIRST_SPAN_FROM_ZERO = True DEFAULT_FIRST_SPAN_FROM_ZERO = True
#: Round 8. The remaining coverage gap round 7 named and left open -- 43 631
#: characters, 2.51 % of the corpus over 8 of 32 documents with a plan -- has
#: ONE cause: three steps remove a candidate AFTER its neighbour's span was
#: already closed against it, so the removed mark's text is in no segment. The
#: rule closes each span against the next SURVIVOR. It adds no boundary, so the
#: concept count is unchanged and the cost to a consumer is a re-run.
DEFAULT_CLOSE_SPAN_GAPS = True
#: Round 3's two spreadsheet rules (D1 and D3), held back through rounds 5 and #: Round 3's two spreadsheet rules (D1 and D3), held back through rounds 5 and
#: 6 by a RETRIEVAL regression rather than by the reference: they take the #: 6 by a RETRIEVAL regression rather than by the reference: they take the
@ -228,6 +235,7 @@ def _propose_plans(
drop_wrapped_outline: bool = False, drop_wrapped_outline: bool = False,
outline_gate: bool = False, outline_gate: bool = False,
first_span_from_zero: bool = False, first_span_from_zero: bool = False,
close_span_gaps: bool = False,
pdf_headings: bool = False, pdf_headings: bool = False,
pdf_headings_reserve: bool = False, pdf_headings_reserve: bool = False,
ocr: bool = False, ocr: bool = False,
@ -263,6 +271,7 @@ def _propose_plans(
drop_wrapped_outline=drop_wrapped_outline, drop_wrapped_outline=drop_wrapped_outline,
outline_gate=outline_gate, outline_gate=outline_gate,
first_span_from_zero=first_span_from_zero, first_span_from_zero=first_span_from_zero,
close_span_gaps=close_span_gaps,
pdf_headings=pdf_headings, pdf_headings=pdf_headings,
pdf_headings_reserve=pdf_headings_reserve, pdf_headings_reserve=pdf_headings_reserve,
ocr=ocr, ocr=ocr,
@ -297,6 +306,7 @@ def build(
drop_wrapped_outline: bool = DEFAULT_DROP_WRAPPED_OUTLINE, drop_wrapped_outline: bool = DEFAULT_DROP_WRAPPED_OUTLINE,
outline_gate: bool = DEFAULT_OUTLINE_GATE, outline_gate: bool = DEFAULT_OUTLINE_GATE,
first_span_from_zero: bool = DEFAULT_FIRST_SPAN_FROM_ZERO, first_span_from_zero: bool = DEFAULT_FIRST_SPAN_FROM_ZERO,
close_span_gaps: bool = DEFAULT_CLOSE_SPAN_GAPS,
pdf_headings: bool = DEFAULT_PDF_HEADINGS, pdf_headings: bool = DEFAULT_PDF_HEADINGS,
pdf_headings_reserve: bool = DEFAULT_PDF_HEADINGS_RESERVE, pdf_headings_reserve: bool = DEFAULT_PDF_HEADINGS_RESERVE,
ocr: bool = DEFAULT_OCR, ocr: bool = DEFAULT_OCR,
@ -366,6 +376,7 @@ def build(
drop_wrapped_outline=drop_wrapped_outline, drop_wrapped_outline=drop_wrapped_outline,
outline_gate=outline_gate, outline_gate=outline_gate,
first_span_from_zero=first_span_from_zero, first_span_from_zero=first_span_from_zero,
close_span_gaps=close_span_gaps,
pdf_headings=pdf_headings, pdf_headings=pdf_headings,
pdf_headings_reserve=pdf_headings_reserve, pdf_headings_reserve=pdf_headings_reserve,
ocr=ocr, ocr=ocr,
@ -649,6 +660,30 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
"pre-2026-09-10 first span byte for byte" "pre-2026-09-10 first span byte for byte"
), ),
) )
build_parser.add_argument(
"--close-span-gaps",
action="store_true",
default=DEFAULT_CLOSE_SPAN_GAPS,
help=(
"Close a concept's span against the next SURVIVING concept, and "
"the last one against the end of the text, so a candidate removed "
"after its neighbour's span was fixed does not take its text out "
"of the plan. Measured over the 39-document corpus: it closes the "
"43 631 characters -- 2.51 %% of the corpus over 8 of the 32 "
"documents with a plan -- that round 7 left, both the 26 041 "
"between entries and the 17 590 after the last. It adds no "
"boundary and moves only spans' ends. ON since 2026-09-11"
),
)
build_parser.add_argument(
"--no-close-span-gaps",
action="store_false",
dest="close_span_gaps",
help=(
"The repair's explicit opt-out, for a consumer who needs the "
"pre-2026-09-11 spans byte for byte"
),
)
build_parser.add_argument( build_parser.add_argument(
"--pdf-headings", "--pdf-headings",
choices=("none", "font", "font-reserve"), choices=("none", "font", "font-reserve"),
@ -721,6 +756,7 @@ def main(argv: list[str] | None = None) -> int:
drop_wrapped_outline=args.drop_wrapped_outline, drop_wrapped_outline=args.drop_wrapped_outline,
outline_gate=args.outline_gate, outline_gate=args.outline_gate,
first_span_from_zero=args.first_span_from_zero, first_span_from_zero=args.first_span_from_zero,
close_span_gaps=args.close_span_gaps,
pdf_headings=args.pdf_headings == "font", pdf_headings=args.pdf_headings == "font",
pdf_headings_reserve=args.pdf_headings == "font-reserve", pdf_headings_reserve=args.pdf_headings == "font-reserve",
ocr=args.ocr, ocr=args.ocr,

View file

@ -514,6 +514,7 @@ def find_candidates(
drop_wrapped_outline: bool = False, drop_wrapped_outline: bool = False,
outline_gate: bool = False, outline_gate: bool = False,
first_span_from_zero: bool = False, first_span_from_zero: bool = False,
close_span_gaps: bool = False,
) -> list[Candidate]: ) -> list[Candidate]:
"""Every boundary the mechanical rules propose, in document order. """Every boundary the mechanical rules propose, in document order.
@ -549,6 +550,16 @@ def find_candidates(
It is its own flag and not part of an arm because the orphan check is 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. reached by every file type, and moving it is a decision about all of them.
`close_span_gaps` is OFF at False, where a mark removed after its
neighbour's span was closed takes that text out of the plan entirely. On,
a span runs to the next SURVIVING candidate's start and the last one runs
to the end of the text. It adds no boundary and removes none -- only `end`
moves -- so a plan's entry count is identical either way. Three steps
remove marks late and all three leak: the orphan check (18 527 characters
over 15 of 39 documents), `fold_units` clause 1 between entries (7 514),
and the same clause on the LAST run (all 17 590 tail characters; with
`unit_fold=False` the corpus tail gap is 0).
`first_span_from_zero` is OFF at False, where the text above the first `first_span_from_zero` is OFF at False, where the text above the first
concept belongs to no segment. On, the first surviving concept starts at 0. concept belongs to no segment. On, the first surviving concept starts at 0.
It adds no boundary and removes none; only the first span's `start` moves. It adds no boundary and removes none; only the first span's `start` moves.
@ -813,6 +824,19 @@ def find_candidates(
# opens at 0: the fold can discard a contents run, and moving the start # opens at 0: the fold can discard a contents run, and moving the start
# before it would put the preamble on an entry the plan never carries. # before it would put the preamble on an entry the plan never carries.
resolved = [replace(resolved[0], start=0), *resolved[1:]] resolved = [replace(resolved[0], start=0), *resolved[1:]]
if close_span_gaps and resolved:
# LAST, and after the fold for the same reason: every step that can
# REMOVE a mark has already run, so this is the one place where the
# surviving list is final. Each span is closed against the next
# SURVIVOR rather than against the next mark, which is what the
# removing steps could not do -- they fixed a neighbour's `end`
# against a candidate that had not been judged yet.
resolved = [
replace(candidate, end=max(candidate.end, next_start))
for candidate, next_start in zip(
resolved, [c.start for c in resolved[1:]] + [end_of_text], strict=True
)
]
return resolved return resolved
@ -1146,6 +1170,7 @@ def build_plan(
drop_wrapped_outline: bool = False, drop_wrapped_outline: bool = False,
outline_gate: bool = False, outline_gate: bool = False,
first_span_from_zero: bool = False, first_span_from_zero: bool = False,
close_span_gaps: 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()
@ -1161,6 +1186,7 @@ def build_plan(
drop_wrapped_outline=drop_wrapped_outline, drop_wrapped_outline=drop_wrapped_outline,
outline_gate=outline_gate, outline_gate=outline_gate,
first_span_from_zero=first_span_from_zero, first_span_from_zero=first_span_from_zero,
close_span_gaps=close_span_gaps,
) )
for candidate in subdivide(text, candidates, max_segment_chars): for candidate in subdivide(text, candidates, max_segment_chars):
entries.append( entries.append(
@ -1236,6 +1262,7 @@ def run(
drop_wrapped_outline: bool = False, drop_wrapped_outline: bool = False,
outline_gate: bool = False, outline_gate: bool = False,
first_span_from_zero: bool = False, first_span_from_zero: bool = False,
close_span_gaps: bool = False,
pdf_headings: bool = False, pdf_headings: bool = False,
pdf_headings_reserve: bool = False, pdf_headings_reserve: bool = False,
ocr: bool = False, ocr: bool = False,
@ -1310,6 +1337,7 @@ def run(
drop_wrapped_outline=drop_wrapped_outline, drop_wrapped_outline=drop_wrapped_outline,
outline_gate=outline_gate, outline_gate=outline_gate,
first_span_from_zero=first_span_from_zero, first_span_from_zero=first_span_from_zero,
close_span_gaps=close_span_gaps,
) )
# 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

View file

@ -7,10 +7,10 @@ guarantee it looks like was never held by anything.
This file pins the CURRENT default: `--outline-run 3 --table-grid --unit-fold This file pins the CURRENT default: `--outline-run 3 --table-grid --unit-fold
--drop-wrapped-outline --outline-gate --first-span-from-zero --drop-wrapped-outline --outline-gate --first-span-from-zero
--sheet-section-rows --keep-table-heading`, plus the reading side's --sheet-section-rows --keep-table-heading --close-span-gaps`, plus the reading
`tie_shared_rank`. Round 6 moved the first five on 2026-09-09 and round 7 moved side's `tie_shared_rank`. Round 6 moved the first five on 2026-09-09, round 7
the last four on 2026-09-10, each after measuring hit@8 on exactly the bundle moved four more on 2026-09-10 and round 8 moved the last on 2026-09-11, each
its own default produces. after measuring hit@8 on exactly the bundle its own default produces.
The gold set is LOCAL-ONLY and stays that way: no question and no The gold set is LOCAL-ONLY and stays that way: no question and no
`gold_document` is reproduced here, and a row is named by its INDEX, the way `gold_document` is reproduced here, and a row is named by its INDEX, the way
@ -37,10 +37,16 @@ import okf_consume_measure # noqa: E402
#: Built by: #: Built by:
#: okf build <corpus>/K2/trinn1 \ #: okf build <corpus>/K2/trinn1 \
#: --bundle ~/corpora/okf-telling-20260829/K2-bundle-default-20260910 \ #: --bundle ~/corpora/okf-telling-20260829/K2-bundle-default-20260911 \
#: --bundle-id k2-trinn1-20260903 --okf-version 0.2 #: --bundle-id k2-trinn1-20260903 --okf-version 0.2
#: with no arm flag at all -- the package default, which is the point. #: with no arm flag at all -- the package default, which is the point.
DEFAULT_BUNDLE = Path.home() / "corpora" / "okf-telling-20260829" / "K2-bundle-default-20260910" #:
#: Rebuilt 2026-09-11 for `--close-span-gaps`. Digest, from inside the bundle:
#: find . -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256
#: -> 8c93e5e3222577a2b3352ca83af980e403d3a571c3a467b83c3d8170b1df2b69
#: Two independent builds of it differ in NOTHING (`diff -rq`), including
#: `log.md`, which carries the corpus path and never the bundle's own.
DEFAULT_BUNDLE = Path.home() / "corpora" / "okf-telling-20260829" / "K2-bundle-default-20260911"
GOLD_SET = PROJECT_ROOT / ".claude/projects/2026-09-07-okf-consume-prepass/hit-at-k-questions.json" GOLD_SET = PROJECT_ROOT / ".claude/projects/2026-09-07-okf-consume-prepass/hit-at-k-questions.json"
requires_default_bundle = pytest.mark.skipif( requires_default_bundle = pytest.mark.skipif(
@ -52,10 +58,12 @@ requires_default_bundle = pytest.mark.skipif(
), ),
) )
#: Measured 2026-09-10 on the bundle above. The count moved 425 -> 436 with #: Measured 2026-09-11 on the bundle above. The count moved 425 -> 436 with
#: `--sheet-section-rows --keep-table-heading`; `--first-span-from-zero` moved #: `--sheet-section-rows --keep-table-heading`; `--first-span-from-zero` and
#: it by NOTHING, which is the point of that rule -- it adds no boundary and #: `--close-span-gaps` each moved it by NOTHING, which is the point of both --
#: only moves one span's start. #: they add no boundary, they only move a span's start or its end. Round 8's
#: rule closed 43 631 characters (2.51 % of the corpus) that were in no
#: segment, and the count is byte-for-byte the same 436.
EXPECTED_CONCEPTS = 436 EXPECTED_CONCEPTS = 436
EXPECTED_HITS = 5 EXPECTED_HITS = 5
#: Rank per question INDEX, `None` for the row that misses on every bundle and #: Rank per question INDEX, `None` for the row that misses on every bundle and

View file

@ -0,0 +1,100 @@
"""`md` recovers every heading it declares, and D1 is what makes that true.
Round 7 reported this cell as **3 of 4**, with the mechanism decomposed: a
`rule:table-block` candidate opens BELOW `## 3 Prising` instead of a
`rule:heading` opening ON it, so the heading is bodiless, the orphan check
drops it, and the fold then merges the nameless block into the section above.
Re-measured on `a364ef4` (round 7's own commit), the default recovers **4 of
4**. The report's § 5 is a measurement of the configuration that existed
BEFORE the same round moved `--keep-table-heading` into the default -- D1 is
precisely the repair for that mechanism, and it shipped in the commit the
report describes. Nothing in the tree said so, because the decomposition and
the default move live in different files.
That is the round-7 trap restated: a number about a rule is a measurement of a
CONFIGURATION. This file is the missing pin. The cell is asserted here, and so
is its cause -- turn D1 off and the third heading becomes a table block again.
"""
from __future__ import annotations
from llm_ingestion_okf import cli
from llm_ingestion_okf.propose import RULE_HEADING, RULE_TABLE_BLOCK, find_candidates
DEFAULT = dict(
outline_run=cli.DEFAULT_OUTLINE_RUN,
table_grid=cli.DEFAULT_TABLE_GRID,
unit_fold=cli.DEFAULT_UNIT_FOLD,
keep_table_heading=cli.DEFAULT_KEEP_TABLE_HEADING,
sheet_section_rows=cli.DEFAULT_SHEET_SECTION_ROWS,
drop_wrapped_outline=cli.DEFAULT_DROP_WRAPPED_OUTLINE,
outline_gate=cli.DEFAULT_OUTLINE_GATE,
first_span_from_zero=cli.DEFAULT_FIRST_SPAN_FROM_ZERO,
close_span_gaps=cli.DEFAULT_CLOSE_SPAN_GAPS,
)
#: The same structure round 7 built: three chapters, the last one a heading
#: with a table directly under it and a closing line after the table.
DOCUMENT = """# Prosjektbeskrivelse Testdokument
Dette dokumentet har en kjent struktur: tre kapitler paa niva 1, hvert med
en kort brodtekst, samt en innledning over det forste kapitlet.
## 1 Omfang
Leveransen omfatter tre delytelser. Hver delytelse har egen frist og egen
akseptansetest. Teksten her er brodtekst og skal ikke bli en overskrift.
## 2 Krav til dokumentasjon
Dokumentasjon leveres i PDF. Tegninger leveres i DWG. Modeller leveres i IFC.
Krav nummer 2.1 gjelder alle tre formatene.
## 3 Prising
| Post | Beskrivelse | Enhet | Mengde |
| --- | --- | --- | --- |
| 1 | Riving av eksisterende dekke | m2 | 420 |
| 2 | Ny baerekonstruksjon | tonn | 38 |
| 3 | Tekniske installasjoner | RS | 1 |
Prisene fylles ut av tilbyder ved tilbudsfrist.
"""
DECLARED = (
"Prosjektbeskrivelse Testdokument",
"1 Omfang",
"2 Krav til dokumentasjon",
"3 Prising",
)
def _declared_headings(**kwargs: object) -> list[str]:
candidates = find_candidates(DOCUMENT, **{**DEFAULT, **kwargs}) # type: ignore[arg-type]
return [c.title for c in candidates if c.rule == RULE_HEADING]
def test_the_default_recovers_all_four_declared_headings() -> None:
assert _declared_headings() == list(DECLARED)
def test_the_priced_section_is_a_heading_and_not_a_table_block() -> None:
"""The specific cell: `## 3 Prising` keeps its own line and its own name."""
candidates = find_candidates(DOCUMENT, **DEFAULT)
prising = [c for c in candidates if c.title == "3 Prising"]
assert len(prising) == 1
assert prising[0].rule == RULE_HEADING
assert DOCUMENT[prising[0].start : prising[0].end].startswith("## 3 Prising")
def test_without_d1_the_cell_falls_back_to_three_of_four() -> None:
"""The known-negative: the pin names its own cause instead of being green
for an unstated reason. Without `--keep-table-heading` the heading is
orphaned and a table block carries its name from a line below it."""
assert _declared_headings(keep_table_heading=False) == list(DECLARED[:3])
candidates = find_candidates(DOCUMENT, **{**DEFAULT, "keep_table_heading": False})
carrier = [c for c in candidates if c.title == "3 Prising"]
assert len(carrier) == 1
assert carrier[0].rule == RULE_TABLE_BLOCK
assert not DOCUMENT[carrier[0].start : carrier[0].end].startswith("## 3 Prising")

153
tests/test_span_gaps.py Normal file
View file

@ -0,0 +1,153 @@
"""No character stands BETWEEN two concepts, or after the last one.
Round 7 closed the text ABOVE the first concept (`--first-span-from-zero`,
163 804 characters, 79 % of the whole gap) and named the remainder without
opening it: 43 631 characters, 2.51 % of the corpus, over 8 of the 32
documents that get a plan -- 26 041 between entries and 17 590 after the last.
Measured here, the remainder has ONE cause, not the two the shape suggests.
Every rule in `find_candidates` closes a span against the NEXT MARK, and three
separate steps then remove a mark after its neighbour's `end` was already
fixed against it:
* the **orphan check** drops a heading with no body, and the heading line
itself -- `## 3 Prising\n`, 14 characters -- is what the next candidate no
longer starts at. Measured with the fold off: **18 527 characters over 15 of
39 documents**;
* `fold_units` **clause 1** discards a contents run, and the text those
entries opened goes with them. It accounts for the remaining **7 514**
characters between entries;
* the same clause discarding the LAST run takes the tail with it. Measured:
with `unit_fold=False` the tail gap over the corpus is **0**, so all
**17 590** tail characters are clause 1's.
That is round 6's own principle -- the outline gate filters at ADMISSION,
"before spans close, so the text a removed mark opened is carried by the mark
above" -- not applied to the two steps that remove marks AFTER spans close.
This rule states it once, at the end, where every removal has already
happened: a candidate's span runs to the next candidate's start, and the last
one runs to the end of the text.
It adds NO boundary and removes none. A plan's entry count is identical either
way, which is the same property `--first-span-from-zero` has and the reason
both can be measured by coverage rather than by count.
"""
from __future__ import annotations
from pathlib import Path
from llm_ingestion_okf import cli
from llm_ingestion_okf.propose import find_candidates
ARMS = dict(outline_run=3, table_grid=True, unit_fold=True, first_span_from_zero=True)
#: The orphan check's gap: `## Tom` has no body, so it is dropped -- and the
#: seven characters of its own heading line belong to no segment.
ORPHAN = "## Forste\nInnhold under.\n## Tom\n## Andre\nInnhold under.\n"
#: Clause 1's gap: three page-numbered siblings are read as a contents list
#: and discarded, and the body under them goes too. 130 of 159 characters.
CONTENTS_TAIL = (
"## Innledning\nBrodtekst her.\n"
"## Kapittel en 3\nNoe innhold.\n"
"## Kapittel to 5\nMer innhold.\n"
"## Kapittel tre 9\nEn avsluttende brodtekst som ingen kandidat dekker.\n"
)
#: The known-negative: nothing is removed, so nothing is carried.
NO_GAP = "## Forste\nInnhold under.\n## Andre\nInnhold under.\n"
def _uncovered(text: str, **kwargs: object) -> int:
candidates = find_candidates(text, **ARMS, **kwargs) # type: ignore[arg-type]
if not candidates:
return len(text)
between = sum(
max(0, candidates[i].start - candidates[i - 1].end) for i in range(1, len(candidates))
)
return candidates[0].start + between + max(0, len(text) - candidates[-1].end)
def test_the_default_still_loses_the_orphaned_heading_line() -> None:
"""The defect, kept visible: the dropped mark's own line goes nowhere."""
assert _uncovered(ORPHAN) == len("## Tom\n")
def test_the_default_still_loses_the_tail_under_a_discarded_contents_run() -> None:
"""82 % of this document is in no segment, and none of it is a listing."""
assert _uncovered(CONTENTS_TAIL) == 130
def test_no_character_lies_between_two_concepts() -> None:
assert _uncovered(ORPHAN, close_span_gaps=True) == 0
candidates = find_candidates(ORPHAN, **ARMS, close_span_gaps=True)
carried = ORPHAN[candidates[0].start : candidates[0].end]
assert "## Tom" in carried, "the removed mark's text is carried by the mark ABOVE"
def test_no_character_lies_after_the_last_concept() -> None:
assert _uncovered(CONTENTS_TAIL, close_span_gaps=True) == 0
candidates = find_candidates(CONTENTS_TAIL, **ARMS, close_span_gaps=True)
assert CONTENTS_TAIL[candidates[-1].start : candidates[-1].end].endswith(
"En avsluttende brodtekst som ingen kandidat dekker.\n"
)
def test_the_rule_adds_and_removes_no_boundary() -> None:
"""Only `end` moves. Titles, rules and starts are identical."""
for text in (ORPHAN, CONTENTS_TAIL, NO_GAP):
plain = find_candidates(text, **ARMS)
closed = find_candidates(text, **ARMS, close_span_gaps=True)
assert [c.title for c in plain] == [c.title for c in closed]
assert [c.rule for c in plain] == [c.rule for c in closed]
assert [c.start for c in plain] == [c.start for c in closed]
def test_a_document_with_no_gap_is_untouched() -> None:
"""The known-negative, as IDENTICAL objects rather than an equal count."""
before = find_candidates(NO_GAP, **ARMS)
after = find_candidates(NO_GAP, **ARMS, close_span_gaps=True)
assert before == after
assert _uncovered(NO_GAP) == 0
def test_it_is_on_by_default_in_the_build_command() -> None:
assert cli.DEFAULT_CLOSE_SPAN_GAPS is True
def test_the_parser_carries_the_default_and_an_opt_out() -> None:
base = ["build", "src", "--bundle", "out", "--bundle-id", "x"]
assert cli.parse_args(base).close_span_gaps is True
assert cli.parse_args([*base, "--no-close-span-gaps"]).close_span_gaps is False
def test_the_flag_reaches_the_proposer_from_the_build_command(tmp_path: Path) -> None:
"""The plumbing, and it needs its own test rather than the smoke build.
Measured 2026-09-11: the operator's five-document folder has a coverage gap
of ZERO under round 7's default already, so building it with the rule on
and off gives `diff -rq` 0 differences. That is the rule behaving correctly
on an input with nothing to carry -- and it means that build proves nothing
about whether the flag arrives. This one uses a document that HAS a gap.
"""
inbox = tmp_path / "in"
inbox.mkdir()
(inbox / "d.md").write_text(CONTENTS_TAIL, encoding="utf-8")
def concepts(bundle: Path, **kwargs: object) -> str:
report = cli.build(
inbox,
bundle=bundle,
bundle_id="t",
okf_version="0.2",
ingested_at="1970-01-01T00:00:00Z",
**kwargs, # type: ignore[arg-type]
)
assert report.codes == ()
return "".join(path.read_text(encoding="utf-8") for path in sorted(bundle.rglob("*.md")))
on = concepts(tmp_path / "on")
off = concepts(tmp_path / "off", close_span_gaps=False)
assert "En avsluttende brodtekst som ingen kandidat dekker." in on
assert "En avsluttende brodtekst som ingen kandidat dekker." not in off