fix(s1): a deterministic pass may not preempt what it cannot supply - #315
Merged
sroussey merged 1 commit intoAug 20, 2026
Merged
Conversation
Four sections declared `clears` and `covers` at TABLE granularity while their model-free parse fills only some of the table's columns, so `preempts` returned true unconditionally — after the section had already cleared its rows. The columns the parse cannot read were rewritten as NULL, the section resolved clean, and every replay took the same path. - underwriters: `role_detail` and `over_allotment_shares` are hardcoded null; the role is prose beside the syndicate table. - beneficial ownership: six columns hardcoded, including `is_selling_stockholder: false` — a positive false claim. - management: `bio` is hardcoded null and `observePerson` upserts the row. - sponsor promote: the `||` gate returns a row on one of two anchors, so the other five columns come from a partial read. No change to `preempts` was needed: destination names are compared as plain strings, so naming a table column by column in both sets makes the pass decline, and a mixed-granularity pair declines in both directions. `covers` may now be a function of the section text, resolved before `extract` and treated as covering nothing if it throws; `promoteCoverage` and `ownershipCoverage` compute it from the same walk their parse performs, which keeps those two passes on for the filings whose tables really do state every column. Management roster closure (`closeUnassertedPersonRoles` for `s1:management`), silently dead because a preempting pass can never report a complete population, resolves as a side effect; `complete: () => false` is deleted as dead config. `use_of_proceeds.note` stays bare — the prompt directs every qualifier into `purpose`, which the parse copies verbatim. `executive_compensation.footnote` is column-qualified: the prompt strips footnote markers out of every other column, so that text lands nowhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
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.
A deterministic pass declares what it can supply (
covers) and is allowed toreplace the AI call only when that covers everything the section rewrites
(
clears). Four sections declared both sets at TABLE granularity while theirparse fills only some of the table's columns, so
preemptsreturned trueunconditionally — and the section's
clear(accession_number)has already run bythen. The columns the parse cannot read were written as NULL, the section
resolved clean, no dead letter was recorded, and every subsequent replay took
the same path, so nothing self-corrected.
The four over-claiming sections, and exactly what each nulled
parseSpacUnderwritersunderwriter_link.role_detail,underwriter_link.over_allotment_shares(both hardcodednull)parseBeneficialOwnershipsecurity_class,shares_offered,shares_after,percent_after,footnote— andis_selling_stockholder: falseparseManagementRosterperson_observation.bio(observePersonUPSERTS the row, so the bio is genuinely rewritten)parseSpacPromoteTerms||gate returns a row on ONE of two anchors, so the other five columns are rewritten from a partial readis_selling_stockholder: falseis the worst of these: it is not an absent valuebut a positive false claim that the holder registers no resale.
The failure is concrete. An S-1 processed before this branch has
underwriter_link.role_detail = "bookrunner";sec extractor backfill S-1 --forceclears the links, the deterministic pass wins, and the rows come backwith
role_detail = NULL.Form_S_1.storage.offering.test.tsPINNED thatbehaviour (
expect(links[0]!.role_detail).toBeNull()), which is why theassertion is inverted here.
The discipline already existed and was simply not applied —
spac-profile,related-partyandoffering-termseach declare acoversthat is a strictsubset of their
clearsand correctly decline.Why column granularity needs no change to
preemptsDestination names are opaque strings compared by exact match. So
covers: {underwriter_link.shares_allocated}does not containunderwriter_link.role_detail, and the pass declines — no new comparison logic,no "bare name expands to all its columns" convenience (which would reopen the
hole in one word). Mixing granularity for one table — bare in one set, qualified
in the other — also declines, in both directions, so a half-migrated
declaration fails safe rather than silently claiming or silently losing a table.
Existing table-granularity pairs (
spac-sponsors,spac-classification) areuntouched and keep working.
The rule is now stated on
DeterministicPass: a table is named bare onlywhen the parse fills every column
persistwrites for it; the moment one columnis beyond the parse, the whole table is named column by column in BOTH sets.
Why
coversbecame text-dependent for promote and ownershipNaming the columns alone turns all four passes off. Two are bought back, because
for them a static set cannot answer the actual question — whether a null column
is a loss or is what the filing says:
and no "after the offering" columns, no selling stockholders and no footnote
markers. There
nullIS the disclosure. A resale registration's table printsall of them, and there the same nulls delete stated figures.
ownershipCoverage(text)reads that off the same headers and rows the parsewalks.
nothing is hardcoded,
walkFieldsattempts all seven columns, and the defectis the
||gate that returns a row on one anchor.promoteCoverage(text)re-runswalkFieldsand claims a column only whenthis filing's tables state it, which makes the gate stop mattering.
coversis therefore widened toReadonlySet<string> | ((text: string) => ReadonlySet<string>), resolved once beforeextractand wrapped intry/catch returning the empty set — a coverage function reads the section, so
it can fail like any parse, and declining costs one model call where propagating
would abort a section the model could still have extracted. The enforced rule:
a coverage function must be derived from the SAME walk
extractperforms, or itis a second implementation that can disagree with the parse it speaks for.
Underwriters and management stay off. Their gaps are parser features, not
declaration fixes, and are follow-up work:
underwriter_link.role_detail— the role is prose beside the syndicate table("sole book-running manager", "co-manager"); teaching the parser to read that
sentence is what would let the column join
covers.person_observation.bio— the biography is the paragraphs following theroster table, which the table walk never reads.
Management roster closure resolves as a side effect
complete: () => falseon the management pass, combined withif (meta.complete)in the persist, made
closeUnassertedPersonRolesfors1:managementdead forevery filing whose roster table parses — the normal case — while CLAUDE.md still
names
s1:managementas one of only two closing populations.No fix to
completewas needed: column-qualifying management stops itpreempting, so
completeis computed on the model path exactly as it was beforethis branch and closure runs again.
complete: () => falseis deleted — acompleteon a pass that never preempts is dead config that reads as a livedecision. The durable argument now sits on the
clears/coverspair: theroster TABLE is not the roster POPULATION — a director named only in the prose
below it is invisible to the walk — so even a zero-decline parse could not close
a role.
Form_S_1.storage.management.test.tsgains"closes a role dropped from an amended roster", which fails if any future passdeclaration disables closure again.
Decision on
use_of_proceeds.noteandexecutive_compensation.footnoteBoth are hardcoded
nullby their parse while the model is asked for them, andthey are the two remaining judgement calls. No database is reachable from this
environment, so the fill rate could not be measured; the basis below is the
section prompts and the committed fixtures/labels, and it splits the two.
The question that separates them is not "does the model fill it" but "is the
field's content recoverable from the row the parse does supply":
use_of_proceeds.notestays bare (the pass keeps preempting). The promptdirects every qualifier a line item carries into
purpose— "the row labelcopied WHOLE, including any parenthetical the cell carries" — and the parse
copies that same cell verbatim. So
noteholds nothing the row does not stillsay. Corroborating: all 42 golden-labelled use-of-proceeds filings label only
purposeandamount, and the extractor's evalcompareFieldsis exactly["purpose", "amount"]— the field has never been verified by anything.executive_compensation.footnoteis column-qualified and that pass goesoff. Nothing redirects footnote text anywhere else: the prompt tells the model
to STRIP footnote markers out of
person_nameand out of every money field,so whatever a footnote says about a row appears on that row in no other
column. Nulling it is a real loss, and the fail-safe answer costs one model
call.
The documented re-key ceremony's step 3a is a corpus-wide backfill:
Run today, on this branch, that would silently downgrade every SPAC's
underwriter roles and ownership columns to null — every one of those sections
resolving clean, with no dead letter and nothing in any coverage number to show
it happened.
Also note that rows already written by #308 do not self-correct. A filing
whose underwriters/ownership/management/promote section already took the
deterministic path keeps its nulls until it is re-extracted:
sec extractor backfill S-1 --forceandsec extractor backfill 424 --force,re-paying the AI cost for those sections.
Tests
s1/deterministicPass.test.ts— declines a columncoversomits; stillpreempts a table-granularity pair; declines a mixed-granularity pair in both
directions; resolves a function-valued
coversagainst the section text;declines when the coverage function throws; declines an undefined
clears.Form_S_1.storage.offering.test.ts— the underwriters test is inverted andrenamed to
"does not preempt the underwriters model on a table it cannot read a role from"; the promote fixture now states all seven figures and keeps itsdeterministic assertion, plus a new
"falls through to the model when the promote table states no trust total".Form_S_1.storage.ownership.test.ts— split into the SPAC table (stilldeterministic under
ownershipCoverage) and a resale table with SharesOffered / Shares After columns (model runs,
security_classandshares_afterstored).
Form_S_1.storage.management.test.ts/.compensation.test.ts— inverted so aparseable table still calls its model, and
bio/footnoteare storednon-null.
Verification
All 18 failures are
Error: Test timed out— zero assertion failures(
grep -c "Test timed out in" == 18). They are the golden-corpus fixtureloaders (
parse*.corpus.test.ts× 10,parseEdgarHtml.golden.test.ts) and thesec versionCLI subprocess tests, none of which touch this change — the runhappened on a shared box under load average ~22. Re-running four of them alone
with
--testTimeout=300000passes clean:src/index.tsis the only prettier failure and is the pre-existing one asibling PR is fixing; it is not touched here.
npx tsc --noEmitexits 0.Also in this PR:
classifier_source's schema description gains| deterministic,and the eleven
source?: "deterministic"JSDoc lines are corrected — "Persist-only;never part of the model JSON schema" was wrong about persist, which reads
SectionPersistMeta.sourceand never this field. The field itself stays: it marksa row as parser-produced and is asserted by the parser unit tests.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT
Generated by Claude Code