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:
Kjell Tore Guttormsen 2026-09-09 14:17:00 +02:00
commit 38104b7df5
16 changed files with 1301 additions and 42 deletions

View file

@ -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,

View file

@ -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 -------------------------

View file

@ -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,

View file

@ -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(),
)

View file

@ -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