feat(propose,consume,profiles,importer): recovery yields to declaration, and 9 % of the corpus that was in no segment
One rule explains every remaining `pdf` miss on the twelve-position reference: where a document DECLARES headings, Arm D's RECOVERED headings are the whole of the excess, and every declared one is a unit the reference wants. `--outline-gate` admits recovery only where the document declares none of its own, plus any one recovered heading covering OUTLINE_SHARE (0.20) of the text. It is `fold_units` clause 2's own principle moved from voting to admission, and it filters at ADMISSION so the text a removed mark opened is carried by the mark above it -- the post-filter form scores identically on all twelve positions and loses that text, which is why only one of them shipped. `--outline-gate` and `--drop-wrapped-outline` become the package default, one decision because neither carries the reference alone: `pdf` 2 of 8 -> 5 of 8 alone, 7 of 8 together; the sheet 5 of 12 -> 10 of 12; `docx` unchanged at 3 of 3. Each keeps an explicit opt-out. The bar the move had to clear was not the reference: hit@8 on a K2 bundle built with it holds 5 of 6 at ranks 1,1,1,1,1,-, no row losing rank 1. `--sheet-section-rows --keep-table-heading` reaches 11 of 12 and does NOT ship, because on a bundle built with it row 1 falls rank 1 -> 2. Cost to a consumer is a re-run: 492 concepts / 944 files -> 425 / 810. DOCUMENT_PRIOR_EXPONENT makes the document prior sublinear (total/n**0.5). A sum measures size and a density is diluted by every unit carrying none of the question, so a document split 1 -> 12 lost its prior by 12. Swept over five values on 18 rows it is at least as good as the delivered density everywhere and strictly better on three. Stated plainly: end to end it moved NOT ONE hit@8 row on any of four bundles, so it did not solve the knot it was adopted for -- what did is that the `pdf` gain never needed `--sheet-section-rows`. `--first-span-from-zero` is off and repairs a measured loss found while chasing one position's 940 characters: 32 of the 32 documents that get a plan leave the text above their first concept in no segment -- 159 704 characters, 9.18 % of the corpus, 45 841 from one document. It changes nothing on the reference. Off because it moves the first span of essentially every bundle with no hit@8 number behind it yet. vegnormal-okf FUNN 2: SPEC section 8's own star row parsed as prose, so every concept behind one was unreachable to the section 9.2 walk. `IndexPolicy.also_reads` carries it for the SEGMENTED profiles, read-only, after the emitted pattern misses -- the asymmetry `sources` already has. DEFAULT and STRICT_V1 untouched (O2). vegnormal-okf FUNN 1: Door C's own outcome was refused at exit 1, `bundle_id_missing`. `import_bundle` now takes `root_frontmatter_values`, keyword-only, rendered before any disk mutation, written only when the index is created -- Door B's mechanism and ordering. Report: docs/2026-09-09-k3-runde6-outline-gaten-og-prioren.md. Suite 1478 passed (1449 before), ruff and mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b01492b7f5
commit
38104b7df5
16 changed files with 1301 additions and 42 deletions
|
|
@ -138,6 +138,29 @@ DEFAULT_OUTLINE_RUN = 3
|
|||
DEFAULT_TABLE_GRID = True
|
||||
DEFAULT_UNIT_FOLD = True
|
||||
|
||||
#: Round 6's two, and they moved together on 2026-09-09 because neither carries
|
||||
#: the reference on its own: G1+G2 takes `pdf` from 2 of 8 to 5 of 8, and
|
||||
#: `--drop-wrapped-outline` -- measured on its own in round 3 as a 1-document
|
||||
#: change and shipped off -- takes the pair to 7 of 8. Each keeps its explicit
|
||||
#: opt-out (`--no-outline-gate`, `--keep-wrapped-outline`), and the two of them
|
||||
#: off reproduce the 2026-09-08 default byte for byte. The acceptance the move
|
||||
#: had to clear was not the reference alone: hit@8 holds rank 1 on every row it
|
||||
#: held before, on every bundle measured. Both measurements are in
|
||||
#: `docs/2026-09-09-k3-runde6-outline-gaten-og-prioren.md`.
|
||||
DEFAULT_OUTLINE_GATE = True
|
||||
DEFAULT_DROP_WRAPPED_OUTLINE = True
|
||||
|
||||
#: Round 6's third rule, and the only one whose default this round did NOT
|
||||
#: decide. Measured over the 39-document corpus: every one of the 32 documents
|
||||
#: that gets a plan loses the text above its first concept -- 159 704
|
||||
#: characters, 9.18 % of the corpus, 45 841 from a single document. The repair
|
||||
#: is one line and it adds no boundary. It is off because it moves the first
|
||||
#: span of essentially every segmented bundle and this round's budget held one
|
||||
#: K2 build, which measured the outline gate; a default that changed every
|
||||
#: bundle's first concept with no hit@8 number behind it would be the move this
|
||||
#: round refused to make elsewhere. The number it needs is named in STATE.
|
||||
DEFAULT_FIRST_SPAN_FROM_ZERO = False
|
||||
|
||||
#: Round 4's two PDF READERS, both off. They are not arms and they are not in
|
||||
#: the paragraph above: an arm changes how the proposer cuts a text, and these
|
||||
#: two change what the text SAYS -- so they sit before every arm rather than
|
||||
|
|
@ -184,6 +207,8 @@ def _propose_plans(
|
|||
keep_table_heading: bool = False,
|
||||
sheet_section_rows: bool = False,
|
||||
drop_wrapped_outline: bool = False,
|
||||
outline_gate: bool = False,
|
||||
first_span_from_zero: bool = False,
|
||||
pdf_headings: bool = False,
|
||||
pdf_headings_reserve: bool = False,
|
||||
ocr: bool = False,
|
||||
|
|
@ -217,6 +242,8 @@ def _propose_plans(
|
|||
keep_table_heading=keep_table_heading,
|
||||
sheet_section_rows=sheet_section_rows,
|
||||
drop_wrapped_outline=drop_wrapped_outline,
|
||||
outline_gate=outline_gate,
|
||||
first_span_from_zero=first_span_from_zero,
|
||||
pdf_headings=pdf_headings,
|
||||
pdf_headings_reserve=pdf_headings_reserve,
|
||||
ocr=ocr,
|
||||
|
|
@ -248,7 +275,9 @@ def build(
|
|||
unit_fold: bool = DEFAULT_UNIT_FOLD,
|
||||
keep_table_heading: bool = False,
|
||||
sheet_section_rows: bool = False,
|
||||
drop_wrapped_outline: bool = False,
|
||||
drop_wrapped_outline: bool = DEFAULT_DROP_WRAPPED_OUTLINE,
|
||||
outline_gate: bool = DEFAULT_OUTLINE_GATE,
|
||||
first_span_from_zero: bool = DEFAULT_FIRST_SPAN_FROM_ZERO,
|
||||
pdf_headings: bool = DEFAULT_PDF_HEADINGS,
|
||||
pdf_headings_reserve: bool = DEFAULT_PDF_HEADINGS_RESERVE,
|
||||
ocr: bool = DEFAULT_OCR,
|
||||
|
|
@ -316,6 +345,8 @@ def build(
|
|||
keep_table_heading=keep_table_heading,
|
||||
sheet_section_rows=sheet_section_rows,
|
||||
drop_wrapped_outline=drop_wrapped_outline,
|
||||
outline_gate=outline_gate,
|
||||
first_span_from_zero=first_span_from_zero,
|
||||
pdf_headings=pdf_headings,
|
||||
pdf_headings_reserve=pdf_headings_reserve,
|
||||
ocr=ocr,
|
||||
|
|
@ -523,6 +554,7 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
build_parser.add_argument(
|
||||
"--drop-wrapped-outline",
|
||||
action="store_true",
|
||||
default=DEFAULT_DROP_WRAPPED_OUTLINE,
|
||||
help=(
|
||||
"D3, passed to the proposer unchanged: do not admit an outline "
|
||||
"candidate whose line continues onto the next one. Recovered "
|
||||
|
|
@ -532,6 +564,47 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
"and the risk-table rows the operator wrote off"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--keep-wrapped-outline",
|
||||
action="store_false",
|
||||
dest="drop_wrapped_outline",
|
||||
help=(
|
||||
"The opt-out for the rule above. With --no-outline-gate it "
|
||||
"reproduces the pre-2026-09-09 default byte for byte"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--outline-gate",
|
||||
action="store_true",
|
||||
default=DEFAULT_OUTLINE_GATE,
|
||||
help=(
|
||||
"G1+G2, passed to the proposer unchanged: admit Arm D's RECOVERED "
|
||||
"headings only where the document declares none of its own, plus "
|
||||
"any one recovered heading whose span covers OUTLINE_SHARE of the "
|
||||
"text. It only removes marks, and it removes them before spans are "
|
||||
"closed, so no character falls outside every segment. Measured on "
|
||||
"the K3 sample: `pdf` 2 of 8 -> 5 of 8 alone, 7 of 8 with "
|
||||
"--drop-wrapped-outline, `docx` unchanged at 3 of 3"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--no-outline-gate",
|
||||
action="store_false",
|
||||
dest="outline_gate",
|
||||
help="The gate's explicit opt-out. A default a caller cannot turn off is not a default",
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--first-span-from-zero",
|
||||
action="store_true",
|
||||
default=DEFAULT_FIRST_SPAN_FROM_ZERO,
|
||||
help=(
|
||||
"Start the first concept at character 0, so the text above it is "
|
||||
"carried by a segment instead of by none. Absent (the default) is "
|
||||
"OFF. Measured over the 39-document corpus: 32 of the 32 documents "
|
||||
"with a plan lose their opening today -- 159 704 characters, 9.18 "
|
||||
"%% of the corpus. It adds no boundary and moves one span's start"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--pdf-headings",
|
||||
choices=("none", "font", "font-reserve"),
|
||||
|
|
@ -602,6 +675,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
keep_table_heading=args.keep_table_heading,
|
||||
sheet_section_rows=args.sheet_section_rows,
|
||||
drop_wrapped_outline=args.drop_wrapped_outline,
|
||||
outline_gate=args.outline_gate,
|
||||
first_span_from_zero=args.first_span_from_zero,
|
||||
pdf_headings=args.pdf_headings == "font",
|
||||
pdf_headings_reserve=args.pdf_headings == "font-reserve",
|
||||
ocr=args.ocr,
|
||||
|
|
|
|||
|
|
@ -949,6 +949,28 @@ def _overlap(
|
|||
)
|
||||
|
||||
|
||||
#: How a document's prior grows with its unit count. `1.0` is a DENSITY and
|
||||
#: `0.0` is a SUM; this is the classical length normalisation between them, and
|
||||
#: it is here rather than inline because the value is a decision a reader should
|
||||
#: find where the decision was taken.
|
||||
#:
|
||||
#: WHY IT MOVED (2026-09-09). A sum measures size -- that is why the density
|
||||
#: replaced it -- but a density is diluted by every unit carrying none of the
|
||||
#: question, so a document the segmenter split from 1 concept into 12 lost its
|
||||
#: prior by a factor of 12. That put the segmentation side and the retrieval
|
||||
#: side in direct competition over one number, and it is what blocked a
|
||||
#: reference-improving default from shipping.
|
||||
#:
|
||||
#: SWEPT, not chosen: the gold document's rank under this prior over 6 questions
|
||||
#: x 3 bundles = 18 rows, at 0.0, 0.25, 0.5, 0.75 and 1.0. 0.5 is at least as
|
||||
#: good as the delivered 1.0 on all 18 rows and strictly better on three; 0.25
|
||||
#: loses one row and 0.0 and 0.75 are measured beside it. HONESTY LIMIT: four
|
||||
#: alternatives on 18 rows, one gold set, one rater -- and the rank of the
|
||||
#: PRIOR is not the rank of the excerpt, because RRF fuses it with two other
|
||||
#: signals. The end-to-end hit@8 measurement is the one that decided it.
|
||||
DOCUMENT_PRIOR_EXPONENT = 0.5
|
||||
|
||||
|
||||
def document_scores(
|
||||
bundle_root: Path,
|
||||
question: str,
|
||||
|
|
@ -966,7 +988,13 @@ def document_scores(
|
|||
nor `bundle_id`; scoring them as members of some parent would put one bug in
|
||||
three places.
|
||||
|
||||
**The score is a DENSITY, not a sum, and that is a correction rather than a
|
||||
**The score grows SUBLINEARLY with the unit count** -- `total /
|
||||
n**DOCUMENT_PRIOR_EXPONENT`, the exponent at 0.5. Both endpoints are wrong
|
||||
and each is wrong in its own direction; the constant above carries the
|
||||
measurement and the sweep. The original correction, from a sum to a
|
||||
density, is kept here because it is still the reason a sum is not used:
|
||||
|
||||
**A sum is not a score, and that is a correction rather than a
|
||||
preference.** A sum over a document's units grows with the number of units,
|
||||
so a large document outscores a small one on size alone. Measured on K2 for
|
||||
the price question: the competition document sums to 6.0 over 79 concepts
|
||||
|
|
@ -1016,7 +1044,10 @@ def document_scores(
|
|||
document,
|
||||
_overlap(question_tokens, entry.label, cost_vocabulary=bridge, weights=weights),
|
||||
)
|
||||
return {document: totals[document] / units[document] for document in totals}
|
||||
return {
|
||||
document: totals[document] / units[document] ** DOCUMENT_PRIOR_EXPONENT
|
||||
for document in totals
|
||||
}
|
||||
|
||||
|
||||
# --- Stage two: which concepts inside those documents -------------------------
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from typing import Protocol
|
|||
from .errors import IngestError, MaterializationError, SourceError
|
||||
from .extract import decode_text
|
||||
from .materialize import (
|
||||
_render_root_frontmatter,
|
||||
check_filename_length,
|
||||
link_in_index,
|
||||
parse_frontmatter,
|
||||
|
|
@ -363,6 +364,7 @@ def import_bundle(
|
|||
channel: str,
|
||||
gate: ImportGate,
|
||||
profile: BundleProfile = DEFAULT,
|
||||
root_frontmatter_values: Mapping[str, str] | None = None,
|
||||
) -> ImportResult:
|
||||
"""Merge the accepted concepts of an external OKF bundle (Door C).
|
||||
|
||||
|
|
@ -374,6 +376,21 @@ def import_bundle(
|
|||
INCLUDING a disposition this library does not recognise and a concept the
|
||||
gate returned no verdict for, fails closed.
|
||||
|
||||
`root_frontmatter_values` supplies the values for the keys the profile's
|
||||
index policy names, exactly as Door B's `materialize_bundle` does, and for
|
||||
the same reason: a profile names a key, the CALLER owns its value (E1).
|
||||
Without it this door's own outcome was not a readable bundle -- the root
|
||||
index carried no frontmatter, so it declared no `bundle_id`, and the
|
||||
reading direction refused it with `bundle_id_missing` because SS 3.1's
|
||||
identity is the `(bundle_id, concept_id)` tuple and half of it was absent.
|
||||
Reported by vegnormal-okf 2026-09-08 (FUNN 1), who worked around it by
|
||||
using this door as a gate and writing the consumable tree themselves.
|
||||
|
||||
Keyword-only with a default of `None`, so every existing call site emits
|
||||
the bytes it always did. The block is written only when the index is
|
||||
CREATED, which is `materialize_bundle`'s rule and is what keeps a second
|
||||
run into an existing bundle byte-identical to the first.
|
||||
|
||||
`profile` names the filename namespace this door writes into and the shape
|
||||
of the index it maintains. It is keyword-only and defaults to `DEFAULT`, so
|
||||
every existing call site emits the bytes it always did — a consumer with
|
||||
|
|
@ -388,6 +405,9 @@ def import_bundle(
|
|||
are wrong for every concept at once: an invalid `ingested_at`, an
|
||||
unrecognised `origin`/`channel`, and a missing source directory.
|
||||
"""
|
||||
# Before any disk mutation, like `materialize_bundle`: a caller naming a key
|
||||
# this profile does not carry must not leave a half-written bundle behind.
|
||||
root_frontmatter = _render_root_frontmatter(root_frontmatter_values or {}, profile=profile)
|
||||
validate_ingested_at(ingested_at)
|
||||
if origin not in _ORIGINS or channel not in _CHANNELS:
|
||||
raise MaterializationError(
|
||||
|
|
@ -541,7 +561,7 @@ def import_bundle(
|
|||
if merged:
|
||||
index_path = bundle / profile.index.name
|
||||
if not index_path.is_file():
|
||||
write_bytes(bundle, profile.index.name, "")
|
||||
write_bytes(bundle, profile.index.name, root_frontmatter)
|
||||
# Projected first, in merge order, so the report of what could not
|
||||
# be rendered reads in the order the concepts were merged. ORDERED
|
||||
# second, through the POLICY's helper — the same one Door B calls,
|
||||
|
|
|
|||
|
|
@ -564,6 +564,27 @@ class IndexPolicy:
|
|||
root_frontmatter: tuple[str, ...] = ()
|
||||
root_frontmatter_required: frozenset[str] = field(default_factory=frozenset)
|
||||
facets: FacetPolicy | None = None
|
||||
#: Row forms this policy READS but never writes. Consulted by `parse_entry`
|
||||
#: only, after `entry_pattern` misses, so no emitted byte can move: every
|
||||
#: line this library writes still comes from `link_template`.
|
||||
#:
|
||||
#: WHY IT EXISTS (vegnormal-okf, FUNN 1/2, 2026-09-08). OKF SPEC section 8
|
||||
#: shows `* [Title](file.md) - description` in its own example and Google's
|
||||
#: generator writes it, while this library's segmented profiles write
|
||||
#: `- [Title](file.md)`. Measured, the star row parsed as `None` -- curated
|
||||
#: prose -- so the section 9.2 index walk could not reach a single concept
|
||||
#: behind one. A bundle we cannot walk is the silent loss the "arbitrary
|
||||
#: bundle" direction forbids.
|
||||
#:
|
||||
#: Reading a form is NOT a licence to emit it. That asymmetry is this
|
||||
#: repository's existing posture, not a new one: `sources` is read in both
|
||||
#: YAML forms and written in one, for the same reason -- the emitted shape
|
||||
#: is what our own parser must round-trip.
|
||||
#:
|
||||
#: Every member must be anchored at both ends, for the reason
|
||||
#: `_faceted_pattern` states: an unanchored alternative would match a
|
||||
#: target mentioned inside curated prose.
|
||||
also_reads: tuple[re.Pattern[str], ...] = ()
|
||||
sort_key: str | None = None
|
||||
sort_order: str = SORT_ASCENDING
|
||||
sort_missing: str = SORT_MISSING_LAST
|
||||
|
|
@ -591,6 +612,13 @@ class IndexPolicy:
|
|||
+ f"(?:{re.escape(self.facets.separator)}(?P<facets>.+))?$"
|
||||
),
|
||||
)
|
||||
for extra in self.also_reads:
|
||||
if not extra.pattern.startswith("^") or not extra.pattern.endswith("$"):
|
||||
raise ValueError(
|
||||
f"an also-read pattern must be anchored at both ends, got "
|
||||
f"{extra.pattern!r} — an unanchored alternative would read a "
|
||||
"target mentioned inside curated prose as an entry"
|
||||
)
|
||||
stray = sorted(self.root_frontmatter_required - set(self.root_frontmatter))
|
||||
if stray:
|
||||
raise ValueError(
|
||||
|
|
@ -672,8 +700,18 @@ class IndexPolicy:
|
|||
Anything this returns `None` for is curated content and survives
|
||||
verbatim: the index is the one file where this library writes beside
|
||||
somebody else's prose.
|
||||
|
||||
`also_reads` is tried only after the emitted form misses, and only
|
||||
here: index MAINTENANCE keys on `entry_pattern` alone, so a foreign row
|
||||
this reads is never a row this rewrites.
|
||||
"""
|
||||
match = self.entry_pattern.match(line.rstrip("\r\n"))
|
||||
stripped = line.rstrip("\r\n")
|
||||
match = self.entry_pattern.match(stripped)
|
||||
if match is None:
|
||||
for extra in self.also_reads:
|
||||
match = extra.match(stripped)
|
||||
if match is not None:
|
||||
break
|
||||
if match is None:
|
||||
return None
|
||||
groups = match.groupdict()
|
||||
|
|
@ -1265,7 +1303,22 @@ SEGMENTED_V1 = BundleProfile(
|
|||
types=STRUCTURED_V1.types,
|
||||
frontmatter=STRUCTURED_V1.frontmatter,
|
||||
paths=STRUCTURED_V1.paths,
|
||||
index=replace(STRUCTURED_V1.index, per_directory=True, root_frontmatter=("bundle_id",)),
|
||||
index=replace(
|
||||
STRUCTURED_V1.index,
|
||||
per_directory=True,
|
||||
root_frontmatter=("bundle_id",),
|
||||
# SPEC section 8's own row form, read and never written. See
|
||||
# `IndexPolicy.also_reads`. Set on the SEGMENTED profiles alone:
|
||||
# `DEFAULT` states commons' spec and `STRICT_V1` the wiki's ratified
|
||||
# contract, and widening either from here would be this repository
|
||||
# editing another repository's contract (O2).
|
||||
also_reads=(
|
||||
re.compile(
|
||||
r"^\* \[(?P<label>[^\]]*)\]\((?P<target>[^)\s]+)\)"
|
||||
r"(?: - (?P<description>.+))?$"
|
||||
),
|
||||
),
|
||||
),
|
||||
ownership=STRUCTURED_V1.ownership,
|
||||
segmentation=SegmentationPolicy(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import json
|
|||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -371,6 +372,75 @@ def outline_runs(
|
|||
SHEET_SECTION_RUN = 3
|
||||
|
||||
|
||||
#: G2. How much of a document a RECOVERED heading must cover to survive the
|
||||
#: gate below. Chosen the way `OCR_CID_SHARE` was: bounded by an empty region
|
||||
#: on one side and by a measured collapse on the other, rather than by a round
|
||||
#: number. Over the four gated reference positions the eight outline
|
||||
#: candidates split 0.004 / 0.006 / 0.011 / 0.025 / 0.035 / 0.039 / 0.065 /
|
||||
#: 0.094 -- every one the reference REJECTS -- and 0.316, the one it keeps.
|
||||
#: Swept end to end, `pdf` holds at 7 of 8 from 0.10 through 0.30 and falls to
|
||||
#: 6 of 8 at 0.05 (a 0.094 candidate enters) and at 0.35 (the 0.316 candidate
|
||||
#: leaves). 0.20 is the middle of that plateau and of the empty region alike.
|
||||
#: HONESTY LIMIT: n = 8 candidates in 4 documents, one rater, one reference.
|
||||
OUTLINE_SHARE = 0.20
|
||||
|
||||
|
||||
def declares_headings(candidates: Iterable[Candidate]) -> bool:
|
||||
"""Whether the document's OWN heading grammar proposed anything at all.
|
||||
|
||||
`RULE_HEADING` is the delivered grammar -- ATX and dotted-numbered -- and
|
||||
`RULE_OUTLINE` is Arm D's RECOVERY of an integer numbering run. This
|
||||
predicate separates the two, and it lives here as one function on purpose:
|
||||
the proposer and the door both reach it through `build_plan`, so there is
|
||||
exactly one call site and the two sides cannot disagree about which
|
||||
documents the gate fired on.
|
||||
|
||||
It is NOT handed to the door as a callable the way `heading_reserve_applies`
|
||||
is, and the difference is worth stating rather than glossing. The reserve
|
||||
chooses which TEXT a plan is proposed against, so a reserve firing on one
|
||||
side only would index a plan against a string the other side never saw --
|
||||
a coded rejection on `text_sha256`. This gate chooses which CANDIDATES a
|
||||
plan contains, from a text both sides already agree on, and the plan is
|
||||
built once. Passing it as a predicate would create a second definition to
|
||||
keep in step and buy nothing: measured, a text-only predicate DISAGREES
|
||||
with the candidate list on 2 of 39 corpus documents, because
|
||||
`keep_table_heading` rescues a heading the orphan check would have dropped.
|
||||
"""
|
||||
return any(candidate.rule == RULE_HEADING for candidate in candidates)
|
||||
|
||||
|
||||
def _gate_outline(
|
||||
marked: list[tuple[int, Candidate]], joined: set[int], end_of_text: int, length: int
|
||||
) -> tuple[list[tuple[int, Candidate]], set[int]]:
|
||||
"""G1 and G2: recovery yields to declaration, unless it carries the document.
|
||||
|
||||
Applied to `marked` -- BEFORE the orphan pass -- rather than to the
|
||||
finished entries, and that is the correctness half of the rule rather than
|
||||
a style choice. The second pass closes each span at the NEXT mark, so
|
||||
removing a mark here lets the preceding span reach through the text that
|
||||
mark used to open. Filtering the finished entries instead leaves that text
|
||||
in no segment at all, which is the silent loss this library refuses
|
||||
everywhere else. Measured, the two forms agree on every one of the twelve
|
||||
reference positions and on the whole 39-document corpus reach; they differ
|
||||
only in the spans, and only one of them conserves the text.
|
||||
|
||||
`joined` holds POSITIONS in `marked`, so it is remapped rather than copied.
|
||||
"""
|
||||
if not declares_headings(candidate for _, candidate in marked):
|
||||
return marked, joined
|
||||
kept: list[tuple[int, Candidate]] = []
|
||||
remap: dict[int, int] = {}
|
||||
for position, entry in enumerate(marked):
|
||||
candidate = entry[1]
|
||||
if candidate.rule == RULE_OUTLINE:
|
||||
following = marked[position + 1][1].start if position + 1 < len(marked) else end_of_text
|
||||
if length <= 0 or (following - candidate.start) / length < OUTLINE_SHARE:
|
||||
continue
|
||||
remap[position] = len(kept)
|
||||
kept.append(entry)
|
||||
return kept, {remap[p] for p in joined if p in remap}
|
||||
|
||||
|
||||
def _wraps_onto_next_line(lines: list[str], index: int) -> bool:
|
||||
"""True when the line at `index` is a sentence that continues below it.
|
||||
|
||||
|
|
@ -442,6 +512,8 @@ def find_candidates(
|
|||
keep_table_heading: bool = False,
|
||||
sheet_section_rows: bool = False,
|
||||
drop_wrapped_outline: bool = False,
|
||||
outline_gate: bool = False,
|
||||
first_span_from_zero: bool = False,
|
||||
) -> list[Candidate]:
|
||||
"""Every boundary the mechanical rules propose, in document order.
|
||||
|
||||
|
|
@ -477,6 +549,17 @@ 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.
|
||||
|
||||
`first_span_from_zero` is OFF at False, where the text above the first
|
||||
concept belongs to no segment. On, the first surviving concept starts at 0.
|
||||
It adds no boundary and removes none; only the first span's `start` moves.
|
||||
|
||||
`outline_gate` is G1+G2 and it is OFF at False. On, Arm D's recovered
|
||||
headings are admitted only where the document declares none of its own,
|
||||
plus any single recovered heading whose span covers `OUTLINE_SHARE` of
|
||||
the text. Like Arm E it only ever REMOVES marks, and it removes them
|
||||
before spans are closed, so the text they opened is carried by the mark
|
||||
above rather than lost.
|
||||
|
||||
`sheet_section_rows` is D3's gate and it is OFF at False, where the scan is
|
||||
not run at all. On, a RUN of numbered rows inside an open table block cuts
|
||||
it: each such row opens a candidate that reaches the next section row, or
|
||||
|
|
@ -649,6 +732,9 @@ def find_candidates(
|
|||
)
|
||||
)
|
||||
|
||||
if outline_gate:
|
||||
marked, joined = _gate_outline(marked, joined, end_of_text, len(text))
|
||||
|
||||
candidates: list[Candidate] = []
|
||||
# The name an orphaned heading leaves behind, and the ONE candidate allowed
|
||||
# to pick it up.
|
||||
|
|
@ -721,7 +807,13 @@ def find_candidates(
|
|||
contents=position_in_list in contents_run,
|
||||
)
|
||||
)
|
||||
return fold_units(candidates) if unit_fold else candidates
|
||||
resolved = fold_units(candidates) 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
|
||||
# before it would put the preamble on an entry the plan never carries.
|
||||
resolved = [replace(resolved[0], start=0), *resolved[1:]]
|
||||
return resolved
|
||||
|
||||
|
||||
def _absorbed_tables(
|
||||
|
|
@ -1052,6 +1144,8 @@ def build_plan(
|
|||
keep_table_heading: bool = False,
|
||||
sheet_section_rows: bool = False,
|
||||
drop_wrapped_outline: bool = False,
|
||||
outline_gate: bool = False,
|
||||
first_span_from_zero: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||
taken: set[str] = set()
|
||||
|
|
@ -1065,6 +1159,8 @@ def build_plan(
|
|||
keep_table_heading=keep_table_heading,
|
||||
sheet_section_rows=sheet_section_rows,
|
||||
drop_wrapped_outline=drop_wrapped_outline,
|
||||
outline_gate=outline_gate,
|
||||
first_span_from_zero=first_span_from_zero,
|
||||
)
|
||||
for candidate in subdivide(text, candidates, max_segment_chars):
|
||||
entries.append(
|
||||
|
|
@ -1138,6 +1234,8 @@ def run(
|
|||
keep_table_heading: bool = False,
|
||||
sheet_section_rows: bool = False,
|
||||
drop_wrapped_outline: bool = False,
|
||||
outline_gate: bool = False,
|
||||
first_span_from_zero: bool = False,
|
||||
pdf_headings: bool = False,
|
||||
pdf_headings_reserve: bool = False,
|
||||
ocr: bool = False,
|
||||
|
|
@ -1210,6 +1308,8 @@ def run(
|
|||
keep_table_heading=keep_table_heading,
|
||||
sheet_section_rows=sheet_section_rows,
|
||||
drop_wrapped_outline=drop_wrapped_outline,
|
||||
outline_gate=outline_gate,
|
||||
first_span_from_zero=first_span_from_zero,
|
||||
)
|
||||
# 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