feat(propose): Arm D reads a document's own numbered outline
This commit is contained in:
parent
c97e250f69
commit
47ae1ed7e3
2 changed files with 277 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue