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

@ -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")