fix(sections): one item header is not an item structure, and TwentyF stops reading the parser we are deleting (dt1f, 07lk.3) - #1040
Merged
Conversation
The section extractor finds headers in layers — semantic headings first, then bold paragraphs, table cells and plain paragraphs as fallbacks — and gated every fallback on whether *any* header collected so far mentioned an item. That is not the question the gate needed answered. On 0001144204-10-017467, a 2010 20-F in EDGARizer HTML, header detection promoted 86 nodes of which three named an item, and one of those three was a sentence: "Please refer to Item 6.E, Directors, Senior Management and Employees". Those three suppressed the strategies that find the filing's fifteen real item headers, so doc.sections returned four items where the legacy ChunkedDocument found twenty-six. Two defects in one predicate. The item test was unanchored (re.search, so prose counted), and one item was treated as proof of a structure. The gate now asks for coverage: have the headers named at least half the items the form defines? The denominator comes from each form's own FormSchema. 8-K and title-based forms get a denominator of 0 and keep the presence test — an 8-K reports only the items it has, so a two-item 8-K is complete and a ratio against the 33 the form allows would put every 8-K below any floor. The 0.5 floor is the middle of a gap that exists in every form's measured distribution over the parity corpus; the values cluster at 10% vs 61%+ (20-F), 17-48% vs 65%+ (10-K), 43% vs 71%+ (10-Q). Any cut in 49-60% separates them identically. Strategies can now run in the same pass rather than only when every earlier one came up empty, so 3, 4 and 5 dedup by position as 3b already did. Measured over all 115 corpus fixtures, before and after, diffed per filing: 20-F legacy_only 26 -> 12 10-Q 3 -> 3 10-K legacy_only 59 -> 59 8-K 1 -> 1 Zero new gaps; zero items the new parser stopped finding. One filing changed, gaining 14 items. Parse timings on the filings where the gate newly fires are unchanged — they reach sections through the TOC path, so the extra walks never happen. The regression fixture is synthetic on purpose: the real filing lives in tests/fixtures/text_boundary_corpus, which is gitignored, so a test reading it would silently skip in CI. The document built there reproduces the shape and fails the same way (2 sections, not 11) against the old gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TwentyF was the last report class where the legacy ChunkedDocument was the
PRIMARY source of items; TenK, TenQ and CurrentReport already used it only
as an empty-result fallback. Deleting edgar.files in 6.0 means deleting
ChunkedDocument, so every remaining primary use has to go first.
Its in-code justification for staying legacy-first — that "the pattern-based
extractor doesn't handle the Table of Contents format well" — did not
survive measurement. On the one 20-F where legacy clearly won, TOC detection
returned no sections at all, so the TOC path was not what was winning. The
cause was the coverage gate fixed in the preceding commit, and with that in
place the 20-F differential fell from 26 legacy-only items to 12.
Item numbers come from Section.item, not from the section key. The key is
not stable across detection strategies — the TOC engine emits
part_i_item_1, its anchor path emits "Item 1", the pattern extractor emits
item_1, and all three are live on the corpus — so keying off it would have
returned section keys on most filings and raw titles ("Part I",
"Signatures") on sections that are not items. Section.item is '1' in every
case, and None on non-items.
.items now also deduplicates and sorts canonically on both paths (Item 4 <
Item 4A < Item 5, Item 16A < Item 19). The legacy path previously returned
document order, which put Item 17 first on several filings. The sort key
moves to _structures.py, where the other cross-form helpers live; ten_k
keeps _item_sort_key bound to it because a test imports that name, and
8-K's dotted-item ordering in current_report is a different key and is
untouched.
This also settles a disagreement: __getitem__ has read the new parser for
some time while .items read legacy, so the two could describe the same
filing differently.
Measured on the 15-filing 20-F corpus: 10 filings identical, 1 gains an
item, 4 take the legacy fallback unchanged, and 2 lose items (20 -> 17 and
26 -> 18). Those 11 are exactly the 20-F entries already in BASELINE_GAPS.
Without the coverage-gate fix first, the second would have been 26 -> 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits, split because their blast radii differ: the first changes section extraction for every form, the second changes 20-F only. The second's case rests on the first having landed, so they ship together but stay separable —
933edc1dwas verified to stand alone in a worktree.1.
933edc1d— one item header is not an item structureThe section extractor finds headers in layers: semantic headings first, then bold paragraphs, table cells and plain paragraphs as fallbacks. Every fallback was gated on whether any header collected so far mentioned an item.
On
0001144204-10-017467, a 2010 20-F in EDGARizer HTML, header detection promoted 86 nodes of which three named an item — and one of those three was a sentence reading "Please refer to Item 6.E, Directors, Senior Management and Employees". Those three suppressed the strategies that find the filing's fifteen real item headers.doc.sectionsreturned four items where the legacyChunkedDocumentfound twenty-six.Two defects in one predicate: the item test was unanchored (
re.search, so prose counted), and one item was treated as proof of a structure. The gate now asks for coverage — have the headers named at least half the items the form defines? The denominator comes from each form's ownFormSchema.8-K and title-based forms keep the presence test. An 8-K reports only the items it has, so a two-item 8-K is complete; a ratio against the 33 the form allows would put every 8-K below any floor. Same reason the parity benchmark gives 8-K no coverage rate.
The 0.5 floor is measured, not chosen. It sits in the middle of a gap that exists in every form's distribution over the parity corpus:
Any cut in 49–60% separates these identically. Strategies can now run in the same pass rather than only when every earlier one came up empty, so 3, 4 and 5 dedup by position as 3b already did.
Measurement — all 115 corpus fixtures, before vs after, diffed per filing
legacy_onlybefore → afterZero new gaps; zero items the new parser stopped finding. Exactly one filing changed, gaining 14 items. 20-F coverage delta moved −8.4% → −3.7%.
Parse timings are unchanged on the filings where the gate newly fires (pfe, wmt, xom 10-Ks; gbdc, nflx 10-Qs) — those reach sections through the TOC path, so the extra tree walks never happen.
2.
d60f2885—TwentyFreads the parser we are keepingTwentyFwas the last class where legacyChunkedDocumentwas primary;TenK,TenQandCurrentReportalready used it only as an empty-result fallback. Deletingedgar.filesin 6.0 means deletingChunkedDocument, so every remaining primary use has to go first.Its in-code justification — "the pattern-based extractor doesn't handle the Table of Contents format well" — did not survive measurement. On the one 20-F where legacy clearly won, TOC detection returned no sections at all, so the TOC path was not what was winning. The cause was the gate above.
Item numbers come from
Section.item, not the section key. The key is not stable across detection strategies — the TOC engine emitspart_i_item_1, its anchor path emitsItem 1, the pattern extractor emitsitem_1, and all three are live on the corpus. Keying off it would have returned section keys on most filings and raw titles (Part I,Signatures) on sections that are not items at all.Section.itemis'1'in every case andNoneon non-items..itemsalso now deduplicates and sorts canonically on both paths (Item 4<Item 4A<Item 5,Item 16A<Item 19); the legacy path previously returned document order, which putItem 17first on several filings. This also settles a disagreement —__getitem__has read the new parser for some time while.itemsread legacy, so the two could describe the same filing differently.What users see, across the 15-filing 20-F corpus
Item 3C)The two that lose are
0001062993-16-008650(20 → 17) and0001144204-10-017467(26 → 18). Those 11 items are exactly the 20-F entries already inBASELINE_GAPS— no new ground lost. Without the gate fix first, the second would have been 26 → 4, which is why these are ordered this way.Verification
tests/issues/regression/+ company reports + filing: 2486 passed, 4 xfailedtest_closed_gaps_are_recorded— the win is banked, 22 → 8933edc1dalone in a detached worktree: 66 passedS110into_contextare untouched)Notes for review
The dt1f regression fixture is synthetic on purpose. The real filing lives in
tests/fixtures/text_boundary_corpus, which is gitignored (91 MB), so a test reading it would silently skip in CI — the failure modetest_section_parity_ratchet.pydocuments at length. The document intest_dt1f_item_coverage_gate.pyreproduces the shape and fails the same way (2 sections, not 11) against the old gate. It carries its own guard test, so it cannot quietly stop reproducing the defect.item_sort_keymoved to_structures.pywhere the other cross-form helpers live.ten_k._item_sort_keystays bound to it becausetests/test_company_reports.pyimports that name; 8-K's dotted-item ordering incurrent_reportis a genuinely different key and is untouched.Still open on
07lk.3. 10-K is now the whole critical path at 59 gaps, against 20-F 12, 10-Q 3, 8-K 1. Deleting theTenK/TenQ/CurrentReportfallbacks needs each form at zero.wfc/10kis the sharpest of those — heading coverage there is 0, so the gates already fire and it is a different failure from this one.🤖 Generated with Claude Code