Story 2285: BP — the source's own typography survives extraction

WHY. LiteParse reports a font name and size on every text item it extracts. We threw both away. So document.md — the machine copy Blake reads and B2 segmentation cuts on — was flat prose: no headings, no bold. The document already said where its sections were, in the most reliable way a PDF can say anything, and we deleted that on the way in. This Story carries it through. RFD-DOC-004 D1.

The gate came first: is the font signal real?

The ticket made this spike-gated for a good reason — a heading detector built on an unreliable signal is worse than no detector, because a false heading invents a section boundary that corrupts segmentation downstream. So nothing was built until the signal was measured on the real corpus (the e5df47c7 arbitration reply, two scans, and a 37-page GST compendium, all pulled from the staging bucket).

QuestionMeasured answer
Is font data actually present? Yes. font_name is non-null on 2,892 / 2,892 items across every native document. Zero gaps.
Does it distinguish headings? Yes. Golden reply: body text is 12.0pt TimesNewRomanPSMT; every section heading is 14.3pt TimesNewRomanPS-BoldMT. A clean tier.
Independent corroboration? LiteParse's own output_format="markdown" derives headings and bold from the same data — a second implementation agreeing the signal is there. (We do not use its markdown; see the trap below.)
Tables? NO. See the NO-GO section — this half of the ticket was refused.

Three thresholds, none of them guessed

Every constant in src/shared/extraction/style.py was derived by measuring the corpus and finding the empty band between two populations, then putting the threshold in the middle of it. This matters: a magic number picked by taste is a magic number that drifts on the next document.

ConstantSeparatesMeasured bandValue
HEADING_SIZE_RATIO section heading vs body body 12.0pt → headings 14.3pt (1.19×) 1.10
HEADING_MAX_CHARS heading vs bold prose longest true heading 93 chars; shortest bold prose run 157 120
CONTINUATION_GAP_FACTOR a wrapped heading's 2nd line vs a new item
and heading vs mid-paragraph bold
continuation lines 0.65–0.74× line height; separate lines 1.24–3.72× 1.0
THE TRAP that the first cut fell into. Bold + short is not a heading. A bold emphasis run inside a body paragraph is also bold and also short — it is character-for- character indistinguishable from a subsection heading. The first version of the classifier promoted 3 of them on the golden reply, each one a fake section boundary handed to B2.

What separates them is not the font at all — it is air. A heading has space above it; a mid-paragraph bold run hangs at ordinary leading. Measured: every true heading sits ≥1.24 line-heights below its predecessor (or opens the page), while every one of those false positives sat at 0.64–0.65. Adding the isolation test took false positives 3 → 0 with zero recall loss. It reuses the same measured constant as the continuation merge, because it is the same physical question: is this the next line of the thing above, or a new thing?

Spike verdict, on the shipped code

These numbers come from running the production path (LiteParseAdapter → compose_paragraphs → serialize_document_markdown), not a prototype. Ground truth is exact-text, hand-labeled against the source PDF, so every emitted heading is accounted for and nothing can hide behind a fuzzy match: 22 section headings + 9 title-block lines = the 31 headings emitted, exactly.

Golden reply (e5df47c7, 14pp)Result
True section headings22
Detected22 — 100% recall
Missed0
False positives (body prose → heading)0 — 100% precision
Title-block / signature lines rendered as headings9
Bold-emphasis blocks carried10

Degradation — no style hallucination on rasterized input:

DocumentShapeResult
soc_5pg_scanned.pdf5pp, no text layer0 items → OCR path → flat, zero style
PRAMAAN_UAT02_Scanned_Register12pp, no text layerflat, zero style
statement_of_deviation_57cr.pdfhybrid: native letterhead p1 + 4 glyphless scanned pagesp1 keeps typography; all 4 raster pages emit zero style
Why the hybrid document is the important one. A scanned PDF often carries an invisible Tesseract text layer whose font is GlyphLessFont and whose “sizes” are fitted box heights, not typography — 8pt to 28pt with no relation to the document's real structure. Infer style from that and you hallucinate headings out of raster noise. The guard rejects it, and it is scoped per page, not per document: statement_of_deviation is 86% glyphless overall but its first page is a genuine native letterhead. A document-scoped guard would have thrown away a real page's typography, or trusted four fake ones.

Table structure: NO-GO (this half of the ticket was refused)

The ticket asked for GFM tables. The spike says no, and the evidence is:

So no table labeler ships. The GFM renderer stays (it predates this Story): the labeler is the only missing half, so when a producer eventually stamps kind="table", the markdown upgrades behind the same artifact key with no renderer change. Follow-up recommended, not faked.

The invariant that made this safe: style is presentation, never identity (D3)

(page_number, block_index) addressing is untouched. The gap-based grouping that decides body block boundaries is unchanged, so a given block_index addresses the same text it always did. The only new boundary rule merges consecutive same-shape styled runs (a heading the PDF happened to wrap across two lines, which would otherwise emit two headings). Body runs are never merged.

tests/unit/test_style_carry_through.py::test_body_block_boundaries_do_not_move_when_style_is_present feeds the identical geometry with and without font data and asserts the block list is identical. 450 anchor / evidence / annotation / chunker / V-CITE tests pass untouched.
Text is never mutated. A block's text is the same " ".join of the same lines it always was. Emphasis is carried as half-open [start, end) character offsets into that text (emphasis_spans), never as markup inside it — so text.txt, the search_vector, and every byte-presence check see exactly what they saw before.

Proved against the real stored production artifact: the new document.md, stripped of # and **, is an identical word sequence to the derived/document.md currently in S3 for e5df47c7 — 4,470 words in, 4,470 words out. Nothing added, dropped, or reordered.

Additive-only, so nothing stored has to change (D12 greenfield)

Four optional, defaulted fields on OcrBlock. A pages.json or a document_pages.blocks row written before this shipped validates unchanged and renders unchanged — asserted against a block copied verbatim from the golden document's persisted rows. No migration; no backfill; new uploads get the richer path.

FieldGrainMeaning
font_name / font_sizelineWhat the engine reported. None for OCR, which has no typography.
heading_levelcomposed block1–6 when the font shape says heading; None for body and for any page with no style signal.
emphasis_spanscomposed blockBold ranges within the block's own text. Empty when there is no signal.
Why we do NOT just use LiteParse's own markdown. It is tempting — it renders headings and bold already, and it even fixes the line-shredding below. But its markdown is a flat string with no block identity: adopting it would sever document.md from the (page, block_index) anchors that pages.json, V-CITE, and the chunker all address, and would break the text-parity contract. document.md stays synthesized from the same blocks that carry the geometry. We use LiteParse's renderer only as independent evidence that the font signal is sound.
Known limitation, disclosed rather than hidden. On a generously-leaded document (most legal filings), the pre-existing paragraph-gap rule fires on every line, so a body paragraph composes to one block per line — and therefore document.md renders one markdown paragraph per line. That predates this Story and is a composition defect, not a style one; you can see it in the "after" sample below. Fixing it means re-deriving the break threshold from the page's modal line pitch, which moves every body block boundary and so needs its own eval and its own ticket. It is called out in paragraphs.py and a follow-up is recommended in the PR. Headings are merged here only because a wrapped heading would otherwise emit two headings — the one case where leaving it alone actively corrupts segmentation.

Before / after (golden reply, first lines)

--- before (the document.md in S3 today)
BEFORE THE LEARNED SOLE ARBITRATOR

CORD CASE NO. CCORD0425A0016

REPLY ON BEHALF OF THE CLAIMANTS TO THE RESPONDENT'S

APPLICATION DATED 01.05.2026 UNDER SECTIONS 18 AND 19 READ WITH

SECTION 31A OF THE ARBITRATION AND CONCILIATION ACT, 1996

I. PRELIMINARY SUBMISSIONS 0.1

+++ after
## BEFORE THE LEARNED SOLE ARBITRATOR

### CORD CASE NO. CCORD0425A0016

**REPLY ON BEHALF OF THE CLAIMANTS TO THE RESPONDENT'S APPLICATION DATED 01.05.2026 UNDER
SECTIONS 18 AND 19 READ WITH SECTION 31A OF THE ARBITRATION AND CONCILIATION ACT, 1996**

## I. PRELIMINARY SUBMISSIONS 0.1

Note the document title: at 172 chars it is over HEADING_MAX_CHARS, so it is not promoted to a heading — it renders as a bold paragraph instead. That is the length guard doing its job. The asymmetry is deliberate: a missed heading costs presentation, a false heading corrupts segmentation. When in doubt, we do not promote.

Where the code is

FileChange
src/shared/extraction/style.pynew — font normalization (the corpus spells one weight six ways), bold/glyphless predicates, the fail-closed page guard, and the three measured constants.
src/shared/ocr/models.py4 optional defaulted fields on OcrBlock.
src/shared/extraction/liteparse.pycarries font_name / font_size through at line grain.
src/shared/extraction/paragraphs.pystyle-aware composition: continuation merge, heading classification, emphasis spans. Body boundaries untouched.
src/workers/document_extraction/derived.pyrenders heading_level as ATX depth and emphasis_spans as **bold**; corrupt spans degrade to plain text rather than slicing it.

Observability (ADR-124): no manual span. Pure in-process CPU predicates over already-extracted data, covered by the extraction worker's per-record consumer span; the closed manual-span registry has no name for this, so it is instrumentation-exempt by the auto/caller-span floor — not by omission.