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>
This commit is contained in:
parent
fbad5b0eab
commit
6ff18fd703
15 changed files with 964 additions and 67 deletions
|
|
@ -62,7 +62,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from .errors import IngestError
|
||||
from .extract import extract_text
|
||||
from .extract import extract_text, strip_converter_attribute
|
||||
from .materialize import reduce_to_id_grammar
|
||||
from .segmentation import observed_extractor_version
|
||||
|
||||
|
|
@ -227,6 +227,16 @@ _OUTLINE = re.compile(r"^\s{0,4}(?P<number>\d{1,2})[.)]?\s+(?P<title>\S.*?)\s*$"
|
|||
# through `_segment_path` -- an unstripped page number would become part of a
|
||||
# filename.
|
||||
_TRAILING_PAGE_NUMBER = re.compile(r"[\s.]+\d{1,4}\s*$")
|
||||
#: Clause 1's name part. An alphabetic run of at least this many characters is
|
||||
#: a NAME; anything shorter is a stray letter in a data row. SWEPT over the
|
||||
#: 43-document corpus at 1, 2 and 3, and it collapses at both ends: at 1 a
|
||||
#: drawing's door schedule keeps a bare `V` and stays misclassified (13 of 19
|
||||
#: data rows rescued, not 16), and 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
|
||||
#: contents entries. At 2 the rescue is 16 of 19 and the regression 0 of 49.
|
||||
CONTENTS_NAME_RUN = 2
|
||||
_NAME_PART = re.compile(r"[^\W\d_]{%d,}" % CONTENTS_NAME_RUN)
|
||||
# D3's grammar, and it reads a CELL rather than a line. A sheet's section label
|
||||
# is a bare number, optionally joined to another by a separator where two groups
|
||||
# were merged (`11+12`), and nothing else: no letters, so a row opening with a
|
||||
|
|
@ -292,6 +302,29 @@ def _strip_page_number(title: str) -> str:
|
|||
return _TRAILING_PAGE_NUMBER.sub("", title)
|
||||
|
||||
|
||||
def bears_a_name(title: str) -> bool:
|
||||
"""Does a NAME survive stripping the trailing page number?
|
||||
|
||||
Clause 1's admission predicate, and the whole of round 9's repair. The
|
||||
shipped rule asked whether `_TRAILING_PAGE_NUMBER` MATCHED, which is a
|
||||
question about the number and not about the title -- a drawing's dimension
|
||||
chain, a P&ID's schematic labels, a door schedule and a borehole log's
|
||||
coordinate column all end in integers, and none of them names anything.
|
||||
Measured on the corpus, 19 of the 68 candidates clause 1 discarded over 5
|
||||
of the 11 documents were rows of that kind.
|
||||
|
||||
Module level and importable for the same reason `outline_lines` is: the
|
||||
reach instrument measures THIS predicate, and an instrument re-implementing
|
||||
the grammar it measures is measuring a second definition.
|
||||
|
||||
Applied to the REST, never to the whole title: `40.23` bears no name and
|
||||
`Poseprover posisjon 24 7` does. Three corpus data rows carry a real word
|
||||
(`- 8.225m Fjell 41`) and this predicate cannot separate them from a
|
||||
contents entry -- that is a stated limit, not a rounding error.
|
||||
"""
|
||||
return _NAME_PART.search(_strip_page_number(title).strip()) is not None
|
||||
|
||||
|
||||
def outline_lines(text: str) -> list[tuple[int, int, str]]:
|
||||
"""Every line the outline grammar admits, as `(line index, integer, title)`.
|
||||
|
||||
|
|
@ -515,6 +548,7 @@ def find_candidates(
|
|||
outline_gate: bool = False,
|
||||
first_span_from_zero: bool = False,
|
||||
close_span_gaps: bool = False,
|
||||
contents_name: bool = False,
|
||||
) -> list[Candidate]:
|
||||
"""Every boundary the mechanical rules propose, in document order.
|
||||
|
||||
|
|
@ -550,6 +584,13 @@ def find_candidates(
|
|||
It is its own flag and not part of an arm because the orphan check is
|
||||
reached by every file type, and moving it is a decision about all of them.
|
||||
|
||||
`contents_name` is round 9's gate and it is OFF at False, where clause 1
|
||||
behaves exactly as it did before the rule existed. On, a title joins a
|
||||
contents run only when a NAME survives stripping its page number, so a run
|
||||
of data rows is no longer read as a contents listing. It can only ever
|
||||
REMOVE members from a run, which is why it only ever rescues candidates and
|
||||
never discards one the shipped rule kept.
|
||||
|
||||
`close_span_gaps` is OFF at False, where a mark removed after its
|
||||
neighbour's span was closed takes that text out of the plan entirely. On,
|
||||
a span runs to the next SURVIVING candidate's start and the last one runs
|
||||
|
|
@ -716,7 +757,11 @@ def find_candidates(
|
|||
if atx is None and numbered is None:
|
||||
continue
|
||||
if atx is not None:
|
||||
title = atx.group("title")
|
||||
# The converter's own anchor is not part of the title, and it must
|
||||
# come off HERE rather than where the filename is reduced: the id
|
||||
# is reduced FROM the title, so stripping it downstream would leave
|
||||
# the two naming the same concept differently.
|
||||
title = strip_converter_attribute(atx.group("title"))
|
||||
level = len(atx.group("hashes"))
|
||||
inner = _NUMBERED.match(title)
|
||||
number = inner.group("number") if inner else None
|
||||
|
|
@ -772,7 +817,9 @@ def find_candidates(
|
|||
# Arm F clause 1's input, and it must be read HERE: the orphan pass below
|
||||
# deletes every bodiless heading, which is every entry of a contents list
|
||||
# but the last, and a run of one is below `CONTENTS_RUN`.
|
||||
contents_run = _contents_run_positions(marked) if unit_fold else set()
|
||||
contents_run = (
|
||||
_contents_run_positions(marked, contents_name=contents_name) if unit_fold else set()
|
||||
)
|
||||
for position_in_list, (_, candidate) in enumerate(marked):
|
||||
if position_in_list in absorbed:
|
||||
continue
|
||||
|
|
@ -818,7 +865,7 @@ def find_candidates(
|
|||
contents=position_in_list in contents_run,
|
||||
)
|
||||
)
|
||||
resolved = fold_units(candidates) if unit_fold else candidates
|
||||
resolved = fold_units(candidates, contents_name=contents_name) if unit_fold else candidates
|
||||
if first_span_from_zero and resolved and resolved[0].start > 0:
|
||||
# Applied AFTER the fold, so the concept that SURVIVES is the one that
|
||||
# opens at 0: the fold can discard a contents run, and moving the start
|
||||
|
|
@ -869,7 +916,9 @@ def _absorbed_tables(
|
|||
return absorbed
|
||||
|
||||
|
||||
def _contents_run_positions(marked: list[tuple[int, Candidate]]) -> set[int]:
|
||||
def _contents_run_positions(
|
||||
marked: list[tuple[int, Candidate]], *, contents_name: bool = False
|
||||
) -> set[int]:
|
||||
"""Arm F clause 1, measured on the PRE-orphan list. Same predicate as there.
|
||||
|
||||
A run of at least `CONTENTS_RUN` consecutive page-numbered headings, table
|
||||
|
|
@ -885,19 +934,22 @@ def _contents_run_positions(marked: list[tuple[int, Candidate]]) -> set[int]:
|
|||
concept while its neighbours were not. What still bounds the rule is the run
|
||||
LENGTH, which is what the `CONTENTS_RUN` sweep bought and is unchanged.
|
||||
"""
|
||||
def member(candidate: Candidate) -> bool:
|
||||
if candidate.rule == RULE_TABLE_BLOCK:
|
||||
return False
|
||||
if not _TRAILING_PAGE_NUMBER.search(candidate.title):
|
||||
return False
|
||||
return bears_a_name(candidate.title) if contents_name else True
|
||||
|
||||
inside: set[int] = set()
|
||||
index = 0
|
||||
while index < len(marked):
|
||||
candidate = marked[index][1]
|
||||
if candidate.rule == RULE_TABLE_BLOCK or not _TRAILING_PAGE_NUMBER.search(candidate.title):
|
||||
if not member(candidate):
|
||||
index += 1
|
||||
continue
|
||||
end = index
|
||||
while (
|
||||
end < len(marked)
|
||||
and marked[end][1].rule != RULE_TABLE_BLOCK
|
||||
and _TRAILING_PAGE_NUMBER.search(marked[end][1].title)
|
||||
):
|
||||
while end < len(marked) and member(marked[end][1]):
|
||||
end += 1
|
||||
if end - index >= CONTENTS_RUN:
|
||||
inside.update(range(index, end))
|
||||
|
|
@ -914,7 +966,7 @@ def _contents_run_positions(marked: list[tuple[int, Candidate]]) -> set[int]:
|
|||
CONTENTS_RUN = 3
|
||||
|
||||
|
||||
def fold_units(candidates: list[Candidate]) -> list[Candidate]:
|
||||
def fold_units(candidates: list[Candidate], *, contents_name: bool = False) -> 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
|
||||
|
|
@ -950,18 +1002,25 @@ def fold_units(candidates: list[Candidate]) -> list[Candidate]:
|
|||
# work to do: a contents list WITH dot leaders keeps every entry, and that
|
||||
# run exists only in this list.
|
||||
drop: set[int] = {position for position, c in enumerate(candidates) if c.contents}
|
||||
|
||||
def member(candidate: Candidate) -> bool:
|
||||
if candidate.rule == RULE_TABLE_BLOCK:
|
||||
return False
|
||||
if not _TRAILING_PAGE_NUMBER.search(candidate.title):
|
||||
return False
|
||||
return bears_a_name(candidate.title) if contents_name else True
|
||||
|
||||
index = 0
|
||||
while index < len(candidates):
|
||||
candidate = candidates[index]
|
||||
if candidate.rule == RULE_TABLE_BLOCK or not _TRAILING_PAGE_NUMBER.search(candidate.title):
|
||||
if not member(candidate):
|
||||
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)
|
||||
and member(candidates[end])
|
||||
):
|
||||
end += 1
|
||||
if end - index >= CONTENTS_RUN:
|
||||
|
|
@ -1171,6 +1230,7 @@ def build_plan(
|
|||
outline_gate: bool = False,
|
||||
first_span_from_zero: bool = False,
|
||||
close_span_gaps: bool = False,
|
||||
contents_name: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||
taken: set[str] = set()
|
||||
|
|
@ -1187,6 +1247,7 @@ def build_plan(
|
|||
outline_gate=outline_gate,
|
||||
first_span_from_zero=first_span_from_zero,
|
||||
close_span_gaps=close_span_gaps,
|
||||
contents_name=contents_name,
|
||||
)
|
||||
for candidate in subdivide(text, candidates, max_segment_chars):
|
||||
entries.append(
|
||||
|
|
@ -1263,6 +1324,7 @@ def run(
|
|||
outline_gate: bool = False,
|
||||
first_span_from_zero: bool = False,
|
||||
close_span_gaps: bool = False,
|
||||
contents_name: bool = False,
|
||||
pdf_headings: bool = False,
|
||||
pdf_headings_reserve: bool = False,
|
||||
ocr: bool = False,
|
||||
|
|
@ -1338,6 +1400,7 @@ def run(
|
|||
outline_gate=outline_gate,
|
||||
first_span_from_zero=first_span_from_zero,
|
||||
close_span_gaps=close_span_gaps,
|
||||
contents_name=contents_name,
|
||||
)
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue