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:
parent
171798ed32
commit
dfaf3cc134
4 changed files with 748 additions and 6 deletions
|
|
@ -56,7 +56,7 @@ import json
|
|||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -307,7 +307,11 @@ def outline_runs(
|
|||
|
||||
|
||||
def find_candidates(
|
||||
text: str, *, outline_run: int = 0, table_grid: bool = False
|
||||
text: str,
|
||||
*,
|
||||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
) -> list[Candidate]:
|
||||
"""Every boundary the mechanical rules propose, in document order.
|
||||
|
||||
|
|
@ -324,6 +328,11 @@ def find_candidates(
|
|||
numbered outline contributes boundaries where the integers sustain an
|
||||
ascending run of at least `N`.
|
||||
|
||||
`unit_fold` is Arm F's gate and it is OFF at False, where `fold_units` is
|
||||
not called at all. On, the candidate list is folded once, at the end: the
|
||||
arm adds no boundary, so every plan it can produce is a subset of the one
|
||||
the flags below produce.
|
||||
|
||||
`table_grid` is Arm E's gate and it is OFF at False, where the branch is not
|
||||
even evaluated. On, a pandoc grid-table rule line no longer closes an open
|
||||
table block, so one grid table proposes one candidate instead of one per row
|
||||
|
|
@ -496,7 +505,111 @@ def find_candidates(
|
|||
grid=position_in_list in joined,
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
return fold_units(candidates) if unit_fold else candidates
|
||||
|
||||
|
||||
#: Arm F clause 1. How many CONSECUTIVE same-level page-numbered headings make
|
||||
#: a contents list. Three, and it is swept rather than guessed: at 1 and 2 the
|
||||
#: rule deletes body chapters (a heading like `... i henhold til TEK 17` ends
|
||||
#: in a number and is not a contents line), and the sweep front is published in
|
||||
#: `docs/2026-09-08-k3-arm-f-mot-enhetsarket.md`. Not a CLI knob: a number a
|
||||
#: caller can turn without recording the sweep is a number nobody has measured.
|
||||
CONTENTS_RUN = 3
|
||||
|
||||
|
||||
def fold_units(candidates: list[Candidate]) -> list[Candidate]:
|
||||
"""Arm F: ONE rule, three clauses, and it only MERGES or DISCARDS.
|
||||
|
||||
Derived from the three rules the operator wrote across the K3 unit
|
||||
worksheet (2026-09-08), not from twelve special cases:
|
||||
|
||||
1. "innholdsfortegnelsen er ikke konsepter" -- a RUN of at least
|
||||
`CONTENTS_RUN` consecutive same-level headings each ending in a page
|
||||
number is a contents list and is discarded. A run of siblings, never a
|
||||
single line: one body heading ending in a number is not a contents list.
|
||||
2. "hvert h2-kapittel med sine h3" -- the unit level is the SHALLOWEST
|
||||
heading level occurring more than once; anything deeper folds into the
|
||||
preceding candidate at or above that level, which EXTENDS the parent's
|
||||
span rather than deleting the child's body.
|
||||
3. "tabellen med innledningen" -- a table folds back into the heading
|
||||
immediately before it when that heading's own span is shorter than the
|
||||
table's. The surviving concept keeps the HEADING's name: merging the
|
||||
right bytes under `Tabell linje 48` would produce a concept no reader
|
||||
can look up.
|
||||
|
||||
It proposes no boundary of its own, so it can only ever reduce a plan. A
|
||||
document with one heading level and no table comes out identical.
|
||||
"""
|
||||
if not candidates:
|
||||
return candidates
|
||||
|
||||
# Clause 1.
|
||||
drop: set[int] = set()
|
||||
index = 0
|
||||
while index < len(candidates):
|
||||
candidate = candidates[index]
|
||||
if candidate.rule == RULE_TABLE_BLOCK or not _TRAILING_PAGE_NUMBER.search(candidate.title):
|
||||
index += 1
|
||||
continue
|
||||
end = index
|
||||
while (
|
||||
end < len(candidates)
|
||||
and candidates[end].rule != RULE_TABLE_BLOCK
|
||||
and candidates[end].level == candidate.level
|
||||
and _TRAILING_PAGE_NUMBER.search(candidates[end].title)
|
||||
):
|
||||
end += 1
|
||||
if end - index >= CONTENTS_RUN:
|
||||
drop.update(range(index, end))
|
||||
index = end
|
||||
|
||||
kept = [c for position, c in enumerate(candidates) if position not in drop]
|
||||
if not kept:
|
||||
return kept
|
||||
|
||||
# Clause 2. The unit level is read from DECLARED headings only -- ATX and
|
||||
# dotted-numbered, `RULE_HEADING`. Two exclusions, each measured:
|
||||
#
|
||||
# - a table block carries the sentinel level 9, and letting it vote would
|
||||
# make every document with two tables a two-level document;
|
||||
# - `RULE_OUTLINE` is Arm D's RECOVERY of an integer numbering run, not a
|
||||
# level the document declares. Letting it vote made it the shallowest
|
||||
# repeated level on every PDF that has both, folding the dotted headings
|
||||
# the operator actually named into it: 23 -> 3, 48 -> 4 and 11 -> 7 on
|
||||
# K3 positions 1, 7 and 9. The unit worksheet showed the operator ATX and
|
||||
# dotted headings and nothing else, which is the same set.
|
||||
#
|
||||
# With no declared heading at all the fold has no level to work from, and
|
||||
# `unit_level` is set below every candidate's, so clause 2 is inert and
|
||||
# only clause 3 can fire. That is the honest behaviour: a document whose
|
||||
# structure was recovered rather than declared has no unit level to read.
|
||||
levels = [c.level for c in kept if c.rule == RULE_HEADING]
|
||||
repeated = sorted({level for level in levels if levels.count(level) > 1})
|
||||
if repeated:
|
||||
unit_level = repeated[0]
|
||||
elif levels:
|
||||
unit_level = min(levels)
|
||||
else:
|
||||
unit_level = -1
|
||||
|
||||
folded: list[Candidate] = []
|
||||
for candidate in kept:
|
||||
table = candidate.rule == RULE_TABLE_BLOCK
|
||||
deeper = candidate.rule == RULE_HEADING and candidate.level > unit_level
|
||||
if table and folded:
|
||||
previous = folded[-1]
|
||||
introduces = (
|
||||
previous.rule != RULE_TABLE_BLOCK
|
||||
and previous.end - previous.start < candidate.end - candidate.start
|
||||
)
|
||||
else:
|
||||
introduces = False
|
||||
if (deeper or introduces) and folded:
|
||||
previous = folded[-1]
|
||||
folded[-1] = replace(previous, end=candidate.end)
|
||||
continue
|
||||
folded.append(candidate)
|
||||
return folded
|
||||
|
||||
|
||||
def _cut_points(text: str, start: int, end: int, cap: int) -> list[int]:
|
||||
|
|
@ -646,12 +759,15 @@ def build_plan(
|
|||
max_segment_chars: int = 0,
|
||||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||
taken: set[str] = set()
|
||||
extractor_id = source.suffix.lower().lstrip(".") or "none"
|
||||
entries: list[dict[str, Any]] = []
|
||||
candidates = find_candidates(text, outline_run=outline_run, table_grid=table_grid)
|
||||
candidates = find_candidates(
|
||||
text, outline_run=outline_run, table_grid=table_grid, unit_fold=unit_fold
|
||||
)
|
||||
for candidate in subdivide(text, candidates, max_segment_chars):
|
||||
entries.append(
|
||||
{
|
||||
|
|
@ -720,6 +836,7 @@ def run(
|
|||
max_segment_chars: int = 0,
|
||||
outline_run: int = 0,
|
||||
table_grid: bool = False,
|
||||
unit_fold: bool = False,
|
||||
) -> int:
|
||||
if max_segment_chars < 0:
|
||||
raise ProposerError(
|
||||
|
|
@ -771,6 +888,7 @@ def run(
|
|||
max_segment_chars=max_segment_chars,
|
||||
outline_run=outline_run,
|
||||
table_grid=table_grid,
|
||||
unit_fold=unit_fold,
|
||||
)
|
||||
# Nothing to propose is an OUTCOME, and it is not an artifact. An empty
|
||||
# plan cannot be replayed -- `process_inbox` refuses one, because a plan
|
||||
|
|
@ -848,6 +966,21 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
"the K3 method file does not name it either"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--unit-fold",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Arm F: fold the proposed candidates into the operator's units --"
|
||||
" discard a run of contents-list headings, fold a deeper heading"
|
||||
" into its parent, and fold a table back into the shorter heading"
|
||||
" that introduces it. ONE rule with three clauses, derived from the"
|
||||
" three rules the K3 unit worksheet records (2026-09-08). It adds"
|
||||
" no boundary, so it can only reduce a plan. Absent (the default)"
|
||||
" is OFF and leaves the artifact byte-identical to the arm below"
|
||||
" it. A boolean: the rule's one number, CONTENTS_RUN, is a module"
|
||||
" constant whose sweep is published, not a knob a caller can turn"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--table-grid",
|
||||
action="store_true",
|
||||
|
|
@ -884,6 +1017,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
max_segment_chars=args.max_segment_chars,
|
||||
outline_run=args.outline_run,
|
||||
table_grid=args.table_grid,
|
||||
unit_fold=args.unit_fold,
|
||||
)
|
||||
except ProposerError as exc:
|
||||
print(f"{PROPOSER_ID}: FAILED - {exc}", file=sys.stderr)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue