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:
Kjell Tore Guttormsen 2026-09-08 18:21:25 +02:00
commit ff79cfa19b
7 changed files with 989 additions and 17 deletions

View file

@ -469,3 +469,115 @@ def test_the_root_index_does_not_link_the_run_log(tmp_path: Path) -> None:
}
assert concepts, "the fixture must produce concepts for this control to mean anything"
assert len(okf_consume.enumerate_concepts(bundle)) == len(concepts)
# --- the arms are reachable, and still off ---------------------------------
#
# Round 2, 2026-09-08. `_propose_plans` called the proposer with no arm flag at
# all, so `okf build` ran Arm B while `tools/okf_propose_segments.py` could run
# Arm D, E and F. Measured on a five-document folder: one tender PDF landed as
# ONE concept from the build path and as NINE from the proposer with
# `--outline-run 3`, and nine is what the operator's unit worksheet asks for.
# The default is unchanged -- that is the operator's decision, not this
# layer's -- but the flags now exist here.
# Two headings the mechanical rules match at two levels, plus a table opening
# directly under a heading: enough for Arm F's clause 2 and D1 to have
# something to do, so "the flag reached the proposer" is measured on the
# ARTIFACT and not on the parser.
ARM_DOCUMENTS = {
"delta.md": (
"## 4 Grunnforhold\n\nGrunnen er morene over berg i hele omraadet.\n\n"
"### 4.1 Loesmasser\n\nLoesmassene er telefarlige og maa skiftes ut.\n"
),
"epsilon.md": ("## 5 Prissammenstilling\n\n| Post | Sum |\n|------|-----|\n| 01 | 100 |\n"),
}
def inbox_for_arms(root: Path) -> Path:
inbox = root / "arm-inbox"
inbox.mkdir(parents=True, exist_ok=True)
for name, body in ARM_DOCUMENTS.items():
(inbox / name).write_text(body, encoding="utf-8", newline="")
return inbox
def plan_titles(plans: Path) -> list[str]:
import json
titles: list[str] = []
for path in sorted(plans.glob("*.json")):
payload = json.loads(path.read_text(encoding="utf-8"))
titles.extend(str(entry["title"]) for entry in payload["entries"])
return titles
def test_the_arms_reach_the_proposer_from_the_build_command(tmp_path: Path) -> None:
"""The red test for round 2: the flags exist here and they change the plan.
Asserted on the PLANS, which is where an arm's effect is visible, and on
titles rather than a count -- a fold that kept the right number of concepts
by discarding the wrong ones would pass a count assertion.
`--unit-fold` folds `4.1 Loesmasser` into `4 Grunnforhold`;
`--keep-table-heading` keeps `5 Prissammenstilling` as the heading it is
instead of letting the table block carry the name.
"""
inbox = inbox_for_arms(tmp_path)
plans = tmp_path / "plans-armed"
assert (
build(
inbox,
tmp_path / "bundle-armed",
"--plans-dir",
str(plans),
"--proposed-at",
PROPOSED_AT,
"--outline-run",
"3",
"--table-grid",
"--unit-fold",
"--keep-table-heading",
)
== 0
)
assert plan_titles(plans) == ["4 Grunnforhold", "5 Prissammenstilling"]
def test_the_build_default_is_unchanged_by_the_flags_existing(tmp_path: Path) -> None:
"""The control the test above rests on: same inbox, no flags, Arm B.
Both halves over the same fixture. Without this, a change that turned an
arm ON by default would leave the assertion above green and move every
bundle every consumer builds.
"""
inbox = inbox_for_arms(tmp_path)
plans = tmp_path / "plans-plain"
assert (
build(
inbox,
tmp_path / "bundle-plain",
"--plans-dir",
str(plans),
"--proposed-at",
PROPOSED_AT,
)
== 0
)
assert plan_titles(plans) == ["4 Grunnforhold", "4.1 Loesmasser", "5 Prissammenstilling"]
def test_a_bundle_built_with_no_flags_is_byte_identical_to_the_shipped_one(
tmp_path: Path,
) -> None:
"""The byte control, on the tree the other build tests use.
The proposer gained a parameter and the CLI gained four. A default that
moved by a byte would break rebuild-equals-incremental for every consumer
who never passes a flag, and a title assertion cannot see that.
"""
inbox = inbox_with_subdirectories(tmp_path)
reference = two_script_bundle(inbox, tmp_path / "reference-2")
bundle = tmp_path / "cli-bundle-2"
assert build(inbox, bundle, "--ingested-at", INGESTED_AT, "--proposed-at", PROPOSED_AT) == 0
assert tree(bundle) == tree(reference)

View file

@ -1709,6 +1709,17 @@ def test_a_contents_run_is_discarded_and_the_body_survives() -> None:
that `_TRAILING_PAGE_NUMBER` reads as a contents line, and discarding it
would delete a chapter. Measured on the K3 sample: one such heading at
position 1.
ROUND 2 MOVED ONE EXPECTATION HERE, and it is stated rather than quietly
updated: `Innhold 1` used to survive because the run required its members
to share a LEVEL, and it is a level-1 heading in front of three level-2
ones. The level condition is gone (a numbered report's contents list
interleaves `1`, `1.1`, `1.1.1`), so the run is now four and the contents
HEADING goes with the contents list -- which is what "innholdsfortegnelsen
er ikke konsepter" says. The cost is real and bounded: this heading has a
body of its own in the fixture, and discarding the candidate leaves that
body in no segment. Measured on the corpus, the relaxation changes ONE
document of 39, and there it removes exactly the leftover contents line.
"""
off = okf_propose_segments.find_candidates(CONTENTS_THEN_BODY)
assert [c.title for c in off] == [
@ -1721,7 +1732,7 @@ def test_a_contents_run_is_discarded_and_the_body_survives() -> None:
"Vurdering",
]
on = okf_propose_segments.find_candidates(CONTENTS_THEN_BODY, unit_fold=True)
assert [c.title for c in on] == ["Innhold 1", "Innledning", "Grunnforhold", "Vurdering"]
assert [c.title for c in on] == ["Innledning", "Grunnforhold", "Vurdering"]
def test_a_deeper_heading_folds_into_its_parent() -> None:
@ -1844,3 +1855,206 @@ def test_a_recovered_outline_level_does_not_decide_the_unit() -> None:
"Loesmasser",
"Berggrunn",
]
# ---------------------------------------------------------------------------
# Round 2, 2026-09-08: two mechanisms the K3 per-file-type row named.
#
# Both stay behind flags that are off by default. The first refines Arm F's
# clause 1 (`--unit-fold`); the second is its own gate, because it changes the
# orphan check, which every file type reaches.
# ---------------------------------------------------------------------------
# A contents list as a PDF converter usually leaves one: no dot leaders, so
# every entry but the last is bodiless and the orphan check removes it. What
# reaches Arm F is a run of ONE, which is below `CONTENTS_RUN`, so clause 1
# cannot fire on the line that survived. This is the K3 position 9 shape.
BODILESS_CONTENTS = """## Formaalet med planen 4
## Orientering om prosjektet 4
## Maalsetting for SHA 5
SHA-plan for Stange skole, revisjon A.
## Formaalet med planen
Planen skal sikre arbeidet.
## Orientering om prosjektet
Prosjektet gjelder en utvidelse.
## Maalsetting for SHA
Null skader er maalet.
"""
# The known-negative for the same refinement: ONE bodiless page-numbered
# heading, no run at all. A rule that discarded it would delete a chapter whose
# title happens to end in a number.
SINGLE_BODILESS_PAGE_NUMBER = """## Sikkerhet i henhold til TEK 17
Kravene staar i forskriften.
## Grunnforhold
Loesmasser og berg.
"""
# The D1 shape: a sheet heading with a table opening on the line below it, so
# the heading's own body is empty. This is what a spreadsheet extracts to.
HEADING_THEN_TABLE = """## Prissammenstilling
| Post | Sum |
|------|-----|
| 01 | 100 |
| 02 | 200 |
"""
# The known-negative for D1: the heading has a body of its own, so it is not
# orphaned and nothing may be absorbed.
HEADING_WITH_BODY_THEN_TABLE = """## Prissammenstilling
Tabellen under viser postene i skjemaet.
| Post | Sum |
|------|-----|
| 01 | 100 |
"""
def test_a_bodiless_contents_run_is_discarded_too() -> None:
"""Clause 1 counts the run BEFORE the orphan check, not after.
The mechanism, measured: the orphan check (`find_candidates`) drops every
heading with an empty body, and a contents list without dot leaders is
exactly a sequence of such headings. All but the LAST entry are gone before
`fold_units` sees the list, so the run clause 1 looks for has length one and
the surviving contents line is emitted as a concept. Measured on the K3
sample: position 9 emits `Informasjon om og oppdatering av SHA-planen 5`
and position 7 emits `Tverrfaglig kontroll ....`, one leftover each.
Both halves over the same text: the flag-on assertion alone would stay
green if the default moved with it.
"""
off = okf_propose_segments.find_candidates(BODILESS_CONTENTS)
assert [c.title for c in off] == [
"Maalsetting for SHA 5",
"Formaalet med planen",
"Orientering om prosjektet",
"Maalsetting for SHA",
]
on = okf_propose_segments.find_candidates(BODILESS_CONTENTS, unit_fold=True)
assert [c.title for c in on] == [
"Formaalet med planen",
"Orientering om prosjektet",
"Maalsetting for SHA",
]
def test_a_single_bodiless_page_numbered_heading_survives() -> None:
"""The known-negative for the refinement above, and it is load-bearing.
Counting the run before the orphan check makes MORE candidates eligible for
clause 1, so the guard the sweep bought -- a run of siblings, never a single
line -- has to be measured again on the pre-orphan list. `TEK 17` is the
real heading that guard exists for.
"""
off = okf_propose_segments.find_candidates(SINGLE_BODILESS_PAGE_NUMBER)
on = okf_propose_segments.find_candidates(SINGLE_BODILESS_PAGE_NUMBER, unit_fold=True)
assert [c.title for c in off] == ["Sikkerhet i henhold til TEK 17", "Grunnforhold"]
assert off == on
def test_a_heading_keeps_the_table_that_opens_under_it() -> None:
"""D1, behind `--keep-table-heading`, off by default.
Default: the heading is bodiless, so the orphan check drops it and the
table block inherits its NAME -- the concept is named right but starts at
the table, so the heading line itself is not in the body any consumer reads.
On: the heading survives and its span extends over the table, which is
absorbed rather than emitted. The count does not move; the concept's first
byte does.
"""
off = okf_propose_segments.find_candidates(HEADING_THEN_TABLE)
assert [(c.title, c.rule) for c in off] == [
("Prissammenstilling", okf_propose_segments.RULE_TABLE_BLOCK)
]
assert off[0].start == HEADING_THEN_TABLE.index("| Post")
on = okf_propose_segments.find_candidates(HEADING_THEN_TABLE, keep_table_heading=True)
assert [(c.title, c.rule) for c in on] == [
("Prissammenstilling", okf_propose_segments.RULE_HEADING)
]
assert on[0].start == 0
assert on[0].end == off[0].end
def test_a_heading_with_its_own_body_absorbs_nothing() -> None:
"""The known-negative for D1: identical objects, not merely an equal count.
The gate is CONDITIONED on the drop. A heading that keeps its own body is
already carried by a live candidate, so absorbing the table as well would
silently merge two concepts on every document that has a table under a
section -- a default-shaped change wearing a flag.
"""
off = okf_propose_segments.find_candidates(HEADING_WITH_BODY_THEN_TABLE)
on = okf_propose_segments.find_candidates(HEADING_WITH_BODY_THEN_TABLE, keep_table_heading=True)
assert len(off) == 2
assert off == on
def test_keep_table_heading_takes_no_argument(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A boolean at the CLI: the variant has no number to sweep."""
source = write(tmp_path, HEADING_THEN_TABLE, "noarg-d1.md")
out = tmp_path / "noarg-d1.json"
with pytest.raises(SystemExit) as exit_info:
okf_propose_segments.main([str(source), "--out", str(out), "--keep-table-heading", "3"])
assert exit_info.value.code == 2
assert "usage:" in capsys.readouterr().err
# A contents list as a numbered report leaves one: the dotted entries carry
# their own depth, so `1.1`, `1.1.1`, `2.1` INTERLEAVE levels and no three
# consecutive entries are siblings. This is the K3 position 7 shape, where the
# level-2 entries fell in same-level runs and `6.2.2` did not.
INTERLEAVED_CONTENTS = """1.1 Om dette dokumentet ......... 4
1.1.1 Bakgrunn ......... 4
2.1 Versjoner ......... 5
Revisjon 04, for bruk til entreprise.
## Innledning
Bakgrunnen for arbeidet.
## Programvare
Programvaren er angitt her.
"""
def test_a_contents_run_crosses_heading_levels() -> None:
"""Clause 1 counts consecutive page-numbered headings, not siblings.
The mechanism, measured on K3 position 7: a numbered report's contents list
interleaves `1.1`, `1.1.1`, `2.1`, so requiring the run's members to share
a LEVEL breaks it at every level change. There the 34 contents entries are
one continuous block of page-numbered headings at levels 2 and 3, and the
level condition cut it into runs of 9, 1, 1, 1, 5, 2, 10, 2 and 3 -- so the
two-long runs survived and `6.2.2 Tverrfaglig kontroll ....` was emitted as
a concept while its neighbours were discarded.
The guard the sweep bought is unchanged and is still what makes this safe:
a run of at least `CONTENTS_RUN`, never a single line. One body heading
ending in a number is not a contents list, and that case has its own test.
Measured on the corpus, dropping the level condition changes ONE document
of 39, and there it removes exactly that leftover line.
"""
off = okf_propose_segments.find_candidates(INTERLEAVED_CONTENTS)
assert [c.title for c in off] == ["Versjoner ......... 5", "Innledning", "Programvare"]
on = okf_propose_segments.find_candidates(INTERLEAVED_CONTENTS, unit_fold=True)
assert [c.title for c in on] == ["Innledning", "Programvare"]