feat(propose,cli): the sheet's rows, the wrapped sentence, and a default that moved
K3 round 3, order 20260908T170037Z-3622420612-from-.claude, plus the PM injection of 17:55Z carrying the operator's D3 answer. TWO NEW RULES, both OFF by default. `--sheet-section-rows` cuts an open table block at the rows that label its sections: a RUN of at least three rows whose first cell is a bare numeric label. It is the first rule here that opens a span INSIDE a table, and the opposite direction from Arm E -- that arm decides how far a block extends, this decides where it is cut inside. The spreadsheet is the one file type no arm had ever moved: 1 concept -> 12 on the priced sheet, of which 11 are the reference's 11 cost groups. The run guard buys exactly three candidates on the corpus (the rows stating a computation basis), and each section carries its own `source_sheet` + `source_rows`, verified on the artifact. `--drop-wrapped-outline` declines an outline candidate whose line continues onto the next one. Round 2 falsified the LENGTH axis on this case; this one is not about size. Over the 12-document sample it separates 8 of 34 candidates -- the quoted regulation paragraphs and the risk-table rows -- and none of the 26 the operator kept. Position 4: 4 concepts -> 1, the reference. The cell is `treffer` by count and lossy by bytes, and the report says so. Whole corpus, one cached extraction: 1 of 39 documents changes under the first rule (0 of 32 pdf, 0 of 5 docx), 5 of 39 under the second (all pdf). Arm B and F2 are byte-identical against a frozenc3b645b, both halves by `diff -r`. THE `okf build` DEFAULT MOVED, and this is the operator's decision executed, not a side effect: no flag now means `--outline-run 3 --unit-fold`. Each arm keeps an explicit opt-out (`--outline-run 0`, `--no-unit-fold`) and the pair reproduces the pre-move bytes exactly. The proposer's own defaults do NOT move: they are what the goldens and every published reproduction block are pinned to, so the two layers now disagree on purpose and `cli.py` says where. Two shipped expectations moved with it and both are stated in place. MEASURED AND REPORTED BACK: the configuration made default scores 2 of 12 on the unit worksheet, `docx` 0 of 3 -- Arm F's published 5 of 12 was measured with `--table-grid` ON, and without it the fold has no joined table to fold. Position 1 is declined for the second round on the axis the order named: 3 of 3 recovered chapters have body under them, so "heading-on-heading" separates 0 of 3. The K2 ranking control round 2 could not measure: two bundles from frozenc3b645bdiffering only in `--keep-table-heading`, 2 of 1108 files apart. The priced concept ranks 96 of 629 in BOTH and is delivered at rank 10 in both, so the flag buys 35 payload bytes and zero rank positions. Round 2's prediction is falsified: the concept's NAME was already restored by the orphan-name inheritance; the flag adds the heading LINE. The stale S7 literal is restated where it stood (`c759a657...`, 171 614 B, re-measured on this HEAD), so the next order cannot inherit the superseded one. 1397 -> 1414 tests. Report: docs/2026-09-08-k3-runde3-per-filtype.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c3b645bccf
commit
47f6619e6f
11 changed files with 1381 additions and 36 deletions
|
|
@ -29,6 +29,7 @@ from __future__ import annotations
|
|||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -82,12 +83,16 @@ def tree(root: Path) -> dict[str, bytes]:
|
|||
}
|
||||
|
||||
|
||||
def two_script_bundle(inbox: Path, out: Path) -> Path:
|
||||
def two_script_bundle(inbox: Path, out: Path, proposer_flags: Sequence[str] = ()) -> Path:
|
||||
"""The path this command replaces, run as it is documented, as subprocesses.
|
||||
|
||||
Re-implementing the loop here would compare the CLI against a copy of
|
||||
itself. Driving the actual scripts is what makes the byte comparison mean
|
||||
"the old path and the new path agree".
|
||||
|
||||
`proposer_flags` is passed through verbatim. It exists because the BUILD
|
||||
default moved and the proposer's did not: the two paths still agree, and
|
||||
saying which flags make them agree is the honest form of that claim.
|
||||
"""
|
||||
plans = out / "plans"
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -109,6 +114,7 @@ def two_script_bundle(inbox: Path, out: Path) -> Path:
|
|||
relative.with_suffix("").as_posix(),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
*proposer_flags,
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
|
|
@ -545,11 +551,18 @@ def test_the_arms_reach_the_proposer_from_the_build_command(tmp_path: Path) -> N
|
|||
|
||||
|
||||
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.
|
||||
"""The control the test above rests on: same inbox, no flags, the DEFAULT.
|
||||
|
||||
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.
|
||||
**This expectation MOVED on 2026-09-08 and the move is stated rather than
|
||||
quietly rewritten.** It was written in round 2 to pin the flagless build to
|
||||
Arm B, so that a change turning an arm on by default could not hide behind
|
||||
the assertion above. The operator then turned two arms on, deliberately,
|
||||
and the titles below are Arm D plus Arm F on this fixture.
|
||||
|
||||
What the test still measures is unchanged and is the reason it stays: the
|
||||
flagless build has ONE declared behaviour and a drifting default moves it.
|
||||
`test_each_arm_in_the_default_has_an_explicit_opt_out` holds the other end,
|
||||
where Arm B's titles are still asserted, from the opt-out.
|
||||
"""
|
||||
inbox = inbox_for_arms(tmp_path)
|
||||
plans = tmp_path / "plans-plain"
|
||||
|
|
@ -564,6 +577,236 @@ def test_the_build_default_is_unchanged_by_the_flags_existing(tmp_path: Path) ->
|
|||
)
|
||||
== 0
|
||||
)
|
||||
# Arm F folds `4.1 Loesmasser` into `4 Grunnforhold`; the sheet heading is
|
||||
# still carried by the table block, because `--keep-table-heading` did NOT
|
||||
# move and is still a flag.
|
||||
assert plan_titles(plans) == ["4 Grunnforhold", "5 Prissammenstilling"]
|
||||
|
||||
|
||||
# A sheet whose units are ROWS: one heading, one continuous table block, a
|
||||
# preamble and a run of three numbered rows. Kept apart from ARM_DOCUMENTS so
|
||||
# the round-2 assertions above keep measuring what they were written for --
|
||||
# `epsilon.md`'s table has ONE numbered row, which is a quantity and not a run.
|
||||
SHEET_DOCUMENT = {
|
||||
"zeta.md": (
|
||||
"## 6 Kostnadsoversikt\n\n"
|
||||
"| Skjema | | |\n|----|----|----|\n"
|
||||
"| Skjemaet fylles ut i sin helhet. | | |\n"
|
||||
"| 01 | Felleskostnader | |\n"
|
||||
"| 02 | Bygning | |\n"
|
||||
"| 07 | Utendoers | |\n"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def inbox_for_sheet_sections(root: Path) -> Path:
|
||||
inbox = root / "sheet-inbox"
|
||||
inbox.mkdir(parents=True, exist_ok=True)
|
||||
for name, body in SHEET_DOCUMENT.items():
|
||||
(inbox / name).write_text(body, encoding="utf-8", newline="")
|
||||
return inbox
|
||||
|
||||
|
||||
def test_sheet_section_rows_reaches_the_proposer_from_the_build_command(tmp_path: Path) -> None:
|
||||
"""The red test for round 3: D3 exists here and it changes the plan.
|
||||
|
||||
Asserted on the plan's TITLES, for the reason round 2's is: a rule that
|
||||
produced the right number of concepts by cutting in the wrong places would
|
||||
pass a count assertion. Each title carries the row's own label, which is
|
||||
what makes the concept findable by the number a reader is holding.
|
||||
"""
|
||||
inbox = inbox_for_sheet_sections(tmp_path)
|
||||
plans = tmp_path / "plans-sheet"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
tmp_path / "bundle-sheet",
|
||||
"--plans-dir",
|
||||
str(plans),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--sheet-section-rows",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert plan_titles(plans) == [
|
||||
"6 Kostnadsoversikt",
|
||||
"01 Felleskostnader",
|
||||
"02 Bygning",
|
||||
"07 Utendoers",
|
||||
]
|
||||
|
||||
|
||||
def test_the_build_default_leaves_a_sheet_as_one_concept(tmp_path: Path) -> None:
|
||||
"""The control the test above rests on: same inbox, no flag, one concept.
|
||||
|
||||
Without it, a change that turned D3 on by default would leave the assertion
|
||||
above green while cutting every table a consumer has ever ingested.
|
||||
"""
|
||||
inbox = inbox_for_sheet_sections(tmp_path)
|
||||
plans = tmp_path / "plans-sheet-plain"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
tmp_path / "bundle-sheet-plain",
|
||||
"--plans-dir",
|
||||
str(plans),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert plan_titles(plans) == ["6 Kostnadsoversikt"]
|
||||
|
||||
|
||||
# A document whose numbering is RECOVERED and whose first numbered line is a
|
||||
# wrapped sentence: the shape of quoted regulation text.
|
||||
WRAPPED_DOCUMENT = {
|
||||
"eta.md": (
|
||||
"Innledning uten nummer.\n\n"
|
||||
"1 Krav og kriterier etter denne bestemmelsen skal ha som maal aa\n"
|
||||
"redusere anskaffelsens samlede klimaavtrykk.\n\n"
|
||||
"2 Ordinaert kapittel\n\nDette kapittelet har sin egen kropp.\n\n"
|
||||
"3 Tredje kapittel\n\nOgsaa dette er en kropp.\n"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def inbox_for_wrapped_outline(root: Path) -> Path:
|
||||
inbox = root / "wrapped-inbox"
|
||||
inbox.mkdir(parents=True, exist_ok=True)
|
||||
for name, body in WRAPPED_DOCUMENT.items():
|
||||
(inbox / name).write_text(body, encoding="utf-8", newline="")
|
||||
return inbox
|
||||
|
||||
|
||||
def test_drop_wrapped_outline_reaches_the_proposer_from_the_build_command(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The second red test for round 3, and its control is the pair below.
|
||||
|
||||
The build path had four flags and the proposer now has six. A rule that
|
||||
only the proposer can run is a rule no bundle is ever built with, which is
|
||||
the defect round 2 fixed for the arms.
|
||||
"""
|
||||
inbox = inbox_for_wrapped_outline(tmp_path)
|
||||
plans = tmp_path / "plans-wrapped"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
tmp_path / "bundle-wrapped",
|
||||
"--plans-dir",
|
||||
str(plans),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--outline-run",
|
||||
"3",
|
||||
"--drop-wrapped-outline",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert plan_titles(plans) == ["Ordinaert kapittel", "Tredje kapittel"]
|
||||
|
||||
|
||||
def test_the_build_default_keeps_a_wrapped_outline_candidate(tmp_path: Path) -> None:
|
||||
"""The control: Arm D alone recovers all three, including the sentence."""
|
||||
inbox = inbox_for_wrapped_outline(tmp_path)
|
||||
plans = tmp_path / "plans-wrapped-plain"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
tmp_path / "bundle-wrapped-plain",
|
||||
"--plans-dir",
|
||||
str(plans),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--outline-run",
|
||||
"3",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert plan_titles(plans) == [
|
||||
"Krav og kriterier etter denne bestemmelsen skal ha som maal aa",
|
||||
"Ordinaert kapittel",
|
||||
"Tredje kapittel",
|
||||
]
|
||||
|
||||
|
||||
# --- the default moved (operator, 2026-09-08) ------------------------------
|
||||
#
|
||||
# Round 3. The operator answered the standing question with alternative (b):
|
||||
# `okf build` with no flag is now Arm D plus Arm F, `--outline-run 3
|
||||
# --unit-fold`. Two arms and not four -- `--table-grid` and
|
||||
# `--keep-table-heading` stay flags until the K2 ranking control is measured.
|
||||
#
|
||||
# Every arm keeps an EXPLICIT opt-out, because a default nobody can turn off is
|
||||
# not a default, it is a behaviour: `--outline-run 0` for Arm D (the number was
|
||||
# always its own switch) and `--no-unit-fold` for Arm F.
|
||||
|
||||
|
||||
def test_the_build_default_is_now_arm_d_plus_arm_f(tmp_path: Path) -> None:
|
||||
"""The red test for the move: no flags must EQUAL the two flags, byte for byte.
|
||||
|
||||
Asserted on the bundle rather than on the parser, and byte for byte rather
|
||||
than on a count: a default that reached the proposer as a different number
|
||||
would still produce a plausible plan, and only the bytes can tell.
|
||||
"""
|
||||
inbox = inbox_for_arms(tmp_path)
|
||||
default = tmp_path / "default-bundle"
|
||||
explicit = tmp_path / "explicit-bundle"
|
||||
assert build(inbox, default, "--proposed-at", PROPOSED_AT) == 0
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
explicit,
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--outline-run",
|
||||
"3",
|
||||
"--unit-fold",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert tree(default) == tree(explicit)
|
||||
|
||||
|
||||
def test_each_arm_in_the_default_has_an_explicit_opt_out(tmp_path: Path) -> None:
|
||||
"""The other half: a caller can still get the pre-move behaviour, and say so.
|
||||
|
||||
`--outline-run 0 --no-unit-fold` must reproduce Arm B exactly. Without
|
||||
this, the move would be irreversible for every consumer who needs the old
|
||||
bytes, and "off by default" would have become "unreachable".
|
||||
"""
|
||||
inbox = inbox_for_arms(tmp_path)
|
||||
opted_out = tmp_path / "opt-out-bundle"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
opted_out,
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--outline-run",
|
||||
"0",
|
||||
"--no-unit-fold",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
plans = tmp_path / "plans-opt-out"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
tmp_path / "opt-out-plans-bundle",
|
||||
"--plans-dir",
|
||||
str(plans),
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--outline-run",
|
||||
"0",
|
||||
"--no-unit-fold",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
# Arm B's own titles, the ones round 2 pinned for the flagless build.
|
||||
assert plan_titles(plans) == ["4 Grunnforhold", "4.1 Loesmasser", "5 Prissammenstilling"]
|
||||
|
||||
|
||||
|
|
@ -572,12 +815,42 @@ def test_a_bundle_built_with_no_flags_is_byte_identical_to_the_shipped_one(
|
|||
) -> 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.
|
||||
**This expectation MOVED on 2026-09-08 and the move is stated rather than
|
||||
quietly rewritten.** Until then `okf build` with no flag was Arm B, so it
|
||||
equalled the two-script path run with no flag either. The operator moved
|
||||
the default to Arm D plus Arm F, so the equivalence now has to name the
|
||||
arms on one side or the other, and both halves are asserted:
|
||||
|
||||
- the two-script path with the two arms equals the new default;
|
||||
- the two-script path with no arm equals `--outline-run 0 --no-unit-fold`,
|
||||
which is what makes the move reversible for a consumer who needs the old
|
||||
bytes.
|
||||
|
||||
A single half would leave the other unmeasured, and the second is the one
|
||||
that goes red if an opt-out stops opting out.
|
||||
"""
|
||||
inbox = inbox_with_subdirectories(tmp_path)
|
||||
reference = two_script_bundle(inbox, tmp_path / "reference-2")
|
||||
armed = two_script_bundle(
|
||||
inbox, tmp_path / "reference-armed", proposer_flags=("--outline-run", "3", "--unit-fold")
|
||||
)
|
||||
bundle = tmp_path / "cli-bundle-2"
|
||||
assert build(inbox, bundle, "--ingested-at", INGESTED_AT, "--proposed-at", PROPOSED_AT) == 0
|
||||
assert tree(bundle) == tree(reference)
|
||||
assert tree(bundle) == tree(armed)
|
||||
|
||||
plain = two_script_bundle(inbox, tmp_path / "reference-2")
|
||||
opted_out = tmp_path / "cli-bundle-opt-out"
|
||||
assert (
|
||||
build(
|
||||
inbox,
|
||||
opted_out,
|
||||
"--ingested-at",
|
||||
INGESTED_AT,
|
||||
"--proposed-at",
|
||||
PROPOSED_AT,
|
||||
"--outline-run",
|
||||
"0",
|
||||
"--no-unit-fold",
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert tree(opted_out) == tree(plain)
|
||||
|
|
|
|||
|
|
@ -1061,7 +1061,13 @@ def test_a_non_integer_outline_run_is_refused_by_argparse(
|
|||
#: Arm C is False on purpose -- its help says "not defined in the K3 method
|
||||
#: file" instead, and the point of the check is that each arm's attribution
|
||||
#: sits in its OWN chunk.
|
||||
ARM_ATTRIBUTION = {"outline-run": True, "table-grid": True, "max-segment-chars": False}
|
||||
ARM_ATTRIBUTION = {
|
||||
"outline-run": True,
|
||||
"table-grid": True,
|
||||
"sheet-section-rows": True,
|
||||
"drop-wrapped-outline": True,
|
||||
"max-segment-chars": False,
|
||||
}
|
||||
|
||||
|
||||
def test_each_arm_flag_carries_its_attribution_inside_its_own_option_chunk(
|
||||
|
|
@ -1100,6 +1106,92 @@ def test_each_arm_flag_carries_its_attribution_inside_its_own_option_chunk(
|
|||
assert squeezed.count("not defined upstream") == expected
|
||||
|
||||
|
||||
# --- D3 round 3: a recovered heading that is really a wrapped sentence -----
|
||||
#
|
||||
# K3 position 4 quotes a regulation. Its numbered paragraphs (`1)`, `3)`, `4)`,
|
||||
# `5)`) match Arm D's grammar exactly, so the arm recovers four "chapters" from
|
||||
# text the operator wrote off in one line: "sitert forskriftstekst er ikke
|
||||
# overskrifter". Round 2 tried to separate them by TITLE LENGTH and falsified
|
||||
# it: a real chapter heading in the same sample is 88 characters and the
|
||||
# quoted sentences are 86, 91, 92 and 100.
|
||||
#
|
||||
# The axis below is a different one and it is not about size. A heading is a
|
||||
# complete line; a wrapped sentence continues on the next one. Measured over
|
||||
# the 12-document sample: 8 of 34 outline candidates are followed by a line
|
||||
# beginning in lower case -- the four quoted paragraphs at position 4 and four
|
||||
# table rows at position 9, where the operator wrote "radene er ikke
|
||||
# overskrifter". 0 of the 26 the operator kept.
|
||||
#
|
||||
# It judges RECOVERED candidates only. A dotted or ATX heading is the
|
||||
# document's own claim about itself, and overruling that is a different
|
||||
# decision from declining to invent one.
|
||||
WRAPPED_OUTLINE = """Innledning til dokumentet uten nummer.
|
||||
|
||||
1 Krav og kriterier etter denne bestemmelsen skal ha som maal aa redusere
|
||||
anskaffelsens samlede klimaavtrykk.
|
||||
|
||||
2 Ordinaert kapittel
|
||||
|
||||
Dette kapittelet har en kropp som begynner med stor forbokstav.
|
||||
|
||||
3 Tredje kapittel
|
||||
|
||||
Ogsaa dette er en kropp.
|
||||
"""
|
||||
|
||||
DECLARED_HEADING_THAT_WRAPS = """## 2.1 Et erklaert kapittel
|
||||
som fortsetter paa neste linje i samme setning.
|
||||
|
||||
## 2.2 Et annet kapittel
|
||||
|
||||
Kropp under det andre kapittelet.
|
||||
"""
|
||||
|
||||
|
||||
def test_a_wrapped_outline_candidate_is_not_a_heading() -> None:
|
||||
"""The red test: the recovered candidate whose line wraps is dropped.
|
||||
|
||||
Only that one. The two whose next line opens a new sentence stay, which is
|
||||
what keeps the rule from being "discard Arm D" wearing a flag.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(WRAPPED_OUTLINE, outline_run=3)
|
||||
assert [c.title for c in off] == [
|
||||
"Krav og kriterier etter denne bestemmelsen skal ha som maal aa redusere",
|
||||
"Ordinaert kapittel",
|
||||
"Tredje kapittel",
|
||||
]
|
||||
on = okf_propose_segments.find_candidates(
|
||||
WRAPPED_OUTLINE, outline_run=3, drop_wrapped_outline=True
|
||||
)
|
||||
assert [c.title for c in on] == ["Ordinaert kapittel", "Tredje kapittel"]
|
||||
|
||||
|
||||
def test_a_declared_heading_that_wraps_is_left_alone() -> None:
|
||||
"""The known-negative, and it is the rule's whole scope statement.
|
||||
|
||||
A document that numbers its own sections has declared them. The rule
|
||||
exists because Arm D RECOVERS a numbering the document never declared, so
|
||||
a recovered candidate that turns out to be a sentence was never a heading;
|
||||
a declared one is a claim this tool does not get to overrule.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(DECLARED_HEADING_THAT_WRAPS)
|
||||
on = okf_propose_segments.find_candidates(
|
||||
DECLARED_HEADING_THAT_WRAPS, drop_wrapped_outline=True
|
||||
)
|
||||
assert [c.title for c in off] == ["2.1 Et erklaert kapittel", "2.2 Et annet kapittel"]
|
||||
assert off == on
|
||||
|
||||
|
||||
def test_the_wrapped_rule_is_off_by_default() -> None:
|
||||
"""The default control: same text, no flag, Arm D's own output."""
|
||||
default = okf_propose_segments.find_candidates(WRAPPED_OUTLINE, outline_run=3)
|
||||
explicit = okf_propose_segments.find_candidates(
|
||||
WRAPPED_OUTLINE, outline_run=3, drop_wrapped_outline=False
|
||||
)
|
||||
assert default == explicit
|
||||
assert len(default) == 3
|
||||
|
||||
|
||||
# --- Arm E: the grid-rule join --------------------------------------------
|
||||
#
|
||||
# Arm E is not defined in `docs/2026-09-02-k3-k4-k5-metode.md` -- that file
|
||||
|
|
|
|||
194
tests/test_propose_sheet_sections.py
Normal file
194
tests/test_propose_sheet_sections.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""D3 round 3: section rows INSIDE a sheet's one table block, behind a flag.
|
||||
|
||||
The spreadsheet is the one file type no arm has ever moved. Its whole body
|
||||
extracts as ONE continuous pipe-table block -- one heading and 101 rows -- so
|
||||
every rule this module has proposes exactly one boundary for it, and the
|
||||
operator's worksheet asks for one concept per numbered cost group. Those groups
|
||||
are ROWS, not headings, which is why no heading rule can reach them and why
|
||||
`--keep-table-heading` moved the first byte without moving the count.
|
||||
|
||||
The rule tested here is the opposite direction from `--table-grid`: that one
|
||||
stops a converter's rule line from CLOSING a block, so one grid table proposes
|
||||
one candidate instead of many; this one CUTS an open block at the rows that
|
||||
label its sections. They read different grammars (a `+---+` rule line against a
|
||||
pipe row's first cell) and compose in one order -- grid decides how far a block
|
||||
extends, section rows decide where it is cut inside.
|
||||
|
||||
Generality is the whole point, so the fixtures below carry no word list and no
|
||||
knowledge of any real document's labels: what makes a row a section row is that
|
||||
its first cell is a bare numeric label and that it is one of a RUN of such rows.
|
||||
The run is the same bounding device `CONTENTS_RUN` already uses in this module,
|
||||
for the same reason: a single numbered row is a quantity, not a section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf import propose as okf_propose_segments
|
||||
|
||||
# One heading, one continuous table block, a preamble, a run of four numbered
|
||||
# section rows, and a summing tail. The shape of a priced sheet, written from
|
||||
# the grammar rather than copied from one.
|
||||
SECTIONED_SHEET = """## Kostnadsoversikt {#sheet-1}
|
||||
|
||||
| Skjema | | |
|
||||
|----|----|----|
|
||||
| Skjemaet fylles ut i sin helhet. | | |
|
||||
| Post | | SUM |
|
||||
| 01 | Felleskostnader | |
|
||||
| 11+12 | Rigging og drift | |
|
||||
| 02 | Bygning | |
|
||||
| 07 | Utendoers | |
|
||||
| Tilbudt fastpris eksklusive avgift | | 100 |
|
||||
"""
|
||||
|
||||
# The same block with the numbered run removed. Nothing here is a section row,
|
||||
# so the flag must leave it exactly as it found it.
|
||||
UNSECTIONED_SHEET = """## Kostnadsoversikt {#sheet-1}
|
||||
|
||||
| Skjema | | |
|
||||
|----|----|----|
|
||||
| Skjemaet fylles ut i sin helhet. | | |
|
||||
| Post | | SUM |
|
||||
| Felleskostnader | | 10 |
|
||||
| Rigging og drift | | 20 |
|
||||
| Tilbudt fastpris eksklusive avgift | | 100 |
|
||||
"""
|
||||
|
||||
# A single row whose first cell is a number -- a computation basis, not a
|
||||
# section. This is the known-negative the run threshold exists for, and it is
|
||||
# the shape that actually occurs: a sheet states a quantity on its own row.
|
||||
LONE_NUMERIC_ROW = """## Regningsarbeider {#sheet-2}
|
||||
|
||||
| Grunnlag | | |
|
||||
|----|----|----|
|
||||
| Beregningsgrunnlag massehaandtering | tonn | 16000 |
|
||||
| Paaslag i prosent | | 10 |
|
||||
"""
|
||||
|
||||
|
||||
def test_a_run_of_numbered_rows_cuts_the_block_into_sections() -> None:
|
||||
"""The rule, red first: one candidate per section row, named from the row.
|
||||
|
||||
Default: the whole sheet is ONE table block whose title is inherited from
|
||||
the orphaned heading. On: the preamble keeps that block and each numbered
|
||||
row opens its own candidate, running to the next section row or to the end
|
||||
of the block -- so the LAST section carries the sheet's tail, which is the
|
||||
honest consequence of a rule that cuts and never discards.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(SECTIONED_SHEET)
|
||||
assert [(c.title, c.rule) for c in off] == [
|
||||
("Kostnadsoversikt {#sheet-1}", okf_propose_segments.RULE_TABLE_BLOCK)
|
||||
]
|
||||
|
||||
on = okf_propose_segments.find_candidates(SECTIONED_SHEET, sheet_section_rows=True)
|
||||
assert [(c.number, c.title, c.rule) for c in on] == [
|
||||
(None, "Kostnadsoversikt {#sheet-1}", okf_propose_segments.RULE_TABLE_BLOCK),
|
||||
("01", "01 Felleskostnader", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
("11+12", "11+12 Rigging og drift", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
("02", "02 Bygning", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
("07", "07 Utendoers", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
]
|
||||
# The preamble block ends where the first section opens, and the last
|
||||
# section reaches the end of the text.
|
||||
assert on[0].end == on[1].start
|
||||
assert on[1].end == on[2].start
|
||||
assert on[-1].end == len(SECTIONED_SHEET)
|
||||
# The tail row is inside the last section rather than in no concept at all.
|
||||
assert "Tilbudt fastpris" in SECTIONED_SHEET[on[-1].start : on[-1].end]
|
||||
|
||||
|
||||
def test_a_section_row_survives_the_orphan_check() -> None:
|
||||
"""A stated exception, because without it the rule cannot fire at all.
|
||||
|
||||
The orphan check reads the lines UNDER a candidate's first line, which is
|
||||
right for a heading -- a heading with nothing under it names nothing. A
|
||||
section row carries its content in its own cells, so every one-row section
|
||||
is bodiless by that test and all but the last would be dropped. The
|
||||
exception is written once, on the rule, and it is what the count below
|
||||
proves.
|
||||
"""
|
||||
on = okf_propose_segments.find_candidates(SECTIONED_SHEET, sheet_section_rows=True)
|
||||
single_row = [c for c in on if c.rule == okf_propose_segments.RULE_SHEET_SECTION][:3]
|
||||
assert len(single_row) == 3
|
||||
for candidate in single_row:
|
||||
assert len(SECTIONED_SHEET[candidate.start : candidate.end].splitlines()) == 1
|
||||
|
||||
|
||||
def test_a_table_without_section_rows_is_untouched() -> None:
|
||||
"""First known-negative: identical objects, not merely an equal count."""
|
||||
off = okf_propose_segments.find_candidates(UNSECTIONED_SHEET)
|
||||
on = okf_propose_segments.find_candidates(UNSECTIONED_SHEET, sheet_section_rows=True)
|
||||
assert len(off) == 1
|
||||
assert off == on
|
||||
|
||||
|
||||
def test_a_lone_numbered_row_is_not_a_section() -> None:
|
||||
"""Second known-negative, and the reason the rule counts a RUN.
|
||||
|
||||
`16000` is a quantity. A rule that read one numbered cell as a section
|
||||
would cut a sheet at every stated basis, which is the ungated widening this
|
||||
module has already refused once for outline candidates.
|
||||
"""
|
||||
off = okf_propose_segments.find_candidates(LONE_NUMERIC_ROW)
|
||||
on = okf_propose_segments.find_candidates(LONE_NUMERIC_ROW, sheet_section_rows=True)
|
||||
assert len(off) == 1
|
||||
assert off == on
|
||||
|
||||
|
||||
def test_the_flag_composes_with_keep_table_heading() -> None:
|
||||
"""Both on: the heading keeps the preamble, the sections stay separate.
|
||||
|
||||
D1 decides where the FIRST concept starts; this rule decides where the
|
||||
block is cut. They read different things and neither disables the other,
|
||||
which is what makes the pair safe to expose as two flags rather than one.
|
||||
"""
|
||||
on = okf_propose_segments.find_candidates(
|
||||
SECTIONED_SHEET, sheet_section_rows=True, keep_table_heading=True
|
||||
)
|
||||
assert [(c.number, c.rule) for c in on] == [
|
||||
(None, okf_propose_segments.RULE_HEADING),
|
||||
("01", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
("11+12", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
("02", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
("07", okf_propose_segments.RULE_SHEET_SECTION),
|
||||
]
|
||||
assert on[0].start == 0
|
||||
assert on[0].end == on[1].start
|
||||
|
||||
|
||||
def test_the_rule_names_itself_in_derived() -> None:
|
||||
"""A proposal an operator disagrees with is traceable to the rule."""
|
||||
plan = okf_propose_segments.build_plan(
|
||||
Path("ark.xlsx"),
|
||||
SECTIONED_SHEET,
|
||||
b"ark",
|
||||
okf_type="requirement",
|
||||
proposed_at="1970-01-01T00:00:00Z",
|
||||
sheet_section_rows=True,
|
||||
)
|
||||
sections = [e for e in plan["entries"] if e["title"].startswith("01")]
|
||||
assert len(sections) == 1
|
||||
assert sections[0]["derived"] == [
|
||||
okf_propose_segments.PROPOSED_MARKER,
|
||||
okf_propose_segments.RULE_SHEET_SECTION,
|
||||
]
|
||||
# The label becomes the directory and is stripped from the stem, the same
|
||||
# way a section number does for a heading.
|
||||
assert sections[0]["path"] == "01/felleskostnader.md"
|
||||
|
||||
|
||||
def test_sheet_section_rows_takes_no_argument(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A boolean at the CLI. The rule's one number is a module constant."""
|
||||
source = tmp_path / "ark.md"
|
||||
source.write_text(SECTIONED_SHEET, encoding="utf-8")
|
||||
out = tmp_path / "ark.json"
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
okf_propose_segments.main([str(source), "--out", str(out), "--sheet-section-rows", "3"])
|
||||
assert exit_info.value.code == 2
|
||||
assert "usage:" in capsys.readouterr().err
|
||||
|
|
@ -28,6 +28,7 @@ import pytest
|
|||
|
||||
from llm_ingestion_okf.extract import extract_text, source_units
|
||||
from llm_ingestion_okf.inbox import render_inbox_concept
|
||||
from llm_ingestion_okf.propose import RULE_SHEET_SECTION, find_candidates
|
||||
from llm_ingestion_okf.profiles import (
|
||||
DEFAULT,
|
||||
SEGMENTED_OKF_V0_2,
|
||||
|
|
@ -229,6 +230,53 @@ def test_a_spreadsheet_concept_names_the_sheet_and_its_rows() -> None:
|
|||
assert "source_rows: [1, 1]\n" in document
|
||||
|
||||
|
||||
@requires_extract
|
||||
def test_each_sheet_section_names_its_own_rows() -> None:
|
||||
"""D3: a section's locator is the section's rows, not the sheet's.
|
||||
|
||||
The rule cuts one table block into one candidate per numbered row, so the
|
||||
provenance layer is what decides whether those concepts are addressable at
|
||||
all. It reads the unit table built AT EXTRACTION and the candidate's own
|
||||
span, and neither knows about the rule — which is exactly why this has to
|
||||
be measured rather than assumed: a locator that reported the whole sheet
|
||||
for every section would look right in the frontmatter and point at nothing.
|
||||
"""
|
||||
data, text = _extract("prisark.xlsx")
|
||||
units = source_units("prisark.xlsx", data, text)
|
||||
candidates = find_candidates(text, sheet_section_rows=True)
|
||||
sections = [c for c in candidates if c.rule == RULE_SHEET_SECTION]
|
||||
assert [c.number for c in sections] == ["01", "02", "03", "04"]
|
||||
|
||||
located = []
|
||||
for candidate in sections:
|
||||
document = render_inbox_concept(
|
||||
text,
|
||||
okf_type="reference",
|
||||
title=candidate.title,
|
||||
source_file="prisark.xlsx",
|
||||
source_bytes=data,
|
||||
ingested_at="2026-09-08T00:00:00Z",
|
||||
profile=SEGMENTED_OKF_V0_2,
|
||||
units=units,
|
||||
span=(candidate.start, candidate.end),
|
||||
)
|
||||
assert "source_sheet: Prisark\n" in document
|
||||
located.append(
|
||||
next(line for line in document.splitlines() if line.startswith("source_rows:"))
|
||||
)
|
||||
# Four sections, four DIFFERENT row locators, ascending, and each one is
|
||||
# the workbook's own row number rather than a line in the extracted text.
|
||||
# The last section stops at the second sheet's heading, which is the next
|
||||
# mark: a span crossing two sheets would get no sheet and no rows at all,
|
||||
# and the fixture is what proves it does not.
|
||||
assert located == [
|
||||
"source_rows: [3, 3]",
|
||||
"source_rows: [4, 4]",
|
||||
"source_rows: [5, 5]",
|
||||
"source_rows: [6, 6]",
|
||||
]
|
||||
|
||||
|
||||
@requires_extract
|
||||
def test_a_range_spanning_two_sheets_names_no_sheet_and_no_rows() -> None:
|
||||
# A row number is only a place in the original once a sheet is named. A
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue