feat(propose): Arm D reads a document's own numbered outline

This commit is contained in:
Kjell Tore Guttormsen 2026-09-07 01:23:29 +02:00
commit 47ae1ed7e3
2 changed files with 277 additions and 0 deletions

View file

@ -575,3 +575,191 @@ def test_the_default_artifact_matches_its_committed_golden(tmp_path: Path) -> No
assert out.read_bytes() == golden.read_bytes(), (
"the default artifact diverges from its committed golden bytes"
)
# --- Arm D: the outline rule ----------------------------------------------
#
# Arm D is NOT defined in `docs/2026-09-02-k3-k4-k5-metode.md` -- that file
# contains no occurrence of the word -- so the definition these tests pin was
# written for the brief of order 20260906T213322Z and is reported as the
# author's, not as a ratified one: Arm B's mechanical rules, plus ONE rule that
# reads the document's OWN numbered outline -- the bare integers `N`, `N.`,
# `N)` that the shipping grammar cannot match because `_NUMBERED` requires a
# dot -- admitted only where the numbers form a maximal ascending run of at
# least a declared length, taking the LAST such run when the outline repeats,
# because a contents listing precedes the body it lists.
#
# EVERY fixture below uses BARE lines with no `#` prefix, and that is load
# bearing rather than stylistic. Measured on today's code: `# 1 Innledning`
# already yields 2 candidates via `_ATX`, and `1.1 Brannkonsept` already yields
# 2 via `_NUMBERED` -- so a fixture using either form would be green before the
# rule exists and would prove nothing. Bare `1` / `1.` / `1)` yield 0.
RUN_OF_THREE_THEN_RUN_OF_TWO = """Forord uten nummerering.
1 Innledning
Bakgrunn for prosjektet og omfanget.
2 Krav
Krav til seksjonering av bygget.
3 Gjennomfoering
Framdrift, faser og overlevering.
1 Vedlegg A
Foerste vedlegg til dokumentet.
2 Vedlegg B
Andre vedlegg til dokumentet.
"""
TWO_MAXIMAL_RUNS = """1 Innledning
Foerste forekomst, i innholdslista.
2 Krav
Andre forekomst, i innholdslista.
3 Gjennomfoering
Tredje forekomst, i innholdslista.
1 Innledning i kroppen
Her begynner selve teksten.
2 Krav i kroppen
Kravene slik de er skrevet ut.
3 Gjennomfoering i kroppen
Gjennomfoeringen slik den er skrevet ut.
"""
ALL_THREE_INTEGER_FORMS = """1 Innledning
Bakgrunn for prosjektet.
2. Krav
Krav til seksjonering.
3) Gjennomfoering
Framdrift og faser.
"""
NUMBER_WITHOUT_A_TITLE = """1 Innledning
Bakgrunn for prosjektet.
2
3 Gjennomfoering
Framdrift og faser.
"""
RUN_NOT_STARTING_AT_ONE = """2 Krav
Krav til seksjonering.
3 Gjennomfoering
Framdrift og faser.
4 Overlevering
Overlevering av bygget.
"""
def outline_titles(runs: list[list[tuple[int, int, str]]]) -> list[list[str]]:
return [[title for _, _, title in run] for run in runs]
def test_a_run_of_three_fires_and_a_run_of_two_in_the_same_document_does_not() -> None:
"""The paired form, and the pairing is the point.
A lone "a 2-run must not fire" assertion is true by absence before the rule
exists and true forever after, so it can never go red. Asserting both in ONE
document makes the negative half depend on the rule actually running.
"""
entries = okf_propose_segments.outline_lines(RUN_OF_THREE_THEN_RUN_OF_TWO)
runs = okf_propose_segments.outline_runs(entries, 3)
assert outline_titles(runs) == [["Innledning", "Krav", "Gjennomfoering"]]
# The known-positive control for the same fixture: at a minimum of 2 the
# second run IS admitted, so its absence above is the gate, not the text.
relaxed = okf_propose_segments.outline_runs(entries, 2)
assert outline_titles(relaxed) == [
["Innledning", "Krav", "Gjennomfoering"],
["Vedlegg A", "Vedlegg B"],
]
def test_runs_restart_at_every_new_one_and_the_last_maximal_run_is_the_last() -> None:
"""Two identical runs; the LAST is the body, the first is the contents."""
entries = okf_propose_segments.outline_lines(TWO_MAXIMAL_RUNS)
runs = okf_propose_segments.outline_runs(entries, 3)
assert len(runs) == 2
assert outline_titles(runs)[-1] == [
"Innledning i kroppen",
"Krav i kroppen",
"Gjennomfoering i kroppen",
]
# And the last run sits later in the document than the first.
assert runs[-1][0][0] > runs[0][-1][0]
def test_all_three_integer_forms_are_admitted() -> None:
"""`N`, `N.` and `N)` are the same declaration in three typographies."""
entries = okf_propose_segments.outline_lines(ALL_THREE_INTEGER_FORMS)
runs = okf_propose_segments.outline_runs(entries, 3)
assert outline_titles(runs) == [["Innledning", "Krav", "Gjennomfoering"]]
def test_a_number_with_no_title_is_not_a_candidate() -> None:
"""A bare `2` on its own line names no unit of knowledge.
It also breaks the run, which is the honest outcome: the document did not
declare a chapter there, so the rule may not invent one.
"""
entries = okf_propose_segments.outline_lines(NUMBER_WITHOUT_A_TITLE)
assert [number for _, number, _ in entries] == [1, 3]
assert okf_propose_segments.outline_runs(entries, 3) == []
def test_a_sequence_not_starting_at_one_is_not_a_run() -> None:
"""A run is anchored at `1`. `2, 3, 4` is page furniture until proven otherwise."""
entries = okf_propose_segments.outline_lines(RUN_NOT_STARTING_AT_ONE)
assert [number for _, number, _ in entries] == [2, 3, 4]
assert okf_propose_segments.outline_runs(entries, 3) == []
def test_a_trailing_page_number_is_stripped_from_an_outline_title() -> None:
"""`Innledning 6` is a contents line; the page number is not part of the title.
Load-bearing rather than cosmetic: titles become concept paths through
`_segment_path`, so a page number left on would become part of a filename.
"""
assert okf_propose_segments._strip_page_number("Innledning 6") == "Innledning"
assert okf_propose_segments._strip_page_number("Vurdering av restrisiko 25") == (
"Vurdering av restrisiko"
)
def test_a_title_that_is_only_digits_is_left_alone_rather_than_emptied() -> None:
"""The negative control for the stripper: it must not eat the whole title.
Emptying it here would hide junk from the stop-word/junk path that is
supposed to see it, and an empty stem falls back to `seksjon`, which would
make junk look like a named section.
"""
assert okf_propose_segments._strip_page_number("477") == "477"

View file

@ -80,11 +80,20 @@ RULE_POPPLER_SIZE_AND_BOLD = "rule:poppler-size-and-bold"
#: begins. It is emitted ALONGSIDE the rule that proposed the origin span, so
#: an operator reading a part can still see what opened it.
RULE_SIZE_SPLIT = "rule:size-split"
#: Arm D only. Like Arm C it is NOT one of Topic 2's ported rules and NOT
#: defined upstream: `docs/2026-09-02-k3-k4-k5-metode.md` contains no
#: occurrence of the word "arm" at all, so this definition was written for the
#: brief of order 20260906T213322Z and is reported as the author's. Unlike Arm
#: C it says nothing about size -- it names the fact that the DOCUMENT ITSELF
#: declared a chapter there, by numbering it in an ascending run its own
#: outline sustains.
RULE_OUTLINE = "rule:outline"
RULE_NAMES = (
RULE_HEADING,
RULE_TABLE_BLOCK,
RULE_POPPLER_SIZE_AND_BOLD,
RULE_SIZE_SPLIT,
RULE_OUTLINE,
)
#: How many characters of context each side of a quote anchor carries. Enough
@ -140,9 +149,29 @@ STOP_WORDS = frozenset(
# A BARE integer is not a section number, for the same reason `structure.py`
# refuses one: `12 ting` is an ordinary line and admitting it would cut a
# document at every list item.
#
# That claim still holds, and Arm D does not weaken it. `_OUTLINE` below admits
# a bare integer ONLY inside an ascending run the document sustains for at
# least a declared length -- which is a property of the whole text, not of the
# line -- and the rule is off unless a caller asks for it. An UNGATED widening
# was measured and rejected: 1681 raw hits against 618 candidates, admitting
# list items, quantities and page furniture. The gate is what makes the signal
# a signal.
_ATX = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>\S.*?)\s*$")
_NUMBERED = re.compile(r"^(?P<number>\d+(?:\.\d+)+)\s+(?P<title>\S.*?)\s*$")
_TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
# Arm D's grammar. Integer-only BY CONSTRUCTION: `\s+` after the optional
# separator is what keeps `1.1 Brannkonsept` out, because `_NUMBERED` requires
# a dot and this requires whitespace, so no line can match both. No exclusion
# clause is written for that: a filter with a measured effect of zero is dead
# code that reads like a guard.
_OUTLINE = re.compile(r"^\s{0,4}(?P<number>\d{1,2})[.)]?\s+(?P<title>\S.*?)\s*$")
# A contents line carries the page it points at (`Innledning 6`). Measured on
# the K2 corpus: stripping it changes 0 of the 144 outline counts and 9 emitted
# titles. It is load-bearing anyway, because titles become concept paths
# through `_segment_path` -- an unstripped page number would become part of a
# filename.
_TRAILING_PAGE_NUMBER = re.compile(r"[\s.]+\d{1,4}\s*$")
class ProposerError(Exception):
@ -170,6 +199,66 @@ def _is_stop_word_only(title: str) -> bool:
return bool(words) and all(word in STOP_WORDS for word in words)
def _strip_page_number(title: str) -> str:
"""Remove a trailing page number from a contents-listing title.
Deliberately NOT applied to a title that is only digits: `477` has no
separator before the number, so the pattern cannot match it and the title
survives for the stop-word and junk paths to see. Emptying it would fall
back to the `seksjon` stem and dress junk as a named section.
"""
return _TRAILING_PAGE_NUMBER.sub("", title)
def outline_lines(text: str) -> list[tuple[int, int, str]]:
"""Every line the outline grammar admits, as `(line index, integer, title)`.
Module level and importable on purpose: the reach instrument measures this
rule, and an instrument that re-implements the grammar it measures is
measuring a second definition that can silently drift from the shipped one.
"""
found: list[tuple[int, int, str]] = []
for index, line in enumerate(text.splitlines()):
match = _OUTLINE.match(line)
if match is None:
continue
title = _strip_page_number(match.group("title")).strip()
if not title or _is_stop_word_only(title):
continue
found.append((index, int(match.group("number")), title))
return found
def outline_runs(
entries: list[tuple[int, int, str]], minimum: int
) -> list[list[tuple[int, int, str]]]:
"""The maximal ascending runs among `entries`, each at least `minimum` long.
A run is anchored at `1` and every later member is its predecessor plus
one; a number that is neither is skipped without closing the run, so a
stray page number between two chapters does not truncate the outline. A new
`1` closes the current run and opens the next, which is what makes a
contents listing and the body it lists two runs rather than one.
Returned in document order. The CALLER chooses among them -- last-run
selection was measured against the alternatives and is stated where it is
applied, not hidden in here.
"""
runs: list[list[tuple[int, int, str]]] = []
current: list[tuple[int, int, str]] = []
for entry in entries:
number = entry[1]
if number == 1:
if current:
runs.append(current)
current = [entry]
elif current and number == current[-1][1] + 1:
current.append(entry)
if current:
runs.append(current)
return [run for run in runs if len(run) >= minimum]
def find_candidates(text: str) -> list[Candidate]:
"""Every boundary the mechanical rules propose, in document order.