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 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).
| Question | Measured 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. |
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.
| Constant | Separates | Measured band | Value |
|---|---|---|---|
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 |
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 headings | 22 |
| Detected | 22 — 100% recall |
| Missed | 0 |
| False positives (body prose → heading) | 0 — 100% precision |
| Title-block / signature lines rendered as headings | 9 |
| Bold-emphasis blocks carried | 10 |
Degradation — no style hallucination on rasterized input:
| Document | Shape | Result |
|---|---|---|
soc_5pg_scanned.pdf | 5pp, no text layer | 0 items → OCR path → flat, zero style |
PRAMAAN_UAT02_Scanned_Register | 12pp, no text layer | flat, zero style |
statement_of_deviation_57cr.pdf | hybrid: native letterhead p1 + 4 glyphless scanned pages | p1 keeps typography; all 4 raster pages emit zero style |
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.
The ticket asked for GFM tables. The spike says no, and the evidence is:
TextItem exposes no table primitive — only
text, x, y, width, height, font_name, font_size, confidence. A GFM table would have to be
reconstructed by geometric column clustering.
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.
(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 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.
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.
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.
| Field | Grain | Meaning |
|---|---|---|
font_name / font_size | line | What the engine reported. None for OCR, which has no typography. |
heading_level | composed block | 1–6 when the font shape says heading; None for body and for any page with no style signal. |
emphasis_spans | composed block | Bold ranges within the block's own text. Empty when there is no signal. |
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.
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 (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.
| File | Change |
|---|---|
src/shared/extraction/style.py | new — 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.py | 4 optional defaulted fields on OcrBlock. |
src/shared/extraction/liteparse.py | carries font_name / font_size through at line grain. |
src/shared/extraction/paragraphs.py | style-aware composition: continuation merge, heading classification, emphasis spans. Body boundaries untouched. |
src/workers/document_extraction/derived.py | renders 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.