feat(propose): Arm F, one unit fold behind a flag, measured against the operator's worksheet [skip-docs]

Order 20260908T133512Z-139864689-from-.claude. First iteration of the
per-file-type directive (operator 2026-09-08 13:05Z), not the last. No
threshold is set: ratifying a bar is the operator's, and setting one inside the
work that produces the measurement would be fitting the bar to the number.

[skip-docs] covers README.md only, and it follows a precedent re-measured this
round rather than quoted: `grep -c` for outline-run, table-grid, Arm C, Arm D
and Arm E returns 0 in README.md and CHANGELOG.md, while --path-prefix, a real
interface change, has a CHANGELOG entry. The rule is "interface and behaviour
changes yes, arm flags no", and --unit-fold is an arm flag that defaults off.
CLAUDE.md IS updated, because its `okf build` bullet enumerates which arms are
off there and would otherwise become false.

FUNN 1, and step 1 asked for it: the reproduction broke. Arm E on HEAD is
byte-identical to the archive on 31 of 33 plans; the two that differ are 2 of 2
spreadsheets in the corpus. The cause is EXTRACTION, not segmentation --
56ae274 writes a workbook as pipe tables, and the sample's price sheet extracts
to 11 048 characters where the worksheet records 100 694, which is the figure
that commit's own message predicts. The consequence is a segmentation
regression against the reference: K3 position 3 went 3 concepts -> 1 under both
Arm D and Arm E, where the operator wants eleven. The mechanism is the orphan
check dropping the sheet heading once a table opens below it (propose.py:461),
already reported there as a ranking regression. Doors unchanged: 43 .err, 4
FAILED, extractable 39/43.

THE MATCH CRITERION WAS WRITTEN DOWN BEFORE ANY CELL WAS SCORED, and it stalls
at 7/12 on the literal calibration gate after three rounds, each revision
recorded. The five failures are not the criterion's: at every one it agrees
with the operator's own (a), (b) or free text and disagrees only with (c).
Column (c) is a RELATIVE judgement ("closest today"); the four K3 categories
are absolute. The only way to reach 12/12 is to define "correct" as "the
closest arm", which reads (c) back out of itself. The dominance gate, declared
in advance as the second reading, holds at 11/12.

ARM F is one rule with three clauses derived from the operator's three, not
twelve special cases, and it only MERGES or DISCARDS: a run of at least
CONTENTS_RUN same-level page-numbered headings is a contents list and goes; a
heading deeper than the unit level folds into its parent, extending the
parent's span; a table folds back into the shorter heading that introduces it,
keeping the HEADING's name. K3 first rater, n=12: 2 coarse / 5 fine / 0
duplicate / 5 correct -- best of four arms, ceiling was 4, two moved, nothing
regressed anywhere.

THE PAPER MEASUREMENT CAME FIRST AND FALSIFIED THE FIRST VERSION. Clause 2 was
letting rule:outline -- Arm D's RECOVERY of an integer numbering run -- vote on
the unit level, which took K3 positions 1, 7 and 9 to 3, 4 and 7 concepts
instead of 17, 34 and 11. A recovered numbering is a heuristic, not a level a
document declares, and the unit worksheet showed the operator ATX and dotted
headings only. Fixed with its own red test; 11 of 12 predictions correct after.

PER FILE TYPE, which is the directive: docx 3 of 3 (solved on this sample), pdf
2 of 8 (lags, unchanged by Arm F, and the remainder is decomposed per position
rather than left as one number), xlsx 0 of 1 (regressed, see FUNN 1). Outside
the corpus, n=1 each: pptx and odt byte-identical, rtf proposes nothing either
way, txt differs and exposes clause 2's fallback.

CONTENTS_RUN swept 1..5 and off. Distance prefers 1; three ships anyway,
because at 1 the body chapter "... i henhold til TEK 17" is deleted for ending
in a number, and no K3 cell differs between 1 and 4 -- the metric prefers a
value that provably deletes a chapter and cannot see the cost.

Whole corpus, all 43 through arm_run in ascending foreground chunks: 32 plans,
491 entries against Arm E's 679, 14 documents changed, 1 plan disappeared
entirely (three drawing-schedule numbers that clause 1 correctly reads as a
contents run) and that is reported rather than special-cased.

THE okf build MECHANISM IS REPRODUCED AND IT IS NOT DOOR B: cli.py calls the
proposer with no arm flag at all, so the shipped build path is Arm B. On a
tender PDF that means no boundary where Arm D finds nine and the reference says
nine. Largest per-file-type gap this round found; it is a default change and
therefore the operator's.

5 tests red first, 1373 -> 1379. ruff clean, mypy --strict clean on 17 files.
K2 consumer bundle unchanged: 1108 files, digest 9cd74519... with the flag off.
No bundle built, no version bump, no tag, no push.

Report: docs/2026-09-08-k3-arm-f-mot-enhetsarket.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-08 16:28:41 +02:00
commit dfaf3cc134
4 changed files with 748 additions and 6 deletions

View file

@ -1624,3 +1624,223 @@ def test_table_grid_takes_no_argument(tmp_path: Path, capsys: pytest.CaptureFixt
okf_propose_segments.main([str(source), "--out", str(out), "--table-grid", "3"])
assert exit_info.value.code == 2
assert "usage:" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# Arm F: the unit fold. ONE rule with three clauses, derived from the three
# rules the operator wrote in the K3 unit worksheet (2026-09-08). It only ever
# MERGES or DISCARDS -- it proposes no boundary of its own -- and it is off
# unless a caller asks, so every default stays byte-identical.
# ---------------------------------------------------------------------------
# A contents list as the converter actually leaves one: each entry keeps the
# dot leaders that carried the page number across the page, so each heading has
# a body and the shipped orphan check does NOT already remove it. A fixture
# whose contents lines were bodiless would be green before Arm F existed.
CONTENTS_THEN_BODY = """# Innhold 1
Oversikt over kapitlene.
## Innledning 4
.................................................
## Grunnforhold 6
.................................................
## Vurdering 9
.................................................
## Innledning
Bakgrunn for arbeidet.
## Grunnforhold
Loesmasser og berg.
## Vurdering
Konklusjonen staar her.
"""
NESTED_HEADINGS = """## Koordinatsystem
Prosjektets koordinatsystem er EUREF89.
### Kartdata og nullpunkt
Nullpunktet ligger i sydvest.
## Aksesystem
Akser nummereres fra A.
"""
INTRO_THEN_TABLE = """## Sjekkliste
Kryss av for hvert punkt under.
| Punkt | Svar |
|-------|------|
| Ett | Ja |
| To | Nei |
"""
FLAT_NO_TABLE = """## Om prosjektet
Prosjektet gjelder en utvidelse.
## Grunnforhold
Grunnen er morene.
"""
def test_a_contents_run_is_discarded_and_the_body_survives() -> None:
"""Clause 1. The operator's words: "innholdsfortegnelsen er ikke konsepter".
Both halves over the same text, for the reason the Arm E tests give: the
flag-on assertion alone stays green if the DEFAULT moved too.
A RUN, never a single line. `... i henhold til TEK 17` is a body heading
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.
"""
off = okf_propose_segments.find_candidates(CONTENTS_THEN_BODY)
assert [c.title for c in off] == [
"Innhold 1",
"Innledning 4",
"Grunnforhold 6",
"Vurdering 9",
"Innledning",
"Grunnforhold",
"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"]
def test_a_deeper_heading_folds_into_its_parent() -> None:
"""Clause 2. The operator's words: "h2-kapittel med sine h3".
The parent's span must EXTEND over the child, not merely lose it: a fold
that dropped the child would delete its body, and a count assertion alone
cannot tell the two apart. That is what the `end` assertion pins.
"""
off = okf_propose_segments.find_candidates(NESTED_HEADINGS)
assert [c.title for c in off] == ["Koordinatsystem", "Kartdata og nullpunkt", "Aksesystem"]
on = okf_propose_segments.find_candidates(NESTED_HEADINGS, unit_fold=True)
assert [c.title for c in on] == ["Koordinatsystem", "Aksesystem"]
assert on[0].start == off[0].start
assert on[0].end == off[1].end
def test_a_table_folds_back_into_the_heading_that_introduces_it() -> None:
"""Clause 3. The operator's words: "tabellen med innledningen".
Conditioned on the introduction being SHORTER than the table, so a long
chapter does not swallow a table that is a unit in its own right. The
surviving concept keeps the heading's NAME -- a fold that kept
`Tabell linje 5` would merge the right bytes under a name no reader can
look up.
"""
off = okf_propose_segments.find_candidates(INTRO_THEN_TABLE)
assert [c.title for c in off] == ["Sjekkliste", "Tabell linje 5"]
on = okf_propose_segments.find_candidates(INTRO_THEN_TABLE, unit_fold=True)
assert [c.title for c in on] == ["Sjekkliste"]
assert on[0].end == off[1].end
def test_a_document_without_a_deeper_heading_or_a_table_is_byte_identical() -> None:
"""The known-negative, and it is the control the other three rest on.
Three clauses that all fire on their own fixture prove each clause runs;
they do not prove the rule is SILENT where the operator said nothing. A
document with one heading level and no table must come out of the fold
unchanged -- identical objects, not merely an equal count.
"""
off = okf_propose_segments.find_candidates(FLAT_NO_TABLE)
on = okf_propose_segments.find_candidates(FLAT_NO_TABLE, unit_fold=True)
assert len(off) == 2
assert off == on
def test_unit_fold_takes_no_argument(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Boolean at the CLI, like Arm E's gate and unlike Arm D's run length.
The one number in the rule -- how many consecutive page-numbered headings
make a contents list -- is a module constant that is swept in the report,
not a knob a caller can turn without recording the sweep.
"""
source = write(tmp_path, FLAT_NO_TABLE, "noarg-f.md")
out = tmp_path / "noarg-f.json"
with pytest.raises(SystemExit) as exit_info:
okf_propose_segments.main([str(source), "--out", str(out), "--unit-fold", "3"])
assert exit_info.value.code == 2
assert "usage:" in capsys.readouterr().err
OUTLINE_PLUS_DOTTED = """1 Innledning
Bakgrunn.
1.1 Formaalet med planen
Formaalet er beskrevet her.
1.2 Orientering om prosjektet
Prosjektet gjelder en utvidelse.
2 Grunnlag
Grunnlaget er dette.
2.1 Loesmasser
Morene over berg.
2.2 Berggrunn
Gneis.
"""
def test_a_recovered_outline_level_does_not_decide_the_unit() -> None:
"""Round 2 of Arm F's clause 2, and the reason it exists is a measurement.
Arm D recovers a document's integer numbering (`1 Innledning`) as a level-1
candidate. Letting that level vote makes it the shallowest repeated level,
so every dotted `1.1` heading folds into it -- and on the K3 sample that
took position 1 from 23 concepts to 3, position 7 from 48 to 4 and position
9 from 11 to 7, all in the direction the operator did NOT choose. The unit
worksheet showed the operator ATX and dotted-numbered headings only, and
those are the levels a document DECLARES. A recovered numbering run is a
heuristic, so it does not decide what a unit is.
Asserted as titles rather than as a count: a fold that kept the right
number of concepts by discarding the dotted headings instead of the
integer ones would pass a count assertion and be exactly backwards.
"""
off = okf_propose_segments.find_candidates(OUTLINE_PLUS_DOTTED, outline_run=2)
assert [c.title for c in off] == [
"Innledning",
"Formaalet med planen",
"Orientering om prosjektet",
"Grunnlag",
"Loesmasser",
"Berggrunn",
]
on = okf_propose_segments.find_candidates(OUTLINE_PLUS_DOTTED, outline_run=2, unit_fold=True)
assert [c.title for c in on] == [
"Innledning",
"Formaalet med planen",
"Orientering om prosjektet",
"Grunnlag",
"Loesmasser",
"Berggrunn",
]