llm-ingestion-okf/tests/test_propose_sheet_sections.py
Kjell Tore Guttormsen 6ff18fd703 feat(propose,extract,cli): a title that ends in a number, and a converter's own anchor in a concept id
Round 9: the four rests in STATE's NESTE that needed no operator decision.

CLAUSE 1 CLASSIFIED BY THE NUMBER, NOT THE TITLE. `_TRAILING_PAGE_NUMBER`
admitted a candidate into a contents run by asking whether the title ended in
an integer -- a question about the number. A drawing's dimension chain, a
schematic's labels, a door schedule, a coordinate column and a soil-layer
table all end in integers and name nothing. Measured over the 43-document
corpus: 68 candidates discarded over 11 of 39 readable documents, of which
19 over 5 documents are data rows.

That corrects round 8's own decomposition. Its "four misclassified numeric
tables and seven real contents listings" needs each document on one side, and
two of the eleven are both. Read across all 68 titles rather than the
three-title sample: 5 documents carry a data row, 8 carry a real entry.

`--contents-name` requires a NAME to survive stripping the page number. The
threshold is SWEPT, not chosen, and collapses at both ends: at an alphabetic
run of 1 a door schedule keeps a stray `V` and 13 of 19 are rescued; at 3 the
two-letter section name `VA` stops being a name, falls out of run membership,
and takes `RIB`, `MMI` and `Tittelfelt` below `CONTENTS_RUN` with it -- one
acronym costing four REAL entries. At 2: 16 of 19 rescued, 0 of 49 regressed.
The three not rescued carry a real word and are named rather than rounded off.

THE CONVERTER'S ANCHOR WAS IN THE CONCEPT ID. Pandoc writes a sheet as
`## <name> {#sheet-N}` and a titled slide as `## <title> {#slide-N}`. Because
a filename is reduced FROM the title, the anchor reached both. Operator
authorised the strip 2026-09-09 after the exposure was counted: 2 of 810
concepts on the previous default bundle, 2 of 1108 on Arm B, 1 of 26 on the
operator's folder. Two ids renamed, one of which `portfolio-optimiser` has
cited in writing; both are in the report so that message can be sent.

One rule in one function, read by BOTH title-forming sites -- a rule in only
one would leave the id and the title naming the same concept differently. The
known-negative is the point: `Mal for {kundenavn}` is a title an author wrote.

odt/rtf/pptx MEASURED END TO END FOR THE FIRST TIME, on hand-built documents,
because the corpus denominator is genuinely zero (86 files: 66 pdf, 10 docx,
4 xlsx, 2 zip, 2 smc, 2 doc). `_EVIDENCE` gains a third class rather than
stretching an existing one: `constructed` means the row has met a document,
but not one anyone wrote for their own purposes. odt 1 of 1 declared headings;
pptx 2 of 2 on a deck that declares slide titles and 0 of 2 on one that does
not -- round 7's reading of pptx was a fixture property, not the format; rtf
0 segments, because the container has no heading style and the author's title
is bold text. rtf is the one open finding.

ACCEPTANCE, all four. The 12-position reference is label-identical in BOTH
readings (pdf 7/8, docx 3/3, xlsx 0/1 or 1/1, sheet 10/12 or 11/12). One K2
bundle carrying both changes: 453 concepts / 865 md, hit@8 [1,1,1,1,1,None]
on it AND on Arm B, with the known-negative still reproducing on the new
bytes. `okf project` byte-equal to `okf build`, `diff -r` empty. Consumer
cost is a re-run: 436/832 -> 453/865, digest 21af4a1aa98315cf.

Three published numbers corrected: README's 596 tests (1515), README's "15
concepts out" for `okf project` (that was the O6 defect; it is 26), and O6's
print-mode method, which does not reproduce without --allowedTools.

Report: docs/2026-09-09-k3-runde9-restene.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:45:08 +02:00

199 lines
8.6 KiB
Python

"""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.
"""
# The title carries NO `{#sheet-1}` since 2026-09-09: the converter anchor
# is stripped where the title is formed, because a concept's id is reduced
# from its title and the two must not name the same concept differently.
# The fixture keeps the anchor -- it is what pandoc writes, and removing it
# from the input would test the strip against text that never has it.
off = okf_propose_segments.find_candidates(SECTIONED_SHEET)
assert [(c.title, c.rule) for c in off] == [
("Kostnadsoversikt", 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", 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