feat(propose): a grid rule line does not close an open table block
Arm E, off by default. `find_candidates` gains a keyword-only `table_grid` whose branch is not even evaluated when it is False, so the flag-off path is byte-identical by construction rather than by test. The defect it addresses is measured. The converter emits pandoc GRID tables, whose rows are separated by `+---+---+` rule lines that `_TABLE_ROW` cannot match, so `in_table` resets between every pair of rows and ONE table becomes one concept per row group. On the K2 corpus that is 33 of Arm D's 709 entries, on exactly 3 of the 33 documents that produce a plan -- and those three are the K3 sample positions 5, 10 and 11, all three rated `too fine`. Three points where this could have gone silently wrong, and what each cost: - `Candidate` is frozen and `dataclasses.replace` is not imported, so the join is recorded as a `set[int]` over `marked` and applied at the orphan-check pass that already rebuilds every candidate. `subdivide` rebuilds them again from an explicit keyword list, so `grid` is copied there too -- exactly the trap `split` already has. - `rule_pending` is cleared on the fall-through together with `in_table` and `open_block`. A grid table ends with a bottom rule, which sets it; without the clear, the NEXT table's first row would be recorded as a join although nothing was joined. `test_two_tables_separated_by_a_blank_line_stay_two_concepts` is built to catch precisely that: its second table has ONE row group, so it cannot be joined, and the test asserts `grid is False` on it. A test that checked only candidate counts would stay green through the defect. - A rule line can never OPEN a block: it is reached only with `in_table` true. So no surviving candidate's `start` moves, bodies only grow, and the orphan check -- which is monotone in the line set -- cannot drop a candidate it previously kept. Asserted as an offset, not a length. Measured on the real corpus with this code, reproducing a prediction written down before it was built: 21 -> 6, 15 -> 3, 2 -> 1 entries, and identical at --outline-run 0 and 3, so Arm D and Arm E do not interact here. Tests first: 8 red, then green. 1235 -> 1243. ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/: 27 files, Success. pytest -q: exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
dc67f86511
commit
7dae0d1a3a
2 changed files with 158 additions and 1 deletions
|
|
@ -228,6 +228,14 @@ class Candidate:
|
|||
#: Kept on the candidate rather than recomputed at write time so the entry
|
||||
#: and the reason it exists cannot drift apart.
|
||||
split: bool = False
|
||||
#: True when Arm E JOINED this table block across at least one grid-rule
|
||||
#: line. Same reasoning as `split`, and the same trap: both reconstruction
|
||||
#: sites below rebuild a `Candidate` from an explicit keyword list, so a
|
||||
#: field not copied there is silently defaulted back and the entry loses
|
||||
#: the only trace of why it exists. NOT set merely because a span contains
|
||||
#: a rule line -- a single-row grid table joins nothing and stays
|
||||
#: byte-identical to Arm D.
|
||||
grid: bool = False
|
||||
|
||||
|
||||
def _is_stop_word_only(title: str) -> bool:
|
||||
|
|
@ -295,7 +303,9 @@ def outline_runs(
|
|||
return [run for run in runs if len(run) >= minimum]
|
||||
|
||||
|
||||
def find_candidates(text: str, *, outline_run: int = 0) -> list[Candidate]:
|
||||
def find_candidates(
|
||||
text: str, *, outline_run: int = 0, table_grid: bool = False
|
||||
) -> list[Candidate]:
|
||||
"""Every boundary the mechanical rules propose, in document order.
|
||||
|
||||
Two gates from Topic 2 are applied here and both REMOVE candidates:
|
||||
|
|
@ -310,6 +320,12 @@ def find_candidates(text: str, *, outline_run: int = 0) -> list[Candidate]:
|
|||
exactly as it did before the rule existed. At `N >= 1` the document's own
|
||||
numbered outline contributes boundaries where the integers sustain an
|
||||
ascending run of at least `N`.
|
||||
|
||||
`table_grid` is Arm E's gate and it is OFF at False, where the branch is not
|
||||
even evaluated. On, a pandoc grid-table rule line no longer closes an open
|
||||
table block, so one grid table proposes one candidate instead of one per row
|
||||
group. It only ever REMOVES marks, which is what keeps every surviving
|
||||
candidate's `start` fixed and the orphan check monotone.
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
offsets: list[int] = []
|
||||
|
|
@ -340,10 +356,21 @@ def find_candidates(text: str, *, outline_run: int = 0) -> list[Candidate]:
|
|||
|
||||
marked: list[tuple[int, Candidate]] = []
|
||||
in_table = False
|
||||
# Arm E's state, and all three of these are cleared together on the
|
||||
# fall-through below. `rule_pending` is the one that matters: a grid table
|
||||
# ends with a bottom rule, which sets it, and if the blank line after the
|
||||
# table did not clear it the NEXT table's first row would be recorded as a
|
||||
# join although nothing was joined. `joined` holds positions in `marked`
|
||||
# rather than mutating a candidate, because `Candidate` is frozen and the
|
||||
# orphan-check pass below already rebuilds every one of them.
|
||||
rule_pending = False
|
||||
open_block: int | None = None
|
||||
joined: set[int] = set()
|
||||
for index, line in enumerate(lines):
|
||||
if _TABLE_ROW.match(line):
|
||||
if not in_table:
|
||||
in_table = True
|
||||
open_block = len(marked)
|
||||
marked.append(
|
||||
(
|
||||
index,
|
||||
|
|
@ -357,8 +384,20 @@ def find_candidates(text: str, *, outline_run: int = 0) -> list[Candidate]:
|
|||
),
|
||||
)
|
||||
)
|
||||
elif rule_pending and open_block is not None:
|
||||
joined.add(open_block)
|
||||
rule_pending = False
|
||||
continue
|
||||
if table_grid and in_table and _GRID_RULE.match(line):
|
||||
# The whole of Arm E: do NOT close the block. Reaching here with
|
||||
# `in_table` False is impossible by construction, so a rule line can
|
||||
# never OPEN a block -- a table's top border is not a boundary, and
|
||||
# every surviving candidate keeps the `start` it had under Arm D.
|
||||
rule_pending = True
|
||||
continue
|
||||
in_table = False
|
||||
rule_pending = False
|
||||
open_block = None
|
||||
|
||||
outline_title = admitted.get(index)
|
||||
if outline_title is not None:
|
||||
|
|
@ -427,6 +466,7 @@ def find_candidates(text: str, *, outline_run: int = 0) -> list[Candidate]:
|
|||
rule=candidate.rule,
|
||||
start=candidate.start,
|
||||
end=end,
|
||||
grid=position_in_list in joined,
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
|
@ -523,6 +563,7 @@ def subdivide(text: str, candidates: list[Candidate], cap: int) -> list[Candidat
|
|||
start=start,
|
||||
end=end,
|
||||
split=True,
|
||||
grid=candidate.grid,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue