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)