feat(propose): the outline rule proposes boundaries, off by default
This commit is contained in:
parent
47ae1ed7e3
commit
5080240366
2 changed files with 215 additions and 2 deletions
|
|
@ -259,7 +259,7 @@ def outline_runs(
|
|||
return [run for run in runs if len(run) >= minimum]
|
||||
|
||||
|
||||
def find_candidates(text: str) -> list[Candidate]:
|
||||
def find_candidates(text: str, *, outline_run: int = 0) -> list[Candidate]:
|
||||
"""Every boundary the mechanical rules propose, in document order.
|
||||
|
||||
Two gates from Topic 2 are applied here and both REMOVE candidates:
|
||||
|
|
@ -269,6 +269,11 @@ def find_candidates(text: str) -> list[Candidate]:
|
|||
- the **orphan check**: a heading with no body under it proposes nothing,
|
||||
because an empty concept is the silent skip this library refuses
|
||||
everywhere else.
|
||||
|
||||
`outline_run` is Arm D's gate and it is OFF at 0: the function then behaves
|
||||
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`.
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
offsets: list[int] = []
|
||||
|
|
@ -278,6 +283,25 @@ def find_candidates(text: str) -> list[Candidate]:
|
|||
position += len(line)
|
||||
end_of_text = position
|
||||
|
||||
# Computed BEFORE the loop, and that is a correctness requirement rather
|
||||
# than a style choice: run selection is a whole-text decision (the LAST
|
||||
# maximal run wins, because a contents listing precedes the body it lists),
|
||||
# and a forward scan cannot know which run is last. Deciding it up front is
|
||||
# also what keeps `marked` sorted by construction -- appending outline
|
||||
# candidates in a second pass would leave `end < start` on some spans, and
|
||||
# `text[start:end]` is then `""`, so the orphan check DELETES them
|
||||
# silently. Silent loss, not a raise: nothing would announce it.
|
||||
admitted: dict[int, str] = {}
|
||||
if outline_run > 0:
|
||||
runs = outline_runs(outline_lines(text), outline_run)
|
||||
if runs:
|
||||
# LAST run, not longest and not first. Measured against both:
|
||||
# first-run opens segments inside the table of contents on 14/39
|
||||
# documents; longest-run differs on 5/39 with no measured reason to
|
||||
# prefer it. "Later occurrence wins" states the document's own
|
||||
# ordering rather than a property of this corpus.
|
||||
admitted = {index: title for index, _, title in runs[-1]}
|
||||
|
||||
marked: list[tuple[int, Candidate]] = []
|
||||
in_table = False
|
||||
for index, line in enumerate(lines):
|
||||
|
|
@ -300,6 +324,25 @@ def find_candidates(text: str) -> list[Candidate]:
|
|||
continue
|
||||
in_table = False
|
||||
|
||||
outline_title = admitted.get(index)
|
||||
if outline_title is not None:
|
||||
outline_match = _OUTLINE.match(line)
|
||||
assert outline_match is not None, "an admitted index still matches the grammar"
|
||||
marked.append(
|
||||
(
|
||||
index,
|
||||
Candidate(
|
||||
title=outline_title,
|
||||
level=1,
|
||||
number=outline_match.group("number"),
|
||||
rule=RULE_OUTLINE,
|
||||
start=offsets[index],
|
||||
end=end_of_text,
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
atx = _ATX.match(line)
|
||||
numbered = _NUMBERED.match(line)
|
||||
if atx is None and numbered is None:
|
||||
|
|
@ -482,12 +525,14 @@ def build_plan(
|
|||
proposed_at: str,
|
||||
path_prefix: str = "",
|
||||
max_segment_chars: int = 0,
|
||||
outline_run: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||
taken: set[str] = set()
|
||||
extractor_id = source.suffix.lower().lstrip(".") or "none"
|
||||
entries: list[dict[str, Any]] = []
|
||||
for candidate in subdivide(text, find_candidates(text), max_segment_chars):
|
||||
candidates = find_candidates(text, outline_run=outline_run)
|
||||
for candidate in subdivide(text, candidates, max_segment_chars):
|
||||
entries.append(
|
||||
{
|
||||
"segment_id": f"p{len(entries) + 1}",
|
||||
|
|
@ -552,6 +597,7 @@ def run(
|
|||
proposed_at: str,
|
||||
path_prefix: str = "",
|
||||
max_segment_chars: int = 0,
|
||||
outline_run: int = 0,
|
||||
) -> int:
|
||||
if max_segment_chars < 0:
|
||||
raise ProposerError(
|
||||
|
|
@ -587,6 +633,7 @@ def run(
|
|||
proposed_at=proposed_at,
|
||||
path_prefix=scope,
|
||||
max_segment_chars=max_segment_chars,
|
||||
outline_run=outline_run,
|
||||
)
|
||||
# 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