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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue