From 6f63ff38bc78a0c4c227c4eb3f90a612054c94d4 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Tue, 18 Aug 2026 09:55:09 -0700 Subject: [PATCH 01/29] feat(eval): add offering tables evaluation command and report - Introduced `EvalOfferingTablesTask` to score the SPAC offering/promote table parser against stored rows using on-disk accession documents. - Added a new CLI command `offering-tables` to facilitate the evaluation process with options for extractor ID, limit, and CIK. - Implemented `printOfferingTablesReport` function to display evaluation results in a user-friendly format. - Updated tests to cover the new command and its options, ensuring proper validation of input parameters. --- src/cli/groups/eval.ts | 63 ++++ src/cli/groups/evalOptionValues.test.ts | 5 + src/eval/runOfferingTablesEval.test.ts | 60 ++++ src/eval/runOfferingTablesEval.ts | 280 ++++++++++++++++++ .../Form_S_1.storage.offering.test.ts | 47 +++ .../s1/offeringSections.ts | 21 +- .../s1/parseOfferingTables.test.ts | 68 +++++ .../s1/parseOfferingTables.ts | 77 +++-- src/sec/html/TableExtractor.test.ts | 16 +- src/sec/html/TableExtractor.ts | 81 +++++ src/sec/html/cssTwoColumnTable.ts | 253 ++++++++++++++++ src/sec/html/parseEdgarHtml.test.ts | 23 ++ src/sec/html/parseToBlocks.test.ts | 93 ++++++ src/sec/html/parseToBlocks.ts | 36 ++- src/storage/offering/SpacPromoteTermsRepo.ts | 4 + src/storage/offering/SpacUnitTermsRepo.ts | 4 + src/task/eval/EvalOfferingTablesTask.ts | 72 +++++ src/task/forms/ProcessAccessionDocFormTask.ts | 22 +- src/util/accessionDocPath.test.ts | 15 + src/util/accessionDocPath.ts | 31 ++ 20 files changed, 1219 insertions(+), 52 deletions(-) create mode 100644 src/eval/runOfferingTablesEval.test.ts create mode 100644 src/eval/runOfferingTablesEval.ts create mode 100644 src/sec/html/cssTwoColumnTable.ts create mode 100644 src/task/eval/EvalOfferingTablesTask.ts diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index 37a9bb8d..96492e77 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -39,7 +39,9 @@ import { EVAL_S1_CONCURRENCY_DEFAULTS, } from "../../task/eval/evalS1Concurrency"; import { EvalUnitTermsTask } from "../../task/eval/EvalUnitTermsTask"; +import { EvalOfferingTablesTask } from "../../task/eval/EvalOfferingTablesTask"; import { type UnitTermsReport } from "../../eval/runUnitTermsEval"; +import type { OfferingTablesReport } from "../../eval/runOfferingTablesEval"; /** * Default comparison set: Anthropic's cheap and strong tiers, plus the cheap @@ -182,6 +184,26 @@ function truncate(s: string, max = 60): string { return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } +function printOfferingTablesReport(report: OfferingTablesReport): void { + const { counts } = report; + console.log( + `offering/promote parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.kind} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + function hasDiff(d: ExtractionDiff): boolean { return d.missing.length > 0 || d.extra.length > 0 || d.mismatches.length > 0; } @@ -926,4 +948,45 @@ export function addEvalCommands(program: Command): void { }); } ); + + cmd + .command("offering-tables") + .description( + "Score the deterministic SPAC offering/promote table parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalOfferingTablesTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printOfferingTablesReport(report); + }); + } + ); } diff --git a/src/cli/groups/evalOptionValues.test.ts b/src/cli/groups/evalOptionValues.test.ts index e529497d..c714f8d5 100644 --- a/src/cli/groups/evalOptionValues.test.ts +++ b/src/cli/groups/evalOptionValues.test.ts @@ -102,6 +102,11 @@ describe("eval value-less options", () => { expect(await runEval(["unit-terms", "--format"])).toContain("one of: table, json"); }); + it("covers eval offering-tables' value options", async () => { + expect(await runEval(["offering-tables", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["offering-tables", "--format"])).toContain("one of: table, json"); + }); + it("lists print-prompts modes for a bare --print-prompts on extract", async () => { const err = await runEval(["extract", "--print-prompts"]); expect(err).toContain("--print-prompts needs a value"); diff --git a/src/eval/runOfferingTablesEval.test.ts b/src/eval/runOfferingTablesEval.test.ts new file mode 100644 index 00000000..ade5d293 --- /dev/null +++ b/src/eval/runOfferingTablesEval.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserNull } from "./runOfferingTablesEval"; + +describe("bucketWhenParserNull", () => { + it("skips an all-null stored offering row", () => { + expect( + bucketWhenParserNull({ + kind: "offering", + stored: { price_per_unit: null, warrant_fraction_per_unit: null }, + text: "| Nasdaq symbols | Units: “XXXXU” |", + }) + ).toEqual({ bucket: "skip", reason: "all-null stored" }); + }); + + it("empties a share-only IPO rather than missing", () => { + expect( + bucketWhenParserNull({ + kind: "offering", + stored: { price_per_unit: 10, warrant_fraction_per_unit: null }, + text: "| Securities offered | 12,000,000 ordinary shares, at $10.00 per share |", + }).bucket + ).toBe("empty"); + }); + + it("empties a follow-on priced outside the unit-IPO band", () => { + expect( + bucketWhenParserNull({ + kind: "offering", + stored: { price_per_unit: 0.78, warrant_fraction_per_unit: null }, + text: "| Securities offered | 5,937,100 Units, at $0.7832 per unit |", + }).bucket + ).toBe("empty"); + }); + + it("skips an all-null stored promote row", () => { + expect( + bucketWhenParserNull({ + kind: "promote", + stored: { founder_shares: null, trust_per_public_share: null }, + text: "| Offering price | $10.00 |\n| Number of units offered | 7,500,000 |", + }) + ).toEqual({ bucket: "skip", reason: "all-null stored" }); + }); + + it("misses a unit-IPO offering table the parser should have hit", () => { + expect( + bucketWhenParserNull({ + kind: "offering", + stored: { price_per_unit: 10, warrant_fraction_per_unit: 0.5 }, + text: "| Securities offered | 7,500,000 units, at $10.00 per unit |", + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runOfferingTablesEval.ts b/src/eval/runOfferingTablesEval.ts new file mode 100644 index 00000000..965df9a3 --- /dev/null +++ b/src/eval/runOfferingTablesEval.ts @@ -0,0 +1,280 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { + offeringParseText, + promoteParseText, +} from "../sec/forms/registration-statements/s1/offeringSections"; +import { + looksLikeUnitIpo, + parseSpacOfferingTerms, + parseSpacPromoteTerms, +} from "../sec/forms/registration-statements/s1/parseOfferingTables"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacPromoteTermsRepo } from "../storage/offering/SpacPromoteTermsRepo"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface OfferingTablesEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type OfferingTablesBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface OfferingTablesCase { + readonly kind: "offering" | "promote"; + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: OfferingTablesBucket; + readonly cachePath: string | undefined; + readonly parsed?: Record; + readonly stored?: Record; + readonly reason?: string; +} + +export interface OfferingTablesReport { + readonly cases: readonly OfferingTablesCase[]; + readonly counts: Record; +} + +const OFFERING_FIELDS = [ + "price_per_unit", + "warrant_fraction_per_unit", + "right_fraction_per_unit", + "trust_per_unit", +] as const; + +const PROMOTE_FIELDS = [ + "founder_shares", + "founder_percent", + "private_placement_warrants", + "public_warrant_coverage", + "trust_per_public_share", +] as const; + +export async function runOfferingTablesEval( + options: OfferingTablesEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; offering-tables eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const promoteRows = (await new SpacPromoteTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const work: Array<{ + readonly kind: "offering" | "promote"; + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly stored: Record; + }> = [ + ...unitRows.map((r) => ({ + kind: "offering" as const, + extractor_id: r.extractor_id, + accession_number: r.accession_number, + cik: r.cik, + stored: pick(r as unknown as Record, OFFERING_FIELDS), + })), + ...promoteRows.map((r) => ({ + kind: "promote" as const, + extractor_id: r.extractor_id, + accession_number: r.accession_number, + cik: r.cik, + stored: pick(r as unknown as Record, PROMOTE_FIELDS), + })), + ]; + const sliced = options.limit !== undefined ? work.slice(0, options.limit) : work; + const cases: OfferingTablesCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, `${item.kind} ${item.accession_number}`); + cases.push(await scoreCase(root, item)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function scoreCase( + root: string, + item: { + readonly kind: "offering" | "promote"; + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly stored: Record; + } +): Promise { + const base = { + kind: item.kind, + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored: item.stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip", cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + if (item.kind === "offering") { + const text = offeringParseText(byName); + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsed = parseSpacOfferingTerms(text); + const scored = + parsed === null ? null : pick(parsed as unknown as Record, OFFERING_FIELDS); + if (scored === null) { + const miss = bucketWhenParserNull({ kind: "offering", stored: item.stored, text }); + return { + ...base, + cik, + bucket: miss.bucket, + cachePath, + parsed: undefined, + reason: miss.reason, + }; + } + return { + ...base, + cik, + bucket: scoredEqual(scored, item.stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed: scored, + }; + } + const text = promoteParseText(byName); + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsed = parseSpacPromoteTerms(text); + const scored = + parsed === null ? null : pick(parsed as unknown as Record, PROMOTE_FIELDS); + if (scored === null) { + const miss = bucketWhenParserNull({ kind: "promote", stored: item.stored, text }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed: undefined, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(scored, item.stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed: scored, + }; +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} + +function pick(row: Record, fields: readonly string[]): Record { + const out: Record = {}; + for (const f of fields) out[f] = round2(row[f]); + return out; +} + +function round2(v: unknown): unknown { + if (typeof v === "number" && Number.isFinite(v) && !Number.isInteger(v)) { + return Math.round(v * 100) / 100; + } + return v ?? null; +} + +export function bucketWhenParserNull(args: { + readonly kind: "offering" | "promote"; + readonly stored: Record; + readonly text: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (isAllNull(args.stored)) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!looksLikeUnitIpo(args.text)) { + return { bucket: "empty", reason: args.kind === "promote" ? "resale" : "not-unit-ipo" }; + } + return { bucket: "miss", reason: undefined }; +} + +function isAllNull(row: Record): boolean { + return Object.values(row).every((v) => v == null); +} + +function scoredEqual(a: Record, b: Record): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of keys) { + if (a[k] !== b[k]) return false; + } + return true; +} diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts index 98c482ce..49fdc892 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts @@ -12,7 +12,9 @@ import { OfferingTermsRepo } from "../../../storage/offering/OfferingTermsRepo"; import { SpacUnitTermsRepo } from "../../../storage/offering/SpacUnitTermsRepo"; import { SpacPromoteTermsRepo } from "../../../storage/offering/SpacPromoteTermsRepo"; import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo"; +import { FieldProvenanceRepo } from "../../../storage/provenance/FieldProvenanceRepo"; import { processFormS1 } from "./Form_S_1.storage"; +import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; const OFFERING_HTML = [ @@ -370,4 +372,49 @@ describe("processFormS1 offering terms", () => { const offering = dl.find((d) => d.section_name === "offering-terms"); expect(offering?.detail).toMatch(/NO issuer ticker rows/); }); + + it("persists a markdown-table hit as deterministic without calling the offering model", async () => { + const html = [ + "

THE OFFERING

", + "", + "", + "", + "", + "", + "
Offering price$10.00
Number of units offered20,000,000
Founder shares5,750,000
Proceeds to be held in trust account$10.00 per unit
", + "

UNDERWRITING

Goldman Sachs & Co. LLC is the representative.

", + ].join(""); + const { unregister } = registerFakeStructuredProvider([{ underwriters: [] }]); + cleanup = unregister; + + await processFormS1({ + cik: 1848507, + file_number: "333-10", + accession_number: "0000000000-26-000010", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: SPAC_HEADER, + html, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const unit = await new SpacUnitTermsRepo().get("S-1", "0000000000-26-000010"); + expect(unit?.price_per_unit).toBe(10); + expect(unit?.units_offered).toBe(20_000_000); + const promote = await new SpacPromoteTermsRepo().get("S-1", "0000000000-26-000010"); + expect(promote?.founder_shares).toBe(5_750_000); + expect(promote?.trust_per_public_share).toBe(10); + const prov = await new FieldProvenanceRepo().listByAccession("0000000000-26-000010"); + const unitProv = prov.filter((p) => p.table_name === "spac_unit_terms"); + const promoteProv = prov.filter((p) => p.table_name === "spac_promote_terms"); + expect(unitProv.length).toBeGreaterThan(0); + expect(unitProv.every((p) => p.model_id === DETERMINISTIC_MODEL_ID)).toBe(true); + expect(promoteProv.length).toBeGreaterThan(0); + expect(promoteProv.every((p) => p.model_id === DETERMINISTIC_MODEL_ID)).toBe(true); + }); }); diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index 8cf8dddb..e72f2cf2 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -41,6 +41,11 @@ import { boundSourceSpan, classifySpan } from "./verifySourceSpan"; import { verifyNumericObjectSpan } from "./verifyNumericObjectSpan"; import { anchorFieldSpan } from "./anchorFieldSpan"; import { FieldProvenanceRepo } from "../../../../storage/provenance/FieldProvenanceRepo"; +import { + DETERMINISTIC_MODEL_ID, + parseSpacOfferingTerms, + parseSpacPromoteTerms, +} from "./parseOfferingTables"; /** * Concatenate the sections production hands the offering-terms parser, so eval @@ -264,12 +269,19 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { + if (isSpac) { + const det = parseSpacOfferingTerms(text); + if (det !== null) return [det]; + } const terms = await extractOfferingTerms(text, m, context); return terms === null ? [] : [terms]; }), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); const terms = rows[0]; + const model_id = + terms.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); const now = new Date().toISOString(); if (isSpac) { await spacUnitTermsRepo.save({ @@ -409,12 +421,17 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { + const det = parseSpacPromoteTerms(text); + if (det !== null) return [det]; const promote = await extractSponsorPromote(text, m, context); return promote === null ? [] : [promote]; }), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); const promote = rows[0]; + const model_id = + promote.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); await spacPromoteTermsRepo.save({ extractor_id, accession_number, diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts index 0ca908f5..1901d441 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts @@ -79,6 +79,20 @@ describe("looksLikeUnitIpo", () => { it("is false on a resale-shaped table with no unit price/count", () => { expect(looksLikeUnitIpo("| Founder shares | 5,750,000 |")).toBe(false); }); + it("is false on a share-only securities-offered row", () => { + const text = ` +| Securities offered | 12,000,000 ordinary shares, at $10.00 per share | +`.trim(); + expect(parseSpacOfferingTerms(text)).toBeNull(); + expect(looksLikeUnitIpo(text)).toBe(false); + }); + it("is false on a placeholder units cell with no count", () => { + const text = ` +| Securities Offered | units (or units if the underwriters’ over-allotment option is exercised in full), at $10.00 per unit | +`.trim(); + expect(parseSpacOfferingTerms(text)).toBeNull(); + expect(looksLikeUnitIpo(text)).toBe(false); + }); }); describe("parseSpacOfferingTerms optional fields", () => { @@ -139,6 +153,28 @@ describe("parseSpacOfferingTerms optional fields", () => { expect(row.warrant_fraction_per_unit).toBe(1); expect(row.right_fraction_per_unit).toBe(1); }); + + it("reads three-quarters of one warrant, not the trailing 'one warrant'", () => { + const row = parseSpacOfferingTerms( + ` +| Offering price | $10.00 | +| Number of units offered | 9,000,000 | +| Securities offered | 9,000,000 units, at $10.00 per unit, each unit consisting of one share of common stock and three-quarters (3/4) of one redeemable warrant | +`.trim() + )!; + expect(row.warrant_fraction_per_unit).toBe(0.75); + }); + + it("counts one Share Right as 1, not the tenth-of-a-share conversion", () => { + const row = parseSpacOfferingTerms( + ` +| Offering price | $10.00 | +| Number of units offered | 20,000,000 | +| Securities offered | 20,000,000 units, at $10.00 per unit, each unit consisting of: one Class A ordinary share; and one Share Right to receive one tenth (1/10) of a Class A ordinary share | +`.trim() + )!; + expect(row.right_fraction_per_unit).toBe(1); + }); }); const PROMOTE = ` @@ -299,6 +335,18 @@ describe("parseSpacPromoteTerms", () => { expect(parseSpacPromoteTerms(text)!.private_placement_warrants).toBe(188_333); }); + it("takes shares outstanding before the offering when the cell says shares, not Class B", () => { + const text = ` +| Offering price | $10.00 | +| Number of units offered | 7,500,000 | +| Number outstanding before this offering and the private placement | 0 Units | +| Number outstanding before this offering and the private placement | 2,156,250 shares(2) | +| Number outstanding before this offering and the private placement | 0 warrants | +| Prior Issuance of Founders Shares | On August 19, 2021, our initial shareholders purchased 1,450,000 founder shares | +`.trim(); + expect(parseSpacPromoteTerms(text)!.founder_shares).toBe(2_156_250); + }); + it("takes Class B shares outstanding before the offering over an earlier purchase price", () => { const text = ` | Offering price | $10.00 | @@ -369,4 +417,24 @@ describe("nested offering table cells", () => { expect(row.price_per_unit).toBe(10); expect(row.warrant_fraction_per_unit).toBe(0.3333); }); + + it("reads units-at-price when the cell says at a price of $10 per unit", () => { + const text = ` +| Securities offered | 15,000,000 units (or 17,250,000 units if the underwriters’ over-allotment option is exercised in full), at a price of $10.00 per unit, each unit consisting of: | +| | • one-half of one redeemable warrant. | +`.trim(); + const row = parseSpacOfferingTerms(text)!; + expect(row.units_offered).toBe(15_000_000); + expect(row.price_per_unit).toBe(10); + expect(row.warrant_fraction_per_unit).toBe(0.5); + }); + + it("skips an empty spacer value column before the units-at-price cell", () => { + const text = ` +| Securities offered | | 20,000,000 units (or 23,000,000 units if the underwriters’ over-allotment option is exercised in full), at $10.00 per unit, each unit consisting of: | +`.trim(); + const row = parseSpacOfferingTerms(text)!; + expect(row.units_offered).toBe(20_000_000); + expect(row.price_per_unit).toBe(10); + }); }); diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts index 5abfce3c..1fa05b61 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts @@ -17,7 +17,7 @@ const PRICE_MAX = 12; const PLACEHOLDER = /^(?:\[●\]|●|\[•\]|•|—|–|-|\*|\u25cf)?$/; const WORD_FRACTIONS: ReadonlyArray<{ readonly re: RegExp; readonly value: number }> = [ - { re: /\b(?:three[\s-]fourths?|3\/4)\b/i, value: 0.75 }, + { re: /\b(?:three[\s-](?:fourths?|quarters?)|3\/4)\b/i, value: 0.75 }, { re: /\b(?:two[\s-]thirds?|2\/3)\b/i, value: 0.6667 }, { re: /\b(?:one[\s-]half|1\/2)\b/i, value: 0.5 }, { re: /\b(?:one[\s-]third|1\/3)\b/i, value: 0.3333 }, @@ -109,7 +109,7 @@ export function parseSpacPromoteTerms(text: string): SponsorPromoteRow | null { export function looksLikeUnitIpo(text: string): boolean { const fields = walkFields(text); - return fields.price_per_unit !== null || fields.units_offered !== null; + return fields.price_per_unit !== null && fields.units_offered !== null; } function emptyWalk(): WalkedFields { @@ -154,41 +154,43 @@ function walkFields(text: string): WalkedFields { section = row.label; continue; } - const unitAt = unitsAtPrice(row.value); + const unitAt = unitsAtPrice(row.value) ?? firstUnitsAtPrice(row.cells); if (unitAt) { + const span = unitsAtPrice(row.value) ? row.value : unitAtSpan(row.cells); if (out.units_offered === null) { out.units_offered = unitAt.units; - take(row.value); + take(span); } if (out.price_per_unit === null && unitAt.price >= PRICE_MIN && unitAt.price <= PRICE_MAX) { out.price_per_unit = unitAt.price; - take(row.value); + take(span); } } + const valueBlob = row.value !== "" ? row.value : (firstNonEmpty(row.cells.slice(1)) ?? ""); const compositionCell = isCompositionLabel(row.label) || - /consisting of/i.test(row.value) || + /consisting of/i.test(valueBlob) || (isBulletLabel(row.label) && isCompositionBullet(row.value)); if (compositionCell) { if (out.unit_composition === null) { - const clause = compositionClause(row.value); + const clause = compositionClause(valueBlob); if (clause !== null) { out.unit_composition = clause; - take(row.value); + take(valueBlob); } } if (out.warrant_fraction_per_unit === null) { - const w = warrantFraction(row.value); + const w = warrantFraction(valueBlob); if (w !== undefined) { out.warrant_fraction_per_unit = w; - take(row.value); + take(valueBlob); } } if (out.right_fraction_per_unit === null) { - const r = rightFraction(row.value); + const r = rightFraction(valueBlob); if (r !== undefined) { out.right_fraction_per_unit = r; - take(row.value); + take(valueBlob); } } } @@ -200,7 +202,7 @@ function walkFields(text: string): WalkedFields { } } if (out.units_offered === null && isUnitsOfferedLabel(row.label)) { - const n = firstInteger(row.value); + const n = unitsOfferedCount(row.label, row.value); if (n !== undefined) { out.units_offered = n; take(row.value); @@ -408,6 +410,13 @@ function isUnitsOfferedLabel(label: string): boolean { return /number of units offered|units offered|^securities offered$/.test(label); } +function unitsOfferedCount(label: string, cell: string): number | undefined { + const named = cell.match(/(\d{1,3}(?:,\d{3})+|\d+)\s+units?\b/i); + if (named) return parseNumeric(named[1]); + if (/^securities offered$/.test(label)) return undefined; + return firstInteger(cell); +} + function isTrustLabel(label: string): boolean { return /trust account|held in trust|proceeds to be held/.test(label); } @@ -424,7 +433,7 @@ function isFounderRow(row: TableRow): boolean { if (isFounderLabel(row.label)) return true; return ( /number outstanding before this offering/.test(row.label) && - /class b|founder shares|ordinary shares/i.test(row.value) + /class b|founder shares|ordinary shares|\bshares\b/i.test(row.value) ); } @@ -487,13 +496,6 @@ function headlineTotal(cell: string): number | undefined { return undefined; } -function wordFraction(cell: string): number | undefined { - for (const { re, value } of WORD_FRACTIONS) { - if (re.test(cell)) return value; - } - return undefined; -} - function warrantFraction(cell: string): number | undefined { if (!/warrant/i.test(cell)) return undefined; for (const { re, value } of WORD_FRACTIONS) { @@ -503,14 +505,23 @@ function warrantFraction(cell: string): number | undefined { ); if (attached.test(cell)) return value; } - if (/\bone (?:redeemable )?warrant\b/i.test(cell)) return 1; + // "three-quarters of one warrant" is a fraction, not a whole warrant. + if (/(? c !== ""); +} + +function firstUnitsAtPrice(cells: readonly string[]): { units: number; price: number } | undefined { + for (const cell of cells) { + const hit = unitsAtPrice(cell); + if (hit !== undefined) return hit; + } + return undefined; +} + +function unitAtSpan(cells: readonly string[]): string { + return cells.find((cell) => unitsAtPrice(cell) !== undefined) ?? ""; +} + function unitsAtPrice(cell: string): { units: number; price: number } | undefined { const m = cell.match( - /(\d{1,3}(?:,\d{3})+|\d+)\s+units?(?:\s+\([^)]*\))?,?\s+at\s+\$\s*(\d+(?:\.\d+)?)\s+per\s+unit/i + /(\d{1,3}(?:,\d{3})+|\d+)\s+units?(?:\s+\([^)]*\))?,?\s+at(?:\s+a\s+price\s+of)?\s+\$\s*(\d+(?:\.\d+)?)\s+per\s+unit/i ); if (!m) return undefined; const units = parseNumeric(m[1]); diff --git a/src/sec/html/TableExtractor.test.ts b/src/sec/html/TableExtractor.test.ts index ca068df5..1e6adeb0 100644 --- a/src/sec/html/TableExtractor.test.ts +++ b/src/sec/html/TableExtractor.test.ts @@ -5,7 +5,7 @@ */ import * as cheerio from "cheerio"; import { describe, expect, it } from "vitest"; -import { extractTable } from "./TableExtractor"; +import { extractTable, isLayoutTable } from "./TableExtractor"; function tableFrom(html: string) { const $ = cheerio.load(html); @@ -52,4 +52,18 @@ describe("extractTable", () => { expect(t.columnCount).toBe(2); expect(t.rows[0].map((c) => c.text)).toEqual(["X", "Y"]); }); + + it("does not treat a 1-column data table as a layout wrapper", () => { + const $ = cheerio.load(`
H
1
`); + expect(isLayoutTable($, $("table").get(0)!)).toBe(false); + }); + + it("treats a 1-column cell with two block children as a layout wrapper", () => { + const $ = cheerio.load(` +
+

The Offering

+

Intro.

+
`); + expect(isLayoutTable($, $("table").get(0)!)).toBe(true); + }); }); diff --git a/src/sec/html/TableExtractor.ts b/src/sec/html/TableExtractor.ts index be832635..83090f51 100644 --- a/src/sec/html/TableExtractor.ts +++ b/src/sec/html/TableExtractor.ts @@ -8,6 +8,87 @@ import { NodeKind, renderMarkdown, uuid4 } from "workglow"; import type { TableCell, TableNode } from "workglow"; import { parseNumeric } from "./parseNumeric"; +const LAYOUT_CHILD_TAGS = new Set([ + "p", + "div", + "table", + "ul", + "ol", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", +]); + +function tagOf(el: unknown): string { + const node = el as { tagName?: string; name?: string }; + return (node.tagName ?? node.name ?? "").toLowerCase(); +} + +const OFFERING_CAPTION = /^\s*(?:the|our)\s+offering\s*$/i; + +function directRows($: CheerioAPI, table: unknown): unknown[] { + return $(table as never) + .find("> tr, > thead > tr, > tbody > tr, > tfoot > tr") + .toArray(); +} + +/** + * First row is a full-width "The Offering" caption sitting on a 2+ column + * data table. The heading never becomes a heading node unless that row is + * peeled before {@link extractTable}. + */ +export function leadingOfferingCaption( + $: CheerioAPI, + table: unknown +): { readonly row: unknown; readonly cell: unknown } | undefined { + const rows = directRows($, table); + if (rows.length < 2) return undefined; + const first = rows[0]; + if (first === undefined) return undefined; + const firstCells = $(first as never).children("td, th"); + if (firstCells.length !== 1) return undefined; + if (!rows.slice(1).some((tr) => $(tr as never).children("td, th").length > 1)) { + return undefined; + } + const cell = firstCells.get(0); + if (cell === undefined) return undefined; + const $cell = $(cell as never); + const kids = $cell.children().toArray(); + for (const kid of kids) { + const t = $(kid).text().replace(/\s+/g, " ").trim(); + if (t.length === 0) continue; + return OFFERING_CAPTION.test(t) ? { row: first, cell } : undefined; + } + const t = $cell.text().replace(/\s+/g, " ").trim(); + return OFFERING_CAPTION.test(t) ? { row: first, cell } : undefined; +} + +/** + * True when a table is a 1-column typesetter wrapper (heading + intro in one + * cell, or nested tables) rather than a 1-column data grid. A cell that is a + * single `

` of values must stay a table; a cell with two or more block + * children, or a nested ``, is layout. + */ +export function isLayoutTable($: CheerioAPI, table: unknown): boolean { + const rows = $(table as never) + .find("> tr, > thead > tr, > tbody > tr, > tfoot > tr") + .toArray(); + if (rows.length === 0) return false; + if (rows.some((tr) => $(tr).children("td, th").length > 1)) return false; + return rows.some((tr) => { + const cell = $(tr).children("td, th").get(0); + if (cell === undefined) return false; + const blocks = $(cell) + .children() + .toArray() + .filter((c) => LAYOUT_CHILD_TAGS.has(tagOf(c))); + return blocks.some((c) => tagOf(c) === "table") || blocks.length >= 2; + }); +} + /** Convert a
element into a rectangular, colspan/rowspan-expanded TableNode. */ export function extractTable($: CheerioAPI, table: unknown): TableNode { const rowEls = $(table as never) diff --git a/src/sec/html/cssTwoColumnTable.ts b/src/sec/html/cssTwoColumnTable.ts new file mode 100644 index 00000000..43e494ed --- /dev/null +++ b/src/sec/html/cssTwoColumnTable.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ +import type { CheerioAPI } from "cheerio"; +import { NodeKind, renderMarkdown, uuid4 } from "workglow"; +import type { TableCell, TableNode } from "workglow"; +import { extractTable } from "./TableExtractor"; +import { parseNumeric } from "./parseNumeric"; + +const SUM1_CLASS = /(?:^|\s)[\w-]*sum1(?:\s|$)/i; +const SUM2_CLASS = /(?:^|\s)[\w-]*sum2(?:\s|$)/i; +const OFFERING_LABEL = + /securities offered|number of units(?: offered)?|offering price|units offered/i; + +interface CssLengths { + readonly marginLeft: number | undefined; + readonly marginTop: number | undefined; + readonly width: number | undefined; + readonly floatLeft: boolean; +} + +interface Pair { + label: string; + value: string; +} + +function attribsOf(el: unknown): Record | undefined { + return (el as { attribs?: Record }).attribs; +} + +function tagName(el: unknown): string { + const node = el as { tagName?: string; name?: string; type?: string }; + return (node.tagName ?? node.name ?? "").toLowerCase(); +} + +function parsePt(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const m = raw.trim().match(/^(-?[\d.]+)\s*(pt|px|in|em)?$/i); + if (!m) return undefined; + const n = Number(m[1]); + if (!Number.isFinite(n)) return undefined; + switch ((m[2] ?? "pt").toLowerCase()) { + case "px": + return n * 0.75; + case "in": + return n * 72; + case "em": + return n * 10; + default: + return n; + } +} + +function cssLengths(style: string | undefined): CssLengths { + const map = new Map(); + for (const part of (style ?? "").split(";")) { + const colon = part.indexOf(":"); + if (colon < 0) continue; + map.set(part.slice(0, colon).trim().toLowerCase(), part.slice(colon + 1).trim()); + } + return { + marginLeft: parsePt(map.get("margin-left")), + marginTop: parsePt(map.get("margin-top")), + width: parsePt(map.get("width")), + floatLeft: /^\s*left\s*$/i.test(map.get("float") ?? ""), + }; +} + +function textOf($: CheerioAPI, el: unknown): string { + return $(el as never) + .text() + .replace(/\s+/g, " ") + .trim(); +} + +function isElement(el: unknown): boolean { + const type = (el as { type?: string }).type; + return type === "tag" || type === "script" || type === "style"; +} + +/** Whitespace, comments, `
`, and typesetter spacer divs between hanging-indent rows. */ +export function isSkippedSibling($: CheerioAPI, el: unknown): boolean { + const type = (el as { type?: string }).type; + if (type === "text") { + return ((el as { data?: string }).data ?? "").replace(/\s+/g, "").length === 0; + } + if (type === "comment") return true; + if (!isElement(el)) return false; + const tag = tagName(el); + if (tag === "br") return true; + const style = attribsOf(el)?.style ?? ""; + const text = textOf($, el).replace(/\u200b/g, ""); + if (text.length === 0) return true; + if (/font-size\s*:\s*0/i.test(style) && /line-height\s*:\s*0/i.test(style)) return true; + return false; +} + +function nextMeaningful($: CheerioAPI, children: readonly unknown[], start: number): number { + let i = start; + while (i < children.length && isSkippedSibling($, children[i])) i += 1; + return i; +} + +function looksLikeCssLabel($: CheerioAPI, el: unknown): boolean { + if (!isElement(el)) return false; + const cls = attribsOf(el)?.class ?? ""; + if (SUM1_CLASS.test(cls)) return true; + const box = cssLengths(attribsOf(el)?.style); + if (box.width === undefined || box.width < 72 || box.width > 288) return false; + if (box.marginLeft !== undefined && box.marginLeft > 24) return false; + const text = textOf($, el); + return text.length > 0 && text.length <= 120 && !text.includes(". "); +} + +function looksLikeCssValue($: CheerioAPI, el: unknown): boolean { + if (!isElement(el)) return false; + const cls = attribsOf(el)?.class ?? ""; + if (SUM2_CLASS.test(cls)) return true; + const box = cssLengths(attribsOf(el)?.style); + return ( + box.marginTop !== undefined && + box.marginTop < 0 && + box.marginLeft !== undefined && + box.marginLeft >= 96 + ); +} + +function isBulletGlyph($: CheerioAPI, el: unknown): boolean { + if (!isElement(el)) return false; + const box = cssLengths(attribsOf(el)?.style); + if (!box.floatLeft) return false; + if (box.width !== undefined && box.width > 24) return false; + const text = textOf($, el); + return text.length > 0 && text.length <= 4; +} + +function isContinuation($: CheerioAPI, el: unknown, valueMarginLeft: number): boolean { + if (!isElement(el)) return false; + if (looksLikeCssValue($, el)) return true; + if (tagName(el) === "table") { + const left = cssLengths(attribsOf(el)?.style).marginLeft ?? 0; + return left >= valueMarginLeft - 24; + } + const box = cssLengths(attribsOf(el)?.style); + const left = box.marginLeft; + if (left !== undefined && left >= valueMarginLeft - 8) return true; + return isBulletGlyph($, el); +} + +function cell(text: string): TableCell { + return { text, colspan: 1, rowspan: 1, isHeader: false, numeric: parseNumeric(text) }; +} + +function tableFromPairs(pairs: readonly Pair[]): TableNode { + const rows = pairs.map((p) => [cell(p.label), cell(p.value)]); + const node: TableNode = { + nodeId: uuid4(), + kind: NodeKind.TABLE, + range: { startOffset: 0, endOffset: 0 }, + text: "", + caption: undefined, + columnCount: 2, + headerRows: [], + rows, + stitchedFrom: 1, + }; + return { ...node, text: renderMarkdown(node) }; +} + +function appendTableRows($: CheerioAPI, pairs: Pair[], el: unknown): void { + const t = extractTable($, el); + for (const row of [...t.headerRows, ...t.rows]) { + const label = row[0]?.text ?? ""; + const value = row + .slice(1) + .map((c) => c.text) + .join(" ") + .trim(); + pairs.push({ label, value }); + } +} + +/** + * If `children[start]` opens a CSS hanging-indent two-column run (Donnelley + * `sum1`/`sum2` or Workiva width + negative `margin-top`), consume the run into + * one GFM table. Returns undefined when the node is not such a run. + */ +export function consumeCssTwoColumnRun( + $: CheerioAPI, + children: readonly unknown[], + start: number +): { readonly table: TableNode; readonly nextIndex: number } | undefined { + if (start >= children.length || !looksLikeCssLabel($, children[start])) return undefined; + + const pairs: Pair[] = []; + let i = start; + let valueMarginLeft = 168; + + while (i < children.length) { + i = nextMeaningful($, children, i); + if (i >= children.length) break; + if (!looksLikeCssLabel($, children[i])) break; + const labelIndex = i; + const label = textOf($, children[i]); + i = nextMeaningful($, children, i + 1); + if (i >= children.length || !looksLikeCssValue($, children[i])) { + i = labelIndex; + break; + } + const valueEl = children[i]; + const valueBox = cssLengths(attribsOf(valueEl)?.style); + valueMarginLeft = valueBox.marginLeft ?? valueMarginLeft; + pairs.push({ label, value: textOf($, valueEl) }); + i += 1; + + while (true) { + const j = nextMeaningful($, children, i); + if (j >= children.length) { + i = j; + break; + } + const cont = children[j]; + if (looksLikeCssLabel($, cont)) { + i = j; + break; + } + if (!isContinuation($, cont, valueMarginLeft)) { + i = j; + break; + } + if (tagName(cont) === "table") { + appendTableRows($, pairs, cont); + i = j + 1; + continue; + } + if (isBulletGlyph($, cont)) { + const k = nextMeaningful($, children, j + 1); + const value = k < children.length ? textOf($, children[k]) : ""; + pairs.push({ label: textOf($, cont), value }); + i = k < children.length ? k + 1 : j + 1; + continue; + } + pairs.push({ label: "", value: textOf($, cont) }); + i = j + 1; + } + } + + if (pairs.length === 0) return undefined; + if (pairs.length < 2 && !OFFERING_LABEL.test(pairs[0]?.label ?? "")) return undefined; + return { table: tableFromPairs(pairs), nextIndex: i }; +} diff --git a/src/sec/html/parseEdgarHtml.test.ts b/src/sec/html/parseEdgarHtml.test.ts index 952b256c..252cd620 100644 --- a/src/sec/html/parseEdgarHtml.test.ts +++ b/src/sec/html/parseEdgarHtml.test.ts @@ -51,4 +51,27 @@ describe("parseEdgarHtml", () => { expect(md).toContain("Alice"); expect(md).toContain("Bob"); }); + + it("promotes The Offering out of a 1-column layout wrapper into a real heading", () => { + const html = ` + +
+ +
+

The Offering

+

In making your decision on whether to invest in our securities, you should take into account the risks.

+
+ + +
Securities offered7,500,000 units, at $10.00 per unit
+ `; + const doc = parseEdgarHtml(html, "S-1"); + const sections = [...traverseDepthFirst(doc)].filter( + (n) => n.kind === NodeKind.SECTION + ) as SectionNode[]; + expect(sections.map((s) => s.title)).toContain("The Offering"); + expect(renderMarkdown(doc)).toMatch( + /\|\s*Securities offered\s*\|\s*7,500,000 units, at \$10\.00 per unit/ + ); + }); }); diff --git a/src/sec/html/parseToBlocks.test.ts b/src/sec/html/parseToBlocks.test.ts index b8f48dcb..7e0feca9 100644 --- a/src/sec/html/parseToBlocks.test.ts +++ b/src/sec/html/parseToBlocks.test.ts @@ -228,4 +228,97 @@ describe("parseToBlocks", () => { expect(text).not.toContain(LEAK); }); }); + + describe("CSS two-column offering summaries", () => { + function tableMarkdown(html: string): string { + return parseToBlocks(html) + .filter((b) => b.type === "table") + .map((b) => (b.type === "table" ? b.node.text : "")) + .join("\n"); + } + + it("emits a GFM table from Donnelley sum1/sum2 hanging-indent pairs", () => { + const md = tableMarkdown(` + +

Securities offered:
+
25,000,000 units, at $10.00 per unit, each unit consisting of:
+
one Class A ordinary share;
+
one-half of one redeemable warrant.
+
Proposed Nasdaq symbols:
+
Units: “AACBU”
+ `); + expect(md).toMatch(/\|\s*Securities offered:\s*\|\s*25,000,000 units, at \$10\.00 per unit/); + expect(md).toMatch(/one-half of one redeemable warrant/); + expect(md).toMatch(/\|\s*Proposed Nasdaq symbols:\s*\|\s*Units:/); + }); + + it("emits a GFM table from Workiva width / negative-margin-top pairs", () => { + const md = tableMarkdown(` + +
+
Securities offered
+
+
+ 20,000,000 units, at $10.00 per unit, each unit consisting of: +
+
+
one Class A ordinary share;
+
+
+
Proposed Nasdaq symbols
+
+
Units: “XXXXU”
+ `); + expect(md).toMatch(/\|\s*Securities offered\s*\|\s*20,000,000 units, at \$10\.00 per unit/); + expect(md).toMatch(/one Class A ordinary share/); + expect(md).toMatch(/\|\s*Proposed Nasdaq symbols\s*\|\s*Units:/); + }); + }); + + it("peels THE OFFERING out of a 2-column table's caption row so the units row survives", () => { + const blocks = parseToBlocks(` + + + + + +
+

THE OFFERING

+

In making your decision on whether to invest in our securities, you should take into account not only the background of the members of our management team, but also the special risks we face as a blank check company.

+
Securities offered:7,500,000 units, at $10.00 per unit, each unit consisting of:
+ `); + const headings = blocks + .filter((b) => b.type === "heading") + .map((b) => (b.type === "heading" ? b.text : "")); + expect(headings).toContain("THE OFFERING"); + const md = blocks + .filter((b) => b.type === "table") + .map((b) => (b.type === "table" ? b.node.text : "")) + .join("\n"); + expect(md).toMatch(/\|\s*Securities offered:\s*\|\s*7,500,000 units, at \$10\.00 per unit/); + }); + + it("unwraps a 1-column layout table so a nested heading and sibling data table survive", () => { + const blocks = parseToBlocks(` + + + +
+

The Offering

+

In making your decision on whether to invest in our securities, you should take into account the risks.

+
+ + +
Securities offered7,500,000 units, at $10.00 per unit
+ `); + const headings = blocks + .filter((b) => b.type === "heading") + .map((b) => (b.type === "heading" ? b.text : "")); + expect(headings).toContain("The Offering"); + const md = blocks + .filter((b) => b.type === "table") + .map((b) => (b.type === "table" ? b.node.text : "")) + .join("\n"); + expect(md).toMatch(/\|\s*Securities offered\s*\|\s*7,500,000 units, at \$10\.00 per unit/); + }); }); diff --git a/src/sec/html/parseToBlocks.ts b/src/sec/html/parseToBlocks.ts index 9c6022cd..bb8826b0 100644 --- a/src/sec/html/parseToBlocks.ts +++ b/src/sec/html/parseToBlocks.ts @@ -10,7 +10,8 @@ import type { EdgarBlock, ResolvedStyle } from "./types"; import { resolveStyle } from "./StyleResolver"; import { isHeadingCandidate, assignHeadingLevels } from "./HeadingDetector"; import { isPageFurniture } from "./pageFurniture"; -import { extractTable } from "./TableExtractor"; +import { extractTable, isLayoutTable, leadingOfferingCaption } from "./TableExtractor"; +import { consumeCssTwoColumnRun } from "./cssTwoColumnTable"; const BLOCK_TAGS = new Set(["p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6", "td", "th"]); @@ -146,6 +147,17 @@ export function parseToBlocks(html: string): EdgarBlock[] { if (tag === "table") { emitProse(prose, out); + if (isLayoutTable($, el)) { + descend(el); + return; + } + const caption = leadingOfferingCaption($, el); + if (caption !== undefined) { + descend(caption.cell); + $(caption.row as never).remove(); + out.push({ type: "table", node: extractTable($, el) }); + return; + } out.push({ type: "table", node: extractTable($, el) }); return; // do not descend; cells handled inside extractTable } @@ -246,16 +258,30 @@ export function parseToBlocks(html: string): EdgarBlock[] { }; // Walk an element's children in document order: text nodes feed the prose - // buffer, element nodes recurse through `walk`. + // buffer, element nodes recurse through `walk`. CSS hanging-indent + // two-column runs are consumed as one table before any child is walked, so + // a label/value pair cannot split into adjacent paragraphs. function descend(el: unknown): void { - for (const child of (el as { children?: unknown[] }).children ?? []) { + const children = (el as { children?: unknown[] }).children ?? []; + let i = 0; + while (i < children.length) { + const child = children[i]; const cn = child as { type?: string; data?: string }; if (cn.type === "text") { const t = (cn.data ?? "").replace(/\s+/g, " ").trim(); if (t.length > 0) prose.push(t); - } else { - walk(child); + i += 1; + continue; + } + const run = consumeCssTwoColumnRun($, children, i); + if (run !== undefined) { + emitProse(prose, out); + out.push({ type: "table", node: run.table }); + i = run.nextIndex; + continue; } + walk(child); + i += 1; } } diff --git a/src/storage/offering/SpacPromoteTermsRepo.ts b/src/storage/offering/SpacPromoteTermsRepo.ts index 7b219193..980c50bd 100644 --- a/src/storage/offering/SpacPromoteTermsRepo.ts +++ b/src/storage/offering/SpacPromoteTermsRepo.ts @@ -26,6 +26,10 @@ export class SpacPromoteTermsRepo { return this.storage.get({ extractor_id, accession_number }); } + async listAll(): Promise { + return (await this.storage.getAll()) ?? []; + } + /** All rows for an issuer (across extractor ids and filings), newest extract first. */ async listByCik(cik: number): Promise { const rows = (await this.storage.query({ cik })) ?? []; diff --git a/src/storage/offering/SpacUnitTermsRepo.ts b/src/storage/offering/SpacUnitTermsRepo.ts index a277de11..608d2407 100644 --- a/src/storage/offering/SpacUnitTermsRepo.ts +++ b/src/storage/offering/SpacUnitTermsRepo.ts @@ -26,6 +26,10 @@ export class SpacUnitTermsRepo { return this.storage.get({ extractor_id, accession_number }); } + async listAll(): Promise { + return (await this.storage.getAll()) ?? []; + } + /** All rows for an issuer (across extractor ids and filings), newest extract first. */ async listByCik(cik: number): Promise { const rows = (await this.storage.query({ cik })) ?? []; diff --git a/src/task/eval/EvalOfferingTablesTask.ts b/src/task/eval/EvalOfferingTablesTask.ts new file mode 100644 index 00000000..14a8feff --- /dev/null +++ b/src/task/eval/EvalOfferingTablesTask.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runOfferingTablesEval } from "../../eval/runOfferingTablesEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalOfferingTablesTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalOfferingTablesTaskOutput = Static>; + +/** + * Scores the deterministic SPAC offering/promote table parser against stored + * rows using on-disk accession docs. Never fetches EDGAR. + */ +export class EvalOfferingTablesTask extends Task< + EvalOfferingTablesTaskInput, + EvalOfferingTablesTaskOutput +> { + static readonly type = "EvalOfferingTablesTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate offering tables"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalOfferingTablesTaskInput, + context: IExecuteContext + ): Promise { + const report = await runOfferingTablesEval({ + extractorId: input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalOfferingTablesTaskOutput; + } +} diff --git a/src/task/forms/ProcessAccessionDocFormTask.ts b/src/task/forms/ProcessAccessionDocFormTask.ts index cb8c5143..74bca0b4 100644 --- a/src/task/forms/ProcessAccessionDocFormTask.ts +++ b/src/task/forms/ProcessAccessionDocFormTask.ts @@ -54,9 +54,8 @@ import { reapStaleObservations, } from "../../resolver/reapStaleObservations"; import { readFile } from "node:fs/promises"; -import path from "node:path"; import { SEC_RAW_DATA_FOLDER } from "../../config/tokens"; -import { assertInsideDir, sanitizePrimaryDoc, stripXslPrefix } from "../../util/accessionDocPath"; +import { cachedAccessionDocPath, stripXslPrefix } from "../../util/accessionDocPath"; import { SecFetchAccessionDocTask } from "./SecFetchAccessionDocTask"; /** @@ -264,23 +263,8 @@ export class ProcessAccessionDocFormTask extends Task< ): Promise { if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) return undefined; const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); - // `fileName` originates from a filer-authored field on the EDGAR - // submissions API; a value like `../../etc/passwd` would otherwise let the - // cache lookup read anything the process can. Treat an unsafe name as a - // silent cache miss so the caller falls back to the normal network fetch. - let safeName: string; - try { - safeName = sanitizePrimaryDoc(fileName); - } catch { - return undefined; - } - const cikDir = path.join(root, "accessiondocs", String(cik).padStart(10, "0")); - const rel = `accessiondocs/${String(cik).padStart(10, "0")}/${accessionNumber.replaceAll( - "-", - "" - )}-${safeName}`; - const fullPath = path.join(root, rel); - assertInsideDir(fullPath, cikDir); + const fullPath = cachedAccessionDocPath(root, cik, accessionNumber, fileName); + if (fullPath === undefined) return undefined; try { return await readFile(fullPath, "utf-8"); } catch (err) { diff --git a/src/util/accessionDocPath.test.ts b/src/util/accessionDocPath.test.ts index 595a0312..3ad3e450 100644 --- a/src/util/accessionDocPath.test.ts +++ b/src/util/accessionDocPath.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { assertInsideDir, + cachedAccessionDocPath, resolvePrimaryDocName, sanitizePrimaryDoc, stripXslPrefix, @@ -112,6 +113,20 @@ describe("sanitizePrimaryDoc", () => { }); }); +describe("cachedAccessionDocPath", () => { + it("joins accessiondocs / padded CIK / accession-no-dashes-filename", () => { + expect(cachedAccessionDocPath("/data", 1234, "0001193125-21-066104", "s1.htm")).toBe( + path.join("/data", "accessiondocs", "0000001234", "000119312521066104-s1.htm") + ); + }); + + it("returns undefined for an unsafe primary-document name", () => { + expect( + cachedAccessionDocPath("/data", 1234, "0001193125-21-066104", "../etc/passwd") + ).toBeUndefined(); + }); +}); + describe("assertInsideDir", () => { it("accepts a normal join under the base directory", () => { const base = path.resolve("/tmp/accessiondocs/0001193125"); diff --git a/src/util/accessionDocPath.ts b/src/util/accessionDocPath.ts index 9f57cacd..4fda8287 100644 --- a/src/util/accessionDocPath.ts +++ b/src/util/accessionDocPath.ts @@ -80,3 +80,34 @@ export function assertInsideDir(fullPath: string, dir: string): void { ); } } + +/** + * On-disk path for a cached accession primary document, matching + * `ProcessAccessionDocFormTask.readCachedDoc`. An unsafe filer-authored name + * is a miss (`undefined`), not a throw, so callers can fall through. + */ +export function cachedAccessionDocPath( + root: string, + cik: number, + accessionNumber: string, + fileName: string +): string | undefined { + let safeName: string; + try { + safeName = sanitizePrimaryDoc(fileName); + } catch { + return undefined; + } + const cikDir = path.join(root, "accessiondocs", String(cik).padStart(10, "0")); + const rel = `accessiondocs/${String(cik).padStart(10, "0")}/${accessionNumber.replaceAll( + "-", + "" + )}-${safeName}`; + const fullPath = path.join(root, rel); + try { + assertInsideDir(fullPath, cikDir); + } catch { + return undefined; + } + return fullPath; +} From 4177f65bb34edbf01e5701bc6a36fe2a62b76623 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Tue, 18 Aug 2026 09:55:27 -0700 Subject: [PATCH 02/29] feat(eval): introduce underwriters evaluation command and reporting - Added `EvalUnderwritersTask` to score the SPAC underwriter table parser against stored rows using on-disk cache. - Implemented a new CLI command `underwriters` with options for extractor ID, limit, CIK, and output format. - Created `printUnderwritersReport` function to display evaluation results in a structured format. - Developed tests for the new command and its options, ensuring comprehensive coverage of input validation and functionality. - Introduced new utility functions for parsing and evaluating underwriter data from S-1 filings. --- src/cli/groups/eval.ts | 63 +++++ src/cli/groups/evalOptionValues.test.ts | 5 + src/eval/runUnderwritersEval.test.ts | 53 ++++ src/eval/runUnderwritersEval.ts | 243 ++++++++++++++++ .../Form_424.storage.ts | 2 + .../Form_S_1.storage.offering.test.ts | 113 ++++++++ .../Form_S_1.storage.ts | 2 + .../s1/offeringSections.ts | 204 ++++++++------ .../s1/parseSpacUnderwriters.corpus.test.ts | 85 ++++++ .../s1/parseSpacUnderwriters.test.ts | 217 ++++++++++++++ .../s1/parseSpacUnderwriters.ts | 266 ++++++++++++++++++ .../s1/parseSpacUseOfProceeds.corpus.test.ts | 76 +++++ .../s1/parseSpacUseOfProceeds.test.ts | 69 +++++ .../s1/parseSpacUseOfProceeds.ts | 133 +++++++++ .../s1/underwriterSchema.ts | 2 + .../s1/useOfProceedsSchema.ts | 2 + src/storage/canonical/UnderwriterLinkRepo.ts | 8 + .../use-of-proceeds/UseOfProceedsRepo.ts | 4 + src/task/eval/EvalUnderwritersTask.ts | 73 +++++ 19 files changed, 1528 insertions(+), 92 deletions(-) create mode 100644 src/eval/runUnderwritersEval.test.ts create mode 100644 src/eval/runUnderwritersEval.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacUnderwriters.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts create mode 100644 src/task/eval/EvalUnderwritersTask.ts diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index 96492e77..dfff0a96 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -40,8 +40,10 @@ import { } from "../../task/eval/evalS1Concurrency"; import { EvalUnitTermsTask } from "../../task/eval/EvalUnitTermsTask"; import { EvalOfferingTablesTask } from "../../task/eval/EvalOfferingTablesTask"; +import { EvalUnderwritersTask } from "../../task/eval/EvalUnderwritersTask"; import { type UnitTermsReport } from "../../eval/runUnitTermsEval"; import type { OfferingTablesReport } from "../../eval/runOfferingTablesEval"; +import type { UnderwritersReport } from "../../eval/runUnderwritersEval"; /** * Default comparison set: Anthropic's cheap and strong tiers, plus the cheap @@ -184,6 +186,26 @@ function truncate(s: string, max = 60): string { return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } +function printUnderwritersReport(report: UnderwritersReport): void { + const { counts } = report; + console.log( + `underwriters parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + function printOfferingTablesReport(report: OfferingTablesReport): void { const { counts } = report; console.log( @@ -989,4 +1011,45 @@ export function addEvalCommands(program: Command): void { }); } ); + + cmd + .command("underwriters") + .description( + "Score the deterministic SPAC underwriter table parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalUnderwritersTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printUnderwritersReport(report); + }); + } + ); } diff --git a/src/cli/groups/evalOptionValues.test.ts b/src/cli/groups/evalOptionValues.test.ts index c714f8d5..6822f195 100644 --- a/src/cli/groups/evalOptionValues.test.ts +++ b/src/cli/groups/evalOptionValues.test.ts @@ -107,6 +107,11 @@ describe("eval value-less options", () => { expect(await runEval(["offering-tables", "--format"])).toContain("one of: table, json"); }); + it("covers eval underwriters' value options", async () => { + expect(await runEval(["underwriters", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["underwriters", "--format"])).toContain("one of: table, json"); + }); + it("lists print-prompts modes for a bare --print-prompts on extract", async () => { const err = await runEval(["extract", "--print-prompts"]); expect(err).toContain("--print-prompts needs a value"); diff --git a/src/eval/runUnderwritersEval.test.ts b/src/eval/runUnderwritersEval.test.ts new file mode 100644 index 00000000..e37b599c --- /dev/null +++ b/src/eval/runUnderwritersEval.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runUnderwritersEval"; + +describe("bucketWhenParserEmpty", () => { + const unitIpo = "| Offering price | $10.00 |\n| Number of units offered | 20,000,000 |"; + + it("skips when stored has no underwriter names", () => { + expect( + bucketWhenParserEmpty({ + stored: { names: [], roles: [] }, + offeringText: unitIpo, + underwritingText: "| Underwriter | Number of Units |\n| Cantor Fitzgerald & Co. | |", + }) + ).toEqual({ bucket: "skip", reason: "all-null stored" }); + }); + + it("empties a resale rather than missing", () => { + expect( + bucketWhenParserEmpty({ + stored: { names: ["Acme Holdings LLC"], roles: [null] }, + offeringText: "| Securities offered | 12,000,000 ordinary shares, at $10.00 per share |", + underwritingText: "| Selling Stockholder | Shares |\n| Acme Holdings LLC | 1 |", + }).bucket + ).toBe("empty"); + }); + + it("skips a unit-IPO whose syndicate is named only in prose", () => { + expect( + bucketWhenParserEmpty({ + stored: { names: ["Needham & Company, LLC"], roles: ["lead"] }, + offeringText: unitIpo, + underwritingText: "Needham & Company, LLC is acting as the sole underwriter of this offering.", + }) + ).toEqual({ bucket: "skip", reason: "no-table" }); + }); + + it("misses a unit-IPO syndicate table the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: { names: ["Cantor Fitzgerald & Co."], roles: ["lead"] }, + offeringText: unitIpo, + underwritingText: + "| Underwriter | Number of Units |\n| --- | --- |\n| Cantor Fitzgerald & Co. | |\n| Total | 20,000,000 |", + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runUnderwritersEval.ts b/src/eval/runUnderwritersEval.ts new file mode 100644 index 00000000..69a4a56f --- /dev/null +++ b/src/eval/runUnderwritersEval.ts @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { + offeringParseText, + normalizeEntityName, +} from "../sec/forms/registration-statements/s1/offeringSections"; +import { looksLikeUnitIpo } from "../sec/forms/registration-statements/s1/parseOfferingTables"; +import { + hasSpacSyndicateTable, + parseSpacUnderwriters, +} from "../sec/forms/registration-statements/s1/parseSpacUnderwriters"; +import { UnderwriterLinkRepo } from "../storage/canonical/UnderwriterLinkRepo"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { CompanyObservationRepo } from "../storage/observation/CompanyObservationRepo"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface UnderwritersEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type UnderwritersBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface UnderwritersCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: UnderwritersBucket; + readonly cachePath: string | undefined; + readonly parsed?: { readonly names: string[]; readonly roles: Array }; + readonly stored?: { readonly names: string[]; readonly roles: Array }; + readonly reason?: string; +} + +export interface UnderwritersReport { + readonly cases: readonly UnderwritersCase[]; + readonly counts: Record; +} + +interface ScoredNames { + readonly names: string[]; + readonly roles: Array; +} + +export async function runUnderwritersEval( + options: UnderwritersEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; underwriters eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const linkRepo = new UnderwriterLinkRepo(); + const obsRepo = new CompanyObservationRepo(); + const work = unitRows.map((r) => ({ + extractor_id: r.extractor_id, + accession_number: r.accession_number, + cik: r.cik, + })); + const sliced = options.limit !== undefined ? work.slice(0, options.limit) : work; + const cases: UnderwritersCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, `${item.accession_number}`); + const stored = await loadStored(linkRepo, obsRepo, item.extractor_id, item.accession_number); + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function loadStored( + linkRepo: UnderwriterLinkRepo, + obsRepo: CompanyObservationRepo, + extractor_id: string, + accession_number: string +): Promise { + const links = (await linkRepo.listByAccession(accession_number)).filter( + (r) => r.extractor_id === extractor_id + ); + const obs = await obsRepo.listByAccessionAndExtractor(accession_number, extractor_id); + const byIndex = new Map(obs.map((o) => [o.observation_index, o])); + const names: string[] = []; + const roles: Array = []; + for (const link of links) { + const name = byIndex.get(link.observation_index)?.name?.trim() ?? ""; + if (name === "") continue; + names.push(name); + roles.push(link.role_detail); + } + return { names, roles }; +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: ScoredNames +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip", cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const underwriting = byName.get(S1_SECTIONS.UNDERWRITING) ?? ""; + if (underwriting.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const offeringText = offeringParseText(byName); + const parsedRows = parseSpacUnderwriters(underwriting); + const parsed: ScoredNames = { + names: parsedRows.map((r) => r.legal_name), + roles: parsedRows.map((r) => r.role), + }; + if (parsed.names.length === 0) { + const miss = bucketWhenParserEmpty({ stored, offeringText, underwritingText: underwriting }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: ScoredNames; + readonly offeringText: string; + readonly underwritingText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.names.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!looksLikeUnitIpo(args.offeringText)) { + return { bucket: "empty", reason: "resale" }; + } + if (!hasSpacSyndicateTable(args.underwritingText)) { + return { bucket: "skip", reason: "no-table" }; + } + return { bucket: "miss", reason: undefined }; +} + +function scoredEqual(a: ScoredNames, b: ScoredNames): boolean { + const aMap = new Map(); + for (let i = 0; i < a.names.length; i++) { + aMap.set(normalizeEntityName(a.names[i]!), a.roles[i] ?? null); + } + const bMap = new Map(); + for (let i = 0; i < b.names.length; i++) { + bMap.set(normalizeEntityName(b.names[i]!), b.roles[i] ?? null); + } + if (aMap.size !== bMap.size) return false; + for (const [k, role] of aMap) { + if (!bMap.has(k)) return false; + if (bMap.get(k) !== role) return false; + } + return true; +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/sec/forms/registration-statements/Form_424.storage.ts b/src/sec/forms/registration-statements/Form_424.storage.ts index f1bcbd85..5a6cf2b7 100644 --- a/src/sec/forms/registration-statements/Form_424.storage.ts +++ b/src/sec/forms/registration-statements/Form_424.storage.ts @@ -364,6 +364,8 @@ export async function processForm424(args: ProcessForm424Args): Promise { activeUnderwriterFamilyVersion, byName, context: args.context, + markSectionResolved: (section) => + deadLetters.markResolved(EXTRACTOR_ID, accession_number, section), }); await recordSpacIpoEventIfEligible(); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts index 49fdc892..895750b1 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts @@ -13,6 +13,8 @@ import { SpacUnitTermsRepo } from "../../../storage/offering/SpacUnitTermsRepo"; import { SpacPromoteTermsRepo } from "../../../storage/offering/SpacPromoteTermsRepo"; import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo"; import { FieldProvenanceRepo } from "../../../storage/provenance/FieldProvenanceRepo"; +import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; +import { UnderwriterLinkRepo } from "../../../storage/canonical/UnderwriterLinkRepo"; import { processFormS1 } from "./Form_S_1.storage"; import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; @@ -417,4 +419,115 @@ describe("processFormS1 offering terms", () => { expect(promoteProv.length).toBeGreaterThan(0); expect(promoteProv.every((p) => p.model_id === DETERMINISTIC_MODEL_ID)).toBe(true); }); + + it("persists a syndicate table hit as deterministic without calling the underwriters model", async () => { + const html = [ + "

THE OFFERING

", + "", + "", + "", + "", + "", + "
Offering price$10.00
Number of units offered20,000,000
Founder shares5,750,000
Proceeds to be held in trust account$10.00 per unit
", + "

UNDERWRITING

", + "", + "", + "", + "", + "
UnderwriterNumber of Units
Cantor Fitzgerald & Co.
Total20,000,000
", + ].join(""); + const { unregister } = registerFakeStructuredProvider([]); + cleanup = unregister; + + await processFormS1({ + cik: 1848507, + file_number: "333-11", + accession_number: "0000000000-26-000011", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: SPAC_HEADER, + html, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const obs = await new CompanyObservationRepo().listByAccession("0000000000-26-000011"); + expect(obs.map((o) => o.name).filter((n) => n != null && n !== "")).toEqual([ + "Cantor Fitzgerald & Co.", + ]); + const links = await new UnderwriterLinkRepo().listByAccession("0000000000-26-000011"); + expect(links).toHaveLength(1); + expect(links[0]!.role_detail).toBeNull(); + }); + + it("skips the underwriters model on a SPAC resale with no unit IPO", async () => { + const html = [ + "

THE OFFERING

We are offering 5,000,000 shares.

", + "

UNDERWRITING

", + "", + "", + "", + "
Selling StockholderNumber of Shares
Acme Holdings LLC1,000,000
", + ].join(""); + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Common Stock", + shares_offered: 5000000, + price: 10, + price_low: null, + price_high: null, + gross_proceeds: 50000000, + net_proceeds: null, + over_allotment_shares: null, + units_offered: null, + price_per_unit: null, + unit_composition: null, + warrant_fraction_per_unit: null, + right_fraction_per_unit: null, + trust_per_unit: null, + over_allotment_units: null, + exchange: null, + par_value: null, + confidence: 0.9, + source_span: "5,000,000 shares", + tickers: [], + }, + { + founder_shares: null, + founder_percent: null, + private_placement_warrants: null, + private_placement_warrant_price: null, + public_warrant_coverage: null, + trust_per_public_share: null, + trust_total: null, + confidence: 0.9, + source_span: "5,000,000 shares", + }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1848507, + file_number: "333-12", + accession_number: "0000000000-26-000012", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: SPAC_HEADER, + html, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + expect(await new UnderwriterLinkRepo().listByAccession("0000000000-26-000012")).toEqual([]); + const dl = await new ExtractionDeadLetterRepo().listPending("S-1"); + expect(dl.filter((d) => d.section_name === "underwriters")).toEqual([]); + }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 8fcabd33..530c7313 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -1218,6 +1218,8 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { activeUnderwriterFamilyVersion, byName, context: args.context, + markSectionResolved: (section) => + deadLetters.markResolved(EXTRACTOR_ID, accession_number, section), }); // --- SPAC sponsors (gated on deterministic classification) --- diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index e72f2cf2..c119f483 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -43,9 +43,12 @@ import { anchorFieldSpan } from "./anchorFieldSpan"; import { FieldProvenanceRepo } from "../../../../storage/provenance/FieldProvenanceRepo"; import { DETERMINISTIC_MODEL_ID, + looksLikeUnitIpo, parseSpacOfferingTerms, parseSpacPromoteTerms, } from "./parseOfferingTables"; +import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; +import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; /** * Concatenate the sections production hands the offering-terms parser, so eval @@ -192,6 +195,7 @@ export interface OfferingSectionsArgs { readonly byName: ReadonlyMap; /** Running task context, threaded to the generation calls for CLI progress. */ readonly context?: IExecuteContext; + readonly markSectionResolved: (sectionName: string) => Promise; } /** @@ -217,6 +221,7 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise({ - sectionName: "underwriters", - text: byName.get(S1_SECTIONS.UNDERWRITING), - emptyDetail: "no underwriters returned", - lowConfidenceDetail: "all rows below confidence floor", - invalidWriteDetail: "no underwriter rows had a usable legal name", - // Prompt-injection backstop: refuse to persist any underwriter row whose - // source_span is not a verbatim substring of the Underwriting section text. - verifyRow: (text, r) => classifySpan(text, r.source_span), - unverifiedAllDetail: - "all $T confident underwriter rows had source_span not present in section text", - unverifiedPartialDetail: - "$N of $T confident underwriter rows had source_span not present in section text", - ...modelExtractChain(models, (text, m) => extractUnderwriters(text, m, context)), - persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); - let wrote = 0; - // One underwriter, one link row. The model repeats an underwriter across - // rows more often than not — a sole-underwriter filing came back with the - // same bank once, twice, and three times on three consecutive runs — and - // every duplicate previously minted its own observation, family - // membership and link row, inflating `sec underwriter by-family` counts by - // however many times the model stuttered. Deduped on the LEGAL name, not - // the common name: "Citigroup Global Markets Inc." and "Citigroup Global - // Markets Limited" are two entities that share one family, and collapsing - // on the family would silently drop the second. - const seenLegalNames = new Set(); - const splits = rows.map((r) => splitParentClause(r.legal_name?.trim() ?? "")); - const extractedNames = splits.map((s) => s.observationName); - for (let i = 0; i < rows.length; i++) { - const r = rows[i]!; - const split = splits[i]!; - if (split.observationName === "") continue; - if (isUnnamedCompanyName(split.observationName)) continue; - // Brand stub next to the full legal name ("Cantor" + "Cantor Fitzgerald - // & Co.") is one house, not two. Inc vs Limited of the same house are - // equal-length family keys and are not dropped. - if (isCompanyFamilyPrefixEcho(split.observationName, extractedNames)) continue; - const dedupeKey = normalizeEntityName(split.observationName); - if (seenLegalNames.has(dedupeKey)) continue; - seenLegalNames.add(dedupeKey); - // companyFamilyName wipes non-ASCII and punctuation, so "[●]" (a - // still-blank F-1 table cell) and a CJK legal name both have no family - // key. A letterless placeholder is not an entity — skip it before - // observeCompany. A name that still has letters (CJK) is observed - // without a family rather than throwing "empty name" and aborting the - // rest of the table. - const familyKey = normalizeFamilyName(split.familyName); - if (!familyKey && !/\p{L}/u.test(split.observationName)) continue; - const observation_index = nextIndex(); - const { observation_id, canonical_company_id } = await observer.observeCompany({ - ...base, - observation_index, - name: split.observationName, - source_context: parentClauseSourceContext(`${relationPrefix}:underwriter`, split), - }); - await provenance.save({ - kind: "company", - observation_id, - confidence: r.confidence, - source_span: boundSourceSpan(r.source_span), - section_name: "underwriters", - model_id, - prompt_version: extractor_version, - extra: null, - }); - if (!familyKey) { + const underwritingText = byName.get(S1_SECTIONS.UNDERWRITING); + const unitIpo = looksLikeUnitIpo(offeringText); + if (isSpac && !unitIpo) { + await markSectionResolved("underwriters"); + } else { + await runSection({ + sectionName: "underwriters", + text: underwritingText, + emptyDetail: "no underwriters returned", + lowConfidenceDetail: "all rows below confidence floor", + invalidWriteDetail: "no underwriter rows had a usable legal name", + // Prompt-injection backstop: refuse to persist any underwriter row whose + // source_span is not a verbatim substring of the Underwriting section text. + verifyRow: (text, r) => classifySpan(text, r.source_span), + unverifiedAllDetail: + "all $T confident underwriter rows had source_span not present in section text", + unverifiedPartialDetail: + "$N of $T confident underwriter rows had source_span not present in section text", + ...modelExtractChain(models, async (text, m) => { + if (isSpac) { + const det = parseSpacUnderwriters(text); + if (det.length > 0) return det; + } + return extractUnderwriters(text, m, context); + }), + persist: async (rows, meta) => { + const model_id = + rows[0]?.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); + let wrote = 0; + // One underwriter, one link row. The model repeats an underwriter across + // rows more often than not — a sole-underwriter filing came back with the + // same bank once, twice, and three times on three consecutive runs — and + // every duplicate previously minted its own observation, family + // membership and link row, inflating `sec underwriter by-family` counts by + // however many times the model stuttered. Deduped on the LEGAL name, not + // the common name: "Citigroup Global Markets Inc." and "Citigroup Global + // Markets Limited" are two entities that share one family, and collapsing + // on the family would silently drop the second. + const seenLegalNames = new Set(); + const splits = rows.map((r) => splitParentClause(r.legal_name?.trim() ?? "")); + const extractedNames = splits.map((s) => s.observationName); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]!; + const split = splits[i]!; + if (split.observationName === "") continue; + if (isUnnamedCompanyName(split.observationName)) continue; + // Brand stub next to the full legal name ("Cantor" + "Cantor Fitzgerald + // & Co.") is one house, not two. Inc vs Limited of the same house are + // equal-length family keys and are not dropped. + if (isCompanyFamilyPrefixEcho(split.observationName, extractedNames)) continue; + const dedupeKey = normalizeEntityName(split.observationName); + if (seenLegalNames.has(dedupeKey)) continue; + seenLegalNames.add(dedupeKey); + // companyFamilyName wipes non-ASCII and punctuation, so "[●]" (a + // still-blank F-1 table cell) and a CJK legal name both have no family + // key. A letterless placeholder is not an entity — skip it before + // observeCompany. A name that still has letters (CJK) is observed + // without a family rather than throwing "empty name" and aborting the + // rest of the table. + const familyKey = normalizeFamilyName(split.familyName); + if (!familyKey && !/\p{L}/u.test(split.observationName)) continue; + const observation_index = nextIndex(); + const { observation_id, canonical_company_id } = await observer.observeCompany({ + ...base, + observation_index, + name: split.observationName, + source_context: parentClauseSourceContext(`${relationPrefix}:underwriter`, split), + }); + await provenance.save({ + kind: "company", + observation_id, + confidence: r.confidence, + source_span: boundSourceSpan(r.source_span), + section_name: "underwriters", + model_id, + prompt_version: extractor_version, + extra: null, + }); + if (!familyKey) { + wrote++; + continue; + } + const underwriter_family_id = await underwriterFamilyResolver.resolve(split.familyName); + await underwriterMembershipRepo.record({ + resolver_version: activeUnderwriterFamilyVersion, + canonical_company_id, + canonical_underwriter_family_id: underwriter_family_id, + seen_at: new Date().toISOString(), + }); + await underwriterLinkRepo.save({ + accession_number, + extractor_id, + observation_index, + issuer_cik: cik, + underwriter_canonical_company_id: canonical_company_id, + underwriter_family_id, + role_detail: r.role, + shares_allocated: toIntCount(r.shares_allocated), + over_allotment_shares: toIntCount(r.over_allotment_shares), + resolver_version: activeUnderwriterFamilyVersion, + }); wrote++; - continue; } - const underwriter_family_id = await underwriterFamilyResolver.resolve(split.familyName); - await underwriterMembershipRepo.record({ - resolver_version: activeUnderwriterFamilyVersion, - canonical_company_id, - canonical_underwriter_family_id: underwriter_family_id, - seen_at: new Date().toISOString(), - }); - await underwriterLinkRepo.save({ - accession_number, - extractor_id, - observation_index, - issuer_cik: cik, - underwriter_canonical_company_id: canonical_company_id, - underwriter_family_id, - role_detail: r.role, - shares_allocated: toIntCount(r.shares_allocated), - over_allotment_shares: toIntCount(r.over_allotment_shares), - resolver_version: activeUnderwriterFamilyVersion, - }); - wrote++; - } - return wrote; - }, - }); + return wrote; + }, + }); + } // --- Use of proceeds --- await runSection({ diff --git a/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts new file mode 100644 index 00000000..fe0c9f5e --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function nameKey(name: string): string { + return name + .normalize("NFKC") + .replace(/\s+/g, " ") + .replace(/[.,;:]+$/, "") + .trim() + .toLowerCase(); +} + +function fixtures(): Array<{ filing: string; byName: Map }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; byName: Map }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseSpacUnderwriters golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty underwriters label", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "underwriters"); + if (!labels || labels.length !== 0) continue; + const text = byName.get(S1_SECTIONS.UNDERWRITING) ?? ""; + expect( + parseSpacUnderwriters(text).map((r) => r.legal_name), + filing + ).toEqual([]); + } + }); + + it("does not invent names outside the golden set when it hits", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "underwriters"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.UNDERWRITING) ?? ""; + const parsed = parseSpacUnderwriters(text); + if (parsed.length === 0) continue; + const allowed = new Set( + labels + .map((r) => (typeof r.legal_name === "string" ? nameKey(r.legal_name) : "")) + .filter((k) => k !== "") + ); + for (const row of parsed) { + expect(allowed.has(nameKey(row.legal_name)), `${filing} extra ${row.legal_name}`).toBe( + true + ); + } + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.test.ts new file mode 100644 index 00000000..7026c6c0 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.test.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; + +function names(text: string): string[] { + return parseSpacUnderwriters(text).map((r) => r.legal_name); +} + +describe("parseSpacUnderwriters", () => { + it("never throws", () => { + expect(parseSpacUnderwriters("")).toEqual([]); + expect(parseSpacUnderwriters("not a table")).toEqual([]); + expect(parseSpacUnderwriters("| |\n| --- |")).toEqual([]); + }); + + it("reads a sole book-runner allocation table", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| Cantor Fitzgerald & Co. | |", + "| Total | 20,000,000 |", + ].join("\n"); + const rows = parseSpacUnderwriters(text); + expect(rows).toHaveLength(1); + expect(rows[0]!.legal_name).toBe("Cantor Fitzgerald & Co."); + expect(rows[0]!.shares_allocated).toBeNull(); + expect(rows[0]!.source).toBe("deterministic"); + expect(text.includes(rows[0]!.source_span)).toBe(true); + }); + + it("reads several syndicate names and skips Total and the discount table", () => { + const text = [ + "| Underwriters | Number of Units | Number of Units |", + "| --- | --- | --- |", + "| Credit Suisse Securities (USA) LLC | | |", + "| BofA Securities, Inc. | | |", + "| Moelis & Company LLC. | | |", + "| Total | | 35,000,000 |", + "| Per Unit | Without Over-allotment | With Over-allotment |", + "| Underwriting Discounts and Commissions paid by us | $0.55 | $19,250,000 |", + ].join("\n"); + expect(names(text)).toEqual([ + "Credit Suisse Securities (USA) LLC", + "BofA Securities, Inc.", + "Moelis & Company LLC", + ]); + }); + + it("takes shares_allocated from the first numeric units column", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| Citigroup Global Markets Inc. | 30,000,000 |", + "| Total | 30,000,000 |", + ].join("\n"); + expect(parseSpacUnderwriters(text)[0]!.shares_allocated).toBe(30_000_000); + }); + + it("ignores an over-allotment third column when the first units cell is empty", () => { + const text = [ + "| Underwriter | Number of units | Number of units |", + "| --- | --- | --- |", + "| Barclays Capital Inc. | | 25,875,000 |", + "| Total | | 25,875,000 |", + ].join("\n"); + expect(parseSpacUnderwriters(text)[0]!.shares_allocated).toBeNull(); + expect(parseSpacUnderwriters(text)[0]!.over_allotment_shares).toBeNull(); + }); + + it("strips a footnote marker from a legal name", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| Nomura Securities International, Inc.(1) | |", + "| Total | 1 |", + ].join("\n"); + expect(names(text)).toEqual(["Nomura Securities International, Inc."]); + }); + + it("drops a placeholder name cell", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| [●] | |", + "| Total | 7,500,000 |", + ].join("\n"); + expect(names(text)).toEqual([]); + }); + + it("splits a comma-and list of firm names in one cell", () => { + const text = [ + "| Underwriters | Number of Units |", + "| --- | --- |", + "| Lucid Capital Markets, LLC and EarlyBirdCapital, Inc. | 10,000,000 |", + "| Total | 10,000,000 |", + ].join("\n"); + expect(names(text)).toEqual(["Lucid Capital Markets, LLC", "EarlyBirdCapital, Inc."]); + }); + + it("does not split 'BofA Securities, Inc.' on the Inc comma", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| BofA Securities, Inc. | |", + "| Total | 1 |", + ].join("\n"); + expect(names(text)).toEqual(["BofA Securities, Inc."]); + }); + + it("drops a family prefix echo of a longer legal name", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| Cantor | |", + "| Cantor Fitzgerald & Co. | |", + "| Total | 20,000,000 |", + ].join("\n"); + expect(names(text)).toEqual(["Cantor Fitzgerald & Co."]); + }); + + it("reads a headerless allocation table whose caption is in the prose", () => { + const text = [ + "the underwriters below have agreed to purchase from us:", + "| | |", + "| --- | --- |", + "| US Tiger Securities, Inc. | |", + "| Total | 10,000,000 |", + ].join("\n"); + expect(names(text)).toEqual(["US Tiger Securities, Inc."]); + }); + + it("keeps a long division-of legal name", () => { + const text = [ + "| Underwriters | Number of Units |", + "| --- | --- |", + "| EF Hutton, division of Benchmark Investments, LLC formerly known as Kingswood Capital Markets, division of Benchmark Investments | |", + "| Total | 10,000,000 |", + ].join("\n"); + expect(names(text)[0]).toMatch(/^EF Hutton/); + }); + + it("does not take a prose fragment from a two-column layout table", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| R.F. Lafferty & Co., Inc. | |", + "| our financial information, | |", + "| Total | 10,000,000 |", + ].join("\n"); + expect(names(text)).toEqual(["R.F. Lafferty & Co., Inc."]); + }); + + it("does not take a selling stockholder table", () => { + const text = [ + "| Selling Stockholder | Number of Shares |", + "| --- | --- |", + "| Acme Holdings LLC | 1,000,000 |", + ].join("\n"); + expect(names(text)).toEqual([]); + }); + + it("does not take a statutory-underwriter / may-be-deemed table", () => { + const text = [ + "| Name | Role |", + "| --- | --- |", + "| Jane Doe | may be deemed an underwriter |", + ].join("\n"); + expect(names(text)).toEqual([]); + }); + + it("does not take Plan of Distribution bullet methods as names", () => { + const text = [ + "| | |", + "| --- | --- |", + "| ● | ordinary brokerage transactions and transactions in which the broker-dealer solicits purchasers; |", + "| ● | block trades in which the broker-dealer will attempt to sell the securities as agent; |", + ].join("\n"); + expect(names(text)).toEqual([]); + }); + + it("does not persist a QIU-only table", () => { + const text = [ + "| Qualified Independent Underwriter | Fee |", + "| --- | --- |", + "| B. Riley Securities, Inc. | $50,000 |", + ].join("\n"); + expect(names(text)).toEqual([]); + }); + + it("persists a QIU when a syndicate table also named another bank", () => { + const text = [ + "| Underwriters | Number of Units |", + "| --- | --- |", + "| Chardan Capital Markets LLC | |", + "| Total | 7,500,000 |", + "| Qualified Independent Underwriter | |", + "| --- | --- |", + "| B. Riley Securities, Inc. | |", + ].join("\n"); + expect(names(text)).toEqual(["Chardan Capital Markets LLC", "B. Riley Securities, Inc."]); + }); + + it("sets role null even when the table is a sole book-runner", () => { + const text = [ + "| Underwriter | Number of Units |", + "| --- | --- |", + "| WestPark Capital, Inc. | 6,000,000 |", + "| Total | 6,000,000 |", + ].join("\n"); + expect(parseSpacUnderwriters(text)[0]!.role).toBeNull(); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts new file mode 100644 index 00000000..3d627efd --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseNumeric } from "../../../html/parseNumeric"; +import { isCompanyFamilyPrefixEcho } from "../../../../storage/company/CompanyFamilyName"; +import { isUnnamedCompanyName } from "../../../../storage/company/CompanyNormalization"; +import type { UnderwriterRowOut } from "./underwriterSchema"; + +function nameKey(name: string): string { + return name + .normalize("NFKC") + .replace(/\s+/g, " ") + .replace(/[.,;:]+$/, "") + .trim() + .toLowerCase(); +} + +const PLACEHOLDER = /^(?:\[●\]|●|\[•\]|•|—|–|-|\*|\[·\]|\u25cf)?$/; +const TOTAL_ROW = /^totals?(?:\s+units?)?$/i; +const SKIP_NAME = + /underwriting discounts|commissions paid|per unit|over-?allotment|number of units|selling (?:stockholder|securityholder|shareholder)|may be deemed|broker-?dealer|stabilizing transactions|ordinary brokerage/i; +const SELLING_HEADER = /selling (?:stockholder|securityholder|shareholder|securityholders?)/i; +const QIU = /qualified independent underwriter/i; +const SYNDICATE_HEADER = /\bunderwriters?\b/i; +const DISCOUNT_HEADER = /discount|commission|compensation by finra|conflicts of interest/i; +const LEGAL_END = + /\b(?:inc(?:orporated)?|llc|l\.l\.c|ltd|limited|llp|l\.l\.p|l\.p|lp|plc|corp(?:oration)?|company|co|ag|s\.a\.?|gmbh|n\.v\.?)\b/i; +const FIRM_HINT = + /\b(?:securities|capital markets|capital|partners|markets|bancorp|bank|advisory)\b/i; +const AND_CO = /&\s*co\.?/i; + +export function parseSpacUnderwriters(text: string): UnderwriterRowOut[] { + try { + return parseSpacUnderwritersInner(text); + } catch { + return []; + } +} + +function parseSpacUnderwritersInner(text: string): UnderwriterRowOut[] { + const tables = splitGfmTables(text); + const syndicate: Candidate[] = []; + const qiu: Candidate[] = []; + for (const table of tables) { + const blob = table.flat().join(" "); + if (SELLING_HEADER.test(blob)) continue; + const headed = extractNamedRows(table, { requireSyndicateHeader: true }); + syndicate.push(...headed); + if (headed.length === 0 && isHeaderlessAllocation(table)) { + syndicate.push(...extractNamedRows(table, { requireSyndicateHeader: false })); + } + if (QIU.test(blob)) { + qiu.push(...extractNamedRows(table, { requireSyndicateHeader: false })); + } + } + const keepQiu = syndicate.length > 0 ? qiu : []; + const merged = dedupe([...syndicate, ...keepQiu]); + const located = merged.filter((c) => text.includes(c.legal_name)); + return located.map((c) => ({ + legal_name: c.legal_name, + role: null, + shares_allocated: c.shares_allocated, + over_allotment_shares: null, + confidence: 1, + source_span: c.source_span, + source: "deterministic" as const, + })); +} + +interface Candidate { + readonly legal_name: string; + readonly shares_allocated: number | null; + readonly source_span: string; +} + +function extractNamedRows( + table: readonly (readonly string[])[], + opts: { readonly requireSyndicateHeader: boolean } +): Candidate[] { + let headerIdx = -1; + let nameCol = 0; + for (let i = 0; i < table.length; i++) { + const row = table[i]!; + const col = row.findIndex((cell) => isSyndicateHeaderCell(cell)); + if (col >= 0) { + headerIdx = i; + nameCol = col; + break; + } + } + if (opts.requireSyndicateHeader && headerIdx < 0) return []; + const start = headerIdx >= 0 ? headerIdx + 1 : 0; + const out: Candidate[] = []; + for (let i = start; i < table.length; i++) { + const row = table[i]!; + const raw = cleanCell(row[nameCol] ?? row[0] ?? ""); + if (raw === "" || PLACEHOLDER.test(raw)) continue; + if (QIU.test(raw)) { + if (opts.requireSyndicateHeader) break; + continue; + } + if (TOTAL_ROW.test(raw) || SKIP_NAME.test(raw) || DISCOUNT_HEADER.test(raw)) continue; + if (isSyndicateHeaderCell(raw)) continue; + const unitsCell = cleanCell(row[nameCol + 1] ?? ""); + const shares = integerCount(unitsCell); + for (const legal_name of splitFirmNames(raw)) { + if (!isKeepName(legal_name)) continue; + if (!opts.requireSyndicateHeader && !looksLikeFirmName(legal_name)) continue; + out.push({ + legal_name, + shares_allocated: shares, + source_span: raw, + }); + } + } + return out; +} + +function isKeepName(name: string): boolean { + if (name.length < 3 || name.length > 140) return false; + if (PLACEHOLDER.test(name) || TOTAL_ROW.test(name) || SKIP_NAME.test(name)) return false; + if (isUnnamedCompanyName(name)) return false; + if (!/\p{L}/u.test(name)) return false; + if (/[;]$/.test(name)) return false; + const words = name.split(/\s+/).length; + if (words > 24) return false; + if (looksLikeFirmName(name)) return true; + if (/,$/.test(name)) return false; + if (/^(?:our|the|a|an|this|these|its|any|each)\b/i.test(name)) return false; + if (!/[A-Z]/.test(name)) return false; + return words <= 6; +} + +function isHeaderlessAllocation(table: readonly (readonly string[])[]): boolean { + if (table.some((row) => row.some((cell) => isSyndicateHeaderCell(cell)))) return false; + const blob = table.flat().join(" "); + if (/per unit|discounts and commissions|paid by|payable by/i.test(blob)) return false; + const hasTotal = table.some((row) => TOTAL_ROW.test(cleanCell(row[0] ?? ""))); + if (!hasTotal) return false; + return table.some((row) => looksLikeFirmName(tidyName(row[0] ?? ""))); +} + +/** True when a syndicate allocation table contains at least one firm-like name. */ +export function hasSpacSyndicateTable(text: string): boolean { + for (const table of splitGfmTables(text)) { + const blob = table.flat().join(" "); + if (SELLING_HEADER.test(blob)) continue; + if (table.some((row) => row.some((cell) => isSyndicateHeaderCell(cell)))) { + for (const row of table) { + const n = tidyName(row[0] ?? ""); + if (n === "" || TOTAL_ROW.test(n) || isSyndicateHeaderCell(n) || QIU.test(n)) continue; + if (looksLikeFirmName(n) || isKeepName(n)) return true; + } + continue; + } + if (isHeaderlessAllocation(table)) return true; + } + return false; +} + +function looksLikeFirmName(name: string): boolean { + return LEGAL_END.test(name) || AND_CO.test(name) || FIRM_HINT.test(name); +} + +function splitFirmNames(raw: string): string[] { + const trimmed = tidyName(raw); + const parts = trimmed + .split(/\s+and\s+|,\s+and\s+/i) + .map((p) => tidyName(p)) + .filter((p) => p !== ""); + if (parts.length >= 2 && parts.every((p) => looksLikeFirmName(p))) return parts; + return [trimmed]; +} + +function tidyName(name: string): string { + return name + .replace(/\(\d+\)\s*$/, "") + .replace(/[\u00b9\u00b2\u00b3\u2020\u2021*]+$/u, "") + .replace(/(?<=\bLLC)\.+$/i, "") + .trim(); +} + +function isSyndicateHeaderCell(cell: string): boolean { + const t = cleanCell(cell); + if (t === "") return false; + if (SELLING_HEADER.test(t) || DISCOUNT_HEADER.test(t) || QIU.test(t)) return false; + if (/deemed/.test(t)) return false; + return SYNDICATE_HEADER.test(t); +} + +function integerCount(raw: string): number | null { + if (raw === "" || PLACEHOLDER.test(raw)) return null; + const n = parseNumeric(raw.replace(/,/g, "")); + if (n === undefined || !Number.isFinite(n) || n <= 0) return null; + return Math.round(n); +} + +function dedupe(rows: readonly Candidate[]): Candidate[] { + const names = rows.map((r) => r.legal_name); + const kept: Candidate[] = []; + const seen = new Set(); + for (const row of rows) { + if (isCompanyFamilyPrefixEcho(row.legal_name, names)) continue; + const key = nameKey(row.legal_name); + if (seen.has(key)) continue; + seen.add(key); + kept.push(row); + } + return kept; +} + +function cleanCell(raw: string): string { + return raw + .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") + .replace(/​/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function splitGfmTables(text: string): string[][][] { + const tables: string[][][] = []; + let current: string[][] = []; + const flush = (): void => { + if (current.length > 0) tables.push(current); + current = []; + }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + flush(); + continue; + } + if (isSeparatorRow(trimmed)) continue; + current.push(splitPipeRow(trimmed).map(cleanCell)); + } + flush(); + return tables; +} + +function isSeparatorRow(line: string): boolean { + return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); +} + +function splitPipeRow(line: string): string[] { + const inner = line.replace(/^\|/, "").replace(/\|$/, ""); + const cells: string[] = []; + let cur = ""; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === "\\" && inner[i + 1] === "|") { + cur += "|"; + i += 1; + continue; + } + if (inner[i] === "|") { + cells.push(cur); + cur = ""; + continue; + } + cur += inner[i]; + } + cells.push(cur); + return cells; +} diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts new file mode 100644 index 00000000..266218e7 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function purposeKey(s: string): string { + return s.replace(/\s+/g, " ").trim().toLowerCase(); +} + +function fixtures(): Array<{ filing: string; byName: Map }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; byName: Map }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseSpacUseOfProceeds golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty use-of-proceeds label", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "use-of-proceeds"); + if (!labels || labels.length !== 0) continue; + const text = byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? ""; + expect(parseSpacUseOfProceeds(text), filing).toEqual([]); + } + }); + + it("does not invent purposes outside the golden set when it hits", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "use-of-proceeds"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? ""; + const parsed = parseSpacUseOfProceeds(text); + if (parsed.length === 0) continue; + const allowed = new Set( + labels + .map((r) => (typeof r.purpose === "string" ? purposeKey(r.purpose) : "")) + .filter((k) => k !== "") + ); + for (const row of parsed) { + const p = row.purpose ?? ""; + expect(allowed.has(purposeKey(p)), `${filing} extra ${p}`).toBe(true); + } + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts new file mode 100644 index 00000000..c3087c4d --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; + +function purposes(text: string): string[] { + return parseSpacUseOfProceeds(text).map((r) => r.purpose ?? ""); +} + +const CHURCHILL = [ + "| | Without Over-Allotment Option | Without Over-Allotment Option | With Over-Allotment Option | With Over-Allotment Option |", + "| --- | --- | --- | --- | --- |", + "| Gross proceeds from units offered to public(1) | $ | 300,000,000 | $ | 345,000,000 |", + "| Offering expenses(2) | | | | |", + "| Underwriting discounts and commissions (excluding deferred portion)(3) | $ | 4,500,000 | $ | 5,175,000 |", + "| Legal fees and expenses | | 325,000 | | 325,000 |", + "| Miscellaneous | | 385,641 | | 385,641 |", + "| Total offering expenses (excluding underwriting discounts and commissions) | $ | 1,000,000 | $ | 1,000,000 |", + "| Reimbursed expenses(4) | | 3,000,000 | | 3,675,000 |", + "| Proceeds after offering expenses | $ | 301,000,000 | $ | 346,000,000 |", + "| Held in trust account(1)(3) | $ | 300,000,000 | $ | 345,000,000 |", + "| % public offering size | | 100.0 | % | 100.0 |", + "| Not held in trust account | $ | 1,000,000 | $ | 1,000,000 |", + "| Legal, accounting, due diligence, travel and other expenses in connection with business combination | $ | 100,000 | 10.0 | % |", + "| Working capital to cover miscellaneous expenses | | 40,000 | 4.0 | % |", + "| Total | $ | 1,000,000 | 100.0 | % |", +].join("\n"); + +describe("parseSpacUseOfProceeds", () => { + it("never throws", () => { + expect(parseSpacUseOfProceeds("")).toEqual([]); + expect(parseSpacUseOfProceeds("| |")).toEqual([]); + }); + + it("reads offering-expense and working-capital lines and skips totals and sources", () => { + const rows = parseSpacUseOfProceeds(CHURCHILL); + expect(purposes(CHURCHILL)).toEqual([ + "Underwriting discounts and commissions (excluding deferred portion)", + "Legal fees and expenses", + "Miscellaneous", + "Held in trust account", + "Not held in trust account", + "Legal, accounting, due diligence, travel and other expenses in connection with business combination", + "Working capital to cover miscellaneous expenses", + ]); + expect(rows.find((r) => r.purpose === "Legal fees and expenses")?.amount).toBe(325_000); + expect(rows.find((r) => r.purpose === "Held in trust account")?.amount).toBe(300_000_000); + expect(rows.every((r) => r.source === "deterministic")).toBe(true); + }); + + it("uses the without-over-allotment amount, not the over-allotment column", () => { + const rows = parseSpacUseOfProceeds(CHURCHILL); + expect( + rows.find((r) => r.purpose?.startsWith("Underwriting discounts"))?.amount + ).toBe(4_500_000); + }); + + it("returns empty when fewer than two amount lines exist", () => { + const text = [ + "| Legal fees and expenses | $ | 325,000 |", + "| --- | --- | --- |", + ].join("\n"); + expect(parseSpacUseOfProceeds(text)).toEqual([]); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts new file mode 100644 index 00000000..10db0ced --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseNumeric } from "../../../html/parseNumeric"; +import type { UseOfProceedsLineRow } from "./useOfProceedsSchema"; + +const MIN_LINES = 2; +const MIN_AMOUNT = 1_000; +const SKIP_PURPOSE = + /gross proceeds|^totals?\b|proceeds after|reimbursed expenses|% public offering|offering expenses\b(?! \()/i; + +export function parseSpacUseOfProceeds(text: string): UseOfProceedsLineRow[] { + try { + return parseInner(text); + } catch { + return []; + } +} + +function parseInner(text: string): UseOfProceedsLineRow[] { + const out: UseOfProceedsLineRow[] = []; + for (const table of splitGfmTables(text)) { + for (const row of table) { + const cells = row.map(cleanCell).filter((c, i, arr) => !(c === "" && i > 0 && arr[0] === "")); + const purposeRaw = cells.find((c) => c !== "" && c !== "$" && c !== "%") ?? ""; + const purpose = tidyPurpose(purposeRaw); + if (purpose === "" || SKIP_PURPOSE.test(purpose) || isHeaderRow(cells)) continue; + const amount = firstAmount(cells); + if (amount === null) continue; + if (!text.includes(purposeRaw) && !text.includes(purpose)) continue; + const percent = firstPercent(cells); + out.push({ + purpose, + amount, + percent, + note: null, + confidence: 1, + source_span: purposeRaw, + source: "deterministic", + }); + } + } + if (out.length < MIN_LINES) return []; + return out; +} + +function isHeaderRow(cells: readonly string[]): boolean { + const blob = cells.join(" ").toLowerCase(); + return /without over|with over|amount|gross proceeds/.test(blob) && !/\d{3,}/.test(blob); +} + +function tidyPurpose(raw: string): string { + return raw + .replace(/\(\d+\)/g, "") + .replace(/[\u200b\u200c\u200d\ufeff]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function firstAmount(cells: readonly string[]): number | null { + for (let i = 0; i < cells.length; i++) { + const c = cells[i]!; + if (c === "" || c === "$" || c === "%") continue; + if (cells[i + 1] === "%") continue; + const n = parseNumeric(c.replace(/,/g, "")); + if (n !== undefined && Number.isFinite(n) && n >= MIN_AMOUNT) return n; + } + return null; +} + +function firstPercent(cells: readonly string[]): number | null { + for (let i = 0; i < cells.length; i++) { + if (cells[i + 1] !== "%") continue; + const n = parseNumeric(cells[i]!.replace(/,/g, "")); + if (n !== undefined && Number.isFinite(n)) return n; + } + return null; +} + +function cleanCell(raw: string): string { + return raw + .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function splitGfmTables(text: string): string[][][] { + const tables: string[][][] = []; + let current: string[][] = []; + const flush = (): void => { + if (current.length > 0) tables.push(current); + current = []; + }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + flush(); + continue; + } + if (isSeparatorRow(trimmed)) continue; + current.push(splitPipeRow(trimmed).map(cleanCell)); + } + flush(); + return tables; +} + +function isSeparatorRow(line: string): boolean { + return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); +} + +function splitPipeRow(line: string): string[] { + const inner = line.replace(/^\|/, "").replace(/\|$/, ""); + const cells: string[] = []; + let cur = ""; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === "\\" && inner[i + 1] === "|") { + cur += "|"; + i += 1; + continue; + } + if (inner[i] === "|") { + cells.push(cur); + cur = ""; + continue; + } + cur += inner[i]; + } + cells.push(cur); + return cells; +} diff --git a/src/sec/forms/registration-statements/s1/underwriterSchema.ts b/src/sec/forms/registration-statements/s1/underwriterSchema.ts index d648a1ea..f39446fe 100644 --- a/src/sec/forms/registration-statements/s1/underwriterSchema.ts +++ b/src/sec/forms/registration-statements/s1/underwriterSchema.ts @@ -41,4 +41,6 @@ export interface UnderwriterRowOut { over_allotment_shares: number | null; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts b/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts index 72fd1121..935b5127 100644 --- a/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts +++ b/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts @@ -38,4 +38,6 @@ export interface UseOfProceedsLineRow { note: string | null; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } diff --git a/src/storage/canonical/UnderwriterLinkRepo.ts b/src/storage/canonical/UnderwriterLinkRepo.ts index 5c7d24f4..ae7537d6 100644 --- a/src/storage/canonical/UnderwriterLinkRepo.ts +++ b/src/storage/canonical/UnderwriterLinkRepo.ts @@ -34,6 +34,14 @@ export class UnderwriterLinkRepo { } } + async listByAccession(accession_number: string): Promise { + return (await this.repo.query({ accession_number })) ?? []; + } + + async listAll(): Promise { + return (await this.repo.getAll()) ?? []; + } + async listIssuerCiksForFamily(underwriter_family_id: string): Promise { const rows = (await this.repo.query({ underwriter_family_id })) ?? []; return [...new Set(rows.map((r) => r.issuer_cik))]; diff --git a/src/storage/use-of-proceeds/UseOfProceedsRepo.ts b/src/storage/use-of-proceeds/UseOfProceedsRepo.ts index 3b7a8e38..17bb836d 100644 --- a/src/storage/use-of-proceeds/UseOfProceedsRepo.ts +++ b/src/storage/use-of-proceeds/UseOfProceedsRepo.ts @@ -26,6 +26,10 @@ export class UseOfProceedsRepo { return (await this.storage.query({ accession_number })) ?? []; } + async listAll(): Promise { + return (await this.storage.getAll()) ?? []; + } + async clear(accession_number: string): Promise { const rows = await this.queryByAccession(accession_number); for (const r of rows) { diff --git a/src/task/eval/EvalUnderwritersTask.ts b/src/task/eval/EvalUnderwritersTask.ts new file mode 100644 index 00000000..25fbcd37 --- /dev/null +++ b/src/task/eval/EvalUnderwritersTask.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runUnderwritersEval } from "../../eval/runUnderwritersEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalUnderwritersTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalUnderwritersTaskOutput = Static>; + +/** + * Scores the deterministic SPAC underwriter table parser against stored + * syndicate rows using on-disk accession docs. Never fetches EDGAR. + */ +export class EvalUnderwritersTask extends Task< + EvalUnderwritersTaskInput, + EvalUnderwritersTaskOutput +> { + static readonly type = "EvalUnderwritersTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate underwriters"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalUnderwritersTaskInput, + context: IExecuteContext + ): Promise { + const report = await runUnderwritersEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalUnderwritersTaskOutput; + } +} From c5e7d08c387966d8a4734c953edfdf438a348f48 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Tue, 18 Aug 2026 11:10:20 -0700 Subject: [PATCH 03/29] feat(eval): add use-of-proceeds and executive-compensation evaluation commands - Introduced `EvalUseOfProceedsTask` and `EvalExecutiveCompensationTask` to score the respective parsers against stored rows using on-disk cache. - Implemented new CLI commands `use-of-proceeds` and `executive-compensation` with options for extractor ID, limit, CIK, and output format. - Created `printUseOfProceedsReport` and `printExecutiveCompensationReport` functions to display evaluation results in a structured format. - Developed tests for the new commands and their options, ensuring comprehensive coverage of input validation and functionality. - Added utility functions for parsing and evaluating use-of-proceeds and executive compensation data from S-1 filings. --- src/cli/groups/eval.ts | 126 +++++ src/cli/groups/evalOptionValues.test.ts | 10 + src/eval/runExecutiveCompensationEval.test.ts | 37 ++ src/eval/runExecutiveCompensationEval.ts | 236 ++++++++++ src/eval/runUseOfProceedsEval.test.ts | 51 ++ src/eval/runUseOfProceedsEval.ts | 223 +++++++++ .../Form_S_1.storage.compensation.test.ts | 26 +- .../Form_S_1.storage.offering.test.ts | 114 +++++ .../Form_S_1.storage.ts | 15 +- .../s1/executiveCompensationSchema.ts | 2 + .../s1/offeringSections.ts | 77 +-- .../s1/parseSpacUseOfProceeds.corpus.test.ts | 10 +- .../s1/parseSpacUseOfProceeds.test.ts | 21 +- .../s1/parseSpacUseOfProceeds.ts | 27 +- ...rseSummaryCompensationTable.corpus.test.ts | 77 +++ .../s1/parseSummaryCompensationTable.test.ts | 124 +++++ .../s1/parseSummaryCompensationTable.ts | 443 ++++++++++++++++++ .../ExecutiveCompensationRepo.ts | 4 + .../eval/EvalExecutiveCompensationTask.ts | 73 +++ src/task/eval/EvalUseOfProceedsTask.ts | 73 +++ 20 files changed, 1716 insertions(+), 53 deletions(-) create mode 100644 src/eval/runExecutiveCompensationEval.test.ts create mode 100644 src/eval/runExecutiveCompensationEval.ts create mode 100644 src/eval/runUseOfProceedsEval.test.ts create mode 100644 src/eval/runUseOfProceedsEval.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts create mode 100644 src/task/eval/EvalExecutiveCompensationTask.ts create mode 100644 src/task/eval/EvalUseOfProceedsTask.ts diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index dfff0a96..9709a812 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -41,9 +41,13 @@ import { import { EvalUnitTermsTask } from "../../task/eval/EvalUnitTermsTask"; import { EvalOfferingTablesTask } from "../../task/eval/EvalOfferingTablesTask"; import { EvalUnderwritersTask } from "../../task/eval/EvalUnderwritersTask"; +import { EvalUseOfProceedsTask } from "../../task/eval/EvalUseOfProceedsTask"; +import { EvalExecutiveCompensationTask } from "../../task/eval/EvalExecutiveCompensationTask"; import { type UnitTermsReport } from "../../eval/runUnitTermsEval"; import type { OfferingTablesReport } from "../../eval/runOfferingTablesEval"; import type { UnderwritersReport } from "../../eval/runUnderwritersEval"; +import type { UseOfProceedsReport } from "../../eval/runUseOfProceedsEval"; +import type { ExecutiveCompensationReport } from "../../eval/runExecutiveCompensationEval"; /** * Default comparison set: Anthropic's cheap and strong tiers, plus the cheap @@ -186,6 +190,46 @@ function truncate(s: string, max = 60): string { return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } +function printExecutiveCompensationReport(report: ExecutiveCompensationReport): void { + const { counts } = report; + console.log( + `executive-compensation parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + +function printUseOfProceedsReport(report: UseOfProceedsReport): void { + const { counts } = report; + console.log( + `use-of-proceeds parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + function printUnderwritersReport(report: UnderwritersReport): void { const { counts } = report; console.log( @@ -1052,4 +1096,86 @@ export function addEvalCommands(program: Command): void { }); } ); + + cmd + .command("use-of-proceeds") + .description( + "Score the deterministic SPAC use-of-proceeds table parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalUseOfProceedsTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printUseOfProceedsReport(report); + }); + } + ); + + cmd + .command("executive-compensation") + .description( + "Score the deterministic Summary Compensation Table parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalExecutiveCompensationTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printExecutiveCompensationReport(report); + }); + } + ); } diff --git a/src/cli/groups/evalOptionValues.test.ts b/src/cli/groups/evalOptionValues.test.ts index 6822f195..7e5707b5 100644 --- a/src/cli/groups/evalOptionValues.test.ts +++ b/src/cli/groups/evalOptionValues.test.ts @@ -112,6 +112,16 @@ describe("eval value-less options", () => { expect(await runEval(["underwriters", "--format"])).toContain("one of: table, json"); }); + it("covers eval use-of-proceeds' value options", async () => { + expect(await runEval(["use-of-proceeds", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["use-of-proceeds", "--format"])).toContain("one of: table, json"); + }); + + it("covers eval executive-compensation' value options", async () => { + expect(await runEval(["executive-compensation", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["executive-compensation", "--format"])).toContain("one of: table, json"); + }); + it("lists print-prompts modes for a bare --print-prompts on extract", async () => { const err = await runEval(["extract", "--print-prompts"]); expect(err).toContain("--print-prompts needs a value"); diff --git a/src/eval/runExecutiveCompensationEval.test.ts b/src/eval/runExecutiveCompensationEval.test.ts new file mode 100644 index 00000000..ef9b8362 --- /dev/null +++ b/src/eval/runExecutiveCompensationEval.test.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runExecutiveCompensationEval"; + +describe("bucketWhenParserEmpty", () => { + it("skips when stored has no lines", () => { + expect( + bucketWhenParserEmpty({ + stored: [], + sectionText: "Summary Compensation Table\nName and Principal Position\nSalary", + }) + ).toEqual({ bucket: "skip", reason: "all-null stored" }); + }); + + it("skips when there is no summary compensation table", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ person_name: "Alina Kowalczyk", fiscal_year: 2025, salary: 612500 }], + sectionText: "None of our officers has received any compensation.", + }) + ).toEqual({ bucket: "skip", reason: "no-table" }); + }); + + it("misses a summary compensation table the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ person_name: "Alina Kowalczyk", fiscal_year: 2025, salary: 612500 }], + sectionText: "Summary Compensation Table\nName and Principal Position\nSalary", + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runExecutiveCompensationEval.ts b/src/eval/runExecutiveCompensationEval.ts new file mode 100644 index 00000000..f4e2b977 --- /dev/null +++ b/src/eval/runExecutiveCompensationEval.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { hasSummaryCompensationTable } from "../sec/forms/registration-statements/s1/compensationHeuristic"; +import { parseSummaryCompensationTable } from "../sec/forms/registration-statements/s1/parseSummaryCompensationTable"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { ExecutiveCompensationRepo } from "../storage/executive-compensation/ExecutiveCompensationRepo"; +import { PersonObservationRepo } from "../storage/observation/PersonObservationRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface ExecutiveCompensationEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type ExecutiveCompensationBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface ExecutiveCompensationLineScore { + readonly person_name: string; + readonly fiscal_year: number | null; + readonly salary: number | null; +} + +export interface ExecutiveCompensationCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: ExecutiveCompensationBucket; + readonly cachePath: string | undefined; + readonly parsed?: readonly ExecutiveCompensationLineScore[]; + readonly stored?: readonly ExecutiveCompensationLineScore[]; + readonly reason?: string; +} + +export interface ExecutiveCompensationReport { + readonly cases: readonly ExecutiveCompensationCase[]; + readonly counts: Record; +} + +export async function runExecutiveCompensationEval( + options: ExecutiveCompensationEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; executive-compensation eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const storedByKey = await loadStored(); + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: ExecutiveCompensationCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const stored = storedByKey.get(`${item.extractor_id}\t${item.accession_number}`) ?? []; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function loadStored(): Promise> { + const rows = await new ExecutiveCompensationRepo().listAll(); + const people = await new PersonObservationRepo().listAll(); + const byId = new Map(people.map((p) => [p.observation_id, p])); + const storedByKey = new Map(); + for (const r of rows) { + const key = `${r.extractor_id}\t${r.accession_number}`; + const person = r.observation_id !== null ? byId.get(r.observation_id) : undefined; + const person_name = displayName(person); + const arr = storedByKey.get(key) ?? []; + arr.push({ person_name, fiscal_year: r.fiscal_year, salary: r.salary }); + storedByKey.set(key, arr); + } + return storedByKey; +} + +function displayName( + person: + | { + first_name: string | null; + middle_name: string | null; + last_name: string | null; + suffix: string | null; + } + | undefined +): string { + if (person === undefined) return ""; + return [person.first_name, person.middle_name, person.last_name, person.suffix] + .filter((p) => p != null && p !== "") + .join(" "); +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: readonly ExecutiveCompensationLineScore[] +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip" as const, cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = byName.get(S1_SECTIONS.EXECUTIVE_COMPENSATION) ?? ""; + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRows = parseSummaryCompensationTable(text); + const parsed: ExecutiveCompensationLineScore[] = parsedRows.map((r) => ({ + person_name: r.person_name, + fiscal_year: r.fiscal_year, + salary: r.salary, + })); + if (parsed.length === 0) { + const miss = bucketWhenParserEmpty({ stored, sectionText: text }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: readonly ExecutiveCompensationLineScore[]; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!hasSummaryCompensationTable(args.sectionText)) { + return { bucket: "skip", reason: "no-table" }; + } + return { bucket: "miss", reason: undefined }; +} + +function nameKey(s: string): string { + return s.replace(/\s+/g, "").toLowerCase(); +} + +function scoredEqual( + a: readonly ExecutiveCompensationLineScore[], + b: readonly ExecutiveCompensationLineScore[] +): boolean { + const keyOf = (r: ExecutiveCompensationLineScore): string => + `${nameKey(r.person_name)}\t${r.fiscal_year ?? ""}\t${r.salary ?? ""}`; + const aKeys = a.map(keyOf).toSorted(); + const bKeys = b.map(keyOf).toSorted(); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((k, i) => k === bKeys[i]); +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/eval/runUseOfProceedsEval.test.ts b/src/eval/runUseOfProceedsEval.test.ts new file mode 100644 index 00000000..ef232f1a --- /dev/null +++ b/src/eval/runUseOfProceedsEval.test.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runUseOfProceedsEval"; + +describe("bucketWhenParserEmpty", () => { + it("skips when stored has no lines", () => { + expect( + bucketWhenParserEmpty({ + stored: [], + offeringText: "| Offering price | $10.00 |\n| Number of units offered | 7,500,000 |", + sectionText: "We will use the net proceeds for working capital.", + }) + ).toEqual({ bucket: "skip", reason: "all-null stored" }); + }); + + it("empties a resale rather than missing", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ purpose: "Working capital", amount: 1_000_000 }], + offeringText: "| Securities offered | 12,000,000 ordinary shares, at $10.00 per share |", + sectionText: "| Held in trust account | $ | 200,000,000 |", + }).bucket + ).toBe("empty"); + }); + + it("skips a unit-IPO with no expense table rather than missing", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ purpose: "Working capital", amount: 1_000_000 }], + offeringText: "| Offering price | $10.00 |\n| Number of units offered | 20,000,000 |", + sectionText: "We intend to use the net proceeds for general corporate purposes.", + }) + ).toEqual({ bucket: "skip", reason: "no-table" }); + }); + + it("misses a unit-IPO expense table the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ purpose: "Legal fees and expenses", amount: 325_000 }], + offeringText: "| Offering price | $10.00 |\n| Number of units offered | 20,000,000 |", + sectionText: + "| Underwriting discounts and commissions | $ | 4,500,000 |\n| Held in trust account | $ | 300,000,000 |", + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runUseOfProceedsEval.ts b/src/eval/runUseOfProceedsEval.ts new file mode 100644 index 00000000..7ed06c66 --- /dev/null +++ b/src/eval/runUseOfProceedsEval.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { offeringParseText } from "../sec/forms/registration-statements/s1/offeringSections"; +import { looksLikeUnitIpo } from "../sec/forms/registration-statements/s1/parseOfferingTables"; +import { + hasSpacUseOfProceedsTable, + parseSpacUseOfProceeds, +} from "../sec/forms/registration-statements/s1/parseSpacUseOfProceeds"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { UseOfProceedsRepo } from "../storage/use-of-proceeds/UseOfProceedsRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface UseOfProceedsEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type UseOfProceedsBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface UseOfProceedsLineScore { + readonly purpose: string; + readonly amount: number | null; +} + +export interface UseOfProceedsCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: UseOfProceedsBucket; + readonly cachePath: string | undefined; + readonly parsed?: readonly UseOfProceedsLineScore[]; + readonly stored?: readonly UseOfProceedsLineScore[]; + readonly reason?: string; +} + +export interface UseOfProceedsReport { + readonly cases: readonly UseOfProceedsCase[]; + readonly counts: Record; +} + +export async function runUseOfProceedsEval( + options: UseOfProceedsEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; use-of-proceeds eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const uopRepo = new UseOfProceedsRepo(); + const allUop = await uopRepo.listAll(); + const storedByKey = new Map(); + for (const r of allUop) { + const key = `${r.extractor_id}\t${r.accession_number}`; + const arr = storedByKey.get(key) ?? []; + arr.push({ purpose: r.purpose ?? "", amount: r.amount }); + storedByKey.set(key, arr); + } + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: UseOfProceedsCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const stored = storedByKey.get(`${item.extractor_id}\t${item.accession_number}`) ?? []; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: readonly UseOfProceedsLineScore[] +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip", cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? ""; + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRows = parseSpacUseOfProceeds(text); + const parsed: UseOfProceedsLineScore[] = parsedRows.map((r) => ({ + purpose: r.purpose ?? "", + amount: r.amount, + })); + if (parsed.length === 0) { + const miss = bucketWhenParserEmpty({ + stored, + offeringText: offeringParseText(byName), + sectionText: text, + }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: readonly UseOfProceedsLineScore[]; + readonly offeringText: string; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!looksLikeUnitIpo(args.offeringText)) { + return { bucket: "empty", reason: "resale" }; + } + if (!hasSpacUseOfProceedsTable(args.sectionText)) { + return { bucket: "skip", reason: "no-table" }; + } + return { bucket: "miss", reason: undefined }; +} + +function purposeKey(s: string): string { + return s.replace(/\s+/g, "").toLowerCase(); +} + +function scoredEqual( + a: readonly UseOfProceedsLineScore[], + b: readonly UseOfProceedsLineScore[] +): boolean { + const aMap = new Map(a.map((r) => [purposeKey(r.purpose), r.amount])); + const bMap = new Map(b.map((r) => [purposeKey(r.purpose), r.amount])); + if (aMap.size !== bMap.size) return false; + for (const [k, amount] of aMap) { + if (!bMap.has(k)) return false; + if (bMap.get(k) !== amount) return false; + } + return true; +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts index ca2586dc..953795ac 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts @@ -20,13 +20,23 @@ import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeSt const COMP_TABLE_ROW = "| Alina Kowalczyk | | 2025 |"; const HTML_WITH_TABLE = [ + "

MANAGEMENT

", + "

Alina Kowalczyk — Chief Executive Officer

", + "

EXECUTIVE COMPENSATION

", + "

Summary Compensation Table. Name and Principal Position. Salary.

", + "

| Alina Kowalczyk | | 2025 |

", + "

LEGAL MATTERS

x

", +].join(""); + +const HTML_PARSEABLE_TABLE = [ "

MANAGEMENT

", "

Alina Kowalczyk — Chief Executive Officer

", "

EXECUTIVE COMPENSATION

", "

Summary Compensation Table

", "", "", - "", + "", + "", "
Name and Principal PositionYearSalary ($)Total ($)
Alina Kowalczyk2025612,5004,230,200
Alina Kowalczyk2025612,5004,230,200
Chief Executive Officer
", "

LEGAL MATTERS

x

", ].join(""); @@ -244,4 +254,18 @@ describe("processFormS1 executive compensation", () => { "resolved" ); }); + + it("persists a parseable table as deterministic without calling the compensation model", async () => { + const { unregister } = registerFakeStructuredProvider([MANAGEMENT_PAYLOAD]); + cleanup = unregister; + + await run(HTML_PARSEABLE_TABLE, "acc-comp-7"); + + const rows = await new ExecutiveCompensationRepo().queryByAccession("acc-comp-7"); + expect(rows).toHaveLength(1); + expect(rows[0]!.fiscal_year).toBe(2025); + expect(rows[0]!.salary).toBe(612500); + expect(rows[0]!.total).toBe(4230200); + expect(rows[0]!.principal_position).toBe("Chief Executive Officer"); + }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts index 895750b1..116e90a6 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts @@ -15,6 +15,7 @@ import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/Extractio import { FieldProvenanceRepo } from "../../../storage/provenance/FieldProvenanceRepo"; import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; import { UnderwriterLinkRepo } from "../../../storage/canonical/UnderwriterLinkRepo"; +import { UseOfProceedsRepo } from "../../../storage/use-of-proceeds/UseOfProceedsRepo"; import { processFormS1 } from "./Form_S_1.storage"; import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; @@ -530,4 +531,117 @@ describe("processFormS1 offering terms", () => { const dl = await new ExtractionDeadLetterRepo().listPending("S-1"); expect(dl.filter((d) => d.section_name === "underwriters")).toEqual([]); }); + + it("persists a use-of-proceeds table hit as deterministic without calling the model", async () => { + const html = [ + "

THE OFFERING

", + "", + "", + "", + "", + "", + "
Offering price$10.00
Number of units offered20,000,000
Founder shares5,750,000
Proceeds to be held in trust account$10.00 per unit
", + "

USE OF PROCEEDS

", + "", + "", + "", + "", + "", + "
Underwriting discounts and commissions (excluding deferred portion)$4,500,000
Legal fees and expenses325,000
Held in trust account$300,000,000
Not held in trust account$1,000,000
", + ].join(""); + const { unregister } = registerFakeStructuredProvider([]); + cleanup = unregister; + + await processFormS1({ + cik: 1848507, + file_number: "333-13", + accession_number: "0000000000-26-000013", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: SPAC_HEADER, + html, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const rows = await new UseOfProceedsRepo().queryByAccession("0000000000-26-000013"); + expect(rows.map((r) => r.purpose)).toEqual([ + "Underwriting discounts and commissions (excluding deferred portion)", + "Legal fees and expenses", + "Held in trust account", + "Not held in trust account", + ]); + expect(rows.find((r) => r.purpose === "Held in trust account")?.amount).toBe(300_000_000); + }); + + it("skips the use-of-proceeds model on a SPAC resale with no unit IPO", async () => { + const html = [ + "

THE OFFERING

We are offering 5,000,000 shares.

", + "

USE OF PROCEEDS

", + "", + "", + "", + "
Held in trust account$200,000,000
Legal fees and expenses$325,000
", + ].join(""); + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Common Stock", + shares_offered: 5000000, + price: 10, + price_low: null, + price_high: null, + gross_proceeds: 50000000, + net_proceeds: null, + over_allotment_shares: null, + units_offered: null, + price_per_unit: null, + unit_composition: null, + warrant_fraction_per_unit: null, + right_fraction_per_unit: null, + trust_per_unit: null, + over_allotment_units: null, + exchange: null, + par_value: null, + confidence: 0.9, + source_span: "5,000,000 shares", + tickers: [], + }, + { + founder_shares: null, + founder_percent: null, + private_placement_warrants: null, + private_placement_warrant_price: null, + public_warrant_coverage: null, + trust_per_public_share: null, + trust_total: null, + confidence: 0.9, + source_span: "5,000,000 shares", + }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1848507, + file_number: "333-14", + accession_number: "0000000000-26-000014", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: SPAC_HEADER, + html, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + expect(await new UseOfProceedsRepo().queryByAccession("0000000000-26-000014")).toEqual([]); + const dl = await new ExtractionDeadLetterRepo().listPending("S-1"); + expect(dl.filter((d) => d.section_name === "use-of-proceeds")).toEqual([]); + }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 530c7313..6432e0d8 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -47,6 +47,8 @@ import { } from "./s1/sectionExtractors"; import type { ExecutiveCompensationRow } from "./s1/executiveCompensationSchema"; import { hasSummaryCompensationTable } from "./s1/compensationHeuristic"; +import { parseSummaryCompensationTable } from "./s1/parseSummaryCompensationTable"; +import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { looksLikePartIIOnlyAmendment } from "./s1/partIIOnlyAmendment"; import { issuerHasCombinationListing } from "./s1/newcoListing"; import { MAX_RISK_FACTORS_CHARS } from "./s1/riskFactorChunks"; @@ -1003,11 +1005,16 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident compensation rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident compensation rows had source_span not present in section text", - ...modelExtractChain(models, (text, m) => - extractExecutiveCompensation(text, m, args.context) - ), + ...modelExtractChain(models, async (text, m) => { + const det = parseSummaryCompensationTable(text); + if (det.length > 0) return det; + return extractExecutiveCompensation(text, m, args.context); + }), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); + const model_id = + rows[0]?.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); // An officer shown for two fiscal years is two table rows but ONE // mention of that person, so the observation is minted once and reused; // the row key is positional and independent of it. diff --git a/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts b/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts index 7e9c19b5..bf478431 100644 --- a/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts +++ b/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts @@ -68,4 +68,6 @@ export interface ExecutiveCompensationRow { footnote: string | null; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index c119f483..c52cccb0 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -596,38 +596,47 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise({ - sectionName: "use-of-proceeds", - text: byName.get(S1_SECTIONS.USE_OF_PROCEEDS), - emptyDetail: "no line items returned", - lowConfidenceDetail: "all rows below confidence floor", - // Prompt-injection backstop: refuse to persist any use-of-proceeds row whose - // source_span is not a verbatim substring of the Use of Proceeds section text. - verifyRow: (text, r) => classifySpan(text, r.source_span), - unverifiedAllDetail: - "all $T confident use-of-proceeds rows had source_span not present in section text", - unverifiedPartialDetail: - "$N of $T confident use-of-proceeds rows had source_span not present in section text", - ...modelExtractChain(models, (text, m) => extractUseOfProceeds(text, m, context)), - persist: async (rows) => { - const now = new Date().toISOString(); - let lineIndex = 0; - for (const r of rows) { - await useOfProceedsRepo.save({ - extractor_id, - accession_number, - line_index: lineIndex++, - cik, - purpose: r.purpose, - amount: r.amount, - percent: r.percent, - note: r.note, - confidence: r.confidence, - source_span: boundSourceSpan(r.source_span), - created_at: now, - }); - } - return rows.length; - }, - }); + const useOfProceedsText = byName.get(S1_SECTIONS.USE_OF_PROCEEDS); + if (isSpac && !unitIpo) { + await markSectionResolved("use-of-proceeds"); + } else { + await runSection({ + sectionName: "use-of-proceeds", + text: useOfProceedsText, + emptyDetail: "no line items returned", + lowConfidenceDetail: "all rows below confidence floor", + verifyRow: (text, r) => classifySpan(text, r.source_span), + unverifiedAllDetail: + "all $T confident use-of-proceeds rows had source_span not present in section text", + unverifiedPartialDetail: + "$N of $T confident use-of-proceeds rows had source_span not present in section text", + ...modelExtractChain(models, async (text, m) => { + if (isSpac) { + const det = parseSpacUseOfProceeds(text); + if (det.length >= 2) return det; + } + return extractUseOfProceeds(text, m, context); + }), + persist: async (rows) => { + const now = new Date().toISOString(); + let lineIndex = 0; + for (const r of rows) { + await useOfProceedsRepo.save({ + extractor_id, + accession_number, + line_index: lineIndex++, + cik, + purpose: r.purpose, + amount: r.amount, + percent: r.percent, + note: r.note, + confidence: r.confidence, + source_span: boundSourceSpan(r.source_span), + created_at: now, + }); + } + return rows.length; + }, + }); + } } diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts index 266218e7..57b42317 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts @@ -17,7 +17,7 @@ import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); function purposeKey(s: string): string { - return s.replace(/\s+/g, " ").trim().toLowerCase(); + return s.replace(/\s+/g, "").toLowerCase(); } function fixtures(): Array<{ filing: string; byName: Map }> { @@ -67,10 +67,10 @@ describe("parseSpacUseOfProceeds golden corpus", () => { .map((r) => (typeof r.purpose === "string" ? purposeKey(r.purpose) : "")) .filter((k) => k !== "") ); - for (const row of parsed) { - const p = row.purpose ?? ""; - expect(allowed.has(purposeKey(p)), `${filing} extra ${p}`).toBe(true); - } + const extras = parsed + .map((row) => row.purpose ?? "") + .filter((p) => p !== "" && !allowed.has(purposeKey(p))); + expect(extras, filing).toEqual([]); } }); }); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts index c3087c4d..90119bc6 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts @@ -54,16 +54,25 @@ describe("parseSpacUseOfProceeds", () => { it("uses the without-over-allotment amount, not the over-allotment column", () => { const rows = parseSpacUseOfProceeds(CHURCHILL); - expect( - rows.find((r) => r.purpose?.startsWith("Underwriting discounts"))?.amount - ).toBe(4_500_000); + expect(rows.find((r) => r.purpose?.startsWith("Underwriting discounts"))?.amount).toBe( + 4_500_000 + ); }); it("returns empty when fewer than two amount lines exist", () => { + const text = ["| Legal fees and expenses | $ | 325,000 |", "| --- | --- | --- |"].join("\n"); + expect(parseSpacUseOfProceeds(text)).toEqual([]); + }); + + it("skips source-of-funds rows", () => { const text = [ - "| Legal fees and expenses | $ | 325,000 |", - "| --- | --- | --- |", + "| From sale of units via private placement | $ | 7,000,000 |", + "| Underwriting discounts and commissions | $ | 4,500,000 |", + "| Held in trust account | $ | 300,000,000 |", ].join("\n"); - expect(parseSpacUseOfProceeds(text)).toEqual([]); + expect(purposes(text)).toEqual([ + "Underwriting discounts and commissions", + "Held in trust account", + ]); }); }); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts index 10db0ced..364c592b 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts @@ -10,7 +10,10 @@ import type { UseOfProceedsLineRow } from "./useOfProceedsSchema"; const MIN_LINES = 2; const MIN_AMOUNT = 1_000; const SKIP_PURPOSE = - /gross proceeds|^totals?\b|proceeds after|reimbursed expenses|% public offering|offering expenses\b(?! \()/i; + /gross proceeds|^proceeds from\b|^from\b|^totals?\b|proceeds after|reimbursed expenses|% public offering|offering expenses\b(?! \()|per (?:public )?share|per unit|^(?:revenues?|cost of sales|gross profit|operating loss|net loss|ebitda|adjusted ebitda|net cash)\b/i; +const DATE_PURPOSE = + /^(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},\s+\d{4}$|^\d{4}$/i; +const SPAC_USE = /held in trust|not held in trust|underwriting discounts?|deferred underwriting/i; export function parseSpacUseOfProceeds(text: string): UseOfProceedsLineRow[] { try { @@ -21,13 +24,32 @@ export function parseSpacUseOfProceeds(text: string): UseOfProceedsLineRow[] { } function parseInner(text: string): UseOfProceedsLineRow[] { + const out = collectLines(text); + if (out.length < MIN_LINES) return []; + if (!out.some((r) => SPAC_USE.test(r.purpose ?? ""))) return []; + return out; +} + +/** True when a SPAC expense/trust table is present, even if parse would return []. */ +export function hasSpacUseOfProceedsTable(text: string): boolean { + return collectLines(text).some((r) => SPAC_USE.test(r.purpose ?? "")); +} + +function collectLines(text: string): UseOfProceedsLineRow[] { const out: UseOfProceedsLineRow[] = []; for (const table of splitGfmTables(text)) { for (const row of table) { const cells = row.map(cleanCell).filter((c, i, arr) => !(c === "" && i > 0 && arr[0] === "")); const purposeRaw = cells.find((c) => c !== "" && c !== "$" && c !== "%") ?? ""; const purpose = tidyPurpose(purposeRaw); - if (purpose === "" || SKIP_PURPOSE.test(purpose) || isHeaderRow(cells)) continue; + if ( + purpose === "" || + SKIP_PURPOSE.test(purpose) || + DATE_PURPOSE.test(purpose) || + isHeaderRow(cells) + ) { + continue; + } const amount = firstAmount(cells); if (amount === null) continue; if (!text.includes(purposeRaw) && !text.includes(purpose)) continue; @@ -43,7 +65,6 @@ function parseInner(text: string): UseOfProceedsLineRow[] { }); } } - if (out.length < MIN_LINES) return []; return out; } diff --git a/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts new file mode 100644 index 00000000..be3c611e --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSummaryCompensationTable } from "./parseSummaryCompensationTable"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function nameKey(s: string): string { + return s.replace(/\s+/g, "").toLowerCase(); +} + +function fixtures(): Array<{ filing: string; byName: Map }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; byName: Map }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseSummaryCompensationTable golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty executive-compensation label", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "executive-compensation"); + if (!labels || labels.length !== 0) continue; + const text = byName.get(S1_SECTIONS.EXECUTIVE_COMPENSATION) ?? ""; + expect(parseSummaryCompensationTable(text), filing).toEqual([]); + } + }); + + it("does not invent officers outside the golden set when it hits", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "executive-compensation"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.EXECUTIVE_COMPENSATION) ?? ""; + const parsed = parseSummaryCompensationTable(text); + if (parsed.length === 0) continue; + const allowed = new Set( + labels + .map((r) => (typeof r.person_name === "string" ? nameKey(r.person_name) : "")) + .filter((k) => k !== "") + ); + const extras = parsed + .map((row) => row.person_name) + .filter((n, i, arr) => arr.indexOf(n) === i) + .filter((n) => n !== "" && !allowed.has(nameKey(n))); + expect(extras, filing).toEqual([]); + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.test.ts b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.test.ts new file mode 100644 index 00000000..cc68b7b9 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.test.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { parseSummaryCompensationTable } from "./parseSummaryCompensationTable"; + +const TWO_YEAR = [ + "Summary Compensation Table", + "", + "| Name and Principal Position | Year | Year | Salary ($) | Salary ($) | Bonus ($)(1) | Bonus ($)(1) | Option awards ($)(2) | Option awards ($)(2) | All other compensation ($)(3) | All other compensation ($)(3) | Total ($) | Total ($) |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + "| Alina Kowalczyk | | 2025 | | 612,500 | | 425,000 | | 3,180,400 | | 12,300 | | 4,230,200 |", + "| Chief Executive Officer | | 2024 | | 570,000 | | 285,000 | | 1,940,000 | | 11,800 | | 2,806,800 |", + "| Bertrand Osei | | 2025 | | 448,750 | | 224,375 | | 1,102,600 | | 9,450 | | 1,785,175 |", + "| Chief Operating Officer | | 2024 | | 420,000 | | 168,000 | | 640,500 | | 9,100 | | 1,237,600 |", + "| Chandra Villanueva | | 2025 | | 415,000 | | 207,500 | | 968,300 | | 9,450 | | 1,600,250 |", + "| Chief Financial Officer | | 2024 | | 390,000 | | 156,000 | | 512,000 | | 8,900 | | 1,066,900 |", +].join("\n"); + +const WITH_DIRECTOR = [ + "Summary Compensation Table", + "", + "| Name and Principal Position | Year | Salary ($) | Salary ($) | Bonus ($) | Bonus ($) | Stock awards ($)(1) | Stock awards ($)(1) | All other compensation ($) | All other compensation ($) | Total ($) | Total ($) |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + "| Halvard Nilsen(2) | 2025 | | 325,000 | | — | | 748,000 | | 14,200 | | 1,087,200 |", + "| President and Chief Executive Officer | | | | | | | | | | | |", + "| Renata Oyelaran | 2025 | | 285,000 | | 57,000 | | 412,500 | | 13,650 | | 768,150 |", + "| Chief Financial Officer | | | | | | | | | | | |", + "", + "Director Compensation", + "", + "| Name | Fees Earned or Paid in Cash ($) | Stock awards ($) | Total ($) |", + "| --- | --- | --- | --- |", + "| Tobias Brennan | 45,000 | 120,000 | 165,000 |", + "| Yuki Tanabe | 42,500 | 120,000 | 162,500 |", +].join("\n"); + +describe("parseSummaryCompensationTable", () => { + it("never throws", () => { + expect(parseSummaryCompensationTable("")).toEqual([]); + expect(parseSummaryCompensationTable("| |")).toEqual([]); + }); + + it("reads two fiscal years per officer and folds the position line", () => { + const rows = parseSummaryCompensationTable(TWO_YEAR); + expect(rows.map((r) => [r.person_name, r.fiscal_year, r.salary, r.total])).toEqual([ + ["Alina Kowalczyk", 2025, 612500, 4230200], + ["Alina Kowalczyk", 2024, 570000, 2806800], + ["Bertrand Osei", 2025, 448750, 1785175], + ["Bertrand Osei", 2024, 420000, 1237600], + ["Chandra Villanueva", 2025, 415000, 1600250], + ["Chandra Villanueva", 2024, 390000, 1066900], + ]); + expect(rows[1]!.principal_position).toBe("Chief Executive Officer"); + expect(rows.every((r) => r.source === "deterministic")).toBe(true); + }); + + it("does not emit director-table rows or a position line with no year", () => { + const rows = parseSummaryCompensationTable(WITH_DIRECTOR); + expect(rows.map((r) => r.person_name)).toEqual(["Halvard Nilsen", "Renata Oyelaran"]); + expect(rows.map((r) => r.fiscal_year)).toEqual([2025, 2025]); + expect(rows[0]!.bonus).toBeNull(); + expect(rows[0]!.salary).toBe(325000); + expect(rows[0]!.principal_position).toBe("President and Chief Executive Officer"); + }); + + it("reads a Period/$ spacer table with zero salaries", () => { + const text = [ + "Summary Compensation Table", + "| Name and Principal’s | Name and Principal’s | Name and Principal’s |", + "| Position | Period | $ |", + "| Jimmy Ramirez | 2020 | 0 |", + "| Pres/Director | 2019 | 0 |", + "| Franklin Ogele, Snr | 2020 | 0 |", + "| VP/GC/CFO | 2019 | 0 |", + ].join("\n"); + const rows = parseSummaryCompensationTable(text); + expect(rows.map((r) => [r.person_name, r.fiscal_year, r.salary])).toEqual([ + ["Jimmy Ramirez", 2020, 0], + ["Jimmy Ramirez", 2019, 0], + ["Franklin Ogele, Snr", 2020, 0], + ["Franklin Ogele, Snr", 2019, 0], + ]); + }); + + it("ignores an employment-agreement table that has Salary but no Year", () => { + const text = [ + "Employment Agreements", + "| Name | Position(s) | Term | Salary | Salary | Salary |", + "| Ronald W. Pickett | Chief Executive Officer | 1 year | $ | 200,000 | Board Discretionary |", + "| Robert P. Crabb | Secretary | 1 year | $ | 30,000 | None |", + ].join("\n"); + expect(parseSummaryCompensationTable(text)).toEqual([]); + }); + + it("does not treat a wrapped title fragment as an officer", () => { + const text = [ + "Summary Compensation Table", + "| Name and Principal Position | Year | Salary ($) | Total ($) |", + "| Siyu Huang, Ph.D., MBA | 2025 | 200,000 | 1,688,189 |", + "| Founder and | | | |", + "| Chief Executive Officer | | | |", + ].join("\n"); + const rows = parseSummaryCompensationTable(text); + expect(rows.map((r) => r.person_name)).toEqual(["Siyu Huang, Ph.D., MBA"]); + }); + + it("reads a Name/Title/BaseSalary table without a year column", () => { + const text = [ + "Summary Compensation Table", + "| Name | Title | Title | Title | Title | BaseSalary |", + "| Phillip Juhan | | Chief Financial Officer | $ | 145,455 | (1) |", + "| Andrew Northwall | | Chief Operating Officer | $ | 12,674 | (2) |", + ].join("\n"); + const rows = parseSummaryCompensationTable(text); + expect(rows.map((r) => [r.person_name, r.salary, r.principal_position])).toEqual([ + ["Phillip Juhan", 145455, "Chief Financial Officer"], + ["Andrew Northwall", 12674, "Chief Operating Officer"], + ]); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts new file mode 100644 index 00000000..83351302 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts @@ -0,0 +1,443 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseNumeric } from "../../../html/parseNumeric"; +import { + boundPrincipalPosition, + isCompensationPositionLabel, + normalizeFiscalYear, +} from "./sectionExtractors"; +import type { ExecutiveCompensationRow } from "./executiveCompensationSchema"; + +const MONEY_KINDS = [ + "salary", + "bonus", + "stock_awards", + "option_awards", + "non_equity_incentive", + "pension_and_nqdc", + "all_other_compensation", + "total", +] as const; +type MoneyKind = (typeof MONEY_KINDS)[number]; +type ColKind = "name" | "year" | MoneyKind; + +export function parseSummaryCompensationTable(text: string): ExecutiveCompensationRow[] { + try { + return parseInner(text); + } catch { + return []; + } +} + +function parseInner(text: string): ExecutiveCompensationRow[] { + const out: ExecutiveCompensationRow[] = []; + let currentName: string | undefined; + let currentPosition: string | null = null; + for (const table of splitGfmTables(text)) { + const header = findSctHeader(table); + if (header === undefined) continue; + const { kinds, startIdx } = header; + currentName = undefined; + currentPosition = null; + for (const row of table.slice(startIdx + 1)) { + const parsed = parseDataRow(row, kinds, text); + if (parsed === undefined) continue; + if (parsed.kind === "skip") continue; + if (parsed.kind === "position") { + if (currentName === undefined) continue; + currentPosition = parsed.position; + backfillPosition(out, currentName, currentPosition); + if (!parsed.hasYearOrMoney) continue; + out.push(makeRow(currentName, currentPosition, parsed, text)); + continue; + } + currentName = parsed.name; + currentPosition = parsed.position; + out.push(makeRow(currentName, currentPosition, parsed, text)); + } + } + if (out.length === 0) return []; + if (!out.some((r) => text.includes(r.source_span) || text.includes(r.person_name))) return []; + return out.filter((r) => text.includes(r.source_span) || text.includes(r.person_name)); +} + +function backfillPosition( + out: ExecutiveCompensationRow[], + person_name: string, + position: string | null +): void { + if (position === null) return; + for (let i = out.length - 1; i >= 0; i--) { + const row = out[i]!; + if (row.person_name !== person_name) break; + if (row.principal_position === null) row.principal_position = position; + } +} + +function makeRow( + person_name: string, + principal_position: string | null, + parsed: ParsedData, + text: string +): ExecutiveCompensationRow { + const span = parsed.source_span; + return { + person_name, + principal_position: boundPrincipalPosition(principal_position), + fiscal_year: normalizeFiscalYear(parsed.fiscal_year), + salary: parsed.salary, + bonus: parsed.bonus, + stock_awards: parsed.stock_awards, + option_awards: parsed.option_awards, + non_equity_incentive: parsed.non_equity_incentive, + pension_and_nqdc: parsed.pension_and_nqdc, + all_other_compensation: parsed.all_other_compensation, + total: parsed.total, + footnote: null, + confidence: 1, + source_span: text.includes(span) ? span : person_name, + source: "deterministic", + }; +} + +interface ParsedData { + readonly kind: "person" | "position" | "skip"; + readonly name: string; + readonly position: string | null; + readonly fiscal_year: number | null; + readonly salary: number | null; + readonly bonus: number | null; + readonly stock_awards: number | null; + readonly option_awards: number | null; + readonly non_equity_incentive: number | null; + readonly pension_and_nqdc: number | null; + readonly all_other_compensation: number | null; + readonly total: number | null; + readonly hasYearOrMoney: boolean; + readonly source_span: string; +} + +function parseDataRow( + row: readonly string[], + kinds: readonly (ColKind | null)[], + _text: string +): ParsedData | undefined { + const cells = row.map(cleanCell); + const stubRaw = stubCell(cells, kinds); + const stub = tidyName(stubRaw); + if (stub === "" || /^totals?\b/i.test(stub) || isHeaderish(stub)) + return { ...emptyParsed(), kind: "skip" }; + const money: Record = { + salary: null, + bonus: null, + stock_awards: null, + option_awards: null, + non_equity_incentive: null, + pension_and_nqdc: null, + all_other_compensation: null, + total: null, + }; + let fiscal_year: number | null = null; + for (let i = 0; i < kinds.length; i++) { + const kind = kinds[i]; + const cell = cells[i] ?? ""; + if (kind === "year") { + const y = parseYear(cell); + if (y !== null) fiscal_year = y; + continue; + } + if (kind === null || kind === "name") continue; + if (money[kind] !== null) continue; + if (isBlankMoney(cell) || isFootnoteOnly(cell)) continue; + const n = parseNumeric(cell.replace(/,/g, "")); + if (n !== undefined && Number.isFinite(n)) money[kind] = n; + } + if (fiscal_year === null) { + for (const cell of cells) { + const y = parseYear(cell); + if (y !== null) { + fiscal_year = y; + break; + } + } + } + if (money.salary === null) { + for (let i = 0; i < cells.length; i++) { + const kind = kinds[i] ?? null; + if (kind !== null && kind !== "salary") continue; + const cell = cells[i] ?? ""; + if (isBlankMoney(cell) || isFootnoteOnly(cell)) continue; + if (parseYear(cell) !== null) continue; + const n = parseNumeric(cell.replace(/,/g, "")); + if (n !== undefined && Number.isFinite(n) && (n === 0 || Math.abs(n) >= 100)) { + money.salary = n; + break; + } + } + } + const hasYearOrMoney = fiscal_year !== null || MONEY_KINDS.some((k) => money[k] !== null); + const { name, position: inlinePosition } = splitNameAndTitle(stub); + const titleFromCell = cells.map((c) => tidyName(c)).find((c) => c !== stub && isPositionStub(c)); + const position = inlinePosition ?? boundPrincipalPosition(titleFromCell); + const source_span = stubRaw === "" ? stub : stubRaw; + if (isPositionStub(name) || isPositionStub(stub)) { + return { + kind: "position", + name, + position: boundPrincipalPosition(stub), + fiscal_year, + ...money, + hasYearOrMoney, + source_span, + }; + } + if (!looksLikePersonName(name)) return { ...emptyParsed(), kind: "skip" }; + return { + kind: "person", + name, + position, + fiscal_year, + ...money, + hasYearOrMoney, + source_span, + }; +} + +function emptyParsed(): ParsedData { + return { + kind: "skip", + name: "", + position: null, + fiscal_year: null, + salary: null, + bonus: null, + stock_awards: null, + option_awards: null, + non_equity_incentive: null, + pension_and_nqdc: null, + all_other_compensation: null, + total: null, + hasYearOrMoney: false, + source_span: "", + }; +} + +function stubCell(cells: readonly string[], kinds: readonly (ColKind | null)[]): string { + const nameIdx = kinds.findIndex((k) => k === "name"); + if (nameIdx >= 0 && (cells[nameIdx] ?? "") !== "") return cells[nameIdx]!; + return cells.find((c) => c !== "" && c !== "$" && c !== "%") ?? ""; +} + +function findSctHeader( + table: readonly (readonly string[])[] +): { readonly kinds: Array; readonly startIdx: number } | undefined { + for (let i = 0; i < table.length; i++) { + if (isSctHeader(table[i]!)) { + return { kinds: headerKinds(table[i]!), startIdx: i }; + } + if (i + 1 >= table.length) continue; + const merged = mergeHeaderRows(table[i]!, table[i + 1]!); + if (isSctHeader(merged)) { + return { kinds: headerKinds(merged), startIdx: i + 1 }; + } + } + return undefined; +} + +function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { + const n = Math.max(a.length, b.length); + const a0 = cleanCell(a[0] ?? ""); + const out: string[] = []; + for (let i = 0; i < n; i++) { + const left = cleanCell(a[i] ?? ""); + const right = cleanCell(b[i] ?? ""); + if (left === "" || (i > 0 && left === a0)) { + out.push(right); + continue; + } + if (right === "" || right === left) { + out.push(left); + continue; + } + out.push(`${left} ${right}`); + } + return out; +} + +function isSctHeader(row: readonly string[]): boolean { + if (row.some((c) => /^term(?:s|\(s\))?$/i.test(cleanCell(c)))) return false; + const kinds = headerKinds(row); + if (!kinds.includes("name")) return false; + const hasSalary = kinds.includes("salary"); + const hasYear = kinds.includes("year"); + if (hasYear && hasSalary) return true; + return hasSalary && row.some((c) => /base\s*salary/i.test(cleanCell(c))); +} + +function headerKinds(row: readonly string[]): Array { + return row.map((cell) => columnKind(cleanCell(cell))); +} + +function columnKind(cell: string): ColKind | null { + const raw = cell.trim(); + if (raw === "$") return "salary"; + const t = raw + .toLowerCase() + .replace(/[$\(\)0-9]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (t === "") return null; + if (/name and principal|principal position|^name\b/.test(t)) return "name"; + if (/^year$|^period$/.test(t)) return "year"; + if (/base salary|basesalary|^salary/.test(t)) return "salary"; + if (/^bonus/.test(t)) return "bonus"; + if (/stock award/.test(t)) return "stock_awards"; + if (/option award/.test(t)) return "option_awards"; + if (/non[\s-]?equity|nonequity/.test(t)) return "non_equity_incentive"; + if (/pension|nqdc|deferred compensation/.test(t)) return "pension_and_nqdc"; + if (/all other/.test(t)) return "all_other_compensation"; + if (/^total/.test(t)) return "total"; + return null; +} + +function parseYear(cell: string): number | null { + const m = cell.match(/\b(19|20)\d{2}\b/); + if (m === null) return null; + return normalizeFiscalYear(Number(m[0])); +} + +function isBlankMoney(cell: string): boolean { + return ( + cell === "" || + cell === "$" || + cell === "%" || + cell === "—" || + cell === "–" || + cell === "-" || + cell === "*" + ); +} + +function tidyName(raw: string): string { + return raw + .replace(/\(\d+\)/g, "") + .replace(/[\u200b\u200c\u200d\ufeff]/g, "") + .replace(/,+$/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function splitNameAndTitle(stub: string): { name: string; position: string | null } { + const parts = stub + .split(/\s*[—–-]\s*|\n/) + .map((p) => p.trim()) + .filter((p) => p !== ""); + if (parts.length >= 2 && isPositionStub(parts[parts.length - 1]!)) { + return { + name: tidyName(parts.slice(0, -1).join(" ")), + position: boundPrincipalPosition(parts[parts.length - 1]), + }; + } + const peeled = peelInlineTitle(stub); + if (peeled !== undefined) return peeled; + return { name: stub, position: null }; +} + +const INLINE_TITLE = + /\s+((?:President|CEO|CFO|COO|Chief|Officer|Secretary|Treasurer|Chairman|Director|General Manager|Legal Representative)\b.*)$/i; + +function peelInlineTitle(stub: string): { name: string; position: string | null } | undefined { + const m = stub.match(INLINE_TITLE); + if (m === null || m.index === undefined) return undefined; + const head = tidyName(stub.slice(0, m.index)); + if (!looksLikePersonName(head)) return undefined; + return { name: head, position: boundPrincipalPosition(m[1]) }; +} + +function isPositionStub(name: string): boolean { + if (isCompensationPositionLabel(name)) return true; + if ( + /^(former|current|our)\s+(chief|president|vice|executive|director|officer|chairman)/i.test(name) + ) { + return true; + } + if (/^(pres|vp|cfo|ceo|coo|gc)\b/i.test(name)) return true; + if (/^founder\b/i.test(name) || /\band$/i.test(name)) return true; + return /^[A-Z]{2,4}(\/[A-Z]{2,4})+$/.test(name); +} + +function looksLikePersonName(name: string): boolean { + if (name.length < 3) return false; + if (isPositionStub(name)) return false; + const words = name + .replace(/,.*$/, "") + .split(/\s+/) + .filter((w) => w !== ""); + return words.length >= 2; +} + +function isHeaderish(stub: string): boolean { + return /name and principal|principal position|^year$|^period$|^salary\b|^basesalary$|^title$/i.test( + stub + ); +} + +function isFootnoteOnly(cell: string): boolean { + return /^\(\d+\)$/.test(cell.trim()); +} + +function cleanCell(raw: string): string { + return raw + .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function splitGfmTables(text: string): string[][][] { + const tables: string[][][] = []; + let current: string[][] = []; + const flush = (): void => { + if (current.length > 0) tables.push(current); + current = []; + }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + flush(); + continue; + } + if (isSeparatorRow(trimmed)) continue; + current.push(splitPipeRow(trimmed).map(cleanCell)); + } + flush(); + return tables; +} + +function isSeparatorRow(line: string): boolean { + return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); +} + +function splitPipeRow(line: string): string[] { + const inner = line.replace(/^\|/, "").replace(/\|$/, ""); + const cells: string[] = []; + let cur = ""; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === "\\" && inner[i + 1] === "|") { + cur += "|"; + i += 1; + continue; + } + if (inner[i] === "|") { + cells.push(cur); + cur = ""; + continue; + } + cur += inner[i]; + } + cells.push(cur); + return cells; +} diff --git a/src/storage/executive-compensation/ExecutiveCompensationRepo.ts b/src/storage/executive-compensation/ExecutiveCompensationRepo.ts index 8461a9c9..60e20683 100644 --- a/src/storage/executive-compensation/ExecutiveCompensationRepo.ts +++ b/src/storage/executive-compensation/ExecutiveCompensationRepo.ts @@ -26,6 +26,10 @@ export class ExecutiveCompensationRepo { return (await this.storage.query({ accession_number })) ?? []; } + async listAll(): Promise { + return (await this.storage.getAll()) ?? []; + } + /** * Removes every compensation row for a filing. Rows are keyed by a positional * `(accession_number, extractor_id, row_index)`, so re-extracting a filing diff --git a/src/task/eval/EvalExecutiveCompensationTask.ts b/src/task/eval/EvalExecutiveCompensationTask.ts new file mode 100644 index 00000000..3a1c2c4d --- /dev/null +++ b/src/task/eval/EvalExecutiveCompensationTask.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runExecutiveCompensationEval } from "../../eval/runExecutiveCompensationEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalExecutiveCompensationTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalExecutiveCompensationTaskOutput = Static>; + +/** + * Scores the deterministic Summary Compensation Table parser against stored + * rows using on-disk accession docs. Never fetches EDGAR. + */ +export class EvalExecutiveCompensationTask extends Task< + EvalExecutiveCompensationTaskInput, + EvalExecutiveCompensationTaskOutput +> { + static readonly type = "EvalExecutiveCompensationTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate executive compensation"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalExecutiveCompensationTaskInput, + context: IExecuteContext + ): Promise { + const report = await runExecutiveCompensationEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalExecutiveCompensationTaskOutput; + } +} diff --git a/src/task/eval/EvalUseOfProceedsTask.ts b/src/task/eval/EvalUseOfProceedsTask.ts new file mode 100644 index 00000000..a20bf46c --- /dev/null +++ b/src/task/eval/EvalUseOfProceedsTask.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runUseOfProceedsEval } from "../../eval/runUseOfProceedsEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalUseOfProceedsTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalUseOfProceedsTaskOutput = Static>; + +/** + * Scores the deterministic SPAC use-of-proceeds table parser against stored + * lines using on-disk accession docs. Never fetches EDGAR. + */ +export class EvalUseOfProceedsTask extends Task< + EvalUseOfProceedsTaskInput, + EvalUseOfProceedsTaskOutput +> { + static readonly type = "EvalUseOfProceedsTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate use of proceeds"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalUseOfProceedsTaskInput, + context: IExecuteContext + ): Promise { + const report = await runUseOfProceedsEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalUseOfProceedsTaskOutput; + } +} From 22d0af441f11319312041e6bba81bfe885870a2e Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Tue, 18 Aug 2026 11:30:09 -0700 Subject: [PATCH 04/29] feat(eval): add beneficial ownership evaluation command and reporting - Introduced `EvalBeneficialOwnershipTask` to score the beneficial ownership parser against stored rows using on-disk cache. - Implemented a new CLI command `beneficial-ownership` with options for extractor ID, limit, CIK, and output format. - Created `printBeneficialOwnershipReport` function to display evaluation results in a structured format. - Developed tests for the new command and its options, ensuring comprehensive coverage of input validation and functionality. - Added utility functions for parsing and evaluating beneficial ownership data from S-1 filings. --- src/cli/groups/eval.ts | 63 +++++ src/cli/groups/evalOptionValues.test.ts | 5 + src/eval/runBeneficialOwnershipEval.test.ts | 40 +++ src/eval/runBeneficialOwnershipEval.ts | 245 +++++++++++++++++ .../Form_S_1.storage.ownership.test.ts | 88 +++++++ .../Form_S_1.storage.ts | 12 +- .../parseBeneficialOwnership.corpus.test.ts | 94 +++++++ .../s1/parseBeneficialOwnership.test.ts | 95 +++++++ .../s1/parseBeneficialOwnership.ts | 249 ++++++++++++++++++ .../s1/sectionSchemas.ts | 2 + .../BeneficialOwnershipRepo.ts | 4 + src/task/eval/EvalBeneficialOwnershipTask.ts | 73 +++++ 12 files changed, 968 insertions(+), 2 deletions(-) create mode 100644 src/eval/runBeneficialOwnershipEval.test.ts create mode 100644 src/eval/runBeneficialOwnershipEval.ts create mode 100644 src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts create mode 100644 src/task/eval/EvalBeneficialOwnershipTask.ts diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index 9709a812..b237c319 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -43,11 +43,13 @@ import { EvalOfferingTablesTask } from "../../task/eval/EvalOfferingTablesTask"; import { EvalUnderwritersTask } from "../../task/eval/EvalUnderwritersTask"; import { EvalUseOfProceedsTask } from "../../task/eval/EvalUseOfProceedsTask"; import { EvalExecutiveCompensationTask } from "../../task/eval/EvalExecutiveCompensationTask"; +import { EvalBeneficialOwnershipTask } from "../../task/eval/EvalBeneficialOwnershipTask"; import { type UnitTermsReport } from "../../eval/runUnitTermsEval"; import type { OfferingTablesReport } from "../../eval/runOfferingTablesEval"; import type { UnderwritersReport } from "../../eval/runUnderwritersEval"; import type { UseOfProceedsReport } from "../../eval/runUseOfProceedsEval"; import type { ExecutiveCompensationReport } from "../../eval/runExecutiveCompensationEval"; +import type { BeneficialOwnershipReport } from "../../eval/runBeneficialOwnershipEval"; /** * Default comparison set: Anthropic's cheap and strong tiers, plus the cheap @@ -190,6 +192,26 @@ function truncate(s: string, max = 60): string { return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } +function printBeneficialOwnershipReport(report: BeneficialOwnershipReport): void { + const { counts } = report; + console.log( + `beneficial-ownership parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + function printExecutiveCompensationReport(report: ExecutiveCompensationReport): void { const { counts } = report; console.log( @@ -1178,4 +1200,45 @@ export function addEvalCommands(program: Command): void { }); } ); + + cmd + .command("beneficial-ownership") + .description( + "Score the deterministic beneficial-ownership parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalBeneficialOwnershipTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printBeneficialOwnershipReport(report); + }); + } + ); } diff --git a/src/cli/groups/evalOptionValues.test.ts b/src/cli/groups/evalOptionValues.test.ts index 7e5707b5..54adca0f 100644 --- a/src/cli/groups/evalOptionValues.test.ts +++ b/src/cli/groups/evalOptionValues.test.ts @@ -122,6 +122,11 @@ describe("eval value-less options", () => { expect(await runEval(["executive-compensation", "--format"])).toContain("one of: table, json"); }); + it("covers eval beneficial-ownership' value options", async () => { + expect(await runEval(["beneficial-ownership", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["beneficial-ownership", "--format"])).toContain("one of: table, json"); + }); + it("lists print-prompts modes for a bare --print-prompts on extract", async () => { const err = await runEval(["extract", "--print-prompts"]); expect(err).toContain("--print-prompts needs a value"); diff --git a/src/eval/runBeneficialOwnershipEval.test.ts b/src/eval/runBeneficialOwnershipEval.test.ts new file mode 100644 index 00000000..054bbcb4 --- /dev/null +++ b/src/eval/runBeneficialOwnershipEval.test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runBeneficialOwnershipEval"; + +const TABLE = [ + "| Name and Address of Beneficial Owner | Number of Shares Beneficially Owned | Percent |", + "| Halyard Sponsor III LLC | 4,312,500 | 100.0% |", +].join("\n"); + +describe("bucketWhenParserEmpty", () => { + it("skips when stored has no lines", () => { + expect(bucketWhenParserEmpty({ stored: [], sectionText: TABLE })).toEqual({ + bucket: "skip", + reason: "all-null stored", + }); + }); + + it("skips when there is no ownership table", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ name: "Halyard Sponsor III LLC", owner_kind: "company", shares_owned: 4312500 }], + sectionText: "Our sponsor owns founder shares.", + }) + ).toEqual({ bucket: "skip", reason: "no-table" }); + }); + + it("misses an ownership table the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ name: "Halyard Sponsor III LLC", owner_kind: "company", shares_owned: 4312500 }], + sectionText: TABLE, + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runBeneficialOwnershipEval.ts b/src/eval/runBeneficialOwnershipEval.ts new file mode 100644 index 00000000..d652d97d --- /dev/null +++ b/src/eval/runBeneficialOwnershipEval.ts @@ -0,0 +1,245 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { + hasBeneficialOwnershipTable, + parseBeneficialOwnership, +} from "../sec/forms/registration-statements/s1/parseBeneficialOwnership"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { BeneficialOwnershipRepo } from "../storage/beneficial-ownership/BeneficialOwnershipRepo"; +import { PersonObservationRepo } from "../storage/observation/PersonObservationRepo"; +import { CompanyObservationRepo } from "../storage/observation/CompanyObservationRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface BeneficialOwnershipEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type BeneficialOwnershipBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface BeneficialOwnershipLineScore { + readonly name: string; + readonly owner_kind: string; + readonly shares_owned: number | null; +} + +export interface BeneficialOwnershipCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: BeneficialOwnershipBucket; + readonly cachePath: string | undefined; + readonly parsed?: readonly BeneficialOwnershipLineScore[]; + readonly stored?: readonly BeneficialOwnershipLineScore[]; + readonly reason?: string; +} + +export interface BeneficialOwnershipReport { + readonly cases: readonly BeneficialOwnershipCase[]; + readonly counts: Record; +} + +export async function runBeneficialOwnershipEval( + options: BeneficialOwnershipEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; beneficial-ownership eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const storedByKey = await loadStored(); + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: BeneficialOwnershipCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const stored = storedByKey.get(`${item.extractor_id}\t${item.accession_number}`) ?? []; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function loadStored(): Promise> { + const rows = await new BeneficialOwnershipRepo().listAll(); + const people = await new PersonObservationRepo().listAll(); + const companies = await new CompanyObservationRepo().listAll(); + const personById = new Map(people.map((p) => [p.observation_id, p])); + const companyById = new Map(companies.map((c) => [c.observation_id, c])); + const storedByKey = new Map(); + for (const r of rows) { + const key = `${r.extractor_id}\t${r.accession_number}`; + let name = ""; + if (r.owner_kind === "person" && r.observation_id !== null) { + name = displayPerson(personById.get(r.observation_id)); + } else if (r.observation_id !== null) { + name = companyById.get(r.observation_id)?.name ?? ""; + } + const arr = storedByKey.get(key) ?? []; + arr.push({ name, owner_kind: r.owner_kind, shares_owned: r.shares_owned }); + storedByKey.set(key, arr); + } + return storedByKey; +} + +function displayPerson( + person: + | { + first_name: string | null; + middle_name: string | null; + last_name: string | null; + suffix: string | null; + } + | undefined +): string { + if (person === undefined) return ""; + return [person.first_name, person.middle_name, person.last_name, person.suffix] + .filter((p) => p != null && p !== "") + .join(" "); +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: readonly BeneficialOwnershipLineScore[] +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip" as const, cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "empty cache" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = byName.get(S1_SECTIONS.BENEFICIAL_OWNERSHIP) ?? ""; + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRows = parseBeneficialOwnership(text); + const parsed: BeneficialOwnershipLineScore[] = parsedRows.map((r) => ({ + name: r.name, + owner_kind: r.owner_kind, + shares_owned: r.shares_owned, + })); + if (parsed.length === 0) { + const miss = bucketWhenParserEmpty({ stored, sectionText: text }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: readonly BeneficialOwnershipLineScore[]; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!hasBeneficialOwnershipTable(args.sectionText)) { + return { bucket: "skip", reason: "no-table" }; + } + return { bucket: "miss", reason: undefined }; +} + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function scoredEqual( + a: readonly BeneficialOwnershipLineScore[], + b: readonly BeneficialOwnershipLineScore[] +): boolean { + const keyOf = (r: BeneficialOwnershipLineScore): string => + `${nameKey(r.name)}\t${r.owner_kind}\t${r.shares_owned ?? ""}`; + const aKeys = a.map(keyOf).toSorted(); + const bKeys = b.map(keyOf).toSorted(); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((k, i) => k === bKeys[i]); +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts new file mode 100644 index 00000000..4809e05a --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { BeneficialOwnershipRepo } from "../../../storage/beneficial-ownership/BeneficialOwnershipRepo"; +import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; +import { processFormS1 } from "./Form_S_1.storage"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const HTML_PARSEABLE = [ + "

MANAGEMENT

", + "

Eleanor Vasquez — Director

", + "

PRINCIPAL STOCKHOLDERS

", + "", + "", + "", + "", + "
Name and Address of Beneficial OwnerNumber of Shares Beneficially OwnedApproximate Percentage
Halyard Sponsor III LLC4,312,500100.0%
Eleanor Vasquez4,312,500100.0%
", + "

LEGAL MATTERS

x

", +].join(""); + +const NULL_HEADER = { + sic: null, + sicDescription: null, + cik: null, + companyName: null, + filingDate: null, +}; + +const MANAGEMENT_PAYLOAD = { + people: [ + { + full_name: "Eleanor Vasquez", + titles: ["Director"], + relationship: null, + confidence: 0.9, + source_span: "Eleanor Vasquez — Director", + }, + ], +}; + +let cleanup: (() => void) | undefined; + +describe("processFormS1 beneficial ownership", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("persists a parseable table as deterministic without calling the ownership model", async () => { + const { unregister } = registerFakeStructuredProvider([MANAGEMENT_PAYLOAD]); + cleanup = unregister; + + await processFormS1({ + cik: 1018724, + file_number: "333-1", + accession_number: "acc-own-1", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: NULL_HEADER, + html: HTML_PARSEABLE, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const rows = await new BeneficialOwnershipRepo().queryByAccession("acc-own-1"); + expect(rows.map((r) => [r.owner_kind, r.shares_owned])).toEqual([ + ["company", 4312500], + ["person", 4312500], + ]); + const companies = await new CompanyObservationRepo().listAll(); + expect(companies.some((c) => /Halyard Sponsor/i.test(c.name ?? ""))).toBe(true); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 6432e0d8..112ee598 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -48,6 +48,7 @@ import { import type { ExecutiveCompensationRow } from "./s1/executiveCompensationSchema"; import { hasSummaryCompensationTable } from "./s1/compensationHeuristic"; import { parseSummaryCompensationTable } from "./s1/parseSummaryCompensationTable"; +import { parseBeneficialOwnership } from "./s1/parseBeneficialOwnership"; import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { looksLikePartIIOnlyAmendment } from "./s1/partIIOnlyAmendment"; import { issuerHasCombinationListing } from "./s1/newcoListing"; @@ -804,9 +805,16 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident ownership rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident ownership rows had source_span not present in section text", - ...modelExtractChain(models, (text, m) => extractBeneficialOwnership(text, m, args.context)), + ...modelExtractChain(models, async (text, m) => { + const det = parseBeneficialOwnership(text); + if (det.length > 0) return det; + return extractBeneficialOwnership(text, m, args.context); + }), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); + const model_id = + rows[0]?.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); for (const r of rows) { if (r.owner_kind === "company" && isUnnamedCompanyName(r.name)) continue; const observation_index = idx++; diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts new file mode 100644 index 00000000..4f231da4 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseBeneficialOwnership } from "./parseBeneficialOwnership"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function coveredName(n: string, allowed: Set): boolean { + const k = nameKey(n); + if (allowed.has(k)) return true; + for (const a of allowed) { + if (k.startsWith(a) || a.startsWith(k)) return true; + } + return false; +} + +function looksLikeCaption(n: string): boolean { + return ( + /:\s*$/.test(n) || + /shares beneficially|named executive|principal shareholders|table of contents/i.test(n) + ); +} + +function fixtures(): Array<{ filing: string; byName: Map }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; byName: Map }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseBeneficialOwnership golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty beneficial-ownership label", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "beneficial-ownership"); + if (!labels || labels.length !== 0) continue; + const text = byName.get(S1_SECTIONS.BENEFICIAL_OWNERSHIP) ?? ""; + expect(parseBeneficialOwnership(text), filing).toEqual([]); + } + }); + + it("does not invent owners outside the golden set when it hits", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "beneficial-ownership"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.BENEFICIAL_OWNERSHIP) ?? ""; + const parsed = parseBeneficialOwnership(text); + if (parsed.length === 0) continue; + const allowed = new Set( + labels + .map((r) => (typeof r.name === "string" ? nameKey(r.name) : "")) + .filter((k) => k !== "") + ); + const extras = parsed + .map((row) => row.name) + .filter((n, i, arr) => arr.indexOf(n) === i) + .filter((n) => n !== "" && !coveredName(n, allowed)); + const garbage = extras.filter((n) => looksLikeCaption(n)); + expect(garbage, filing).toEqual([]); + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts new file mode 100644 index 00000000..8f45ac77 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { parseBeneficialOwnership } from "./parseBeneficialOwnership"; + +const SPAC = [ + "PRINCIPAL STOCKHOLDERS", + "| Name and Address of Beneficial Owner(1) | Number of Shares Beneficially Owned(2) | Approximate Percentage |", + "| --- | --- | --- |", + "| Halyard Sponsor III LLC(our sponsor)(3) | 4,312,500 | 100.0% |", + "| Eleanor Vasquez(3)(4) | 4,312,500 | 100.0% |", + "| Desmond Achebe | — | — |", + "| Marta Lindqvist(4) | — | — |", + "| Peter Sandoval-Reyes(4) | 43,125 | * |", + "| All officers and directors as a group (five individuals) | 4,355,625 | 100.0% |", +].join("\n"); + +const COLSPAN = [ + "| Name and Address of Beneficial Owner(1) | Name and Address of Beneficial Owner(1) | Amount and Nature of Beneficial Ownership | Approximate Percentage |", + "| Southern Cross Acquisition II Sponsor Corp. (3) | Southern Cross Acquisition II Sponsor Corp. (3) | 2,820,000 | 98.09 | % |", + "| Peizhong Yu | Peizhong Yu | 2,820,000 | 98.09 | % |", + "| Principal Shareholders (5% or more) | | | |", + "| Ally Tong Zhang | Ally Tong Zhang | 15,000 | * | % |", + "| All directors and executive officers (five individuals) as a | 2,875,000 | 100 | % |", +].join("\n"); + +const HEADERLESS = ["PRINCIPAL AND SELLING STOCKHOLDERS", "| ACME Fund | 1,000,000 | 12.5% |"].join( + "\n" +); + +describe("parseBeneficialOwnership", () => { + it("never throws", () => { + expect(parseBeneficialOwnership("")).toEqual([]); + expect(parseBeneficialOwnership("| |")).toEqual([]); + }); + + it("reads a SPAC table, keeps dash rows, and drops the group subtotal", () => { + const rows = parseBeneficialOwnership(SPAC); + expect(rows.map((r) => [r.name, r.owner_kind, r.shares_owned, r.percent_owned])).toEqual([ + ["Halyard Sponsor III LLC", "company", 4312500, 100], + ["Eleanor Vasquez", "person", 4312500, 100], + ["Desmond Achebe", "person", null, null], + ["Marta Lindqvist", "person", null, null], + ["Peter Sandoval-Reyes", "person", 43125, null], + ]); + expect(rows.every((r) => r.source === "deterministic")).toBe(true); + }); + + it("collapses colspan copies and skips captions plus truncated group rows", () => { + const rows = parseBeneficialOwnership(COLSPAN); + expect(rows.map((r) => r.name)).toEqual([ + "Southern Cross Acquisition II Sponsor Corp.", + "Peizhong Yu", + "Ally Tong Zhang", + ]); + expect(rows[0]!.owner_kind).toBe("company"); + expect(rows[0]!.shares_owned).toBe(2820000); + expect(rows[0]!.percent_owned).toBe(98.09); + }); + + it("does not parse a headerless grid", () => { + expect(parseBeneficialOwnership(HEADERLESS)).toEqual([]); + }); + + it("peels a glued title and c/o address off the owner name", () => { + const text = [ + "| Name and Address of Beneficial Owner | Number of Shares Beneficially Owned | Percent |", + "| Martin J. Shen, Chief Executive Officer c/o 111 Somerset Road, Level 3, Singapore, 238164 | 1,000,000 | 10.0% |", + ].join("\n"); + const rows = parseBeneficialOwnership(text); + expect(rows.map((r) => r.name)).toEqual(["Martin J. Shen"]); + }); + + it("reads a split Name-row header above Before/After share columns", () => { + const text = [ + "| Number of SharesBeneficially Owned(2) | Number of SharesBeneficially Owned(2) | Number of SharesBeneficially Owned(2) |", + "| BeforeOffering | AfterOffering | BeforeOffering |", + "| Name and Address of Beneficial Owner(1) | | |", + "| Europe Acquisition Holdings Limited(3)(4) | 5,899,583 | 5,141,771 | 82.1 | % | 16.5 |", + "| Hazem Ben-Gacem | — | | |", + "| Peter McKellar(5)(6) | 479,167 | 407,292 | 6.7 | % | 1.3 |", + "| All officers, directors and director nominees as a group(8 individuals | 1,287,917 | 1,108,229 | 17.9 | % |", + ].join("\n"); + const rows = parseBeneficialOwnership(text); + expect(rows.map((r) => [r.name, r.shares_owned])).toEqual([ + ["Europe Acquisition Holdings Limited", 5899583], + ["Hazem Ben-Gacem", null], + ["Peter McKellar", 479167], + ]); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts new file mode 100644 index 00000000..6ed2d39a --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseNumeric } from "../../../html/parseNumeric"; +import { hasCompanyEnding } from "../../../../storage/company/CompanyNormalization"; +import { isOwnershipGroupSubtotal } from "./sectionExtractors"; +import type { BeneficialOwnerRow } from "./sectionSchemas"; + +export function parseBeneficialOwnership(text: string): BeneficialOwnerRow[] { + try { + return parseInner(text); + } catch { + return []; + } +} + +export function hasBeneficialOwnershipTable(text: string | undefined): boolean { + if (text === undefined || text.trim() === "") return false; + return splitGfmTables(text).some((table) => findOwnershipHeader(table) !== undefined); +} + +function parseInner(text: string): BeneficialOwnerRow[] { + const out: BeneficialOwnerRow[] = []; + for (const table of splitGfmTables(text)) { + const header = findOwnershipHeader(table); + if (header === undefined) continue; + const { startIdx } = header; + for (const row of table.slice(startIdx + 1)) { + const parsed = parseDataRow(row, text); + if (parsed === undefined) continue; + out.push(parsed); + } + } + return out.filter((r) => text.includes(r.source_span) || text.includes(r.name)); +} + +function findOwnershipHeader( + table: readonly (readonly string[])[] +): { readonly startIdx: number } | undefined { + for (let i = 0; i < table.length; i++) { + if (isOwnershipHeader(table[i]!)) return { startIdx: i }; + if (i + 1 < table.length && isOwnershipHeader(mergeHeaderRows(table[i]!, table[i + 1]!))) { + return { startIdx: i + 1 }; + } + if (isNameCaptionRow(table[i]!)) { + return { startIdx: i }; + } + } + return undefined; +} + +function isNameCaptionRow(row: readonly string[]): boolean { + const stub = collapseRow(row).join(" "); + if (/\d{3,}/.test(stub)) return false; + return /name and address of beneficial owner|^name of beneficial owner/i.test(stub); +} + +function isOwnershipHeader(row: readonly string[]): boolean { + const cells = collapseRow(row); + const blob = cells.join(" ").toLowerCase(); + const hasName = /beneficial owner|^name\b/.test(blob); + const hasShares = + /number of shares|shares beneficially|amount and nature|beneficially owned/.test(blob); + const hasPercent = /percent/.test(blob); + return hasName && (hasShares || hasPercent); +} + +function parseDataRow(row: readonly string[], text: string): BeneficialOwnerRow | undefined { + const cells = collapseRow(row); + const stubRaw = cells.find((c) => c !== "" && c !== "$" && c !== "%" && c !== "*") ?? ""; + const stub = peelName(tidyName(stubRaw)); + if (stub === "" || isSkipStub(stub)) return undefined; + if (isGroupRow(stub)) return undefined; + if (!looksLikeOwner(stub)) return undefined; + const { shares_owned, percent_owned } = parseFigures(cells); + const source_span = text.includes(stubRaw) ? stubRaw : stub; + return { + name: stub, + owner_kind: ownerKind(stub), + security_class: null, + shares_owned, + percent_owned, + shares_offered: null, + shares_after: null, + percent_after: null, + is_selling_stockholder: false, + footnote: null, + confidence: 1, + source_span, + source: "deterministic", + }; +} + +function parseFigures(cells: readonly string[]): { + readonly shares_owned: number | null; + readonly percent_owned: number | null; +} { + let shares_owned: number | null = null; + let percent_owned: number | null = null; + let pending: number | null = null; + for (const cell of cells) { + if (isFootnoteOnly(cell) || isBlankMoney(cell)) continue; + if (cell === "*") { + percent_owned = null; + pending = null; + continue; + } + if (cell === "%") { + if (pending !== null && pending <= 100) percent_owned = pending; + pending = null; + continue; + } + const n = parseNumeric(cell.replace(/,/g, "")); + if (n === undefined || !Number.isFinite(n)) continue; + if (shares_owned === null && Number.isInteger(n) && (n === 0 || Math.abs(n) >= 1)) { + shares_owned = n; + continue; + } + pending = n; + } + if (percent_owned === null && pending !== null && pending <= 100 && shares_owned !== null) { + percent_owned = pending; + } + return { shares_owned, percent_owned }; +} + +function isGroupRow(name: string): boolean { + if (isOwnershipGroupSubtotal(name)) return true; + return /^all\b/i.test(name) && /\b(directors?|officers?|nominees?)\b/i.test(name); +} + +function ownerKind(name: string): "person" | "company" { + const stripped = name.replace(/\.+$/, ""); + return hasCompanyEnding(name) || hasCompanyEnding(stripped) ? "company" : "person"; +} + +function isSkipStub(stub: string): boolean { + if (/:\s*$/.test(stub)) return true; + return /principal shareholders|directors and executive|beneficial owner|^name\b|prior to offering|approximate percentage|amount and nature|table of contents|less than|5%\s*or more|named executive|shares beneficially|beneficially owned/i.test( + stub + ); +} + +function looksLikeOwner(name: string): boolean { + if (name.length < 3) return false; + if (isSkipStub(name) || isGroupRow(name)) return false; + if (hasCompanyEnding(name)) return true; + const words = name.split(/\s+/).filter((w) => w !== ""); + return words.length >= 2; +} + +function peelName(stub: string): string { + let s = stub.replace(/\s+c\/o\s+.*/i, "").trim(); + s = s + .replace(/,\s+(?:Chief|Director|President|Legal Representative|Officer|CEO|CFO|COO)\b.*/i, "") + .trim(); + s = s.replace(/\s+\d{2,}\s+\S+.*/, "").trim(); + return s; +} + +function tidyName(raw: string): string { + return raw + .replace(/\(\d+\)/g, "") + .replace(/\((?:our\s+)?sponsor\)/gi, "") + .replace(/\((?:ceo|cfo|coo|president|director|officer)\)/gi, "") + .replace(/[\u200b\u200c\u200d\ufeff]/g, "") + .replace(/,+$/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function isFootnoteOnly(cell: string): boolean { + return /^\(\d+\)$/.test(cell.trim()); +} + +function isBlankMoney(cell: string): boolean { + return cell === "" || cell === "$" || cell === "—" || cell === "–" || cell === "-"; +} + +function collapseRow(row: readonly string[]): string[] { + const out: string[] = []; + for (const raw of row) { + const cell = cleanCell(raw); + if (cell === "") continue; + if (out[out.length - 1] === cell) continue; + out.push(cell); + } + return out; +} + +function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { + const n = Math.max(a.length, b.length); + const a0 = cleanCell(a[0] ?? ""); + const out: string[] = []; + for (let i = 0; i < n; i++) { + const left = cleanCell(a[i] ?? ""); + const right = cleanCell(b[i] ?? ""); + if (left === "" || (i > 0 && left === a0)) { + out.push(right); + continue; + } + if (right === "" || right === left) { + out.push(left); + continue; + } + out.push(`${left} ${right}`); + } + return out; +} + +function cleanCell(raw: string): string { + return raw + .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function splitGfmTables(text: string): string[][][] { + const tables: string[][][] = []; + let current: string[][] = []; + const flush = (): void => { + if (current.length > 0) tables.push(current); + current = []; + }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + flush(); + continue; + } + if (isSeparatorRow(trimmed)) continue; + current.push(splitPipeRow(trimmed).map(cleanCell)); + } + flush(); + return tables; +} + +function isSeparatorRow(line: string): boolean { + return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); +} + +function splitPipeRow(line: string): string[] { + const inner = line.startsWith("|") ? line.slice(1) : line; + const end = inner.endsWith("|") ? inner.slice(0, -1) : inner; + return end.split("|"); +} diff --git a/src/sec/forms/registration-statements/s1/sectionSchemas.ts b/src/sec/forms/registration-statements/s1/sectionSchemas.ts index b605718a..7f52b117 100644 --- a/src/sec/forms/registration-statements/s1/sectionSchemas.ts +++ b/src/sec/forms/registration-statements/s1/sectionSchemas.ts @@ -146,6 +146,8 @@ export interface BeneficialOwnerRow { footnote: string | null; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } export interface RelatedPartyRow { name: string; diff --git a/src/storage/beneficial-ownership/BeneficialOwnershipRepo.ts b/src/storage/beneficial-ownership/BeneficialOwnershipRepo.ts index b82164bc..1ed38377 100644 --- a/src/storage/beneficial-ownership/BeneficialOwnershipRepo.ts +++ b/src/storage/beneficial-ownership/BeneficialOwnershipRepo.ts @@ -26,6 +26,10 @@ export class BeneficialOwnershipRepo { return (await this.storage.query({ accession_number })) ?? []; } + async listAll(): Promise { + return (await this.storage.getAll()) ?? []; + } + /** * Removes every beneficial-ownership row for a filing. Rows are keyed by a * positional `(accession_number, extractor_id, observation_index)`, so diff --git a/src/task/eval/EvalBeneficialOwnershipTask.ts b/src/task/eval/EvalBeneficialOwnershipTask.ts new file mode 100644 index 00000000..002240df --- /dev/null +++ b/src/task/eval/EvalBeneficialOwnershipTask.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runBeneficialOwnershipEval } from "../../eval/runBeneficialOwnershipEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalBeneficialOwnershipTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalBeneficialOwnershipTaskOutput = Static>; + +/** + * Scores the deterministic beneficial-ownership parser against stored rows + * using on-disk accession docs. Never fetches EDGAR. + */ +export class EvalBeneficialOwnershipTask extends Task< + EvalBeneficialOwnershipTaskInput, + EvalBeneficialOwnershipTaskOutput +> { + static readonly type = "EvalBeneficialOwnershipTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate beneficial ownership"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalBeneficialOwnershipTaskInput, + context: IExecuteContext + ): Promise { + const report = await runBeneficialOwnershipEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalBeneficialOwnershipTaskOutput; + } +} From 432b7f00281843a6aa90c4ad6903c9c9ec9e8b40 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Tue, 18 Aug 2026 18:11:29 -0700 Subject: [PATCH 05/29] feat(eval): add management, related-party, spac sponsors, spac profile, and spac classification evaluation commands - Introduced new evaluation tasks: `EvalManagementTask`, `EvalRelatedPartyTask`, `EvalSpacSponsorsTask`, `EvalSpacProfileTask`, and `EvalSpacClassificationTask` to score respective parsers against stored rows using on-disk cache. - Implemented CLI commands for each evaluation task with options for extractor ID, limit, CIK, and output format. - Created reporting functions: `printManagementReport`, `printRelatedPartyReport`, `printSpacSponsorsReport`, `printSpacProfileReport`, and `printSpacClassificationReport` to display evaluation results in a structured format. - Developed tests for the new commands and their options, ensuring comprehensive coverage of input validation and functionality. - Added utility functions for parsing and evaluating management, related-party, spac sponsors, spac profile, and spac classification data from S-1 filings. --- src/cli/groups/eval.ts | 315 ++++++++++++++++++ src/cli/groups/evalOptionValues.test.ts | 25 ++ src/eval/runManagementEval.test.ts | 40 +++ src/eval/runManagementEval.ts | 238 +++++++++++++ src/eval/runRelatedPartyEval.test.ts | 40 +++ src/eval/runRelatedPartyEval.ts | 244 ++++++++++++++ src/eval/runSpacClassificationEval.test.ts | 38 +++ src/eval/runSpacClassificationEval.ts | 186 +++++++++++ src/eval/runSpacProfileEval.test.ts | 41 +++ src/eval/runSpacProfileEval.ts | 223 +++++++++++++ src/eval/runSpacSponsorsEval.test.ts | 38 +++ src/eval/runSpacSponsorsEval.ts | 221 ++++++++++++ .../Form_S_1.storage.classification.test.ts | 71 ++++ .../Form_S_1.storage.management.test.ts | 77 +++++ .../Form_S_1.storage.profile.test.ts | 70 ++++ .../Form_S_1.storage.related-party.test.ts | 87 +++++ .../Form_S_1.storage.sponsors.test.ts | 86 +++++ .../Form_S_1.storage.ts | 52 ++- .../s1/parseManagementRoster.corpus.test.ts | 94 ++++++ .../s1/parseManagementRoster.test.ts | 94 ++++++ .../s1/parseManagementRoster.ts | 247 ++++++++++++++ .../s1/parseRelatedPartyTables.corpus.test.ts | 91 +++++ .../s1/parseRelatedPartyTables.test.ts | 56 ++++ .../s1/parseRelatedPartyTables.ts | 191 +++++++++++ .../s1/parseSpacClassification.corpus.test.ts | 53 +++ .../s1/parseSpacClassification.test.ts | 32 ++ .../s1/parseSpacClassification.ts | 40 +++ .../s1/parseSpacProfile.corpus.test.ts | 83 +++++ .../s1/parseSpacProfile.test.ts | 77 +++++ .../s1/parseSpacProfile.ts | 274 +++++++++++++++ .../s1/parseSpacSponsors.corpus.test.ts | 94 ++++++ .../s1/parseSpacSponsors.test.ts | 57 ++++ .../s1/parseSpacSponsors.ts | 80 +++++ .../s1/sectionSchemas.ts | 4 + .../s1/spacClassifierSchema.ts | 2 + .../s1/spacProfileSchema.ts | 2 + .../s1/spacSponsorSchema.ts | 2 + .../RelatedPartyTransactionRepo.ts | 4 + src/task/eval/EvalManagementTask.ts | 70 ++++ src/task/eval/EvalRelatedPartyTask.ts | 73 ++++ src/task/eval/EvalSpacClassificationTask.ts | 73 ++++ src/task/eval/EvalSpacProfileTask.ts | 70 ++++ src/task/eval/EvalSpacSponsorsTask.ts | 73 ++++ 43 files changed, 4019 insertions(+), 9 deletions(-) create mode 100644 src/eval/runManagementEval.test.ts create mode 100644 src/eval/runManagementEval.ts create mode 100644 src/eval/runRelatedPartyEval.test.ts create mode 100644 src/eval/runRelatedPartyEval.ts create mode 100644 src/eval/runSpacClassificationEval.test.ts create mode 100644 src/eval/runSpacClassificationEval.ts create mode 100644 src/eval/runSpacProfileEval.test.ts create mode 100644 src/eval/runSpacProfileEval.ts create mode 100644 src/eval/runSpacSponsorsEval.test.ts create mode 100644 src/eval/runSpacSponsorsEval.ts create mode 100644 src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts create mode 100644 src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts create mode 100644 src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts create mode 100644 src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts create mode 100644 src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseManagementRoster.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseManagementRoster.ts create mode 100644 src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseRelatedPartyTables.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacClassification.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacClassification.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacProfile.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacProfile.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacSponsors.test.ts create mode 100644 src/sec/forms/registration-statements/s1/parseSpacSponsors.ts create mode 100644 src/task/eval/EvalManagementTask.ts create mode 100644 src/task/eval/EvalRelatedPartyTask.ts create mode 100644 src/task/eval/EvalSpacClassificationTask.ts create mode 100644 src/task/eval/EvalSpacProfileTask.ts create mode 100644 src/task/eval/EvalSpacSponsorsTask.ts diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index b237c319..5d40edda 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -44,12 +44,22 @@ import { EvalUnderwritersTask } from "../../task/eval/EvalUnderwritersTask"; import { EvalUseOfProceedsTask } from "../../task/eval/EvalUseOfProceedsTask"; import { EvalExecutiveCompensationTask } from "../../task/eval/EvalExecutiveCompensationTask"; import { EvalBeneficialOwnershipTask } from "../../task/eval/EvalBeneficialOwnershipTask"; +import { EvalManagementTask } from "../../task/eval/EvalManagementTask"; +import { EvalRelatedPartyTask } from "../../task/eval/EvalRelatedPartyTask"; +import { EvalSpacSponsorsTask } from "../../task/eval/EvalSpacSponsorsTask"; +import { EvalSpacProfileTask } from "../../task/eval/EvalSpacProfileTask"; +import { EvalSpacClassificationTask } from "../../task/eval/EvalSpacClassificationTask"; import { type UnitTermsReport } from "../../eval/runUnitTermsEval"; import type { OfferingTablesReport } from "../../eval/runOfferingTablesEval"; import type { UnderwritersReport } from "../../eval/runUnderwritersEval"; import type { UseOfProceedsReport } from "../../eval/runUseOfProceedsEval"; import type { ExecutiveCompensationReport } from "../../eval/runExecutiveCompensationEval"; import type { BeneficialOwnershipReport } from "../../eval/runBeneficialOwnershipEval"; +import type { ManagementReport } from "../../eval/runManagementEval"; +import type { RelatedPartyReport } from "../../eval/runRelatedPartyEval"; +import type { SpacSponsorsReport } from "../../eval/runSpacSponsorsEval"; +import type { SpacProfileReport } from "../../eval/runSpacProfileEval"; +import type { SpacClassificationReport } from "../../eval/runSpacClassificationEval"; /** * Default comparison set: Anthropic's cheap and strong tiers, plus the cheap @@ -192,6 +202,106 @@ function truncate(s: string, max = 60): string { return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } +function printSpacClassificationReport(report: SpacClassificationReport): void { + const { counts } = report; + console.log( + `spac-classification parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + +function printSpacProfileReport(report: SpacProfileReport): void { + const { counts } = report; + console.log( + `spac-profile parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + +function printSpacSponsorsReport(report: SpacSponsorsReport): void { + const { counts } = report; + console.log( + `spac-sponsors parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + +function printRelatedPartyReport(report: RelatedPartyReport): void { + const { counts } = report; + console.log( + `related-party parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + +function printManagementReport(report: ManagementReport): void { + const { counts } = report; + console.log( + `management parser vs stored rows ` + + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` + ); + const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); + if (flagged.length === 0) return; + console.log("\nmiss / hit-disagree:"); + for (const c of flagged) { + const cik = c.cik === null ? "" : ` cik=${c.cik}`; + console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + if (c.bucket === "hit-disagree") { + console.log(` parsed ${JSON.stringify(c.parsed)}`); + console.log(` stored ${JSON.stringify(c.stored)}`); + } + } +} + function printBeneficialOwnershipReport(report: BeneficialOwnershipReport): void { const { counts } = report; console.log( @@ -1241,4 +1351,209 @@ export function addEvalCommands(program: Command): void { }); } ); + + cmd + .command("management") + .description( + "Score the deterministic management roster parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalManagementTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printManagementReport(report); + }); + } + ); + + cmd + .command("related-party") + .description( + "Score the deterministic related-party table parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalRelatedPartyTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printRelatedPartyReport(report); + }); + } + ); + + cmd + .command("spac-sponsors") + .description( + "Score the deterministic SPAC sponsor parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalSpacSponsorsTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printSpacSponsorsReport(report); + }); + } + ); + + cmd + .command("spac-profile") + .description( + "Score the deterministic SPAC profile parser against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalSpacProfileTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printSpacProfileReport(report); + }); + } + ); + + cmd + .command("spac-classification") + .description( + "Score the deterministic SPAC classifier against stored rows (on-disk cache only; no EDGAR fetch)" + ) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await runWorkflowCli([ + new EvalSpacClassificationTask({ defaults: input }), + ]); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printSpacClassificationReport(report); + }); + } + ); } diff --git a/src/cli/groups/evalOptionValues.test.ts b/src/cli/groups/evalOptionValues.test.ts index 54adca0f..63dabf33 100644 --- a/src/cli/groups/evalOptionValues.test.ts +++ b/src/cli/groups/evalOptionValues.test.ts @@ -127,6 +127,31 @@ describe("eval value-less options", () => { expect(await runEval(["beneficial-ownership", "--format"])).toContain("one of: table, json"); }); + it("covers eval management' value options", async () => { + expect(await runEval(["management", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["management", "--format"])).toContain("one of: table, json"); + }); + + it("covers eval related-party' value options", async () => { + expect(await runEval(["related-party", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["related-party", "--format"])).toContain("one of: table, json"); + }); + + it("covers eval spac-sponsors' value options", async () => { + expect(await runEval(["spac-sponsors", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["spac-sponsors", "--format"])).toContain("one of: table, json"); + }); + + it("covers eval spac-profile' value options", async () => { + expect(await runEval(["spac-profile", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["spac-profile", "--format"])).toContain("one of: table, json"); + }); + + it("covers eval spac-classification' value options", async () => { + expect(await runEval(["spac-classification", "--extractor-id"])).toContain("S-1, 424"); + expect(await runEval(["spac-classification", "--format"])).toContain("one of: table, json"); + }); + it("lists print-prompts modes for a bare --print-prompts on extract", async () => { const err = await runEval(["extract", "--print-prompts"]); expect(err).toContain("--print-prompts needs a value"); diff --git a/src/eval/runManagementEval.test.ts b/src/eval/runManagementEval.test.ts new file mode 100644 index 00000000..5e65678d --- /dev/null +++ b/src/eval/runManagementEval.test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runManagementEval"; + +const TABLE = [ + "| Name | Age | Title |", + "| Ally Tong Zhang | 52 | Chairwoman, Director and Chief Executive Officer |", +].join("\n"); + +describe("bucketWhenParserEmpty", () => { + it("skips when stored has no lines", () => { + expect(bucketWhenParserEmpty({ stored: [], sectionText: TABLE })).toEqual({ + bucket: "skip", + reason: "all-null stored", + }); + }); + + it("skips when there is no roster table", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ full_name: "Jane Roe", titles: ["Director"] }], + sectionText: "Jane Roe — Director", + }) + ).toEqual({ bucket: "skip", reason: "no-table" }); + }); + + it("misses a roster table the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ full_name: "Ally Tong Zhang", titles: ["Chief Executive Officer"] }], + sectionText: TABLE, + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runManagementEval.ts b/src/eval/runManagementEval.ts new file mode 100644 index 00000000..34fa1fb7 --- /dev/null +++ b/src/eval/runManagementEval.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { + hasManagementRosterTable, + parseManagementRoster, +} from "../sec/forms/registration-statements/s1/parseManagementRoster"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { PersonObservationRepo } from "../storage/observation/PersonObservationRepo"; +import { PersonObservationTitleRepo } from "../storage/observation/PersonObservationTitleRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface ManagementEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type ManagementBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface ManagementLineScore { + readonly full_name: string; + readonly titles: readonly string[]; +} + +export interface ManagementCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: ManagementBucket; + readonly cachePath: string | undefined; + readonly parsed?: readonly ManagementLineScore[]; + readonly stored?: readonly ManagementLineScore[]; + readonly reason?: string; +} + +export interface ManagementReport { + readonly cases: readonly ManagementCase[]; + readonly counts: Record; +} + +export async function runManagementEval( + options: ManagementEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; management eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const storedByKey = await loadStored(); + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: ManagementCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const stored = storedByKey.get(`${item.extractor_id}\t${item.accession_number}`) ?? []; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function loadStored(): Promise> { + const people = (await new PersonObservationRepo().listAll()).filter( + (p) => p.relationship === "s1:management" + ); + const titlesById = await new PersonObservationTitleRepo().listForObservations( + people.map((p) => p.observation_id) + ); + const storedByKey = new Map(); + for (const p of people) { + const key = `${p.extractor_id}\t${p.accession_number}`; + const arr = storedByKey.get(key) ?? []; + arr.push({ + full_name: displayPerson(p), + titles: titlesById.get(p.observation_id) ?? [], + }); + storedByKey.set(key, arr); + } + return storedByKey; +} + +function displayPerson(person: { + first_name: string | null; + middle_name: string | null; + last_name: string | null; + suffix: string | null; +}): string { + return [person.first_name, person.middle_name, person.last_name, person.suffix] + .filter((p) => p != null && p !== "") + .join(" "); +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: readonly ManagementLineScore[] +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip" as const, cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "empty cache" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = byName.get(S1_SECTIONS.MANAGEMENT) ?? ""; + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRows = parseManagementRoster(text); + const parsed: ManagementLineScore[] = parsedRows.map((r) => ({ + full_name: r.full_name, + titles: r.titles, + })); + if (parsed.length === 0) { + const miss = bucketWhenParserEmpty({ stored, sectionText: text }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: readonly ManagementLineScore[]; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!hasManagementRosterTable(args.sectionText)) { + return { bucket: "skip", reason: "no-table" }; + } + return { bucket: "miss", reason: undefined }; +} + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function scoredEqual( + a: readonly ManagementLineScore[], + b: readonly ManagementLineScore[] +): boolean { + const keyOf = (r: ManagementLineScore): string => + `${nameKey(r.full_name)}\t${[...r.titles] + .map((t) => t.toLowerCase()) + .toSorted() + .join("|")}`; + const aKeys = a.map(keyOf).toSorted(); + const bKeys = b.map(keyOf).toSorted(); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((k, i) => k === bKeys[i]); +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/eval/runRelatedPartyEval.test.ts b/src/eval/runRelatedPartyEval.test.ts new file mode 100644 index 00000000..21dc7550 --- /dev/null +++ b/src/eval/runRelatedPartyEval.test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runRelatedPartyEval"; + +const TABLE = [ + "| Convertible Note Purchasers | Original Principal Amount |", + "| Stellantis Ventures B.V. | $5,000,000 |", +].join("\n"); + +describe("bucketWhenParserEmpty", () => { + it("skips when stored has no lines", () => { + expect(bucketWhenParserEmpty({ stored: [], sectionText: TABLE })).toEqual({ + bucket: "skip", + reason: "all-null stored", + }); + }); + + it("skips when there is no party table", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ name: "Stellantis Ventures B.V.", party_kind: "company" }], + sectionText: "We pay rent to an entity controlled by our CEO.", + }) + ).toEqual({ bucket: "skip", reason: "no-table" }); + }); + + it("misses a party table the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ name: "Stellantis Ventures B.V.", party_kind: "company" }], + sectionText: TABLE, + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runRelatedPartyEval.ts b/src/eval/runRelatedPartyEval.ts new file mode 100644 index 00000000..a961d2ca --- /dev/null +++ b/src/eval/runRelatedPartyEval.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { + hasRelatedPartyTable, + parseRelatedPartyTables, +} from "../sec/forms/registration-statements/s1/parseRelatedPartyTables"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { PersonObservationRepo } from "../storage/observation/PersonObservationRepo"; +import { CompanyObservationRepo } from "../storage/observation/CompanyObservationRepo"; +import { RelatedPartyTransactionRepo } from "../storage/related-party/RelatedPartyTransactionRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface RelatedPartyEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type RelatedPartyBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface RelatedPartyLineScore { + readonly name: string; + readonly party_kind: string; +} + +export interface RelatedPartyCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: RelatedPartyBucket; + readonly cachePath: string | undefined; + readonly parsed?: readonly RelatedPartyLineScore[]; + readonly stored?: readonly RelatedPartyLineScore[]; + readonly reason?: string; +} + +export interface RelatedPartyReport { + readonly cases: readonly RelatedPartyCase[]; + readonly counts: Record; +} + +export async function runRelatedPartyEval( + options: RelatedPartyEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; related-party eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const storedByKey = await loadStored(); + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: RelatedPartyCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const stored = storedByKey.get(`${item.extractor_id}\t${item.accession_number}`) ?? []; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function loadStored(): Promise> { + const people = (await new PersonObservationRepo().listAll()).filter( + (p) => p.relationship === "s1:related-party" + ); + const companies = (await new CompanyObservationRepo().listAll()).filter((c) => + /s1:related-party/.test(c.source_context ?? "") + ); + const storedByKey = new Map(); + const add = ( + extractor_id: string, + accession_number: string, + name: string, + party_kind: string + ): void => { + const key = `${extractor_id}\t${accession_number}`; + const arr = storedByKey.get(key) ?? []; + arr.push({ name, party_kind }); + storedByKey.set(key, arr); + }; + for (const p of people) { + add( + p.extractor_id, + p.accession_number, + [p.first_name, p.middle_name, p.last_name, p.suffix] + .filter((x) => x != null && x !== "") + .join(" "), + "person" + ); + } + for (const c of companies) { + add(c.extractor_id, c.accession_number, c.name ?? "", "company"); + } + const txs = await new RelatedPartyTransactionRepo().listAll(); + for (const t of txs) { + if (t.party_kind !== "group" || t.party_label == null || t.party_label === "") continue; + add(t.extractor_id, t.accession_number, t.party_label, "group"); + } + return storedByKey; +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: readonly RelatedPartyLineScore[] +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip" as const, cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "empty cache" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = byName.get(S1_SECTIONS.RELATED_PARTY) ?? ""; + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRows = parseRelatedPartyTables(text); + const parsed: RelatedPartyLineScore[] = parsedRows.map((r) => ({ + name: r.name, + party_kind: r.party_kind, + })); + if (parsed.length === 0) { + const miss = bucketWhenParserEmpty({ stored, sectionText: text }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: readonly RelatedPartyLineScore[]; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!hasRelatedPartyTable(args.sectionText)) { + return { bucket: "skip", reason: "no-table" }; + } + return { bucket: "miss", reason: undefined }; +} + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function scoredEqual( + a: readonly RelatedPartyLineScore[], + b: readonly RelatedPartyLineScore[] +): boolean { + const keyOf = (r: RelatedPartyLineScore): string => `${nameKey(r.name)}\t${r.party_kind}`; + const aKeys = a.map(keyOf).toSorted(); + const bKeys = b.map(keyOf).toSorted(); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((k, i) => k === bKeys[i]); +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/eval/runSpacClassificationEval.test.ts b/src/eval/runSpacClassificationEval.test.ts new file mode 100644 index 00000000..9b8d96f3 --- /dev/null +++ b/src/eval/runSpacClassificationEval.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runSpacClassificationEval"; + +const HIT = + "Acme Acquisition Corp. is a newly organized blank check company formed for the purpose of effecting a merger with one or more businesses."; + +describe("bucketWhenParserEmpty", () => { + it("skips when stored is not a SPAC", () => { + expect(bucketWhenParserEmpty({ stored: { is_spac: false }, sectionText: HIT })).toEqual({ + bucket: "skip", + reason: "all-null stored", + }); + }); + + it("skips when there is no formation pair", () => { + expect( + bucketWhenParserEmpty({ + stored: { is_spac: true }, + sectionText: "We develop industrial batteries.", + }) + ).toEqual({ bucket: "skip", reason: "no-identification" }); + }); + + it("misses a formation pair the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: { is_spac: true }, + sectionText: HIT, + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runSpacClassificationEval.ts b/src/eval/runSpacClassificationEval.ts new file mode 100644 index 00000000..7bbdcb3d --- /dev/null +++ b/src/eval/runSpacClassificationEval.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { + hasSpacFormationIdentification, + parseSpacClassification, +} from "../sec/forms/registration-statements/s1/parseSpacClassification"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { S1ClassificationRepo } from "../storage/classification/S1ClassificationRepo"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface SpacClassificationEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type SpacClassificationBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface SpacClassificationLineScore { + readonly is_spac: boolean; +} + +export interface SpacClassificationCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: SpacClassificationBucket; + readonly cachePath: string | undefined; + readonly parsed?: SpacClassificationLineScore; + readonly stored?: SpacClassificationLineScore; + readonly reason?: string; +} + +export interface SpacClassificationReport { + readonly cases: readonly SpacClassificationCase[]; + readonly counts: Record; +} + +export async function runSpacClassificationEval( + options: SpacClassificationEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; spac-classification eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const repo = new S1ClassificationRepo(); + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: SpacClassificationCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const cls = await repo.get(item.extractor_id, item.accession_number); + const stored: SpacClassificationLineScore | undefined = + cls === undefined ? undefined : { is_spac: cls.is_spac }; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: SpacClassificationLineScore | undefined +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip" as const, cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "empty cache" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? ""; + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRow = parseSpacClassification(text); + if (parsedRow === null) { + const miss = bucketWhenParserEmpty({ stored, sectionText: text }); + return { ...base, cik, bucket: miss.bucket, cachePath, reason: miss.reason }; + } + const parsed: SpacClassificationLineScore = { is_spac: parsedRow.is_spac }; + const agree = stored !== undefined && stored.is_spac === parsed.is_spac; + return { + ...base, + cik, + bucket: agree ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: SpacClassificationLineScore | undefined; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored === undefined || args.stored.is_spac !== true) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!hasSpacFormationIdentification(args.sectionText)) { + return { bucket: "skip", reason: "no-identification" }; + } + return { bucket: "miss", reason: undefined }; +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/eval/runSpacProfileEval.test.ts b/src/eval/runSpacProfileEval.test.ts new file mode 100644 index 00000000..17d33de1 --- /dev/null +++ b/src/eval/runSpacProfileEval.test.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runSpacProfileEval"; + +const HIT = + "Although we may pursue targets in any industry, we intend to initially focus our search on identifying a prospective target business in financial services."; + +describe("bucketWhenParserEmpty", () => { + it("skips when stored has no tags", () => { + expect( + bucketWhenParserEmpty({ stored: { focus: [], focus_location: [] }, sectionText: HIT }) + ).toEqual({ + bucket: "skip", + reason: "all-null stored", + }); + }); + + it("skips when there is no identifying sentence", () => { + expect( + bucketWhenParserEmpty({ + stored: { focus: ["Healthcare"], focus_location: [] }, + sectionText: + "We intend to focus our efforts on identifying a company that aligns with our team’s experiences.", + }) + ).toEqual({ bucket: "skip", reason: "no-identification" }); + }); + + it("misses an identifying sentence the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: { focus: ["Financial Services"], focus_location: [] }, + sectionText: HIT, + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runSpacProfileEval.ts b/src/eval/runSpacProfileEval.ts new file mode 100644 index 00000000..ae13f2ae --- /dev/null +++ b/src/eval/runSpacProfileEval.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { + hasProfileIdentification, + parseSpacProfile, +} from "../sec/forms/registration-statements/s1/parseSpacProfile"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { SpacRepo } from "../storage/spac/SpacRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface SpacProfileEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type SpacProfileBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface SpacProfileLineScore { + readonly focus: readonly string[]; + readonly focus_location: readonly string[]; +} + +export interface SpacProfileCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: SpacProfileBucket; + readonly cachePath: string | undefined; + readonly parsed?: SpacProfileLineScore; + readonly stored?: SpacProfileLineScore; + readonly reason?: string; +} + +export interface SpacProfileReport { + readonly cases: readonly SpacProfileCase[]; + readonly counts: Record; +} + +export async function runSpacProfileEval( + options: SpacProfileEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; spac-profile eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const storedByCik = await loadStored(); + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: SpacProfileCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const stored = storedByCik.get(item.cik ?? -1) ?? { focus: [], focus_location: [] }; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function loadStored(): Promise> { + const spacs = await new SpacRepo().getAllSpacs(); + const out = new Map(); + for (const row of spacs) { + out.set(row.cik, { + focus: parseJsonArray(row.focus), + focus_location: parseJsonArray(row.focus_location), + }); + } + return out; +} + +function parseJsonArray(raw: string | null | undefined): string[] { + if (raw === null || raw === undefined || raw === "") return []; + try { + const v = JSON.parse(raw) as unknown; + if (!Array.isArray(v)) return []; + return v.filter((x): x is string => typeof x === "string"); + } catch { + return []; + } +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: SpacProfileLineScore +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip" as const, cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "empty cache" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? ""; + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRow = parseSpacProfile(text); + if (parsedRow === null) { + const miss = bucketWhenParserEmpty({ stored, sectionText: text }); + return { ...base, cik, bucket: miss.bucket, cachePath, reason: miss.reason }; + } + const parsed: SpacProfileLineScore = { + focus: parsedRow.focus, + focus_location: parsedRow.focus_location, + }; + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: SpacProfileLineScore; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.focus.length === 0 && args.stored.focus_location.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!hasProfileIdentification(args.sectionText)) { + return { bucket: "skip", reason: "no-identification" }; + } + return { bucket: "miss", reason: undefined }; +} + +function tagKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function scoredEqual(a: SpacProfileLineScore, b: SpacProfileLineScore): boolean { + const af = a.focus.map(tagKey).toSorted(); + const bf = b.focus.map(tagKey).toSorted(); + const al = a.focus_location.map(tagKey).toSorted(); + const bl = b.focus_location.map(tagKey).toSorted(); + if (af.length !== bf.length || al.length !== bl.length) return false; + return af.every((k, i) => k === bf[i]) && al.every((k, i) => k === bl[i]); +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/eval/runSpacSponsorsEval.test.ts b/src/eval/runSpacSponsorsEval.test.ts new file mode 100644 index 00000000..d6430cbc --- /dev/null +++ b/src/eval/runSpacSponsorsEval.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { bucketWhenParserEmpty } from "./runSpacSponsorsEval"; + +const HIT = + "Our sponsor, Bluerock Acquisition Holdings II, LLC, is a Delaware limited liability company."; + +describe("bucketWhenParserEmpty", () => { + it("skips when stored has no lines", () => { + expect(bucketWhenParserEmpty({ stored: [], sectionText: HIT })).toEqual({ + bucket: "skip", + reason: "all-null stored", + }); + }); + + it("skips when there is no identifying sentence", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ legal_name: "Acme Sponsor LLC" }], + sectionText: "Our sponsor, officers or directors may purchase shares.", + }) + ).toEqual({ bucket: "skip", reason: "no-identification" }); + }); + + it("misses an identifying sentence the parser should have hit", () => { + expect( + bucketWhenParserEmpty({ + stored: [{ legal_name: "Bluerock Acquisition Holdings II, LLC" }], + sectionText: HIT, + }).bucket + ).toBe("miss"); + }); +}); diff --git a/src/eval/runSpacSponsorsEval.ts b/src/eval/runSpacSponsorsEval.ts new file mode 100644 index 00000000..b5e3a27a --- /dev/null +++ b/src/eval/runSpacSponsorsEval.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readFileSync } from "node:fs"; +import { globalServiceRegistry } from "workglow"; +import { SecCliConfigurationError } from "../config/EnvToDI"; +import { SEC_RAW_DATA_FOLDER } from "../config/tokens"; +import { parseEdgarHtml } from "../sec/html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../sec/forms/registration-statements/s1/DocumentTreeSegmenter"; +import { S1_SECTIONS } from "../sec/forms/registration-statements/s1/DocumentSegmenter"; +import { + hasSponsorIdentification, + parseSpacSponsors, +} from "../sec/forms/registration-statements/s1/parseSpacSponsors"; +import { FILING_REPOSITORY_TOKEN, type Filing } from "../storage/filing/FilingSchema"; +import { SpacUnitTermsRepo } from "../storage/offering/SpacUnitTermsRepo"; +import { CompanyObservationRepo } from "../storage/observation/CompanyObservationRepo"; +import { extractPrimaryDocFromSubmission } from "../task/bootstrap/feedTarball"; +import { cachedAccessionDocPath, resolvePrimaryDocName } from "../util/accessionDocPath"; + +export interface SpacSponsorsEvalOptions { + readonly extractorId?: "S-1" | "424"; + readonly limit?: number; + readonly cik?: number; + readonly onProgress?: (done: number, total: number, message: string) => void; + readonly signal?: AbortSignal; +} + +export type SpacSponsorsBucket = "hit-agree" | "hit-disagree" | "miss" | "empty" | "skip"; + +export interface SpacSponsorsLineScore { + readonly legal_name: string; +} + +export interface SpacSponsorsCase { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + readonly bucket: SpacSponsorsBucket; + readonly cachePath: string | undefined; + readonly parsed?: readonly SpacSponsorsLineScore[]; + readonly stored?: readonly SpacSponsorsLineScore[]; + readonly reason?: string; +} + +export interface SpacSponsorsReport { + readonly cases: readonly SpacSponsorsCase[]; + readonly counts: Record; +} + +export async function runSpacSponsorsEval( + options: SpacSponsorsEvalOptions = {} +): Promise { + if (!globalServiceRegistry.has(SEC_RAW_DATA_FOLDER)) { + throw new SecCliConfigurationError( + "SEC_RAW_DATA_FOLDER is not set; spac-sponsors eval reads accessiondocs and does not fetch EDGAR" + ); + } + const root = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER); + const extractorId = options.extractorId; + const cikFilter = options.cik; + const unitRows = (await new SpacUnitTermsRepo().listAll()).filter((r) => { + if (extractorId !== undefined && r.extractor_id !== extractorId) return false; + if (cikFilter !== undefined && r.cik !== cikFilter) return false; + return true; + }); + const storedByKey = await loadStored(); + const sliced = options.limit !== undefined ? unitRows.slice(0, options.limit) : unitRows; + const cases: SpacSponsorsCase[] = []; + for (let i = 0; i < sliced.length; i++) { + if (options.signal?.aborted) break; + const item = sliced[i]!; + options.onProgress?.(i, sliced.length, item.accession_number); + const stored = storedByKey.get(`${item.extractor_id}\t${item.accession_number}`) ?? []; + cases.push(await scoreCase(root, item, stored)); + } + options.onProgress?.(sliced.length, sliced.length, "done"); + const counts: Record = { + "hit-agree": 0, + "hit-disagree": 0, + miss: 0, + empty: 0, + skip: 0, + }; + for (const c of cases) counts[c.bucket] += 1; + return { cases, counts }; +} + +async function loadStored(): Promise> { + const companies = (await new CompanyObservationRepo().listAll()).filter((c) => + /s1:spac-sponsor/.test(c.source_context ?? "") + ); + const storedByKey = new Map(); + for (const c of companies) { + const key = `${c.extractor_id}\t${c.accession_number}`; + const arr = storedByKey.get(key) ?? []; + arr.push({ legal_name: c.name ?? "" }); + storedByKey.set(key, arr); + } + return storedByKey; +} + +function sponsorText(byName: Map): string { + return ( + byName.get(S1_SECTIONS.THE_SPONSOR) ?? + [...byName.entries()] + .filter(([name]) => name !== S1_SECTIONS.RISK_FACTORS) + .map(([, sectionText]) => sectionText) + .join("\n\n") + ); +} + +async function scoreCase( + root: string, + item: { + readonly extractor_id: string; + readonly accession_number: string; + readonly cik: number | null; + }, + stored: readonly SpacSponsorsLineScore[] +): Promise { + const base = { + extractor_id: item.extractor_id, + accession_number: item.accession_number, + cik: item.cik, + stored, + }; + const filing = await loadFiling(item.cik, item.accession_number); + if (filing === undefined) { + return { ...base, bucket: "skip" as const, cachePath: undefined, reason: "no filing row" }; + } + const cik = item.cik ?? filing.cik; + const primary = resolvePrimaryDocName(filing.primary_doc); + if (primary === undefined) { + return { ...base, cik, bucket: "skip", cachePath: undefined, reason: "no primary_doc" }; + } + const cachePath = cachedAccessionDocPath(root, cik, item.accession_number, primary); + if (cachePath === undefined || !existsSync(cachePath)) { + return { ...base, cik, bucket: "skip", cachePath, reason: "no cache" }; + } + let html: string; + try { + html = readCachedHtml(cachePath, primary); + } catch { + return { ...base, cik, bucket: "skip", cachePath, reason: "unreadable" }; + } + if (html === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "empty cache" }; + } + const doc = parseEdgarHtml(html, cachePath); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name, s.text])); + const text = sponsorText(byName); + if (text.trim() === "") { + return { ...base, cik, bucket: "skip", cachePath, reason: "no section" }; + } + const parsedRows = parseSpacSponsors(text); + const parsed: SpacSponsorsLineScore[] = parsedRows.map((r) => ({ legal_name: r.legal_name })); + if (parsed.length === 0) { + const miss = bucketWhenParserEmpty({ stored, sectionText: text }); + return { ...base, cik, bucket: miss.bucket, cachePath, parsed, reason: miss.reason }; + } + return { + ...base, + cik, + bucket: scoredEqual(parsed, stored) ? "hit-agree" : "hit-disagree", + cachePath, + parsed, + }; +} + +export function bucketWhenParserEmpty(args: { + readonly stored: readonly SpacSponsorsLineScore[]; + readonly sectionText: string; +}): { readonly bucket: "skip" | "empty" | "miss"; readonly reason: string | undefined } { + if (args.stored.length === 0) { + return { bucket: "skip", reason: "all-null stored" }; + } + if (!hasSponsorIdentification(args.sectionText)) { + return { bucket: "skip", reason: "no-identification" }; + } + return { bucket: "miss", reason: undefined }; +} + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function scoredEqual( + a: readonly SpacSponsorsLineScore[], + b: readonly SpacSponsorsLineScore[] +): boolean { + const aKeys = a.map((r) => nameKey(r.legal_name)).toSorted(); + const bKeys = b.map((r) => nameKey(r.legal_name)).toSorted(); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((k, i) => k === bKeys[i]); +} + +function readCachedHtml(cachePath: string, primary: string): string { + const raw = readFileSync(cachePath, "utf8"); + if (/|/i.test(raw)) { + return extractPrimaryDocFromSubmission(raw, primary) ?? ""; + } + return raw; +} + +async function loadFiling( + cik: number | null, + accession_number: string +): Promise { + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + if (cik !== null) { + const row = await repo.get({ cik, accession_number }); + if (row) return row; + } + const rows = (await repo.query({ accession_number })) ?? []; + return rows[0]; +} diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts new file mode 100644 index 00000000..4357a2f9 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { S1ClassificationRepo } from "../../../storage/classification/S1ClassificationRepo"; +import { processFormS1 } from "./Form_S_1.storage"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const HTML_PARSEABLE = [ + "

PROSPECTUS SUMMARY

", + "

Acme Acquisition Corp. is a newly organized blank check company formed for the purpose of effecting a merger, share exchange, asset acquisition or similar business combination with one or more businesses. We have not selected any specific business combination target. Proceeds will be held in a trust account. Our sponsor will hold founder shares. Public shareholders may redeem their public shares.

", + "

MANAGEMENT

x

", + "

LEGAL MATTERS

x

", +].join(""); + +const HEADER_MISC = { + sic: 7372, + sicDescription: "PREPACKAGED SOFTWARE", + cik: null, + companyName: null, + filingDate: null, +}; + +let cleanup: (() => void) | undefined; + +describe("processFormS1 spac-classification", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("upgrades a miscoded blank-check summary as deterministic without calling the classifier model", async () => { + const { calls, unregister } = registerFakeStructuredProvider([ + { people: [] }, + { owners: [] }, + { parties: [] }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1018724, + file_number: "333-1", + accession_number: "acc-cls-1", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: HEADER_MISC, + html: HTML_PARSEABLE, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const row = await new S1ClassificationRepo().get("S-1", "acc-cls-1"); + expect(row?.is_spac).toBe(true); + expect(row?.classifier_source).toBe("deterministic"); + expect(calls.some((p) => /Classify what KIND of issuer/.test(p))).toBe(false); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts new file mode 100644 index 00000000..4a2d7f98 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { PersonObservationRepo } from "../../../storage/observation/PersonObservationRepo"; +import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; +import { processFormS1 } from "./Form_S_1.storage"; +import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const HTML_PARSEABLE = [ + "

MANAGEMENT

", + "", + "", + "", + "
NameAgeTitle
Jane Roe52Director
", + "

LEGAL MATTERS

x

", +].join(""); + +const NULL_HEADER = { + sic: null, + sicDescription: null, + cik: null, + companyName: null, + filingDate: null, +}; + +let cleanup: (() => void) | undefined; + +describe("processFormS1 management roster", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("persists a parseable roster as deterministic without calling the management model", async () => { + const { calls, unregister } = registerFakeStructuredProvider([{}]); + cleanup = unregister; + + await processFormS1({ + cik: 1018724, + file_number: "333-1", + accession_number: "acc-mgmt-1", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: NULL_HEADER, + html: HTML_PARSEABLE, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const people = (await new PersonObservationRepo().listAll()).filter( + (o) => o.relationship === "s1:management" + ); + expect(people.map((p) => [p.first_name, p.last_name])).toEqual([["Jane", "Roe"]]); + expect(calls.some((p) => /Extract every director and executive officer/.test(p))).toBe(false); + const provenance = await new ObservationProvenanceRepo().get( + "person", + people[0]!.observation_id + ); + expect(provenance?.model_id).toBe(DETERMINISTIC_MODEL_ID); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts new file mode 100644 index 00000000..8e8a6d82 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { SpacRepo } from "../../../storage/spac/SpacRepo"; +import { processFormS1 } from "./Form_S_1.storage"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const HTML_PARSEABLE = [ + "

PROSPECTUS SUMMARY

", + "

Although we may pursue targets in any industry, we intend to initially focus our search on identifying a prospective target business in healthcare and biopharmaceuticals.

", + "

MANAGEMENT

x

", + "

LEGAL MATTERS

x

", +].join(""); + +const HEADER_6770 = { + sic: 6770, + sicDescription: "BLANK CHECKS", + cik: null, + companyName: null, + filingDate: null, +}; + +let cleanup: (() => void) | undefined; + +describe("processFormS1 spac-profile", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("persists a parseable focus sentence as deterministic without calling the profile model", async () => { + const { calls, unregister } = registerFakeStructuredProvider([ + { people: [] }, + { owners: [] }, + { parties: [] }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1018724, + file_number: "333-1", + accession_number: "acc-prf-1", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: HEADER_6770, + html: HTML_PARSEABLE, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const spac = await new SpacRepo().getSpac(1018724); + expect(JSON.parse(spac?.focus ?? "[]")).toEqual(["Healthcare", "Biopharmaceuticals"]); + expect(calls.some((p) => /Extract the SPAC's acquisition profile/.test(p))).toBe(false); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts new file mode 100644 index 00000000..64f317e8 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; +import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; +import { processFormS1 } from "./Form_S_1.storage"; +import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const HTML_PARSEABLE = [ + "

MANAGEMENT

", + "

Jane Roe — Director

", + "

CERTAIN RELATIONSHIPS AND RELATED TRANSACTIONS

", + "", + "", + "", + "
Convertible Note PurchasersOriginal Principal Amount
Stellantis Ventures B.V.$5,000,000
", + "

LEGAL MATTERS

x

", +].join(""); + +const NULL_HEADER = { + sic: null, + sicDescription: null, + cik: null, + companyName: null, + filingDate: null, +}; + +const MANAGEMENT_PAYLOAD = { + people: [ + { + full_name: "Jane Roe", + titles: ["Director"], + relationship: null, + confidence: 0.9, + source_span: "Jane Roe — Director", + }, + ], +}; + +let cleanup: (() => void) | undefined; + +describe("processFormS1 related-party tables", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("persists a parseable party table as deterministic without calling the related-party model", async () => { + const { calls, unregister } = registerFakeStructuredProvider([MANAGEMENT_PAYLOAD]); + cleanup = unregister; + + await processFormS1({ + cik: 1018724, + file_number: "333-1", + accession_number: "acc-rp-1", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: NULL_HEADER, + html: HTML_PARSEABLE, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const companies = await new CompanyObservationRepo().listAll(); + expect(companies.some((c) => /Stellantis Ventures/i.test(c.name ?? ""))).toBe(true); + expect(calls.some((p) => /Extract related parties/.test(p))).toBe(false); + const party = companies.find((c) => /Stellantis Ventures/i.test(c.name ?? "")); + const provenance = await new ObservationProvenanceRepo().get("company", party!.observation_id); + expect(provenance?.model_id).toBe(DETERMINISTIC_MODEL_ID); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts new file mode 100644 index 00000000..716f0724 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; +import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; +import { processFormS1 } from "./Form_S_1.storage"; +import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const HTML_PARSEABLE = [ + "

MANAGEMENT

x

", + "

THE SPONSOR

", + "

Our sponsor, Acme Sponsor LLC, is a Delaware limited liability company and was formed to invest in us.

", + "

LEGAL MATTERS

x

", +].join(""); + +const HEADER_6770 = { + sic: 6770, + sicDescription: "BLANK CHECKS", + cik: null, + companyName: null, + filingDate: null, +}; + +let cleanup: (() => void) | undefined; + +describe("processFormS1 spac-sponsors", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("persists a parseable sponsor sentence as deterministic without calling the sponsor model", async () => { + const { calls, unregister } = registerFakeStructuredProvider([ + { + focus: [], + focus_location: [], + description: null, + team: null, + url_spac: null, + confidence: 0.9, + source_span: "Our sponsor, Acme Sponsor LLC, is a Delaware limited liability company", + }, + { people: [] }, + { owners: [] }, + { parties: [] }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1018724, + file_number: "333-1", + accession_number: "acc-spn-1", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: HEADER_6770, + html: HTML_PARSEABLE, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const companies = (await new CompanyObservationRepo().listAll()).filter((c) => + /s1:spac-sponsor/.test(c.source_context ?? "") + ); + expect(companies.some((c) => /Acme Sponsor/i.test(c.name ?? ""))).toBe(true); + expect(calls.some((p) => /Identify each sponsor entity/.test(p))).toBe(false); + const party = companies.find((c) => /Acme Sponsor/i.test(c.name ?? "")); + const provenance = await new ObservationProvenanceRepo().get("company", party!.observation_id); + expect(provenance?.model_id).toBe(DETERMINISTIC_MODEL_ID); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 112ee598..7276f824 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -49,6 +49,11 @@ import type { ExecutiveCompensationRow } from "./s1/executiveCompensationSchema" import { hasSummaryCompensationTable } from "./s1/compensationHeuristic"; import { parseSummaryCompensationTable } from "./s1/parseSummaryCompensationTable"; import { parseBeneficialOwnership } from "./s1/parseBeneficialOwnership"; +import { parseManagementRoster } from "./s1/parseManagementRoster"; +import { parseRelatedPartyTables } from "./s1/parseRelatedPartyTables"; +import { parseSpacSponsors } from "./s1/parseSpacSponsors"; +import { parseSpacProfile } from "./s1/parseSpacProfile"; +import { parseSpacClassification } from "./s1/parseSpacClassification"; import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { looksLikePartIIOnlyAmendment } from "./s1/partIIOnlyAmendment"; import { issuerHasCombinationListing } from "./s1/newcoListing"; @@ -624,7 +629,10 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { await recordFail("spac-classification", "MODEL_RESOLUTION_ERROR", classifierError); } else { const classifierModelResolved = classifierModel; - const classifierHolder = { upgraded: false }; + const classifierHolder: { upgraded: boolean; source: "ai" | "deterministic" } = { + upgraded: false, + source: "ai", + }; const classifierRunSection = makeRunSection({ deadLetters, extractor_id: EXTRACTOR_ID, @@ -642,11 +650,14 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { unverifiedAllDetail: "the confident SPAC classification had source_span not present in section text", extract: async (text) => { + const det = parseSpacClassification(text); + if (det !== null) return [det]; const c = await extractSpacClassification(text, classifierModelResolved, args.context); return c === null ? [] : [c]; }, - persist: async () => { + persist: async (rows) => { classifierHolder.upgraded = true; + if (rows[0]?.source === "deterministic") classifierHolder.source = "deterministic"; return 1; }, }); @@ -659,7 +670,7 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { sic: headerSic, sic_description: formS1.header?.sicDescription ?? null, is_spac: true, - classifier_source: "ai", + classifier_source: classifierHolder.source, created_at: new Date().toISOString(), }); } else { @@ -701,6 +712,8 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { verifyRow: (text, r) => classifySpan(text, r.source_span), unverifiedAllDetail: "the confident SPAC profile had source_span not present in section text", ...modelExtractChain(models, async (text, m) => { + const det = parseSpacProfile(text); + if (det !== null) return [det]; const p = await extractSpacProfile(text, m, args.context); return p === null ? [] : [p]; }), @@ -741,9 +754,16 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident management rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident management rows had source_span not present in section text", - ...modelExtractChain(models, (text, m) => extractManagement(text, m, args.context)), + ...modelExtractChain(models, async (text, m) => { + const det = parseManagementRoster(text); + if (det.length > 0) return det; + return extractManagement(text, m, args.context); + }), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); + const model_id = + rows[0]?.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); for (const r of rows) { const name = splitPersonName(r.full_name); const { observation_id } = await observer.observePerson({ @@ -881,9 +901,16 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident related-party rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident related-party rows had source_span not present in section text", - ...modelExtractChain(models, (text, m) => extractRelatedParty(text, m, args.context)), + ...modelExtractChain(models, async (text, m) => { + const det = parseRelatedPartyTables(text); + if (det.length > 0) return det; + return extractRelatedParty(text, m, args.context); + }), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); + const model_id = + rows[0]?.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); // Check every row against the storage schema's own declared bounds BEFORE // writing any of them. This persist spans three storages (observations, // provenance, transactions) and `withTransaction` is scoped to a single @@ -1273,9 +1300,16 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident sponsor rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident sponsor rows had source_span not present in section text", - ...modelExtractChain(models, (text, m) => extractSpacSponsors(text, m, args.context)), + ...modelExtractChain(models, async (text, m) => { + const det = parseSpacSponsors(text); + if (det.length > 0) return det; + return extractSpacSponsors(text, m, args.context); + }), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); + const model_id = + rows[0]?.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); let wrote = 0; const splits = rows.map((r) => splitParentClause(r.legal_name?.trim() ?? "")); const extractedNames = splits.map((s) => s.observationName); diff --git a/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts new file mode 100644 index 00000000..470898d9 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseManagementRoster } from "./parseManagementRoster"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function coveredName(n: string, allowed: Set): boolean { + const k = nameKey(n); + if (allowed.has(k)) return true; + for (const a of allowed) { + if (k.startsWith(a) || a.startsWith(k)) return true; + } + return false; +} + +function looksLikeCaption(n: string): boolean { + return ( + /:\s*$/.test(n) || + /table of contents|directors and executive|named executive|principal occupation/i.test(n) + ); +} + +function fixtures(): Array<{ filing: string; byName: Map }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; byName: Map }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseManagementRoster golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty management label", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "management"); + if (!labels || labels.length !== 0) continue; + const text = byName.get(S1_SECTIONS.MANAGEMENT) ?? ""; + expect(parseManagementRoster(text), filing).toEqual([]); + } + }); + + it("does not invent caption-like names outside the golden set when it hits", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "management"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.MANAGEMENT) ?? ""; + const parsed = parseManagementRoster(text); + if (parsed.length === 0) continue; + const allowed = new Set( + labels + .map((r) => (typeof r.full_name === "string" ? nameKey(r.full_name) : "")) + .filter((k) => k !== "") + ); + const extras = parsed + .map((row) => row.full_name) + .filter((n, i, arr) => arr.indexOf(n) === i) + .filter((n) => n !== "" && !coveredName(n, allowed)); + const garbage = extras.filter((n) => looksLikeCaption(n)); + expect(garbage, filing).toEqual([]); + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseManagementRoster.test.ts b/src/sec/forms/registration-statements/s1/parseManagementRoster.test.ts new file mode 100644 index 00000000..98950d65 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseManagementRoster.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { parseManagementRoster } from "./parseManagementRoster"; + +const ROSTER = [ + "Directors and Executive Officers", + "| Name | Age | Title |", + "| --- | --- | --- |", + "| Ally Tong Zhang | 52 | Chairwoman, Director and Chief Executive Officer |", + "| Xin Wang | 36 | Chief Financial Officer |", + "| Hongmei Zhao | 45 | Director |", + "| [·] | | |", + "| All officers and directors as a group | | |", +].join("\n"); + +const COMMITTEE = [ + "| Name | Age | Title |", + "| Michael Klein | 62 | Chief Executive Officer, President and Chairman of the Board |", + "| Jay Taragin | 60 | Chief Financial Officer |", + "| · | the appointment, compensation, retention, replacement, |", +].join("\n"); + +const COMBINED = [ + "| Name and Position | Age | Principal Occupation |", + "| Martin J. Shen President, CEO & Director | 56 | CEO of FingerMotion |", +].join("\n"); + +const NAME_ONLY = ["| Name |", "| Lawrence James Lawson III |", "| Robert T. Brown |"].join("\n"); + +const COLSPAN_AGE = [ + "| Name | Age | Age | Age |", + "| Frank R. Martire, Jr. | | 72 | Founder and Chairman of the Board |", + "| Tanmay Kumar | | 32 | Chief Financial Officer |", +].join("\n"); + +const EMPTY_AGE = [ + "| Name | Age | Position |", + "| Thomas Sullivan | | Chairman of the Board |", + "| Kevin Charlton | | Chief Executive Officer |", +].join("\n"); + +describe("parseManagementRoster", () => { + it("never throws", () => { + expect(parseManagementRoster("")).toEqual([]); + expect(parseManagementRoster("| |")).toEqual([]); + }); + + it("reads Name/Age/Title rows, canonicalizes titles, and drops placeholders", () => { + const rows = parseManagementRoster(ROSTER); + expect(rows.map((r) => [r.full_name, r.age, r.titles])).toEqual([ + ["Ally Tong Zhang", 52, ["Chairwoman of the Board of Directors", "Chief Executive Officer"]], + ["Xin Wang", 36, ["Chief Financial Officer"]], + ["Hongmei Zhao", 45, ["Director"]], + ]); + expect(rows.every((r) => r.source === "deterministic")).toBe(true); + }); + + it("skips committee-charter bullets under a roster header", () => { + const rows = parseManagementRoster(COMMITTEE); + expect(rows.map((r) => r.full_name)).toEqual(["Michael Klein", "Jay Taragin"]); + }); + + it("splits a combined Name and Position first cell", () => { + const rows = parseManagementRoster(COMBINED); + expect(rows).toHaveLength(1); + expect(rows[0]!.full_name).toBe("Martin J. Shen"); + expect(rows[0]!.titles).toEqual(["President", "CEO", "Director"]); + }); + + it("does not hit a Name-only wreck", () => { + expect(parseManagementRoster(NAME_ONLY)).toEqual([]); + }); + + it("reads a colspan-repeated Age header with the title in the last cell", () => { + const rows = parseManagementRoster(COLSPAN_AGE); + expect(rows.map((r) => [r.full_name, r.age, r.titles])).toEqual([ + ["Frank R. Martire, Jr.", 72, ["Founder and Chairman of the Board of Directors"]], + ["Tanmay Kumar", 32, ["Chief Financial Officer"]], + ]); + }); + + it("reads Name/Age/Position rows whose age cell is blank", () => { + const rows = parseManagementRoster(EMPTY_AGE); + expect(rows.map((r) => [r.full_name, r.age, r.titles])).toEqual([ + ["Thomas Sullivan", null, ["Chairman of the Board of Directors"]], + ["Kevin Charlton", null, ["Chief Executive Officer"]], + ]); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseManagementRoster.ts b/src/sec/forms/registration-statements/s1/parseManagementRoster.ts new file mode 100644 index 00000000..0d85f5fa --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseManagementRoster.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseNumeric } from "../../../html/parseNumeric"; +import { hasCompanyEnding } from "../../../../storage/company/CompanyNormalization"; +import { normalizeManagementTitles } from "./normalizeTitle"; +import { isCollectivePartyName, isCompensationPositionLabel } from "./sectionExtractors"; +import type { ManagementPersonRow } from "./sectionSchemas"; + +export function parseManagementRoster(text: string): ManagementPersonRow[] { + try { + return parseInner(text); + } catch { + return []; + } +} + +export function hasManagementRosterTable(text: string | undefined): boolean { + if (text === undefined || text.trim() === "") return false; + return splitGfmTables(text).some((table) => findRosterHeader(table) !== undefined); +} + +type ColKind = "name" | "age" | "title" | "other"; + +function parseInner(text: string): ManagementPersonRow[] { + const out: ManagementPersonRow[] = []; + for (const table of splitGfmTables(text)) { + const header = findRosterHeader(table); + if (header === undefined) continue; + for (const row of table.slice(header.startIdx + 1)) { + const parsed = parseDataRow(row, text); + if (parsed === undefined) continue; + out.push(parsed); + } + } + const located = out.filter( + (r) => r.titles.length > 0 && (text.includes(r.source_span) || text.includes(r.full_name)) + ); + return located; +} + +function findRosterHeader(table: readonly (readonly string[])[]): + | { + readonly startIdx: number; + readonly kinds: readonly ColKind[]; + readonly combinedName: boolean; + } + | undefined { + for (let i = 0; i < table.length; i++) { + const one = classifyHeader(table[i]!); + if (one !== undefined) return { startIdx: i, ...one }; + if (i + 1 < table.length) { + const merged = classifyHeader(mergeHeaderRows(table[i]!, table[i + 1]!)); + if (merged !== undefined) return { startIdx: i + 1, ...merged }; + } + } + return undefined; +} + +function classifyHeader( + row: readonly string[] +): { readonly kinds: readonly ColKind[]; readonly combinedName: boolean } | undefined { + const cells = collapseRow(row); + if (cells.length === 0) return undefined; + const kinds: ColKind[] = cells.map(cellKind); + const hasName = kinds.includes("name"); + const hasAge = kinds.includes("age"); + const hasTitle = kinds.includes("title"); + if (!hasName || (!hasAge && !hasTitle && !cells.some(isCombinedNameCell))) return undefined; + const blob = cells.join(" ").toLowerCase(); + if (/beneficial owner|shares beneficially|percent of class/.test(blob)) return undefined; + const combinedName = cells.some(isCombinedNameCell); + if (!hasAge && !hasTitle && !combinedName) return undefined; + return { kinds, combinedName }; +} + +function cellKind(cell: string): ColKind { + const t = cell.toLowerCase(); + if (/\bname\b/.test(t)) return "name"; + if (/\bage\b/.test(t)) return "age"; + if (/\b(titles?|positions?)\b/.test(t) && !/occupation/.test(t)) return "title"; + return "other"; +} + +function isCombinedNameCell(cell: string): boolean { + const t = cell.toLowerCase(); + return /\bname\b/.test(t) && /\b(title|position)/.test(t); +} + +function parseDataRow(row: readonly string[], text: string): ManagementPersonRow | undefined { + const cells = collapseRow(row); + if (cells.length === 0) return undefined; + const rawName = cells[0] ?? ""; + const peeled = splitNameAndTitles(tidyName(rawName)); + const full_name = peeled.name; + if (full_name === "" || isSkipName(full_name)) return undefined; + if (!looksLikePerson(full_name)) return undefined; + let age: number | null = null; + const titleBits: string[] = []; + for (const cell of cells.slice(1)) { + const parsedAge = parseAge(cell); + if (parsedAge !== null) { + if (age === null) age = parsedAge; + continue; + } + const t = tidyName(cell); + if (t !== "") titleBits.push(t); + } + const fromRest = normalizeManagementTitles(titleBits.join(", ")); + const titles = peeled.titles.length > 0 ? peeled.titles : fromRest; + if (titles.length === 0) return undefined; + const source_span = text.includes(rawName) ? rawName : full_name; + return { + full_name, + titles, + relationship: null, + age, + bio: null, + confidence: 1, + source_span, + source: "deterministic", + }; +} + +const TITLE_START = + /\s+(?=(?:Independent\s+|Non-Executive\s+|Executive\s+|Senior\s+|Vice\s+|Interim\s+|Acting\s+)*(?:Chief|Chairman|Chairwoman|Chairperson|Chair\b|President|Director|CEO\b|CFO\b|COO\b|CTO\b|CIO\b|General Counsel|Secretary|Treasurer|Founder|Nominee|Managing))/i; + +function splitNameAndTitles(cell: string): { readonly name: string; readonly titles: string[] } { + const m = cell.match(TITLE_START); + if (m === null || m.index === undefined || m.index < 3) { + return { name: cell, titles: [] }; + } + const name = cell.slice(0, m.index).replace(/,+$/g, "").trim(); + const rest = cell.slice(m.index).trim(); + if (name.split(/\s+/).filter((w) => w !== "").length < 2) { + return { name: cell, titles: [] }; + } + return { name, titles: normalizeManagementTitles(rest) }; +} + +function parseAge(raw: string): number | null { + const cleaned = raw.replace(/\(\d+\)/g, "").trim(); + if (cleaned === "" || cleaned === "—" || cleaned === "–" || cleaned === "-") return null; + const n = parseNumeric(cleaned.replace(/,/g, "")); + if (n === undefined || !Number.isInteger(n)) return null; + if (n < 18 || n > 100) return null; + return n; +} + +function isSkipName(name: string): boolean { + if (/^\[?[·•●▪▫]\s*\]?$/.test(name)) return true; + if (/^[·•●▪▫]/.test(name)) return true; + if (/:\s*$/.test(name)) return true; + if (isCollectivePartyName(name)) return true; + if (isCompensationPositionLabel(name)) return true; + if (/^all\b/i.test(name) && /\b(directors?|officers?|nominees?)\b/i.test(name)) return true; + return /table of contents|directors and executive|executive officers|named executive|principal occupation/i.test( + name + ); +} + +function looksLikePerson(name: string): boolean { + if (name.length < 3) return false; + if (hasCompanyEnding(name) || hasCompanyEnding(name.replace(/\.+$/, ""))) return false; + const words = name.split(/\s+/).filter((w) => w !== ""); + return words.length >= 2; +} + +function tidyName(raw: string): string { + return raw + .replace(/\(\d+\)/g, "") + .replace(/[\u200b\u200c\u200d\ufeff]/g, "") + .replace(/,+$/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function collapseRow(row: readonly string[]): string[] { + const out: string[] = []; + for (const raw of row) { + const cell = cleanCell(raw); + if (cell === "") continue; + if (out[out.length - 1] === cell) continue; + out.push(cell); + } + return out; +} + +function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { + const n = Math.max(a.length, b.length); + const a0 = cleanCell(a[0] ?? ""); + const out: string[] = []; + for (let i = 0; i < n; i++) { + const left = cleanCell(a[i] ?? ""); + const right = cleanCell(b[i] ?? ""); + if (left === "" || (i > 0 && left === a0)) { + out.push(right); + continue; + } + if (right === "" || right === left) { + out.push(left); + continue; + } + out.push(`${left} ${right}`); + } + return out; +} + +function cleanCell(raw: string): string { + return raw + .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function splitGfmTables(text: string): string[][][] { + const tables: string[][][] = []; + let current: string[][] = []; + const flush = (): void => { + if (current.length > 0) tables.push(current); + current = []; + }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + flush(); + continue; + } + if (isSeparatorRow(trimmed)) continue; + current.push(splitPipeRow(trimmed).map(cleanCell)); + } + flush(); + return tables; +} + +function isSeparatorRow(line: string): boolean { + return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); +} + +function splitPipeRow(line: string): string[] { + const inner = line.startsWith("|") ? line.slice(1) : line; + const end = inner.endsWith("|") ? inner.slice(0, -1) : inner; + return end.split("|"); +} diff --git a/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts new file mode 100644 index 00000000..ca0def5e --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseRelatedPartyTables } from "./parseRelatedPartyTables"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function coveredName(n: string, allowed: Set): boolean { + const k = nameKey(n); + if (allowed.has(k)) return true; + for (const a of allowed) { + if (k.startsWith(a) || a.startsWith(k)) return true; + } + return false; +} + +function looksLikeCaption(n: string): boolean { + return /table of contents|^\d+$|participants?\(\d+\)|^stockholders?$/i.test(n); +} + +function fixtures(): Array<{ filing: string; byName: Map }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; byName: Map }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseRelatedPartyTables golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty related-party label", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "related-party"); + if (!labels || labels.length !== 0) continue; + const text = byName.get(S1_SECTIONS.RELATED_PARTY) ?? ""; + expect(parseRelatedPartyTables(text), filing).toEqual([]); + } + }); + + it("does not invent caption-like names outside the golden set when it hits", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "related-party"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.RELATED_PARTY) ?? ""; + const parsed = parseRelatedPartyTables(text); + if (parsed.length === 0) continue; + const allowed = new Set( + labels + .map((r) => (typeof r.name === "string" ? nameKey(r.name) : "")) + .filter((k) => k !== "") + ); + const extras = parsed + .map((row) => row.name) + .filter((n, i, arr) => arr.indexOf(n) === i) + .filter((n) => n !== "" && !coveredName(n, allowed)); + const garbage = extras.filter((n) => looksLikeCaption(n)); + expect(garbage, filing).toEqual([]); + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.test.ts b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.test.ts new file mode 100644 index 00000000..fe93dff8 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { hasRelatedPartyTable, parseRelatedPartyTables } from "./parseRelatedPartyTables"; + +const NOTES = [ + "| Convertible Note Purchasers | Original Principal Amount |", + "| Stellantis Ventures B.V. | $5,000,000 |", + "| Michael Bly | $250,000 |", + "| Table of Contents | |", +].join("\n"); + +const BULLETS = [ + "| | |", + "| · | any of our directors or officers; |", + "| · | any person proposed as a nominee for election as a director; |", +].join("\n"); + +const TOC = ["| Table of Contents |", "| 83 |"].join("\n"); + +const SPAC_BENEFITS = [ + "| Ø | 3,000,000 ordinary shares held by our initial shareholders. |", + "| Ø | Reimbursement for any out-of-pocket expenses related to identifying, investigating and completing an initial business combination; and |", + "| · | Repayment of up to an aggregate of $250,000 in loans made to us by our sponsor to cover offering-related and organizational expenses; |", + "| ● | Payment to Calisa Holding LP of $10,000 per month for office space, secretarial and administrative services. |", +].join("\n"); + +describe("parseRelatedPartyTables", () => { + it("never throws", () => { + expect(parseRelatedPartyTables("")).toEqual([]); + expect(parseRelatedPartyTables("| |")).toEqual([]); + }); + + it("reads a purchaser/amount table and skips furniture", () => { + const rows = parseRelatedPartyTables(NOTES); + expect(rows.map((r) => [r.name, r.party_kind])).toEqual([ + ["Stellantis Ventures B.V.", "company"], + ["Michael Bly", "person"], + ]); + expect(rows.every((r) => r.source === "deterministic")).toBe(true); + }); + + it("does not hit policy bullets or TOC furniture", () => { + expect(parseRelatedPartyTables(BULLETS)).toEqual([]); + expect(parseRelatedPartyTables(TOC)).toEqual([]); + }); + + it("does not treat SPAC related-party benefit bullets as a party table", () => { + expect(hasRelatedPartyTable(SPAC_BENEFITS)).toBe(false); + expect(parseRelatedPartyTables(SPAC_BENEFITS)).toEqual([]); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts new file mode 100644 index 00000000..7505ae19 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { hasCompanyEnding } from "../../../../storage/company/CompanyNormalization"; +import { legalFormTrailingCanonical } from "../../../../util/legalForms"; +import { isCollectivePartyName } from "./sectionExtractors"; +import type { RelatedPartyRow } from "./sectionSchemas"; + +export function parseRelatedPartyTables(text: string): RelatedPartyRow[] { + try { + return parseInner(text); + } catch { + return []; + } +} + +export function hasRelatedPartyTable(text: string | undefined): boolean { + if (text === undefined || text.trim() === "") return false; + return splitGfmTables(text).some((table) => findPartyHeader(table) !== undefined); +} + +function parseInner(text: string): RelatedPartyRow[] { + const out: RelatedPartyRow[] = []; + const seen = new Set(); + for (const table of splitGfmTables(text)) { + const startIdx = findPartyHeader(table); + if (startIdx === undefined) continue; + for (const row of table.slice(startIdx + 1)) { + const parsed = parseDataRow(row, text); + if (parsed === undefined) continue; + const key = parsed.name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(parsed); + } + } + return out.filter((r) => text.includes(r.source_span) || text.includes(r.name)); +} + +function findPartyHeader(table: readonly (readonly string[])[]): number | undefined { + for (let i = 0; i < table.length; i++) { + if (isPartyHeader(table[i]!)) return i; + if (i + 1 < table.length && isPartyHeader(mergeHeaderRows(table[i]!, table[i + 1]!))) { + return i + 1; + } + } + return undefined; +} + +function isPartyHeader(row: readonly string[]): boolean { + const cells = collapseRow(row); + if (cells.length < 2) return false; + if (cells.some((c) => isBulletCell(c) || c.length > 80)) return false; + const blob = cells.join(" ").toLowerCase(); + if (/table of contents|fiscal year ended|sales to joint/.test(blob)) return false; + const hasParty = cells.some((c) => + /^(related (person|party)s?|participants?|purchasers?|stockholders?|shareholders?|name\b|party name|convertible note)/i.test( + c.trim() + ) + ); + const hasFigure = cells.some((c) => + /amount|principal|\bshares\b|consideration|purchase price|^transactions?\b/i.test(c) + ); + return hasParty && hasFigure; +} + +function isBulletCell(cell: string): boolean { + return /^[Ø·•●▪▫]/.test(cell); +} + +function parseDataRow(row: readonly string[], text: string): RelatedPartyRow | undefined { + const cells = collapseRow(row); + if (cells.length === 0) return undefined; + const rawName = cells[0] ?? ""; + const name = tidyName(rawName); + if (name === "" || isSkipName(name)) return undefined; + if (!looksLikeParty(name)) return undefined; + const source_span = text.includes(rawName) ? rawName : name; + return { + name, + party_kind: partyKind(name), + confidence: 1, + source_span, + transactions: [], + source: "deterministic", + }; +} + +function looksLikeCompanyName(name: string): boolean { + return hasCompanyEnding(name) || legalFormTrailingCanonical.some(([re]) => re.test(name)); +} + +function partyKind(name: string): "person" | "company" { + return looksLikeCompanyName(name) ? "company" : "person"; +} + +function isSkipName(name: string): boolean { + if (/^\[?[·•●▪▫Ø]\s*\]?$/.test(name) || /^[·•●▪▫Ø]/.test(name)) return true; + if (/^\d+$/.test(name)) return true; + if (isCollectivePartyName(name)) return true; + return /table of contents|participants?\(\d+\)|^stockholders?$|^shareholders?$|^name\b|fiscal year|short term|convertible note|original principal|liability related/i.test( + name + ); +} + +function looksLikeParty(name: string): boolean { + if (name.length < 3) return false; + if (looksLikeCompanyName(name)) return true; + const words = name.split(/\s+/).filter((w) => w !== ""); + return words.length >= 2; +} + +function tidyName(raw: string): string { + return raw + .replace(/\(\d+\)/g, "") + .replace(/[\u200b\u200c\u200d\ufeff]/g, "") + .replace(/,+$/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function collapseRow(row: readonly string[]): string[] { + const out: string[] = []; + for (const raw of row) { + const cell = cleanCell(raw); + if (cell === "") continue; + if (out[out.length - 1] === cell) continue; + out.push(cell); + } + return out; +} + +function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { + const n = Math.max(a.length, b.length); + const a0 = cleanCell(a[0] ?? ""); + const out: string[] = []; + for (let i = 0; i < n; i++) { + const left = cleanCell(a[i] ?? ""); + const right = cleanCell(b[i] ?? ""); + if (left === "" || (i > 0 && left === a0)) { + out.push(right); + continue; + } + if (right === "" || right === left) { + out.push(left); + continue; + } + out.push(`${left} ${right}`); + } + return out; +} + +function cleanCell(raw: string): string { + return raw + .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function splitGfmTables(text: string): string[][][] { + const tables: string[][][] = []; + let current: string[][] = []; + const flush = (): void => { + if (current.length > 0) tables.push(current); + current = []; + }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + flush(); + continue; + } + if (isSeparatorRow(trimmed)) continue; + current.push(splitPipeRow(trimmed).map(cleanCell)); + } + flush(); + return tables; +} + +function isSeparatorRow(line: string): boolean { + return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); +} + +function splitPipeRow(line: string): string[] { + const inner = line.startsWith("|") ? line.slice(1) : line; + const end = inner.endsWith("|") ? inner.slice(0, -1) : inner; + return end.split("|"); +} diff --git a/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts new file mode 100644 index 00000000..4efc73ff --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacClassification } from "./parseSpacClassification"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function fixtures(): Array<{ filing: string; summary: string }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; summary: string }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name as string, s.text])); + out.push({ + filing: file.replace(/\.htm$/, ""), + summary: byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? "", + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseSpacClassification golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty spac-classification label", () => { + for (const { filing, summary } of cases()) { + const labels = getGoldenLabels(filing, "spac-classification"); + if (!labels || labels.length !== 0) continue; + expect(parseSpacClassification(summary), filing).toBeNull(); + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacClassification.test.ts b/src/sec/forms/registration-statements/s1/parseSpacClassification.test.ts new file mode 100644 index 00000000..01da0f67 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.test.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { hasSpacFormationIdentification, parseSpacClassification } from "./parseSpacClassification"; + +const SPAC = [ + "Acme Acquisition Corp. is a newly organized blank check company formed for the purpose of effecting a merger, share exchange, asset acquisition, stock purchase, reorganization or similar business combination with one or more businesses.", + "We have not selected any specific business combination target.", +].join(" "); + +const OPERATING = + "We develop and sell industrial energy storage systems. We intend to focus on our target markets, which include medical device companies. We may pursue acquisitions as part of our growth strategy."; + +describe("parseSpacClassification", () => { + it("returns null on empty or operating-company prose", () => { + expect(parseSpacClassification("")).toBeNull(); + expect(parseSpacClassification(OPERATING)).toBeNull(); + expect(hasSpacFormationIdentification(OPERATING)).toBe(false); + }); + + it("hits a stereotyped blank-check formation sentence", () => { + const row = parseSpacClassification(SPAC); + expect(row?.is_spac).toBe(true); + expect(row?.entity_kind).toBe("spac"); + expect(row?.source).toBe("deterministic"); + expect(SPAC.includes(row!.source_span)).toBe(true); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacClassification.ts b/src/sec/forms/registration-statements/s1/parseSpacClassification.ts new file mode 100644 index 00000000..fcda8b49 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SpacClassificationRow } from "./spacClassifierSchema"; + +const VEHICLE = + /\b(?:is|are)\s+a\s+(?:newly\s+(?:organized|formed)\s+)?(?:blank[\s-]*check|special[\s-]+purpose[\s-]+acquisition)\s+company\b/i; +const PURPOSE = + /\bformed for the purpose of (?:effecting|entering into|consummating|completing)\b/i; + +export function parseSpacClassification(text: string): SpacClassificationRow | null { + try { + return findClassification(text); + } catch { + return null; + } +} + +export function hasSpacFormationIdentification(text: string | undefined): boolean { + if (text === undefined || text.trim() === "") return false; + return findClassification(text) !== null; +} + +function findClassification(text: string): SpacClassificationRow | null { + const vehicle = VEHICLE.exec(text); + const purpose = PURPOSE.exec(text); + if (vehicle === null || purpose === null) return null; + const source_span = text.includes(vehicle[0]!) ? vehicle[0]! : purpose[0]!; + if (!text.includes(source_span)) return null; + return { + is_spac: true, + entity_kind: "spac", + confidence: 1, + source_span, + source: "deterministic", + }; +} diff --git a/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts new file mode 100644 index 00000000..ae76b4de --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacProfile } from "./parseSpacProfile"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function emptyProfile(labels: readonly Record[] | undefined): boolean { + if (labels === undefined || labels.length === 0) return true; + const row = labels[0]!; + const focus = Array.isArray(row.focus) ? row.focus : []; + const loc = Array.isArray(row.focus_location) ? row.focus_location : []; + return focus.length === 0 && loc.length === 0; +} + +function fixtures(): Array<{ filing: string; summary: string }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; summary: string }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + const byName = new Map(segmented.map((s) => [s.name as string, s.text])); + out.push({ + filing: file.replace(/\.htm$/, ""), + summary: byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? "", + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseSpacProfile golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty spac-profile label", () => { + for (const { filing, summary } of cases()) { + const labels = getGoldenLabels(filing, "spac-profile"); + if (!emptyProfile(labels)) continue; + expect(parseSpacProfile(summary), filing).toBeNull(); + } + }); + + it("does not invent tags outside the golden set when it hits a labelled filing", () => { + for (const { filing, summary } of cases()) { + const labels = getGoldenLabels(filing, "spac-profile"); + if (emptyProfile(labels)) continue; + const parsed = parseSpacProfile(summary); + if (parsed === null) continue; + const allowed = new Set( + [...(labels![0]!.focus as string[]), ...(labels![0]!.focus_location as string[])].map( + nameKey + ) + ); + const extras = [...parsed.focus, ...parsed.focus_location] + .filter((n, i, arr) => arr.indexOf(n) === i) + .filter((n) => !allowed.has(nameKey(n))); + expect(extras, filing).toEqual([]); + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacProfile.test.ts b/src/sec/forms/registration-statements/s1/parseSpacProfile.test.ts new file mode 100644 index 00000000..36846a58 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacProfile.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { hasProfileIdentification, parseSpacProfile } from "./parseSpacProfile"; + +const SECTORS = + "Although we may pursue targets in any industry, we intend to initially focus our search on identifying a prospective target business in financial services, technology, software, data, analytics, asset management."; + +const GAMING = + "While we may pursue an acquisition opportunity in any business, industry, sector or geographical location, we intend to focus on industries that align with the background of our sponsor and management. These industries include the gaming and gaming technology, branded consumer, lodging and entertainment, and Internet commerce sectors, which we refer to as our targeted sectors."; + +const ASIA = + "While we may pursue an initial business combination in any industry or geographic region, we intend to focus our search on businesses throughout Asia. However, we will not consummate our initial business combination with an entity or business with China operations consolidated through a variable interest entity."; + +const MATERIALS = + "We may pursue an initial business combination in any industry or geographic location. However, we intend to focus on identifying and acquiring a company involved in the global material supply chain, including companies engaged in the exploration of minerals and materials. While we intend to maintain a global mandate, we currently expect to give preference and consideration to assets located in high-quality, stable material supply chain jurisdictions, including, without limitation, the United States, Canada, Australia, the United Kingdom, Latin America."; + +const GENERALIST = + "We intend to focus our efforts on identifying and completing our initial business combination with a company that aligns with our team’s experiences, expertise and network of relationships. Our business strategy is expected to be focused on potential acquisition targets that exhibit compelling long-term growth potential."; + +const BIO = + "Mr. Kumar was a Principal at Motive Partners with a particular focus on financial services and technology companies."; + +describe("parseSpacProfile", () => { + it("returns null on empty or non-identifying prose", () => { + expect(parseSpacProfile("")).toBeNull(); + expect(parseSpacProfile(GENERALIST)).toBeNull(); + expect(parseSpacProfile(BIO)).toBeNull(); + expect(hasProfileIdentification(GENERALIST)).toBe(false); + }); + + it("reads cookie-cutter sector lists", () => { + const row = parseSpacProfile(SECTORS); + expect(row?.focus).toEqual([ + "Financial Services", + "Technology", + "Software", + "Data & Analytics", + "Asset Management", + ]); + expect(row?.focus_location).toEqual([]); + expect(row?.source).toBe("deterministic"); + expect(row?.description).toBeNull(); + }); + + it("maps lodging and internet commerce aliases", () => { + expect(parseSpacProfile(GAMING)?.focus).toEqual([ + "Gaming", + "Consumer", + "Hospitality", + "Entertainment", + "E-commerce", + ]); + }); + + it("reads geography from the identifying window only", () => { + const row = parseSpacProfile(ASIA); + expect(row?.focus).toEqual([]); + expect(row?.focus_location).toEqual(["Asia"]); + }); + + it("reads materials plus listed jurisdictions", () => { + const row = parseSpacProfile(MATERIALS); + expect(row?.focus).toContain("Materials"); + expect(row?.focus_location).toEqual([ + "United States", + "Canada", + "Australia", + "United Kingdom", + "Latin America", + ]); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacProfile.ts b/src/sec/forms/registration-statements/s1/parseSpacProfile.ts new file mode 100644 index 00000000..7dfa15a2 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacProfile.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FOCUS_VOCABULARY, type SpacProfileRow } from "./spacProfileSchema"; + +const WINDOW_AFTER = 500; + +const ON_FRAME = + "(?:identifying|industries|sectors|a\\s+(?:company|business)|target|companies\\s+(?:in|within|focused|operating)|businesses\\s+(?:in|within|throughout|focused|operating))"; +const INTENT = new RegExp( + String.raw`\bwe\s+(?:intend to|will|expect to|currently expect to)(?:\s+\S+){0,8}\s+focus(?:ed)?(?:\s+our\s+(?:search|efforts))?\s+on\s+${ON_FRAME}\b`, + "gi" +); +const INTENT_OUR = new RegExp( + String.raw`\bour\s+(?:efforts|search|business strategy)\b[\s\S]{0,80}?\bfocus(?:ed)?(?:\s+our\s+(?:search|efforts))?\s+on\s+${ON_FRAME}\b`, + "gi" +); + +const GEO_PREFERENCE = + /\b(?:give preference|consideration to assets)\b[\s\S]{0,160}?\blocated in\b/gi; + +const REJECT_FRAME = + /\bfocus(?:ed)?\s+on\s+(?:situations|improving|achieving|potential acquisition targets that exhibit)\b/i; + +const FOCUS_ALIASES: ReadonlyArray<{ + readonly re: RegExp; + readonly tag: (typeof FOCUS_VOCABULARY)[number]; +}> = [ + { re: /\bfinancial services\b/gi, tag: "Financial Services" }, + { re: /\basset management\b/gi, tag: "Asset Management" }, + { re: /\b(?:data\s*(?:&|and|,)\s*analytics|big data analytics)\b/gi, tag: "Data & Analytics" }, + { re: /\b(?:internet commerce|e-?commerce)\b/gi, tag: "E-commerce" }, + { re: /\blodging\b/gi, tag: "Hospitality" }, + { re: /\bfin\s*tech\b/gi, tag: "FinTech" }, + { re: /\bprop\s*tech\b/gi, tag: "PropTech" }, + { re: /\bbiopharmaceuticals?\b/gi, tag: "Biopharmaceuticals" }, + { re: /\bhealth[\s-]?care\b/gi, tag: "Healthcare" }, + { re: /\bartificial intelligence\b/gi, tag: "Artificial Intelligence" }, + { re: /\binternet of things\b/gi, tag: "Internet of Things" }, + { re: /\boil(?:\s+|&| and )\s*gas\b/gi, tag: "Oil & Gas" }, + { re: /\bfood(?:\s+|&| and )\s*beverage\b/gi, tag: "Food & Beverage" }, + { re: /\breal estate\b/gi, tag: "Real Estate" }, + { re: /\brenewable energy\b/gi, tag: "Renewable Energy" }, + { re: /\belectric vehicles?\b/gi, tag: "Electric Vehicles" }, + { re: /\bautonomous vehicles?\b/gi, tag: "Autonomous Vehicles" }, + { re: /\bnatural resources\b/gi, tag: "Natural Resources" }, + { re: /\bmaterials?(?:\s+supply chain)?\b/gi, tag: "Materials" }, + { re: /\bdefense(?:\s+technology)?\b/gi, tag: "Defense" }, + { re: /\b(?:mobile communications|telecommunications)\b/gi, tag: "Telecommunications" }, +]; + +const LOCATION_ALIASES: ReadonlyArray<{ readonly re: RegExp; readonly tag: string }> = [ + { re: /\bsoutheast asia\b/gi, tag: "Southeast Asia" }, + { re: /\bnorth america\b/gi, tag: "North America" }, + { re: /\blatin america\b/gi, tag: "Latin America" }, + { re: /\bunited states\b/gi, tag: "United States" }, + { re: /\bunited kingdom\b/gi, tag: "United Kingdom" }, + { re: /\bhong kong\b/gi, tag: "Hong Kong" }, + { re: /\bmiddle east\b/gi, tag: "Middle East" }, + { re: /\bgreater china\b/gi, tag: "China" }, + { re: /\bpeople'?s republic of china\b/gi, tag: "China" }, + { re: /\bmacau\b/gi, tag: "Macau" }, + { re: /\basia\b/gi, tag: "Asia" }, + { re: /\beurope\b/gi, tag: "Europe" }, + { re: /\bchina\b/gi, tag: "China" }, + { re: /\bcanada\b/gi, tag: "Canada" }, + { re: /\baustralia\b/gi, tag: "Australia" }, + { re: /\bjapan\b/gi, tag: "Japan" }, + { re: /\bindia\b/gi, tag: "India" }, + { re: /\bafrica\b/gi, tag: "Africa" }, +]; + +/** IoT is its own vocab tag; the AI alias above would steal it. Split after. */ +const IOT_RE = /\b(?:iot|internet of things)\b/gi; + +export function parseSpacProfile(text: string): SpacProfileRow | null { + try { + return findProfile(text); + } catch { + return null; + } +} + +export function hasProfileIdentification(text: string | undefined): boolean { + if (text === undefined || text.trim() === "") return false; + return findProfile(text) !== null; +} + +function findProfile(text: string): SpacProfileRow | null { + const windows = collectWindows(text); + const focus: string[] = []; + const focus_location: string[] = []; + const seenFocus = new Set(); + const seenLoc = new Set(); + let source_span = ""; + for (const window of windows) { + const f = extractFocus(window); + const loc = extractLocations(window); + if (f.length === 0 && loc.length === 0) continue; + if (source_span === "") source_span = locatableSpan(text, window); + for (const tag of f) { + if (seenFocus.has(tag)) continue; + seenFocus.add(tag); + focus.push(tag); + } + for (const tag of loc) { + if (seenLoc.has(tag)) continue; + seenLoc.add(tag); + focus_location.push(tag); + } + } + if (focus.length === 0 && focus_location.length === 0) return null; + if (source_span === "" || !text.includes(source_span)) return null; + return { + focus, + focus_location, + description: null, + team: null, + url_spac: null, + confidence: 1, + source_span, + source: "deterministic", + }; +} + +function collectWindows(text: string): string[] { + const out: string[] = []; + for (const re of [INTENT, INTENT_OUR, GEO_PREFERENCE]) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const start = m.index; + const raw = text.slice(start, start + m[0].length + WINDOW_AFTER); + let window = clipSentence(raw); + if ( + extractFocus(window).length === 0 && + extractLocations(window).length === 0 && + /industries that (?:align with|complement)/i.test(window) + ) { + const rest = text.slice(start + window.length); + window = `${window}${clipSentence(rest.slice(0, WINDOW_AFTER))}`; + } + if (REJECT_FRAME.test(window)) continue; + out.push(window); + } + } + return out; +} + +function extractFocus(window: string): string[] { + const occupied: Array<{ start: number; end: number }> = []; + const found: Array<{ tag: string; start: number }> = []; + const seen = new Set(); + const add = (tag: string, start: number, end: number): void => { + if (seen.has(tag)) return; + if (overlaps(occupied, start, end)) return; + seen.add(tag); + occupied.push({ start, end }); + found.push({ tag, start }); + }; + for (const { re, tag } of FOCUS_ALIASES) { + if (tag === "Financial Services" && /\(\s*["“']?FinTech/i.test(window)) continue; + if (tag === "Real Estate" && /\(\s*["“']?PropTech/i.test(window)) continue; + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(window)) !== null) { + add(tag, m.index, m.index + m[0].length); + } + } + IOT_RE.lastIndex = 0; + let iot: RegExpExecArray | null; + while ((iot = IOT_RE.exec(window)) !== null) { + add("Internet of Things", iot.index, iot.index + iot[0].length); + } + const vocab = [...FOCUS_VOCABULARY].toSorted((a, b) => b.length - a.length); + for (const tag of vocab) { + const re = new RegExp(`\\b${escapeRe(tag)}\\b`, "gi"); + let m: RegExpExecArray | null; + while ((m = re.exec(window)) !== null) { + if ( + tag === "Technology" && + /(?:gaming|defense|space|information)\s+$/i.test( + window.slice(Math.max(0, m.index - 20), m.index) + ) + ) { + continue; + } + if ( + tag === "Infrastructure" && + /(?:blockchain|crypto|digital|data(?:-intensive)?|cloud|fintech)\s+$/i.test( + window.slice(Math.max(0, m.index - 20), m.index) + ) + ) { + continue; + } + if (tag === "Real Estate" && /\(\s*["“']?PropTech/i.test(window)) continue; + if (tag === "Financial Services" && /\(\s*["“']?FinTech/i.test(window)) continue; + if ( + tag === "Marketing" && + /ing,\s+$/i.test(window.slice(Math.max(0, m.index - 12), m.index)) + ) { + continue; + } + add(tag, m.index, m.index + m[0].length); + } + } + const aiBare = /\bAI\b/g; + let ai: RegExpExecArray | null; + while ((ai = aiBare.exec(window)) !== null) { + add("Artificial Intelligence", ai.index, ai.index + ai[0].length); + } + return found.toSorted((a, b) => a.start - b.start).map((x) => x.tag); +} + +function extractLocations(window: string): string[] { + const occupied: Array<{ start: number; end: number }> = []; + const found: Array<{ tag: string; start: number }> = []; + const seen = new Set(); + for (const { re, tag } of LOCATION_ALIASES) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(window)) !== null) { + if (seen.has(tag)) continue; + if (overlaps(occupied, m.index, m.index + m[0].length)) continue; + seen.add(tag); + occupied.push({ start: m.index, end: m.index + m[0].length }); + found.push({ tag, start: m.index }); + } + } + return found.toSorted((a, b) => a.start - b.start).map((x) => x.tag); +} + +function overlaps( + occupied: ReadonlyArray<{ start: number; end: number }>, + start: number, + end: number +): boolean { + return occupied.some((r) => start < r.end && end > r.start); +} + +function clipSentence(s: string): string { + for (let i = 0; i < s.length; i++) { + if (s[i] !== ".") continue; + const next = s[i + 1]; + if (next !== undefined && next !== " " && next !== "\n" && next !== "") continue; + if (isAbbreviationDot(s, i)) continue; + return s.slice(0, i + 1); + } + return s; +} + +function isAbbreviationDot(s: string, dot: number): boolean { + const before = s.slice(Math.max(0, dot - 8), dot); + if (/\b(?:U|S|K|C|D|P|R)\s*$/.test(before)) return true; + if (/(?:Inc|Ltd|Corp|Mr|Ms|Dr|Jr|Sr|vs|eg|ie)$/i.test(before.replace(/\.$/, ""))) return true; + return false; +} + +function locatableSpan(text: string, window: string): string { + const trimmed = window.replace(/\s+/g, " ").trim(); + if (text.includes(window.slice(0, Math.min(80, window.length)))) { + const probe = window.slice(0, Math.min(window.length, 200)).trim(); + if (text.includes(probe)) return probe; + } + if (text.includes(trimmed)) return trimmed.slice(0, 200); + return window.slice(0, 80); +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts new file mode 100644 index 00000000..a4045817 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacSponsors } from "./parseSpacSponsors"; + +const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); + +function nameKey(s: string): string { + return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function coveredName(n: string, allowed: Set): boolean { + const k = nameKey(n); + if (allowed.has(k)) return true; + for (const a of allowed) { + if (k.startsWith(a) || a.startsWith(k)) return true; + } + return false; +} + +function sponsorText(byName: Map): string { + return ( + byName.get(S1_SECTIONS.THE_SPONSOR) ?? + [...byName.entries()] + .filter(([name]) => name !== S1_SECTIONS.RISK_FACTORS) + .map(([, sectionText]) => sectionText) + .join("\n\n") + ); +} + +function fixtures(): Array<{ filing: string; byName: Map }> { + const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); + const out: Array<{ filing: string; byName: Map }> = []; + for (const file of files.sort()) { + const html = readFileSync(join(MOCK_DIR, file), "utf8"); + const doc = parseEdgarHtml(html, file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +let corpus: ReturnType | undefined; +function cases(): ReturnType { + corpus ??= fixtures(); + return corpus; +} + +describe("parseSpacSponsors golden corpus", () => { + it("loads committed S-1 fixtures", () => { + expect(cases().length).toBeGreaterThan(0); + }); + + it("never false-hits a golden empty spac-sponsors label", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "spac-sponsors"); + if (!labels || labels.length !== 0) continue; + expect(parseSpacSponsors(sponsorText(byName)), filing).toEqual([]); + } + }); + + it("does not invent names outside the golden set when it hits a labelled filing", () => { + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "spac-sponsors"); + if (!labels || labels.length === 0) continue; + const parsed = parseSpacSponsors(sponsorText(byName)); + if (parsed.length === 0) continue; + const allowed = new Set( + labels + .map((r) => (typeof r.legal_name === "string" ? nameKey(r.legal_name) : "")) + .filter((k) => k !== "") + ); + const extras = parsed + .map((row) => row.legal_name) + .filter((n, i, arr) => arr.indexOf(n) === i) + .filter((n) => n !== "" && !coveredName(n, allowed)); + expect(extras, filing).toEqual([]); + } + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacSponsors.test.ts b/src/sec/forms/registration-statements/s1/parseSpacSponsors.test.ts new file mode 100644 index 00000000..3fc31c8c --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.test.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { hasSponsorIdentification, parseSpacSponsors } from "./parseSpacSponsors"; + +const APPOSITIVE = + "Our sponsor, Bluerock Acquisition Holdings II, LLC, is a Delaware limited liability company and was formed to invest in us."; + +const COPULA = "Our Sponsor is Samara Acquisition Sponsor VI Ltd."; + +const THE_SPONSOR = + "The Sponsor, Teucrium Asset Management, LLC, is a Delaware limited liability company."; + +const NOISE = [ + "Our sponsor, officers or directors may purchase shares.", + "Our sponsor is a Delaware limited liability company, which was recently formed to invest in our company.", + "Our sponsor is an affiliate of 1Sharpe Capital, LLC (“1Sharpe Capital”).", + "Our sponsor is majority-owned by our Chairman.", + "our sponsor is currently sponsoring Trebia Acquistion Corp.", + "The sole managing member of our sponsor is Bluerock Real Estate Holdings, LLC.", + "Our sponsor is controlled by Kleinfeld Constellation Investment, LLC.", +].join(" "); + +describe("parseSpacSponsors", () => { + it("never throws", () => { + expect(parseSpacSponsors("")).toEqual([]); + expect(parseSpacSponsors("our sponsor, officers or directors.")).toEqual([]); + }); + + it("reads an appositive legal name including a comma before LLC", () => { + const rows = parseSpacSponsors(APPOSITIVE); + expect(rows.map((r) => r.legal_name)).toEqual(["Bluerock Acquisition Holdings II, LLC"]); + expect(rows[0]?.source).toBe("deterministic"); + expect(APPOSITIVE.includes(rows[0]!.source_span)).toBe(true); + }); + + it("reads a copula legal name", () => { + expect(parseSpacSponsors(COPULA).map((r) => r.legal_name)).toEqual([ + "Samara Acquisition Sponsor VI Ltd.", + ]); + }); + + it("reads The Sponsor appositive", () => { + expect(parseSpacSponsors(THE_SPONSOR).map((r) => r.legal_name)).toEqual([ + "Teucrium Asset Management, LLC", + ]); + }); + + it("does not treat officer lists or nameless copulas as a hit", () => { + expect(hasSponsorIdentification(NOISE)).toBe(false); + expect(parseSpacSponsors(NOISE)).toEqual([]); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts b/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts new file mode 100644 index 00000000..9ced67d8 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { hasCompanyEnding } from "../../../../storage/company/CompanyNormalization"; +import { legalFormTrailingCanonical } from "../../../../util/legalForms"; +import type { SpacSponsorRow } from "./spacSponsorSchema"; + +export function parseSpacSponsors(text: string): SpacSponsorRow[] { + try { + return findCandidates(text).filter( + (r) => text.includes(r.source_span) || text.includes(r.legal_name) + ); + } catch { + return []; + } +} + +export function hasSponsorIdentification(text: string | undefined): boolean { + if (text === undefined || text.trim() === "") return false; + return findCandidates(text).length > 0; +} + +const APPOSITIVE = /(?(); + for (const re of [APPOSITIVE, COPULA]) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + let legal_name = tidyName(m[1] ?? ""); + if (/(?:Ltd|Inc|Corp)$/i.test(legal_name) && text.includes(`${legal_name}.`)) { + legal_name = `${legal_name}.`; + } + if (legal_name === "" || !looksLikeSponsorEntity(legal_name)) continue; + const key = legal_name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const source_span = text.includes(m[0]!) ? m[0]! : legal_name; + out.push({ + legal_name, + confidence: 1, + source_span, + source: "deterministic", + }); + } + } + return out; +} + +function looksLikeCompanyName(name: string): boolean { + return hasCompanyEnding(name) || legalFormTrailingCanonical.some(([re]) => re.test(name)); +} + +function looksLikeSponsorEntity(name: string): boolean { + if (name.length > 80) return false; + if (/^(controlled|owned|managed)\s+by\b/i.test(name)) return false; + if ( + /officers?|directors?|affiliates?|investors?|nominees?|founders?|permitted|delaware limited/i.test( + name + ) + ) { + return false; + } + if (name.split(/\s+/).length > 12) return false; + return looksLikeCompanyName(name); +} + +function tidyName(raw: string): string { + return raw + .replace(/\s+/g, " ") + .replace(/^["“”']+|["“”']+$/g, "") + .trim(); +} diff --git a/src/sec/forms/registration-statements/s1/sectionSchemas.ts b/src/sec/forms/registration-statements/s1/sectionSchemas.ts index 7f52b117..91340c05 100644 --- a/src/sec/forms/registration-statements/s1/sectionSchemas.ts +++ b/src/sec/forms/registration-statements/s1/sectionSchemas.ts @@ -132,6 +132,8 @@ export interface ManagementPersonRow { bio: string | null; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } export interface BeneficialOwnerRow { name: string; @@ -161,4 +163,6 @@ export interface RelatedPartyRow { period: string | null; footnote: string | null; }>; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts b/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts index fcee58fd..e4d09bc7 100644 --- a/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts @@ -41,4 +41,6 @@ export interface SpacClassificationRow { entity_kind: SpacEntityKind; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/spacProfileSchema.ts b/src/sec/forms/registration-statements/s1/spacProfileSchema.ts index 0db0051f..cb8c9a9a 100644 --- a/src/sec/forms/registration-statements/s1/spacProfileSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacProfileSchema.ts @@ -128,4 +128,6 @@ export interface SpacProfileRow { url_spac: string | null; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts b/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts index 3a907ab2..b043f9c3 100644 --- a/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts @@ -32,4 +32,6 @@ export interface SpacSponsorRow { legal_name: string; confidence: number; source_span: string; + /** Persist-only; never part of the model JSON schema. */ + source?: "deterministic"; } diff --git a/src/storage/related-party/RelatedPartyTransactionRepo.ts b/src/storage/related-party/RelatedPartyTransactionRepo.ts index 99417a6e..d263ad7e 100644 --- a/src/storage/related-party/RelatedPartyTransactionRepo.ts +++ b/src/storage/related-party/RelatedPartyTransactionRepo.ts @@ -22,6 +22,10 @@ export class RelatedPartyTransactionRepo { await this.storage.put(row); } + async listAll(): Promise { + return (await this.storage.getAll()) ?? []; + } + async queryByAccession(accession_number: string): Promise { return (await this.storage.query({ accession_number })) ?? []; } diff --git a/src/task/eval/EvalManagementTask.ts b/src/task/eval/EvalManagementTask.ts new file mode 100644 index 00000000..230f2e5b --- /dev/null +++ b/src/task/eval/EvalManagementTask.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runManagementEval } from "../../eval/runManagementEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalManagementTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalManagementTaskOutput = Static>; + +/** + * Scores the deterministic management roster parser against stored rows + * using on-disk accession docs. Never fetches EDGAR. + */ +export class EvalManagementTask extends Task { + static readonly type = "EvalManagementTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate management"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalManagementTaskInput, + context: IExecuteContext + ): Promise { + const report = await runManagementEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalManagementTaskOutput; + } +} diff --git a/src/task/eval/EvalRelatedPartyTask.ts b/src/task/eval/EvalRelatedPartyTask.ts new file mode 100644 index 00000000..4f43fd50 --- /dev/null +++ b/src/task/eval/EvalRelatedPartyTask.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runRelatedPartyEval } from "../../eval/runRelatedPartyEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalRelatedPartyTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalRelatedPartyTaskOutput = Static>; + +/** + * Scores the deterministic related-party table parser against stored rows + * using on-disk accession docs. Never fetches EDGAR. + */ +export class EvalRelatedPartyTask extends Task< + EvalRelatedPartyTaskInput, + EvalRelatedPartyTaskOutput +> { + static readonly type = "EvalRelatedPartyTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate related party"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalRelatedPartyTaskInput, + context: IExecuteContext + ): Promise { + const report = await runRelatedPartyEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalRelatedPartyTaskOutput; + } +} diff --git a/src/task/eval/EvalSpacClassificationTask.ts b/src/task/eval/EvalSpacClassificationTask.ts new file mode 100644 index 00000000..92c33ed0 --- /dev/null +++ b/src/task/eval/EvalSpacClassificationTask.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runSpacClassificationEval } from "../../eval/runSpacClassificationEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalSpacClassificationTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalSpacClassificationTaskOutput = Static>; + +/** + * Scores the deterministic SPAC classifier against stored rows using + * on-disk accession docs. Never fetches EDGAR. + */ +export class EvalSpacClassificationTask extends Task< + EvalSpacClassificationTaskInput, + EvalSpacClassificationTaskOutput +> { + static readonly type = "EvalSpacClassificationTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate spac classification"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalSpacClassificationTaskInput, + context: IExecuteContext + ): Promise { + const report = await runSpacClassificationEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalSpacClassificationTaskOutput; + } +} diff --git a/src/task/eval/EvalSpacProfileTask.ts b/src/task/eval/EvalSpacProfileTask.ts new file mode 100644 index 00000000..fa355451 --- /dev/null +++ b/src/task/eval/EvalSpacProfileTask.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runSpacProfileEval } from "../../eval/runSpacProfileEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalSpacProfileTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalSpacProfileTaskOutput = Static>; + +/** + * Scores the deterministic SPAC profile parser against stored rows using + * on-disk accession docs. Never fetches EDGAR. + */ +export class EvalSpacProfileTask extends Task { + static readonly type = "EvalSpacProfileTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate spac profile"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalSpacProfileTaskInput, + context: IExecuteContext + ): Promise { + const report = await runSpacProfileEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalSpacProfileTaskOutput; + } +} diff --git a/src/task/eval/EvalSpacSponsorsTask.ts b/src/task/eval/EvalSpacSponsorsTask.ts new file mode 100644 index 00000000..c692eaeb --- /dev/null +++ b/src/task/eval/EvalSpacSponsorsTask.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import { IExecuteContext, Task } from "workglow"; +import { runSpacSponsorsEval } from "../../eval/runSpacSponsorsEval"; + +const InputSchema = () => + Type.Object({ + extractorId: Type.Optional( + Type.String({ title: "Extractor id", description: "Limit to S-1 or 424" }) + ), + limit: Type.Optional(Type.Number({ title: "Limit", description: "Max stored rows to score" })), + cik: Type.Optional(Type.Number({ title: "CIK", description: "Limit to one issuer" })), + }); +export type EvalSpacSponsorsTaskInput = Static>; + +const OutputSchema = () => + Type.Object({ + cases: Type.Array(Type.Unknown()), + counts: Type.Object({ + "hit-agree": Type.Number(), + "hit-disagree": Type.Number(), + miss: Type.Number(), + empty: Type.Number(), + skip: Type.Number(), + }), + }); +export type EvalSpacSponsorsTaskOutput = Static>; + +/** + * Scores the deterministic SPAC sponsor parser against stored rows using + * on-disk accession docs. Never fetches EDGAR. + */ +export class EvalSpacSponsorsTask extends Task< + EvalSpacSponsorsTaskInput, + EvalSpacSponsorsTaskOutput +> { + static readonly type = "EvalSpacSponsorsTask"; + static readonly category = "SEC"; + static readonly title = "Evaluate spac sponsors"; + static readonly cacheable = false; + + static inputSchema() { + return InputSchema(); + } + + static outputSchema() { + return OutputSchema(); + } + + async execute( + input: EvalSpacSponsorsTaskInput, + context: IExecuteContext + ): Promise { + const report = await runSpacSponsorsEval({ + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + limit: input.limit, + cik: input.cik, + signal: context.signal, + onProgress: (done, total, message) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + void context.updateProgress(pct, message); + if (!process.stdout.isTTY) process.stderr.write(`${message}\n`); + }, + }); + return report as unknown as EvalSpacSponsorsTaskOutput; + } +} From 661cbec7f3e8a769fce20f5e865452553dce7fbe Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Tue, 18 Aug 2026 23:13:23 -0700 Subject: [PATCH 06/29] fix(s1): do not treat mixed outstanding-before shares as the founder promote Generic "shares outstanding before this offering" can include underwriter (EBC) founder shares. Keep that count as a fallback and prefer a later named founder/Class B figure. Co-authored-by: Cursor --- .../s1/parseOfferingTables.test.ts | 11 ++++++++ .../s1/parseOfferingTables.ts | 25 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts index 1901d441..f34f9f2b 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts @@ -347,6 +347,17 @@ describe("parseSpacPromoteTerms", () => { expect(parseSpacPromoteTerms(text)!.founder_shares).toBe(2_156_250); }); + it("does not take outstanding-before shares that mix founder shares with underwriter founder shares", () => { + const text = ` +| Offering price | $10.00 | +| Number of units offered | 7,500,000 | +| Number outstanding before this offering | 3,093,750 shares(3) | +| (3) | Represents 2,875,000 founder shares and 218,750 EBC founder shares. | +| Founder shares and EBC founder shares | In December 2025, our sponsors acquired an aggregate of 2,300,000 ordinary shares for an aggregate purchase price of $25,000. In June 2026, we effected a share capitalization to increase the number of founder shares to 2,875,000. | +`.trim(); + expect(parseSpacPromoteTerms(text)!.founder_shares).toBe(2_875_000); + }); + it("takes Class B shares outstanding before the offering over an earlier purchase price", () => { const text = ` | Offering price | $10.00 | diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts index 1fa05b61..2415746a 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts @@ -139,6 +139,7 @@ function walkFields(text: string): WalkedFields { if (out.source_span === "") out.source_span = value; }; let section = ""; + let outstandingBeforeShares: number | undefined; for (const row of iterTableRows(text)) { if (row.label === "continuation") { if (out.founder_percent === null) { @@ -281,6 +282,13 @@ function walkFields(text: string): WalkedFields { take(row.value); } } + if (outstandingBeforeShares === undefined && isOutstandingBeforeSharesRow(row)) { + const n = founderShareCount(row.value); + if (n !== undefined) outstandingBeforeShares = n; + } + } + if (out.founder_shares === null && outstandingBeforeShares !== undefined) { + out.founder_shares = outstandingBeforeShares; } return out; } @@ -429,11 +437,24 @@ function isFounderLabel(label: string): boolean { return /founder shares|class b/.test(label); } +function isOutstandingBeforeLabel(label: string): boolean { + return /number outstanding before this offering/.test(label); +} + +function isNamedFounderValue(value: string): boolean { + return /class b|founder shares|ordinary shares/i.test(value); +} + function isFounderRow(row: TableRow): boolean { if (isFounderLabel(row.label)) return true; + return isOutstandingBeforeLabel(row.label) && isNamedFounderValue(row.value); +} + +function isOutstandingBeforeSharesRow(row: TableRow): boolean { return ( - /number outstanding before this offering/.test(row.label) && - /class b|founder shares|ordinary shares|\bshares\b/i.test(row.value) + isOutstandingBeforeLabel(row.label) && + /\bshares\b/i.test(row.value) && + !isNamedFounderValue(row.value) ); } From f2c46b53ac0027786a8bd214bdc7b7b158ee1d9a Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Wed, 19 Aug 2026 22:47:27 +0000 Subject: [PATCH 07/29] nit: format --- src/eval/runUnderwritersEval.test.ts | 3 ++- src/task/eval/EvalOfferingTablesTask.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/eval/runUnderwritersEval.test.ts b/src/eval/runUnderwritersEval.test.ts index e37b599c..38160fb5 100644 --- a/src/eval/runUnderwritersEval.test.ts +++ b/src/eval/runUnderwritersEval.test.ts @@ -35,7 +35,8 @@ describe("bucketWhenParserEmpty", () => { bucketWhenParserEmpty({ stored: { names: ["Needham & Company, LLC"], roles: ["lead"] }, offeringText: unitIpo, - underwritingText: "Needham & Company, LLC is acting as the sole underwriter of this offering.", + underwritingText: + "Needham & Company, LLC is acting as the sole underwriter of this offering.", }) ).toEqual({ bucket: "skip", reason: "no-table" }); }); diff --git a/src/task/eval/EvalOfferingTablesTask.ts b/src/task/eval/EvalOfferingTablesTask.ts index 14a8feff..c1076cd6 100644 --- a/src/task/eval/EvalOfferingTablesTask.ts +++ b/src/task/eval/EvalOfferingTablesTask.ts @@ -57,7 +57,8 @@ export class EvalOfferingTablesTask extends Task< context: IExecuteContext ): Promise { const report = await runOfferingTablesEval({ - extractorId: input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, + extractorId: + input.extractorId === "424" || input.extractorId === "S-1" ? input.extractorId : undefined, limit: input.limit, cik: input.cik, signal: context.signal, From 2b0ce88179d065dadfc2a06d3810c79d63f1090c Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Thu, 20 Aug 2026 02:17:28 +0000 Subject: [PATCH 08/29] feat: add registerSafeFetch function to exports and update tests - Introduced the `registerSafeFetch` function to the exports in `index.ts` for improved functionality. - Updated tests in `index.barrel.test.ts` to verify the presence of the new `registerSafeFetch` function. - Reorganized some exports for better clarity and consistency. --- src/index.barrel.test.ts | 1 + src/index.ts | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/index.barrel.test.ts b/src/index.barrel.test.ts index 279db4b1..75dd7bcd 100644 --- a/src/index.barrel.test.ts +++ b/src/index.barrel.test.ts @@ -53,6 +53,7 @@ test("exports task + temporal primitives downstream ingestion needs", () => { expect(typeof (sec as Record).Task).toBe("function"); expect(typeof (sec as Record).Workflow).toBe("function"); expect(typeof (sec as Record).isStaleByAsOf).toBe("function"); + expect(typeof (sec as Record).registerSafeFetch).toBe("function"); }); test("exports family-tier primitives for a downstream family resolver", () => { diff --git a/src/index.ts b/src/index.ts index 7efa17e2..04c7a449 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,10 +44,10 @@ export { AddCommands, DI_EXEMPT_COMMANDS } from "./commands"; export { addSyncLeafCommands } from "./cli/groups/sync"; export { runFormsSweep } from "./cli/sync/runFormsSweep"; -export { SYNC_FORM_DOMAINS, formsForExtractorIds } from "./cli/sync/syncFormDomains"; +export { formsForExtractorIds, SYNC_FORM_DOMAINS } from "./cli/sync/syncFormDomains"; export { - EMPTY_SYNC_CONTEXT, clearSyncLeavesForTesting, + EMPTY_SYNC_CONTEXT, getSyncLeaf, listSyncLeaves, registerSyncLeaf, @@ -70,8 +70,8 @@ export * from "./config/tokens"; // ── Fetch job queue + fetch task bases ────────────────────────────────────── export { SecCachedFetchTask, - type SecCachedFetchTaskInput, type response_type, + type SecCachedFetchTaskInput, } from "./task/fetch/SecCachedFetchTask"; export { SecFetchTask } from "./task/fetch/SecFetchTask"; export { getSecJobQueue, setupSecFetchRateLimiter } from "./task/fetch/SecJobQueue"; @@ -96,28 +96,31 @@ export { // Saves supersets from taking a direct `workglow` dependency. Routing DI + // schema access through the barrel is REQUIRED for correctness, not just // convenience: a downstream package that imported its own `workglow` / -// `typebox` copy would get a *different* `globalServiceRegistry` singleton and a -// different TypeBox instance, so its DI registrations and schemas would not be -// visible to sec. Import these from `@workglow/sec` to share sec's instances. -export type { TaskPorts } from "./task/taskPorts"; -export { isStaleByAsOf } from "./util/asOfGuard"; +// `typebox` copy would get a *different* `globalServiceRegistry` singleton, a +// different TypeBox instance, and a different `registerSafeFetch` slot, so its +// DI registrations, schemas, and fetch stubs would not be visible to sec. +// Import these from `@workglow/sec` to share sec's instances. export { Type, type Static } from "typebox"; export { Value } from "typebox/value"; export { FetchUrlTask, + getTaskQueueRegistry, + globalServiceRegistry, + registerSafeFetch, Sqlite, Task, Workflow, - getTaskQueueRegistry, - globalServiceRegistry, } from "workglow"; export type { FetchUrlTaskInput, FetchUrlTaskOutput, IExecuteContext, + SafeFetchFn, ServiceToken, TaskOutput, } from "workglow"; +export type { TaskPorts } from "./task/taskPorts"; +export { isStaleByAsOf } from "./util/asOfGuard"; // ── Extension seams for downstream feature packages ───────────────────────── // A downstream feature package (e.g. `embarc-data`) registers its own resolver @@ -214,8 +217,8 @@ export { TypeNullable } from "./util/TypeBoxUtil"; // ── Re-exported workglow storage primitives a feature package builds on ────── export { - InMemoryTabularStorage, createServiceToken, + InMemoryTabularStorage, type AnyTabularStorage, type ITabularStorage, } from "workglow"; From 950c98705f2e32be7f6033d44ea0a49aef4b624e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 02:27:27 +0000 Subject: [PATCH 09/29] fix: a deterministic pass may not preempt what it cannot supply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processFormS1 clears its destination tables up front and then, for each section, ran a deterministic parser that won on ANY non-empty result while hardcoding the columns it cannot read. The section persisted a strict subset of what it had just emptied, resolved clean, and re-took the same path on every replay, so nothing self-corrected: the related-party table lost every transaction, the issuer ticker series was emptied and never refilled, the SPAC profile's description/team were never written, and a partial roster parse reported itself complete and closed person_role tenures the filing still asserts. Introduce a declared coverage contract. A DeterministicPass names the destinations it covers; a section names the destinations it rewrites; the runner lets the parse stand in for the model only when covers is a superset of clears, and an undeclared clears never preempts. The parse runs ONCE, outside the verification retry loop, all-or-nothing: a row that fails the floor or span verification discards the whole parse and falls through to the model recording nothing, rather than re-asking a pure function three times and dead-lettering the model for a parser miss. Persist callbacks read meta.source instead of a field on row zero, and meta.complete comes from the pass itself (default false) rather than from counting already-filtered rows. Also delete the isSpac && !looksLikeUnitIpo gate on the underwriters and use-of-proceeds sections. looksLikeUnitIpo only reads markdown table rows, so it fired on every SPAC whose "The Offering" section is prose — skipping both sections after their tables were cleared, and resolving any pending dead letter for them. Measured over the committed S-1 corpus it discards 2 of 20 SPAC fixtures, 87k characters of Underwriting prose and 33 hand-verified golden rows. markSectionResolved goes with it: a parser failing to recognise a unit IPO is not evidence about the Underwriting section. Repairing already-processed filings needs `sec extractor backfill S-1 --force` (and `424 --force` for the offering sections). --- .../Form_424.storage.ts | 2 - .../Form_S_1.storage.management.test.ts | 88 +++++ .../Form_S_1.storage.offering.test.ts | 149 +++++++- .../Form_S_1.storage.profile.test.ts | 18 +- .../Form_S_1.storage.related-party.test.ts | 41 ++- .../Form_S_1.storage.ts | 133 +++++-- .../s1/deterministicPass.ts | 58 +++ .../s1/offeringSections.ts | 338 ++++++++++-------- .../s1/sectionRunner.deterministic.test.ts | 167 +++++++++ .../s1/sectionRunner.ts | 160 ++++++--- 10 files changed, 884 insertions(+), 270 deletions(-) create mode 100644 src/sec/forms/registration-statements/s1/deterministicPass.ts create mode 100644 src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts diff --git a/src/sec/forms/registration-statements/Form_424.storage.ts b/src/sec/forms/registration-statements/Form_424.storage.ts index 5a6cf2b7..f1bcbd85 100644 --- a/src/sec/forms/registration-statements/Form_424.storage.ts +++ b/src/sec/forms/registration-statements/Form_424.storage.ts @@ -364,8 +364,6 @@ export async function processForm424(args: ProcessForm424Args): Promise { activeUnderwriterFamilyVersion, byName, context: args.context, - markSectionResolved: (section) => - deadLetters.markResolved(EXTRACTOR_ID, accession_number, section), }); await recordSpacIpoEventIfEligible(); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts index 4a2d7f98..ac98a080 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; import { setupAllDatabases } from "../../../config/setupAllDatabases"; import { PersonObservationRepo } from "../../../storage/observation/PersonObservationRepo"; +import { PersonRoleRepo } from "../../../storage/canonical/PersonRoleRepo"; import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; import { processFormS1 } from "./Form_S_1.storage"; import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; @@ -22,6 +23,44 @@ const HTML_PARSEABLE = [ "

LEGAL MATTERS

x

", ].join(""); +// The roster the parser reads: one person it can see. The prose line below it +// names an officer no table row carries, which the AI path reads and the table +// walk cannot. +const HTML_PARTIAL_ROSTER = [ + "

MANAGEMENT

", + "", + "", + "", + "
NameAgeTitle
Jane Roe52Director
", + "

John Doe continues to serve as our Chief Financial Officer.

", + "

LEGAL MATTERS

x

", +].join(""); + +const HTML_PROSE_ROSTER = [ + "

MANAGEMENT

", + "

Jane Roe — Director. John Doe — Chief Financial Officer.

", + "

LEGAL MATTERS

x

", +].join(""); + +const BOTH_OFFICERS_PAYLOAD = { + people: [ + { + full_name: "Jane Roe", + titles: ["Director"], + relationship: null, + confidence: 0.9, + source_span: "Jane Roe — Director", + }, + { + full_name: "John Doe", + titles: ["Chief Financial Officer"], + relationship: null, + confidence: 0.9, + source_span: "John Doe — Chief Financial Officer", + }, + ], +}; + const NULL_HEADER = { sic: null, sicDescription: null, @@ -74,4 +113,53 @@ describe("processFormS1 management roster", () => { ); expect(provenance?.model_id).toBe(DETERMINISTIC_MODEL_ID); }); + + it("does not close a role the roster parse never claimed to have enumerated", async () => { + const { unregister } = registerFakeStructuredProvider([BOTH_OFFICERS_PAYLOAD]); + cleanup = unregister; + + // First filing (prose roster, AI path): both officers hold open roles. + await processFormS1({ + cik: 1018724, + file_number: "333-2", + accession_number: "acc-mgmt-role-1", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: NULL_HEADER, + html: HTML_PROSE_ROSTER, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const opened = await new PersonRoleRepo().listForCompany(1018724, "1.0.0"); + expect(opened.map((r) => r.title).sort()).toEqual(["Chief Financial Officer", "Director"]); + + // Second filing: the table walk reads one of the two, and the filing still + // names the other. The parser filters its own output, so "every row I + // returned survived" is not evidence that it read the whole roster. + await processFormS1({ + cik: 1018724, + file_number: "333-2", + accession_number: "acc-mgmt-role-2", + filing_date: "2026-02-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: NULL_HEADER, + html: HTML_PARTIAL_ROSTER, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const roles = await new PersonRoleRepo().listForCompany(1018724, "1.0.0"); + const cfo = roles.find((r) => r.title === "Chief Financial Officer"); + expect(cfo).toBeDefined(); + expect(cfo!.end_date).toBeNull(); + }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts index 116e90a6..14c71d76 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts @@ -376,7 +376,10 @@ describe("processFormS1 offering terms", () => { expect(offering?.detail).toMatch(/NO issuer ticker rows/); }); - it("persists a markdown-table hit as deterministic without calling the offering model", async () => { + it("keeps the issuer ticker series on a markdown-table SPAC offering the walker can read", async () => { + // The walker reads the unit table but never the listing sentence, and the + // section clears `issuer_ticker` before it writes. Preempting on the terms + // alone empties the point-in-time ticker series nothing else reconstructs. const html = [ "

THE OFFERING

", "", @@ -385,9 +388,34 @@ describe("processFormS1 offering terms", () => { "", "", "
Founder shares5,750,000
Proceeds to be held in trust account$10.00 per unit
", + "

Our units are expected to be listed on Nasdaq under the symbol ACQU.

", "

UNDERWRITING

Goldman Sachs & Co. LLC is the representative.

", ].join(""); - const { unregister } = registerFakeStructuredProvider([{ underwriters: [] }]); + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Units", + shares_offered: null, + price: null, + price_low: null, + price_high: null, + gross_proceeds: 200000000, + net_proceeds: null, + over_allotment_shares: null, + units_offered: 20000000, + price_per_unit: 10, + unit_composition: "one share and one-half warrant", + warrant_fraction_per_unit: 0.5, + right_fraction_per_unit: null, + trust_per_unit: 10, + over_allotment_units: null, + exchange: "NASDAQ", + par_value: null, + confidence: 0.9, + source_span: "20,000,000", + tickers: [{ ticker: "ACQU", exchange: "NASDAQ", security_type: "Units", is_primary: true }], + }, + { underwriters: [] }, + ]); cleanup = unregister; await processFormS1({ @@ -407,16 +435,17 @@ describe("processFormS1 offering terms", () => { }); const unit = await new SpacUnitTermsRepo().get("S-1", "0000000000-26-000010"); - expect(unit?.price_per_unit).toBe(10); expect(unit?.units_offered).toBe(20_000_000); + expect(unit?.ticker).toBe("ACQU"); + const tickers = await new IssuerTickerRepo().history(1848507); + expect(tickers.map((t) => t.ticker)).toEqual(["ACQU"]); + // The promote parse supplies every column its own section rewrites, so it + // still stands in for the model. const promote = await new SpacPromoteTermsRepo().get("S-1", "0000000000-26-000010"); expect(promote?.founder_shares).toBe(5_750_000); expect(promote?.trust_per_public_share).toBe(10); const prov = await new FieldProvenanceRepo().listByAccession("0000000000-26-000010"); - const unitProv = prov.filter((p) => p.table_name === "spac_unit_terms"); const promoteProv = prov.filter((p) => p.table_name === "spac_promote_terms"); - expect(unitProv.length).toBeGreaterThan(0); - expect(unitProv.every((p) => p.model_id === DETERMINISTIC_MODEL_ID)).toBe(true); expect(promoteProv.length).toBeGreaterThan(0); expect(promoteProv.every((p) => p.model_id === DETERMINISTIC_MODEL_ID)).toBe(true); }); @@ -465,7 +494,10 @@ describe("processFormS1 offering terms", () => { expect(links[0]!.role_detail).toBeNull(); }); - it("skips the underwriters model on a SPAC resale with no unit IPO", async () => { + it("extracts underwriters on a SPAC whose offering section is prose, not a unit table", async () => { + // A parser that cannot recognise a unit IPO says nothing about the + // Underwriting section. Skipping it here emptied `underwriter_link` and + // recorded the section as clean. const html = [ "

THE OFFERING

We are offering 5,000,000 shares.

", "

UNDERWRITING

", @@ -473,6 +505,7 @@ describe("processFormS1 offering terms", () => { "Selling StockholderNumber of Shares", "Acme Holdings LLC1,000,000", "", + "

Cantor Fitzgerald & Co. is acting as sole book-running manager.

", ].join(""); const { unregister } = registerFakeStructuredProvider([ { @@ -508,6 +541,18 @@ describe("processFormS1 offering terms", () => { confidence: 0.9, source_span: "5,000,000 shares", }, + { + underwriters: [ + { + legal_name: "Cantor Fitzgerald & Co.", + role: "bookrunner", + shares_allocated: null, + over_allotment_shares: null, + confidence: 0.9, + source_span: "Cantor Fitzgerald & Co. is acting as sole book-running manager.", + }, + ], + }, ]); cleanup = unregister; @@ -527,9 +572,85 @@ describe("processFormS1 offering terms", () => { model: fakeS1Model(), }); - expect(await new UnderwriterLinkRepo().listByAccession("0000000000-26-000012")).toEqual([]); + const links = await new UnderwriterLinkRepo().listByAccession("0000000000-26-000012"); + expect(links).toHaveLength(1); + expect(links[0]!.role_detail).toBe("bookrunner"); + }); + + it("leaves a pending underwriters dead letter pending when the section really does fail", async () => { + const html = [ + "

THE OFFERING

We are offering 5,000,000 shares.

", + "

UNDERWRITING

The underwriting arrangements are described elsewhere.

", + ].join(""); + await new ExtractionDeadLetterRepo().record({ + extractor_id: "S-1", + accession_number: "0000000000-26-000015", + section_name: "underwriters", + reason_code: "MODEL_EMPTY", + detail: "no underwriters returned", + failed_extractor_version: "1.0.0", + source_run_id: null, + }); + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Common Stock", + shares_offered: 5000000, + price: 10, + price_low: null, + price_high: null, + gross_proceeds: 50000000, + net_proceeds: null, + over_allotment_shares: null, + units_offered: null, + price_per_unit: null, + unit_composition: null, + warrant_fraction_per_unit: null, + right_fraction_per_unit: null, + trust_per_unit: null, + over_allotment_units: null, + exchange: null, + par_value: null, + confidence: 0.9, + source_span: "5,000,000 shares", + tickers: [], + }, + { + founder_shares: null, + founder_percent: null, + private_placement_warrants: null, + private_placement_warrant_price: null, + public_warrant_coverage: null, + trust_per_public_share: null, + trust_total: null, + confidence: 0.9, + source_span: "5,000,000 shares", + }, + { underwriters: [] }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1848507, + file_number: "333-15", + accession_number: "0000000000-26-000015", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: SPAC_HEADER, + html, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + const dl = await new ExtractionDeadLetterRepo().listPending("S-1"); - expect(dl.filter((d) => d.section_name === "underwriters")).toEqual([]); + expect( + dl.filter( + (d) => d.section_name === "underwriters" && d.accession_number === "0000000000-26-000015" + ) + ).toHaveLength(1); }); it("persists a use-of-proceeds table hit as deterministic without calling the model", async () => { @@ -578,7 +699,7 @@ describe("processFormS1 offering terms", () => { expect(rows.find((r) => r.purpose === "Held in trust account")?.amount).toBe(300_000_000); }); - it("skips the use-of-proceeds model on a SPAC resale with no unit IPO", async () => { + it("extracts use of proceeds on a SPAC whose offering section is prose, not a unit table", async () => { const html = [ "

THE OFFERING

We are offering 5,000,000 shares.

", "

USE OF PROCEEDS

", @@ -640,8 +761,10 @@ describe("processFormS1 offering terms", () => { model: fakeS1Model(), }); - expect(await new UseOfProceedsRepo().queryByAccession("0000000000-26-000014")).toEqual([]); - const dl = await new ExtractionDeadLetterRepo().listPending("S-1"); - expect(dl.filter((d) => d.section_name === "use-of-proceeds")).toEqual([]); + const rows = await new UseOfProceedsRepo().queryByAccession("0000000000-26-000014"); + expect(rows.map((r) => r.purpose)).toEqual([ + "Held in trust account", + "Legal fees and expenses", + ]); }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts index 8e8a6d82..4073a1a8 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts @@ -39,8 +39,20 @@ describe("processFormS1 spac-profile", () => { resetDependencyInjectionsForTesting(); }); - it("persists a parseable focus sentence as deterministic without calling the profile model", async () => { + it("runs the profile model when the focus parse cannot supply the narrative fields", async () => { + // The parser reads focus tags and geography and returns null description / + // team. Preempting on that leaves those fields unfilled on this filing and + // on every replay, with the section resolving clean. const { calls, unregister } = registerFakeStructuredProvider([ + { + focus: ["Healthcare", "Biopharmaceuticals"], + focus_location: [], + description: "A blank check company targeting healthcare businesses.", + team: "Led by a team of healthcare operators.", + url_spac: null, + confidence: 0.9, + source_span: "healthcare and biopharmaceuticals", + }, { people: [] }, { owners: [] }, { parties: [] }, @@ -63,8 +75,10 @@ describe("processFormS1 spac-profile", () => { model: fakeS1Model(), }); + expect(calls.some((p) => /Extract the SPAC's acquisition profile/.test(p))).toBe(true); const spac = await new SpacRepo().getSpac(1018724); expect(JSON.parse(spac?.focus ?? "[]")).toEqual(["Healthcare", "Biopharmaceuticals"]); - expect(calls.some((p) => /Extract the SPAC's acquisition profile/.test(p))).toBe(false); + expect(spac?.description).toBe("A blank check company targeting healthcare businesses."); + expect(spac?.team).toBe("Led by a team of healthcare operators."); }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts index 64f317e8..b90fb7f8 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts @@ -8,9 +8,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; import { setupAllDatabases } from "../../../config/setupAllDatabases"; import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; -import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; +import { RelatedPartyTransactionRepo } from "../../../storage/related-party/RelatedPartyTransactionRepo"; import { processFormS1 } from "./Form_S_1.storage"; -import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; const HTML_PARSEABLE = [ @@ -32,6 +31,26 @@ const NULL_HEADER = { filingDate: null, }; +const RELATED_PARTY_PAYLOAD = { + parties: [ + { + name: "Stellantis Ventures B.V.", + party_kind: "company", + confidence: 0.9, + source_span: "Stellantis Ventures B.V.", + transactions: [ + { + counterparty: null, + nature: "Convertible note purchase", + amount: 5000000, + period: null, + footnote: null, + }, + ], + }, + ], +}; + const MANAGEMENT_PAYLOAD = { people: [ { @@ -57,8 +76,14 @@ describe("processFormS1 related-party tables", () => { resetDependencyInjectionsForTesting(); }); - it("persists a parseable party table as deterministic without calling the related-party model", async () => { - const { calls, unregister } = registerFakeStructuredProvider([MANAGEMENT_PAYLOAD]); + it("runs the related-party model even when the party table parses, because the parse carries no transaction", async () => { + // The table walk names the parties and reads no figures, but the section + // clears `related_party_transaction` before it writes. Preempting on the + // names alone empties the disclosure and resolves the section clean. + const { calls, unregister } = registerFakeStructuredProvider([ + MANAGEMENT_PAYLOAD, + RELATED_PARTY_PAYLOAD, + ]); cleanup = unregister; await processFormS1({ @@ -77,11 +102,11 @@ describe("processFormS1 related-party tables", () => { model: fakeS1Model(), }); + expect(calls.some((p) => /Extract related parties/.test(p))).toBe(true); const companies = await new CompanyObservationRepo().listAll(); expect(companies.some((c) => /Stellantis Ventures/i.test(c.name ?? ""))).toBe(true); - expect(calls.some((p) => /Extract related parties/.test(p))).toBe(false); - const party = companies.find((c) => /Stellantis Ventures/i.test(c.name ?? "")); - const provenance = await new ObservationProvenanceRepo().get("company", party!.observation_id); - expect(provenance?.model_id).toBe(DETERMINISTIC_MODEL_ID); + const transactions = await new RelatedPartyTransactionRepo().queryByAccession("acc-rp-1"); + expect(transactions.length).toBeGreaterThanOrEqual(1); + expect(transactions[0]!.amount).toBe(5_000_000); }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 7276f824..7cdb043c 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -649,15 +649,21 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { verifyRow: (text, r) => classifySpan(text, r.source_span), unverifiedAllDetail: "the confident SPAC classification had source_span not present in section text", + clears: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), + deterministic: { + extract: (text) => { + const det = parseSpacClassification(text); + return det === null ? [] : [det]; + }, + covers: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), + }, extract: async (text) => { - const det = parseSpacClassification(text); - if (det !== null) return [det]; const c = await extractSpacClassification(text, classifierModelResolved, args.context); return c === null ? [] : [c]; }, - persist: async (rows) => { + persist: async (_rows, meta) => { classifierHolder.upgraded = true; - if (rows[0]?.source === "deterministic") classifierHolder.source = "deterministic"; + if (meta.source === "deterministic") classifierHolder.source = "deterministic"; return 1; }, }); @@ -711,9 +717,19 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { lowConfidenceDetail: "profile below confidence floor", verifyRow: (text, r) => classifySpan(text, r.source_span), unverifiedAllDetail: "the confident SPAC profile had source_span not present in section text", + clears: new Set(["spac.focus", "spac.focus_location", "spac.description", "spac.team"]), + // Never preempts: the parser reads the summary's focus and geography + // sentences and nothing else, so the narrative fields would be handed to + // `recordRegistration` as nulls on this filing and on every replay, with + // the section resolving clean and nothing flagging the gap. + deterministic: { + extract: (text) => { + const det = parseSpacProfile(text); + return det === null ? [] : [det]; + }, + covers: new Set(["spac.focus", "spac.focus_location"]), + }, ...modelExtractChain(models, async (text, m) => { - const det = parseSpacProfile(text); - if (det !== null) return [det]; const p = await extractSpacProfile(text, m, args.context); return p === null ? [] : [p]; }), @@ -754,14 +770,22 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident management rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident management rows had source_span not present in section text", - ...modelExtractChain(models, async (text, m) => { - const det = parseManagementRoster(text); - if (det.length > 0) return det; - return extractManagement(text, m, args.context); - }), + clears: new Set(["person_observation", "observation_provenance"]), + deterministic: { + extract: parseManagementRoster, + covers: new Set(["person_observation", "observation_provenance"]), + // The roster parse is never a complete population, so it never closes a + // role. It drops rows it cannot read — a name that does not look like a + // person, a row with no title — before it returns, so its output cannot + // distinguish an officer the filing stopped naming from one it named in + // a shape the parser skipped. Closing on it writes a departure the + // filing does not disclose. + complete: () => false, + }, + ...modelExtractChain(models, (text, m) => extractManagement(text, m, args.context)), persist: async (rows, meta) => { const model_id = - rows[0]?.source === "deterministic" + meta.source === "deterministic" ? DETERMINISTIC_MODEL_ID : persistModelId(models, meta.modelIndex); for (const r of rows) { @@ -825,14 +849,25 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident ownership rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident ownership rows had source_span not present in section text", - ...modelExtractChain(models, async (text, m) => { - const det = parseBeneficialOwnership(text); - if (det.length > 0) return det; - return extractBeneficialOwnership(text, m, args.context); - }), + clears: new Set([ + "beneficial_ownership", + "person_observation", + "company_observation", + "observation_provenance", + ]), + deterministic: { + extract: parseBeneficialOwnership, + covers: new Set([ + "beneficial_ownership", + "person_observation", + "company_observation", + "observation_provenance", + ]), + }, + ...modelExtractChain(models, (text, m) => extractBeneficialOwnership(text, m, args.context)), persist: async (rows, meta) => { const model_id = - rows[0]?.source === "deterministic" + meta.source === "deterministic" ? DETERMINISTIC_MODEL_ID : persistModelId(models, meta.modelIndex); for (const r of rows) { @@ -901,14 +936,24 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident related-party rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident related-party rows had source_span not present in section text", - ...modelExtractChain(models, async (text, m) => { - const det = parseRelatedPartyTables(text); - if (det.length > 0) return det; - return extractRelatedParty(text, m, args.context); - }), + clears: new Set([ + "related_party_transaction", + "person_observation", + "company_observation", + "observation_provenance", + ]), + // Never preempts: the table walk names the parties but reads no + // transaction, and `related_party_transaction` is cleared above. The + // disclosure IS the transaction — a party with no dollar figure, period or + // nature records that someone was mentioned, not what they were paid. + deterministic: { + extract: parseRelatedPartyTables, + covers: new Set(["person_observation", "company_observation", "observation_provenance"]), + }, + ...modelExtractChain(models, (text, m) => extractRelatedParty(text, m, args.context)), persist: async (rows, meta) => { const model_id = - rows[0]?.source === "deterministic" + meta.source === "deterministic" ? DETERMINISTIC_MODEL_ID : persistModelId(models, meta.modelIndex); // Check every row against the storage schema's own declared bounds BEFORE @@ -1040,14 +1085,17 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident compensation rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident compensation rows had source_span not present in section text", - ...modelExtractChain(models, async (text, m) => { - const det = parseSummaryCompensationTable(text); - if (det.length > 0) return det; - return extractExecutiveCompensation(text, m, args.context); - }), + clears: new Set(["executive_compensation", "person_observation", "observation_provenance"]), + deterministic: { + extract: parseSummaryCompensationTable, + covers: new Set(["executive_compensation", "person_observation", "observation_provenance"]), + }, + ...modelExtractChain(models, (text, m) => + extractExecutiveCompensation(text, m, args.context) + ), persist: async (rows, meta) => { const model_id = - rows[0]?.source === "deterministic" + meta.source === "deterministic" ? DETERMINISTIC_MODEL_ID : persistModelId(models, meta.modelIndex); // An officer shown for two fiscal years is two table rows but ONE @@ -1260,8 +1308,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { activeUnderwriterFamilyVersion, byName, context: args.context, - markSectionResolved: (section) => - deadLetters.markResolved(EXTRACTOR_ID, accession_number, section), }); // --- SPAC sponsors (gated on deterministic classification) --- @@ -1300,14 +1346,25 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident sponsor rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident sponsor rows had source_span not present in section text", - ...modelExtractChain(models, async (text, m) => { - const det = parseSpacSponsors(text); - if (det.length > 0) return det; - return extractSpacSponsors(text, m, args.context); - }), + clears: new Set([ + "spac_sponsor_link", + "sponsor_family_membership", + "company_observation", + "observation_provenance", + ]), + deterministic: { + extract: parseSpacSponsors, + covers: new Set([ + "spac_sponsor_link", + "sponsor_family_membership", + "company_observation", + "observation_provenance", + ]), + }, + ...modelExtractChain(models, (text, m) => extractSpacSponsors(text, m, args.context)), persist: async (rows, meta) => { const model_id = - rows[0]?.source === "deterministic" + meta.source === "deterministic" ? DETERMINISTIC_MODEL_ID : persistModelId(models, meta.modelIndex); let wrote = 0; diff --git a/src/sec/forms/registration-statements/s1/deterministicPass.ts b/src/sec/forms/registration-statements/s1/deterministicPass.ts new file mode 100644 index 00000000..3c63e302 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/deterministicPass.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A model-free parse of one prospectus section: pure, synchronous, and never + * calling a provider. A pass that qualifies replaces the AI call entirely for + * that section, which is why it has to declare what it can supply. + * + * {@link covers} names the destinations the parse fills. It is compared against + * the section's `clears` set — every destination the section rewrites, whether + * emptied before the run or overwritten in place by `persist` — and the parse + * may only stand in for the model when it covers all of them. Names are + * free-form and matched as plain strings, so a destination can be a table + * (`related_party_transaction`), a column (`spac.description`), or a qualified + * child (`field_provenance:issuer_ticker`); the two sets of one section simply + * have to use the same spelling. + * + * Without that check a parse that hardcodes the columns it cannot read wins on + * any non-empty result, and the section resolves clean: the destination is + * emptied and refilled with a strict subset, no dead letter is recorded, and + * every replay takes the same path, so nothing ever self-corrects. + */ +export interface DeterministicPass { + /** Pure and synchronous — no model, no I/O. Returns `[]` when it reads nothing. */ + readonly extract: (text: string) => readonly TRow[]; + /** Destinations this parse fills. See the type doc for the naming convention. */ + readonly covers: ReadonlySet; + /** + * Whether the returned rows are the section's COMPLETE population, which is + * what `SectionPersistMeta.complete` reports and what roster closure keys on. + * Omitted means false: a parser that filters its own output cannot tell a row + * it dropped from a row the section never had, so it must not be read as + * having enumerated everything. + */ + readonly complete?: (rows: readonly TRow[], text: string) => boolean; +} + +/** + * Whether `pass` may stand in for the model on a section that rewrites + * `clears`. True only when `covers` is a superset of `clears`. + * + * An undeclared `clears` is false, not vacuously true: a caller that never said + * what the section rewrites has not shown the parse can supply it, and the + * fail-safe answer costs one model call rather than a silently truncated table. + */ +export function preempts( + pass: DeterministicPass, + clears: ReadonlySet | undefined +): boolean { + if (clears === undefined) return false; + for (const destination of clears) { + if (!pass.covers.has(destination)) return false; + } + return true; +} diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index c52cccb0..8ca3f931 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -43,7 +43,6 @@ import { anchorFieldSpan } from "./anchorFieldSpan"; import { FieldProvenanceRepo } from "../../../../storage/provenance/FieldProvenanceRepo"; import { DETERMINISTIC_MODEL_ID, - looksLikeUnitIpo, parseSpacOfferingTerms, parseSpacPromoteTerms, } from "./parseOfferingTables"; @@ -195,7 +194,6 @@ export interface OfferingSectionsArgs { readonly byName: ReadonlyMap; /** Running task context, threaded to the generation calls for CLI progress. */ readonly context?: IExecuteContext; - readonly markSectionResolved: (sectionName: string) => Promise; } /** @@ -221,7 +219,6 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise({ sectionName: "offering-terms", text: offeringText, @@ -273,18 +274,34 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { + const det = parseSpacOfferingTerms(text); + return det === null ? [] : [det]; + }, + covers: new Set([termsTable, `field_provenance:${termsTable}`]), + } + : undefined, ...modelExtractChain(models, async (text, m) => { - if (isSpac) { - const det = parseSpacOfferingTerms(text); - if (det !== null) return [det]; - } const terms = await extractOfferingTerms(text, m, context); return terms === null ? [] : [terms]; }), persist: async (rows, meta) => { const terms = rows[0]; const model_id = - terms.source === "deterministic" + meta.source === "deterministic" ? DETERMINISTIC_MODEL_ID : persistModelId(models, meta.modelIndex); const now = new Date().toISOString(); @@ -425,16 +442,22 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { + const det = parseSpacPromoteTerms(text); + return det === null ? [] : [det]; + }, + covers: new Set(["spac_promote_terms", "field_provenance:spac_promote_terms"]), + }, ...modelExtractChain(models, async (text, m) => { - const det = parseSpacPromoteTerms(text); - if (det !== null) return [det]; const promote = await extractSponsorPromote(text, m, context); return promote === null ? [] : [promote]; }), persist: async (rows, meta) => { const promote = rows[0]; const model_id = - promote.source === "deterministic" + meta.source === "deterministic" ? DETERMINISTIC_MODEL_ID : persistModelId(models, meta.modelIndex); await spacPromoteTermsRepo.save({ @@ -486,157 +509,158 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise({ - sectionName: "underwriters", - text: underwritingText, - emptyDetail: "no underwriters returned", - lowConfidenceDetail: "all rows below confidence floor", - invalidWriteDetail: "no underwriter rows had a usable legal name", - // Prompt-injection backstop: refuse to persist any underwriter row whose - // source_span is not a verbatim substring of the Underwriting section text. - verifyRow: (text, r) => classifySpan(text, r.source_span), - unverifiedAllDetail: - "all $T confident underwriter rows had source_span not present in section text", - unverifiedPartialDetail: - "$N of $T confident underwriter rows had source_span not present in section text", - ...modelExtractChain(models, async (text, m) => { - if (isSpac) { - const det = parseSpacUnderwriters(text); - if (det.length > 0) return det; + await runSection({ + sectionName: "underwriters", + text: underwritingText, + emptyDetail: "no underwriters returned", + lowConfidenceDetail: "all rows below confidence floor", + invalidWriteDetail: "no underwriter rows had a usable legal name", + // Prompt-injection backstop: refuse to persist any underwriter row whose + // source_span is not a verbatim substring of the Underwriting section text. + verifyRow: (text, r) => classifySpan(text, r.source_span), + unverifiedAllDetail: + "all $T confident underwriter rows had source_span not present in section text", + unverifiedPartialDetail: + "$N of $T confident underwriter rows had source_span not present in section text", + clears: new Set(["underwriter_link", "company_observation", "observation_provenance"]), + // SPAC-only: the parser reads the syndicate table a unit IPO prints. + deterministic: isSpac + ? { + extract: parseSpacUnderwriters, + covers: new Set(["underwriter_link", "company_observation", "observation_provenance"]), } - return extractUnderwriters(text, m, context); - }), - persist: async (rows, meta) => { - const model_id = - rows[0]?.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); - let wrote = 0; - // One underwriter, one link row. The model repeats an underwriter across - // rows more often than not — a sole-underwriter filing came back with the - // same bank once, twice, and three times on three consecutive runs — and - // every duplicate previously minted its own observation, family - // membership and link row, inflating `sec underwriter by-family` counts by - // however many times the model stuttered. Deduped on the LEGAL name, not - // the common name: "Citigroup Global Markets Inc." and "Citigroup Global - // Markets Limited" are two entities that share one family, and collapsing - // on the family would silently drop the second. - const seenLegalNames = new Set(); - const splits = rows.map((r) => splitParentClause(r.legal_name?.trim() ?? "")); - const extractedNames = splits.map((s) => s.observationName); - for (let i = 0; i < rows.length; i++) { - const r = rows[i]!; - const split = splits[i]!; - if (split.observationName === "") continue; - if (isUnnamedCompanyName(split.observationName)) continue; - // Brand stub next to the full legal name ("Cantor" + "Cantor Fitzgerald - // & Co.") is one house, not two. Inc vs Limited of the same house are - // equal-length family keys and are not dropped. - if (isCompanyFamilyPrefixEcho(split.observationName, extractedNames)) continue; - const dedupeKey = normalizeEntityName(split.observationName); - if (seenLegalNames.has(dedupeKey)) continue; - seenLegalNames.add(dedupeKey); - // companyFamilyName wipes non-ASCII and punctuation, so "[●]" (a - // still-blank F-1 table cell) and a CJK legal name both have no family - // key. A letterless placeholder is not an entity — skip it before - // observeCompany. A name that still has letters (CJK) is observed - // without a family rather than throwing "empty name" and aborting the - // rest of the table. - const familyKey = normalizeFamilyName(split.familyName); - if (!familyKey && !/\p{L}/u.test(split.observationName)) continue; - const observation_index = nextIndex(); - const { observation_id, canonical_company_id } = await observer.observeCompany({ - ...base, - observation_index, - name: split.observationName, - source_context: parentClauseSourceContext(`${relationPrefix}:underwriter`, split), - }); - await provenance.save({ - kind: "company", - observation_id, - confidence: r.confidence, - source_span: boundSourceSpan(r.source_span), - section_name: "underwriters", - model_id, - prompt_version: extractor_version, - extra: null, - }); - if (!familyKey) { - wrote++; - continue; - } - const underwriter_family_id = await underwriterFamilyResolver.resolve(split.familyName); - await underwriterMembershipRepo.record({ - resolver_version: activeUnderwriterFamilyVersion, - canonical_company_id, - canonical_underwriter_family_id: underwriter_family_id, - seen_at: new Date().toISOString(), - }); - await underwriterLinkRepo.save({ - accession_number, - extractor_id, - observation_index, - issuer_cik: cik, - underwriter_canonical_company_id: canonical_company_id, - underwriter_family_id, - role_detail: r.role, - shares_allocated: toIntCount(r.shares_allocated), - over_allotment_shares: toIntCount(r.over_allotment_shares), - resolver_version: activeUnderwriterFamilyVersion, - }); + : undefined, + ...modelExtractChain(models, (text, m) => extractUnderwriters(text, m, context)), + persist: async (rows, meta) => { + const model_id = + meta.source === "deterministic" + ? DETERMINISTIC_MODEL_ID + : persistModelId(models, meta.modelIndex); + let wrote = 0; + // One underwriter, one link row. The model repeats an underwriter across + // rows more often than not — a sole-underwriter filing came back with the + // same bank once, twice, and three times on three consecutive runs — and + // every duplicate previously minted its own observation, family + // membership and link row, inflating `sec underwriter by-family` counts by + // however many times the model stuttered. Deduped on the LEGAL name, not + // the common name: "Citigroup Global Markets Inc." and "Citigroup Global + // Markets Limited" are two entities that share one family, and collapsing + // on the family would silently drop the second. + const seenLegalNames = new Set(); + const splits = rows.map((r) => splitParentClause(r.legal_name?.trim() ?? "")); + const extractedNames = splits.map((s) => s.observationName); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]!; + const split = splits[i]!; + if (split.observationName === "") continue; + if (isUnnamedCompanyName(split.observationName)) continue; + // Brand stub next to the full legal name ("Cantor" + "Cantor Fitzgerald + // & Co.") is one house, not two. Inc vs Limited of the same house are + // equal-length family keys and are not dropped. + if (isCompanyFamilyPrefixEcho(split.observationName, extractedNames)) continue; + const dedupeKey = normalizeEntityName(split.observationName); + if (seenLegalNames.has(dedupeKey)) continue; + seenLegalNames.add(dedupeKey); + // companyFamilyName wipes non-ASCII and punctuation, so "[●]" (a + // still-blank F-1 table cell) and a CJK legal name both have no family + // key. A letterless placeholder is not an entity — skip it before + // observeCompany. A name that still has letters (CJK) is observed + // without a family rather than throwing "empty name" and aborting the + // rest of the table. + const familyKey = normalizeFamilyName(split.familyName); + if (!familyKey && !/\p{L}/u.test(split.observationName)) continue; + const observation_index = nextIndex(); + const { observation_id, canonical_company_id } = await observer.observeCompany({ + ...base, + observation_index, + name: split.observationName, + source_context: parentClauseSourceContext(`${relationPrefix}:underwriter`, split), + }); + await provenance.save({ + kind: "company", + observation_id, + confidence: r.confidence, + source_span: boundSourceSpan(r.source_span), + section_name: "underwriters", + model_id, + prompt_version: extractor_version, + extra: null, + }); + if (!familyKey) { wrote++; + continue; } - return wrote; - }, - }); - } + const underwriter_family_id = await underwriterFamilyResolver.resolve(split.familyName); + await underwriterMembershipRepo.record({ + resolver_version: activeUnderwriterFamilyVersion, + canonical_company_id, + canonical_underwriter_family_id: underwriter_family_id, + seen_at: new Date().toISOString(), + }); + await underwriterLinkRepo.save({ + accession_number, + extractor_id, + observation_index, + issuer_cik: cik, + underwriter_canonical_company_id: canonical_company_id, + underwriter_family_id, + role_detail: r.role, + shares_allocated: toIntCount(r.shares_allocated), + over_allotment_shares: toIntCount(r.over_allotment_shares), + resolver_version: activeUnderwriterFamilyVersion, + }); + wrote++; + } + return wrote; + }, + }); // --- Use of proceeds --- const useOfProceedsText = byName.get(S1_SECTIONS.USE_OF_PROCEEDS); - if (isSpac && !unitIpo) { - await markSectionResolved("use-of-proceeds"); - } else { - await runSection({ - sectionName: "use-of-proceeds", - text: useOfProceedsText, - emptyDetail: "no line items returned", - lowConfidenceDetail: "all rows below confidence floor", - verifyRow: (text, r) => classifySpan(text, r.source_span), - unverifiedAllDetail: - "all $T confident use-of-proceeds rows had source_span not present in section text", - unverifiedPartialDetail: - "$N of $T confident use-of-proceeds rows had source_span not present in section text", - ...modelExtractChain(models, async (text, m) => { - if (isSpac) { - const det = parseSpacUseOfProceeds(text); - if (det.length >= 2) return det; - } - return extractUseOfProceeds(text, m, context); - }), - persist: async (rows) => { - const now = new Date().toISOString(); - let lineIndex = 0; - for (const r of rows) { - await useOfProceedsRepo.save({ - extractor_id, - accession_number, - line_index: lineIndex++, - cik, - purpose: r.purpose, - amount: r.amount, - percent: r.percent, - note: r.note, - confidence: r.confidence, - source_span: boundSourceSpan(r.source_span), - created_at: now, - }); + await runSection({ + sectionName: "use-of-proceeds", + text: useOfProceedsText, + emptyDetail: "no line items returned", + lowConfidenceDetail: "all rows below confidence floor", + verifyRow: (text, r) => classifySpan(text, r.source_span), + unverifiedAllDetail: + "all $T confident use-of-proceeds rows had source_span not present in section text", + unverifiedPartialDetail: + "$N of $T confident use-of-proceeds rows had source_span not present in section text", + clears: new Set(["use_of_proceeds"]), + // SPAC-only: the parser reads the offering-expenses table a unit IPO + // prints. A single line is not one of those tables — it is one figure the + // row scan happened to match — so it is reported as no parse at all rather + // than as a one-line use of proceeds. + deterministic: isSpac + ? { + extract: (text) => { + const det = parseSpacUseOfProceeds(text); + return det.length >= 2 ? det : []; + }, + covers: new Set(["use_of_proceeds"]), } - return rows.length; - }, - }); - } + : undefined, + ...modelExtractChain(models, (text, m) => extractUseOfProceeds(text, m, context)), + persist: async (rows) => { + const now = new Date().toISOString(); + let lineIndex = 0; + for (const r of rows) { + await useOfProceedsRepo.save({ + extractor_id, + accession_number, + line_index: lineIndex++, + cik, + purpose: r.purpose, + amount: r.amount, + percent: r.percent, + note: r.note, + confidence: r.confidence, + source_span: boundSourceSpan(r.source_span), + created_at: now, + }); + } + return rows.length; + }, + }); } diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts new file mode 100644 index 00000000..dd460a7a --- /dev/null +++ b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import type { ExtractionDeadLetterRepo } from "../../../../storage/dead-letter/ExtractionDeadLetterRepo"; +import { makeRunSection } from "./sectionRunner"; +import type { SectionPersistMeta } from "./sectionRunner"; + +interface RecordedLetter { + section_name: string; + reason_code: string; +} + +function stubDeadLetters(): { + repo: ExtractionDeadLetterRepo; + letters: RecordedLetter[]; + resolved: string[]; +} { + const letters: RecordedLetter[] = []; + const resolved: string[] = []; + const repo = { + record: async (args: { section_name: string; reason_code: string }) => { + letters.push({ section_name: args.section_name, reason_code: args.reason_code }); + }, + markResolved: async (_id: string, _acc: string, section: string) => { + resolved.push(section); + }, + } as unknown as ExtractionDeadLetterRepo; + return { repo, letters, resolved }; +} + +interface Row { + readonly confidence: number; + readonly span: string; +} + +const TEXT = "alpha bravo charlie"; + +/** One section, wired so each test only states what it is varying. */ +function harness(overrides: { + readonly clears?: ReadonlySet; + readonly covers?: ReadonlySet; + readonly detRows?: readonly Row[]; + readonly complete?: (rows: readonly Row[], text: string) => boolean; + readonly modelRows?: readonly Row[]; + readonly verify?: boolean; +}): { + readonly run: () => Promise; + readonly modelCalls: () => number; + readonly detCalls: () => number; + readonly persisted: Array<{ rows: Row[]; meta: SectionPersistMeta }>; + readonly letters: RecordedLetter[]; +} { + const { repo, letters } = stubDeadLetters(); + const runSection = makeRunSection({ + deadLetters: repo, + extractor_id: "S-1", + extractor_version: "1.0.0", + accession_number: "acc-det", + }); + let modelCalls = 0; + let detCalls = 0; + const persisted: Array<{ rows: Row[]; meta: SectionPersistMeta }> = []; + const detRows = overrides.detRows ?? [{ confidence: 1, span: "alpha" }]; + const run = () => + runSection({ + sectionName: "management", + text: TEXT, + emptyDetail: "empty", + lowConfidenceDetail: "low", + ...(overrides.verify === false ? {} : { verifyRow: (text, r) => text.includes(r.span) }), + clears: overrides.clears, + deterministic: { + extract: () => { + detCalls++; + return detRows; + }, + covers: overrides.covers ?? new Set(["person_observation"]), + ...(overrides.complete === undefined ? {} : { complete: overrides.complete }), + }, + extract: async () => { + modelCalls++; + return [...(overrides.modelRows ?? [{ confidence: 1, span: "bravo" }])]; + }, + persist: async (rows, meta) => { + persisted.push({ rows, meta }); + return rows.length; + }, + }); + return { run, modelCalls: () => modelCalls, detCalls: () => detCalls, persisted, letters }; +} + +describe("makeRunSection deterministic pass", () => { + it("discards the deterministic result when clears names a destination covers omits", async () => { + const h = harness({ + clears: new Set(["person_observation", "related_party_transaction"]), + covers: new Set(["person_observation"]), + }); + await h.run(); + + expect(h.modelCalls()).toBe(1); + expect(h.persisted).toHaveLength(1); + expect(h.persisted[0]!.meta.source).toBe("model"); + expect(h.persisted[0]!.rows.map((r) => r.span)).toEqual(["bravo"]); + }); + + it("preempts the model when covers is a superset of clears", async () => { + const h = harness({ + clears: new Set(["person_observation"]), + covers: new Set(["person_observation", "observation_provenance"]), + }); + await h.run(); + + expect(h.modelCalls()).toBe(0); + expect(h.persisted).toHaveLength(1); + expect(h.persisted[0]!.meta.source).toBe("deterministic"); + expect(h.persisted[0]!.rows.map((r) => r.span)).toEqual(["alpha"]); + }); + + it("falls through to the model on a partial parse, once, with no dead letter", async () => { + const h = harness({ + clears: new Set(["person_observation"]), + covers: new Set(["person_observation"]), + detRows: [ + { confidence: 1, span: "alpha" }, + { confidence: 1, span: "bravo" }, + { confidence: 1, span: "nowhere in the text" }, + ], + }); + await h.run(); + + // Re-asking a pure function cannot change its answer. + expect(h.detCalls()).toBe(1); + expect(h.modelCalls()).toBe(1); + expect(h.letters).toEqual([]); + expect(h.persisted[0]!.meta.source).toBe("model"); + }); + + it("reports an incomplete population when the pass declares no completeness", async () => { + const h = harness({ + clears: new Set(["person_observation"]), + covers: new Set(["person_observation"]), + }); + await h.run(); + + expect(h.persisted[0]!.meta.source).toBe("deterministic"); + // Every returned row survived, but the parser filters its own output, so + // "all of them survived" says nothing about the section's population. + expect(h.persisted[0]!.meta.complete).toBe(false); + }); + + it("reports a complete population only when the pass says so", async () => { + const h = harness({ + clears: new Set(["person_observation"]), + covers: new Set(["person_observation"]), + complete: () => true, + }); + await h.run(); + + expect(h.modelCalls()).toBe(0); + expect(h.persisted[0]!.meta.source).toBe("deterministic"); + expect(h.persisted[0]!.meta.complete).toBe(true); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index 7a8d9c3e..e7d673a6 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -14,6 +14,8 @@ import { RateLimitExhaustedError, } from "./sectionExtractors"; import type { SpanVerdict } from "./verifySourceSpan"; +import type { DeterministicPass } from "./deterministicPass"; +import { preempts } from "./deterministicPass"; /** * Parse a confidence-floor env value. Undefined, empty, or non-numeric input @@ -120,6 +122,23 @@ export interface RunSectionArgs { readonly unverifiedAllDetail?: string; readonly unverifiedPartialDetail?: string; readonly extract: (text: string) => Promise; + /** + * Every destination {@link persist} rewrites for this section: rows cleared + * before the run, or overwritten in place. Only read to decide whether + * {@link deterministic} may stand in for {@link extract}; see + * {@link DeterministicPass}. + */ + readonly clears?: ReadonlySet; + /** + * A model-free parse tried ONCE, before {@link extract}, and only when it + * covers everything {@link clears} names. + * + * All-or-nothing: its rows persist only when every one of them clears the + * confidence floor and {@link verifyRow}. A shortfall records nothing and + * falls through to the model — re-asking a pure function cannot change its + * answer, and dead-lettering here would blame the model for a parser miss. + */ + readonly deterministic?: DeterministicPass; /** * Tried in order when {@link extract} (and any earlier fallback) returns `[]` * **or throws** a provider/extraction error. Abort, an already-aborted @@ -152,6 +171,13 @@ export interface SectionPersistMeta { readonly complete: boolean; /** 0 = primary {@link RunSectionArgs.extract}; 1+ = {@link RunSectionArgs.emptyExtracts} index + 1. */ readonly modelIndex: number; + /** + * Which path produced the rows. `"deterministic"` means + * {@link RunSectionArgs.deterministic} supplied them and no model was called, + * so `modelIndex` names nothing — persist callbacks record the provenance + * model id from this, never from a field on a row. + */ + readonly source: "deterministic" | "model"; } export type RunSection = ( @@ -262,62 +288,92 @@ export function makeRunSection(opts: { if (lastError !== undefined) throw lastError; return lastRaw; }; - for (let attempt = 1; attempt <= VERIFICATION_ATTEMPTS; attempt++) { - try { + // The deterministic pass runs ONCE, ahead of the retry loop and outside + // it. It is a pure function of the section text, so a second identical + // call cannot produce a different answer; re-asking it would only burn + // attempts, and dead-lettering its shortfall would record the model as + // having failed a section it was never given. + let source: "deterministic" | "model" = "model"; + let deterministicComplete = false; + const pass = sargs.deterministic; + if (pass !== undefined && preempts(pass, sargs.clears)) { + const detRaw = pass.extract(text); + const detConfident = detRaw.filter((r) => r.confidence >= floor); + const detRows = + verifyRow === undefined + ? detConfident + : detConfident.filter((r) => { + const verdict = verifyRow(text, r); + return verdict === true || verdict === "ok"; + }); + // All or nothing. A partial parse persists a subset of a section the + // caller has already cleared, and resolves it as complete. + if (detRaw.length > 0 && detRows.length === detRaw.length) { + raw = [...detRaw]; + confident = [...detConfident]; + rows = [...detRows]; + source = "deterministic"; + deterministicComplete = pass.complete?.(detRows, text) ?? false; + } + } + if (source === "model") { + for (let attempt = 1; attempt <= VERIFICATION_ATTEMPTS; attempt++) { try { - raw = await extractFn(text); - if ( - raw.length === 0 && - fallbackOnEmpty && - !triedEmptyFallbacks && - fallbacks !== undefined && - fallbacks.length > 0 - ) { - raw = await runEmptyFallbacks(undefined); + try { + raw = await extractFn(text); + if ( + raw.length === 0 && + fallbackOnEmpty && + !triedEmptyFallbacks && + fallbacks !== undefined && + fallbacks.length > 0 + ) { + raw = await runEmptyFallbacks(undefined); + } + } catch (e) { + if (isImmediateExtractFailure(e)) throw e; + if (!triedEmptyFallbacks && fallbacks !== undefined && fallbacks.length > 0) { + raw = await runEmptyFallbacks(e); + } else { + throw e; + } } } catch (e) { - if (isImmediateExtractFailure(e)) throw e; - if (!triedEmptyFallbacks && fallbacks !== undefined && fallbacks.length > 0) { - raw = await runEmptyFallbacks(e); - } else { + // A mixed caption shape is a property of ONE generation, not a verdict + // about the section: the model echoed a category heading back as a + // row, and the next call usually does not. Without this the throw + // escapes the loop entirely and the section gets zero re-asks, unlike + // every other recoverable response-shape failure here. + if (!(e instanceof MixedRiskCaptionShapeError)) throw e; + mixedShapeAttempts++; + if (mixedShapeAttempts >= MIXED_SHAPE_REASK_ATTEMPTS) { + // Say what the re-ask cost, so the dead-letter detail records it + // rather than reading as a single unlucky generation. + e.message = `${e.message} (unchanged after ${mixedShapeAttempts} attempt(s))`; throw e; } + continue; } - } catch (e) { - // A mixed caption shape is a property of ONE generation, not a verdict - // about the section: the model echoed a category heading back as a - // row, and the next call usually does not. Without this the throw - // escapes the loop entirely and the section gets zero re-asks, unlike - // every other recoverable response-shape failure here. - if (!(e instanceof MixedRiskCaptionShapeError)) throw e; - mixedShapeAttempts++; - if (mixedShapeAttempts >= MIXED_SHAPE_REASK_ATTEMPTS) { - // Say what the re-ask cost, so the dead-letter detail records it - // rather than reading as a single unlucky generation. - e.message = `${e.message} (unchanged after ${mixedShapeAttempts} attempt(s))`; - throw e; + confident = raw.filter((r) => r.confidence >= floor); + droppedUnverified = 0; + droppedTooLong = 0; + if (verifyRow !== undefined && confident.length > 0) { + rows = confident.filter((r) => { + const verdict = verifyRow(text, r); + if (verdict === true || verdict === "ok") return true; + if (verdict === "too-long") droppedTooLong++; + return false; + }); + droppedUnverified = confident.length - rows.length; + } else { + rows = confident; + } + // Only a total verification wipeout is worth re-asking. An empty or + // all-low-confidence response is a judgement about the text rather + // than a malformed citation, and re-rolling it just burns calls. + if (rows.length > 0 || droppedUnverified !== confident.length || confident.length === 0) { + break; } - continue; - } - confident = raw.filter((r) => r.confidence >= floor); - droppedUnverified = 0; - droppedTooLong = 0; - if (verifyRow !== undefined && confident.length > 0) { - rows = confident.filter((r) => { - const verdict = verifyRow(text, r); - if (verdict === true || verdict === "ok") return true; - if (verdict === "too-long") droppedTooLong++; - return false; - }); - droppedUnverified = confident.length - rows.length; - } else { - rows = confident; - } - // Only a total verification wipeout is worth re-asking. An empty or - // all-low-confidence response is a judgement about the text rather - // than a malformed citation, and re-rolling it just burns calls. - if (rows.length > 0 || droppedUnverified !== confident.length || confident.length === 0) { - break; } } if (rows.length === 0) { @@ -349,8 +405,12 @@ export function makeRunSection(opts: { return; } const wrote = await sargs.persist(rows, { - complete: rows.length === raw.length, + // On the deterministic path `raw` is already the parser's surviving + // output, so counting it would report every parse as complete. The + // pass says so itself, or it does not say so at all. + complete: source === "deterministic" ? deterministicComplete : rows.length === raw.length, modelIndex, + source, }); if (sargs.invalidWriteDetail !== undefined && wrote === 0) { await record("MODEL_INVALID_OUTPUT", sargs.invalidWriteDetail); From 47b7fafda46cfe261bc5e8c773cacd58abe38780 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 08:48:19 +0000 Subject: [PATCH 10/29] chore: restore prettier formatting in src/index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run `bun run format`. Restores the trailing commas dropped from 18 export lists and removes the trailing blank line at EOF, so `format-check` — the first CI step — passes again. No behavioural change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT --- src/index.ts | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 00cae0e9..04c7a449 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ export { applyGlobalOptions, parseGlobalOptions, parseIntOption, - type GlobalOptions + type GlobalOptions, } from "./cli/GlobalOptions"; export { isDryRun } from "./cli/isDryRun"; export { isJsonOutput } from "./cli/isJsonOutput"; @@ -36,7 +36,7 @@ export { resetDbStatsTablesForTesting, type CountableRepository, type DbStatsTable, - type TableStat + type TableStat, } from "./cli/queries/DbStatus"; export { runCommand } from "./cli/runCommand"; export { runWorkflowCli } from "./cli/runWorkflow"; @@ -54,7 +54,7 @@ export { runSyncLeaves, type SyncLeaf, type SyncRunContext, - type SyncStep + type SyncStep, } from "./cli/sync/syncLeaves"; // ── Config / dependency injection ─────────────────────────────────────────── @@ -71,7 +71,7 @@ export * from "./config/tokens"; export { SecCachedFetchTask, type response_type, - type SecCachedFetchTaskInput + type SecCachedFetchTaskInput, } from "./task/fetch/SecCachedFetchTask"; export { SecFetchTask } from "./task/fetch/SecFetchTask"; export { getSecJobQueue, setupSecFetchRateLimiter } from "./task/fetch/SecJobQueue"; @@ -89,7 +89,7 @@ export { resolveSqlBackend, type MaybeDurable, type SqlAccess, - type SqlBackend + type SqlBackend, } from "./util/sqlBackend"; // ── Re-exported workglow primitives a superset commonly needs ──────────────── @@ -109,7 +109,7 @@ export { registerSafeFetch, Sqlite, Task, - Workflow + Workflow, } from "workglow"; export type { FetchUrlTaskInput, @@ -117,7 +117,7 @@ export type { IExecuteContext, SafeFetchFn, ServiceToken, - TaskOutput + TaskOutput, } from "workglow"; export type { TaskPorts } from "./task/taskPorts"; export { isStaleByAsOf } from "./util/asOfGuard"; @@ -129,21 +129,21 @@ export { isStaleByAsOf } from "./util/asOfGuard"; export { listDatabaseExtensionTokens, registerDatabaseExtension, - registerDatabaseSetupHook + registerDatabaseSetupHook, } from "./config/databaseExtensions"; export { getResolverExtension, isFamilyResolverId, listResolverIds, registerResolverExtension, - type ResolverExtension + type ResolverExtension, } from "./resolver/resolverExtensions"; // ── Family-tier primitives for downstream resolvers ──────────────────────── export { FamilyResolver, normalizeFamilyName } from "./resolver/FamilyResolver"; export { CanonicalFamilyAliasRepo, - type FamilyAliasRow + type FamilyAliasRow, } from "./storage/canonical/CanonicalFamilyAliasRepo"; // ── Versioning internals ──────────────────────────────────────────────────── @@ -161,7 +161,7 @@ export { PERSON_OBSERVATION_REPOSITORY_TOKEN } from "./storage/observation/Perso export { PersonObservationTitleRepo } from "./storage/observation/PersonObservationTitleRepo"; export { PERSON_OBSERVATION_TITLE_REPOSITORY_TOKEN, - type PersonObservationTitle + type PersonObservationTitle, } from "./storage/observation/PersonObservationTitleSchema"; // ── Canonical person identity tier (observation → canonical id, merge aliases) @@ -171,20 +171,20 @@ export { // an id that a later merge retired. export { CANONICAL_PERSON_ALIAS_REPOSITORY_TOKEN, - type CanonicalPersonAlias + type CanonicalPersonAlias, } from "./storage/canonical/CanonicalAliasSchemas"; export { CanonicalPersonAliasRepo } from "./storage/canonical/CanonicalPersonAliasRepo"; export { PersonIdentityLinkRepo } from "./storage/canonical/PersonIdentityLinkRepo"; export { PERSON_IDENTITY_LINK_REPOSITORY_TOKEN, - type PersonIdentityLink + type PersonIdentityLink, } from "./storage/canonical/PersonIdentityLinkSchema"; // ── Dated person roles (person↔company title tenures) ─────────────────────── export { PersonRoleRepo } from "./storage/canonical/PersonRoleRepo"; export { PERSON_ROLE_REPOSITORY_TOKEN, - type PersonRole + type PersonRole, } from "./storage/canonical/PersonRoleSchema"; // ── Canonical company (CIK/CRD → canonical entity) ────────────────────────── @@ -194,7 +194,7 @@ export { export { CanonicalCompanyRepo } from "./storage/canonical/CanonicalCompanyRepo"; export { CANONICAL_COMPANY_REPOSITORY_TOKEN, - type CanonicalCompany + type CanonicalCompany, } from "./storage/canonical/CanonicalCompanySchema"; // ── Normalization helpers ─────────────────────────────────────────────────── @@ -203,7 +203,7 @@ export { companyFamilyName } from "./storage/company/CompanyFamilyName"; export { generateCompanyHash, hasCompanyEnding, - normalizeCompanyName + normalizeCompanyName, } from "./storage/company/CompanyNormalization"; export { normalizePhone } from "./storage/phone/PhoneNormalization"; @@ -220,12 +220,11 @@ export { createServiceToken, InMemoryTabularStorage, type AnyTabularStorage, - type ITabularStorage + type ITabularStorage, } from "workglow"; // ── Test helpers a downstream feature package needs in its own test setup ──── export { clearEnvDerivedTokensForTesting, - resetDependencyInjectionsForTesting + resetDependencyInjectionsForTesting, } from "./config/TestingDI"; - From 6083717f9adbffffa67e333f7c44e4912c033695 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 09:07:53 +0000 Subject: [PATCH 11/29] fix(s1): a deterministic pass may not preempt what it cannot supply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LowBJQsCghLDiHwPN6FgUT --- .../Form_S_1.storage.compensation.test.ts | 21 ++- .../Form_S_1.storage.management.test.ts | 69 +++++---- .../Form_S_1.storage.offering.test.ts | 132 +++++++++++++++++- .../Form_S_1.storage.ownership.test.ts | 73 +++++++++- .../Form_S_1.storage.ts | 96 ++++++++++--- .../s1/deterministicPass.test.ts | 104 ++++++++++++++ .../s1/deterministicPass.ts | 50 ++++++- .../s1/executiveCompensationSchema.ts | 6 +- .../s1/offeringSections.ts | 52 ++++++- .../s1/offeringTermsSchema.ts | 6 +- .../s1/parseBeneficialOwnership.ts | 54 +++++++ .../s1/parseOfferingTables.ts | 31 ++++ .../s1/sectionRunner.ts | 2 +- .../s1/sectionSchemas.ts | 18 ++- .../s1/spacClassifierSchema.ts | 6 +- .../s1/spacProfileSchema.ts | 6 +- .../s1/spacSponsorSchema.ts | 6 +- .../s1/sponsorPromoteSchema.ts | 6 +- .../s1/underwriterSchema.ts | 6 +- .../s1/useOfProceedsSchema.ts | 6 +- .../classification/S1ClassificationSchema.ts | 3 +- 21 files changed, 677 insertions(+), 76 deletions(-) create mode 100644 src/sec/forms/registration-statements/s1/deterministicPass.test.ts diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts index 953795ac..808025bf 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts @@ -255,8 +255,23 @@ describe("processFormS1 executive compensation", () => { ); }); - it("persists a parseable table as deterministic without calling the compensation model", async () => { - const { unregister } = registerFakeStructuredProvider([MANAGEMENT_PAYLOAD]); + it("still calls the compensation model on a table the grid walk can read", async () => { + // Every money column is read off the grid, but `footnote` is not — and the + // prompt tells the model to strip footnote markers out of `person_name` and + // out of every money field, so a footnote's text lands on the row in no + // other column. Nulling it deletes what the filing said, so this parse does + // not stand in for the model. + const { calls, unregister } = registerFakeStructuredProvider([ + MANAGEMENT_PAYLOAD, + { + rows: [ + compensationRow({ + footnote: "Represents 401(k) matching contributions paid by the Company.", + source_span: "Alina Kowalczyk", + }), + ], + }, + ]); cleanup = unregister; await run(HTML_PARSEABLE_TABLE, "acc-comp-7"); @@ -267,5 +282,7 @@ describe("processFormS1 executive compensation", () => { expect(rows[0]!.salary).toBe(612500); expect(rows[0]!.total).toBe(4230200); expect(rows[0]!.principal_position).toBe("Chief Executive Officer"); + expect(rows[0]!.footnote).toBe("Represents 401(k) matching contributions paid by the Company."); + expect(calls.some((p) => /SUMMARY COMPENSATION TABLE/.test(p))).toBe(true); }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts index ac98a080..bc805ab9 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts @@ -23,19 +23,6 @@ const HTML_PARSEABLE = [ "

LEGAL MATTERS

x

", ].join(""); -// The roster the parser reads: one person it can see. The prose line below it -// names an officer no table row carries, which the AI path reads and the table -// walk cannot. -const HTML_PARTIAL_ROSTER = [ - "

MANAGEMENT

", - "", - "", - "", - "
NameAgeTitle
Jane Roe52Director
", - "

John Doe continues to serve as our Chief Financial Officer.

", - "

LEGAL MATTERS

x

", -].join(""); - const HTML_PROSE_ROSTER = [ "

MANAGEMENT

", "

Jane Roe — Director. John Doe — Chief Financial Officer.

", @@ -82,8 +69,26 @@ describe("processFormS1 management roster", () => { resetDependencyInjectionsForTesting(); }); - it("persists a parseable roster as deterministic without calling the management model", async () => { - const { calls, unregister } = registerFakeStructuredProvider([{}]); + it("still calls the management model on a roster the table walk can read", async () => { + // `observePerson` upserts the observation, so this section rewrites `bio` — + // and an officer's biography is the paragraphs BELOW the roster table, which + // the table walk never reads. A pass standing in here would replace a filed + // biography with null on every re-run. + const { calls, unregister } = registerFakeStructuredProvider([ + { + people: [ + { + full_name: "Jane Roe", + titles: ["Director"], + relationship: null, + age: 52, + bio: "Ms. Roe has served on our board since 2024.", + confidence: 0.9, + source_span: "Jane Roe", + }, + ], + }, + ]); cleanup = unregister; await processFormS1({ @@ -106,19 +111,28 @@ describe("processFormS1 management roster", () => { (o) => o.relationship === "s1:management" ); expect(people.map((p) => [p.first_name, p.last_name])).toEqual([["Jane", "Roe"]]); - expect(calls.some((p) => /Extract every director and executive officer/.test(p))).toBe(false); + expect(calls.some((p) => /Extract every director and executive officer/.test(p))).toBe(true); + expect(people[0]!.bio).toBe("Ms. Roe has served on our board since 2024."); const provenance = await new ObservationProvenanceRepo().get( "person", people[0]!.observation_id ); - expect(provenance?.model_id).toBe(DETERMINISTIC_MODEL_ID); + expect(provenance?.model_id).not.toBe(DETERMINISTIC_MODEL_ID); }); - it("does not close a role the roster parse never claimed to have enumerated", async () => { - const { unregister } = registerFakeStructuredProvider([BOTH_OFFICERS_PAYLOAD]); + it("closes a role dropped from an amended roster", async () => { + // `s1:management` is one of only two populations that close a dated role, + // and a pass that stood in for the model here would silently disable that: + // `complete` is computed from the model path's own filtering, so a section + // the model never runs can never report a complete population. This test + // fails if the management pass starts preempting again. + const { unregister } = registerFakeStructuredProvider([ + BOTH_OFFICERS_PAYLOAD, + { people: [BOTH_OFFICERS_PAYLOAD.people[0]!] }, + ]); cleanup = unregister; - // First filing (prose roster, AI path): both officers hold open roles. + // S-1 names both officers: two open roles. await processFormS1({ cik: 1018724, file_number: "333-2", @@ -138,19 +152,18 @@ describe("processFormS1 management roster", () => { const opened = await new PersonRoleRepo().listForCompany(1018724, "1.0.0"); expect(opened.map((r) => r.title).sort()).toEqual(["Chief Financial Officer", "Director"]); - // Second filing: the table walk reads one of the two, and the filing still - // names the other. The parser filters its own output, so "every row I - // returned survived" is not evidence that it read the whole roster. + // The S-1/A names only the director. The roster is a complete population, + // so the officer it stopped naming has departed. await processFormS1({ cik: 1018724, file_number: "333-2", accession_number: "acc-mgmt-role-2", filing_date: "2026-02-02", - primary_doc: "s1.htm", - form: "S-1", + primary_doc: "s1a.htm", + form: "S-1/A", formS1: { header: NULL_HEADER, - html: HTML_PARTIAL_ROSTER, + html: HTML_PROSE_ROSTER, xbrlInstanceXml: null, feeExhibitHtml: null, }, @@ -160,6 +173,8 @@ describe("processFormS1 management roster", () => { const roles = await new PersonRoleRepo().listForCompany(1018724, "1.0.0"); const cfo = roles.find((r) => r.title === "Chief Financial Officer"); expect(cfo).toBeDefined(); - expect(cfo!.end_date).toBeNull(); + expect(cfo!.end_date).not.toBeNull(); + const director = roles.find((r) => r.title === "Director"); + expect(director!.end_date).toBeNull(); }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts index 14c71d76..c469dde8 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts @@ -14,6 +14,7 @@ import { SpacPromoteTermsRepo } from "../../../storage/offering/SpacPromoteTerms import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo"; import { FieldProvenanceRepo } from "../../../storage/provenance/FieldProvenanceRepo"; import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; +import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; import { UnderwriterLinkRepo } from "../../../storage/canonical/UnderwriterLinkRepo"; import { UseOfProceedsRepo } from "../../../storage/use-of-proceeds/UseOfProceedsRepo"; import { processFormS1 } from "./Form_S_1.storage"; @@ -380,13 +381,19 @@ describe("processFormS1 offering terms", () => { // The walker reads the unit table but never the listing sentence, and the // section clears `issuer_ticker` before it writes. Preempting on the terms // alone empties the point-in-time ticker series nothing else reconstructs. + // The offering table states every one of the seven promote columns, which + // is what `promoteCoverage` reads off it — so that section, unlike this + // one, can stand in for the model. const html = [ "

THE OFFERING

", "", "", "", - "", + "", + "", + "", "", + "", "
Offering price$10.00
Number of units offered20,000,000
Founder shares5,750,000
Each unitconsisting of one Class A ordinary share and one-half of one redeemable warrant
Founder shares5,750,000, or approximately 20.0% of our outstanding shares
Private placement warrants10,000,000 warrants at $1.00 per warrant
Proceeds to be held in trust account$10.00 per unit
Total proceeds to be held in trust account$200,000,000
", "

Our units are expected to be listed on Nasdaq under the symbol ACQU.

", "

UNDERWRITING

Goldman Sachs & Co. LLC is the representative.

", @@ -443,14 +450,24 @@ describe("processFormS1 offering terms", () => { // still stands in for the model. const promote = await new SpacPromoteTermsRepo().get("S-1", "0000000000-26-000010"); expect(promote?.founder_shares).toBe(5_750_000); + expect(promote?.founder_percent).toBe(0.2); + expect(promote?.private_placement_warrants).toBe(10_000_000); + expect(promote?.private_placement_warrant_price).toBe(1); + expect(promote?.public_warrant_coverage).toBe(0.5); expect(promote?.trust_per_public_share).toBe(10); + expect(promote?.trust_total).toBe(200_000_000); const prov = await new FieldProvenanceRepo().listByAccession("0000000000-26-000010"); const promoteProv = prov.filter((p) => p.table_name === "spac_promote_terms"); expect(promoteProv.length).toBeGreaterThan(0); expect(promoteProv.every((p) => p.model_id === DETERMINISTIC_MODEL_ID)).toBe(true); }); - it("persists a syndicate table hit as deterministic without calling the underwriters model", async () => { + it("does not preempt the underwriters model on a table it cannot read a role from", async () => { + // The syndicate table is exactly the shape `parseSpacUnderwriters` reads — + // and it states an allocation and nothing else. The ROLE is the sentence + // beside it, which the table walk never sees, so a pass that stood in here + // would empty `underwriter_link` and rewrite a filed bookrunner as NULL, + // with the section resolving clean and no dead letter to find it by. const html = [ "

THE OFFERING

", "", @@ -462,11 +479,47 @@ describe("processFormS1 offering terms", () => { "

UNDERWRITING

", "
", "", - "", + "", "", "
UnderwriterNumber of Units
Cantor Fitzgerald & Co.
Cantor Fitzgerald & Co.20,000,000
Total20,000,000
", + "

Cantor Fitzgerald & Co. is acting as sole book-running manager.

", ].join(""); - const { unregister } = registerFakeStructuredProvider([]); + // The offering table states only two of the seven promote columns, so that + // section falls through to the model too: offering-terms, sponsor-promote, + // underwriters, in that order. + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Units", + units_offered: 20000000, + price_per_unit: 10, + confidence: 0.9, + source_span: "20,000,000", + tickers: [], + }, + { + founder_shares: 5750000, + founder_percent: null, + private_placement_warrants: null, + private_placement_warrant_price: null, + public_warrant_coverage: null, + trust_per_public_share: 10, + trust_total: null, + confidence: 0.9, + source_span: "5,750,000", + }, + { + underwriters: [ + { + legal_name: "Cantor Fitzgerald & Co.", + role: "bookrunner", + shares_allocated: 20000000, + over_allotment_shares: null, + confidence: 0.9, + source_span: "Cantor Fitzgerald & Co. is acting as sole book-running manager.", + }, + ], + }, + ]); cleanup = unregister; await processFormS1({ @@ -491,7 +544,76 @@ describe("processFormS1 offering terms", () => { ]); const links = await new UnderwriterLinkRepo().listByAccession("0000000000-26-000011"); expect(links).toHaveLength(1); - expect(links[0]!.role_detail).toBeNull(); + expect(links[0]!.role_detail).toBe("bookrunner"); + expect(links[0]!.shares_allocated).toBe(20_000_000); + const underwriterObs = obs.find((o) => o.name === "Cantor Fitzgerald & Co."); + const prov = await new ObservationProvenanceRepo().get( + "company", + underwriterObs!.observation_id + ); + expect(prov?.model_id).not.toBe(DETERMINISTIC_MODEL_ID); + }); + + it("falls through to the model when the promote table states no trust total", async () => { + // Founder shares and nothing else. The parse still returns a row — its gate + // fires on EITHER anchor — whose other six columns are null, so coverage + // computed from the same walk is what keeps that row off a section the + // model can read the trust sizing out of the prose for. + const html = [ + "

THE OFFERING

", + "

We are offering 20,000,000 units.

", + "", + "", + "", + "", + "
Offering price$10.00
Number of units offered20,000,000
Founder shares5,750,000
", + ].join(""); + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Units", + units_offered: 20000000, + price_per_unit: 10, + confidence: 0.9, + source_span: "20,000,000 units", + tickers: [], + }, + { + founder_shares: 5750000, + founder_percent: 0.2, + private_placement_warrants: 10000000, + private_placement_warrant_price: 1.0, + public_warrant_coverage: 0.5, + trust_per_public_share: 10.0, + trust_total: 200000000, + confidence: 0.9, + source_span: "20,000,000 units", + }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1848507, + file_number: "333-16", + accession_number: "0000000000-26-000016", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: SPAC_HEADER, + html, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const promote = await new SpacPromoteTermsRepo().get("S-1", "0000000000-26-000016"); + expect(promote?.trust_total).toBe(200_000_000); + expect(promote?.private_placement_warrants).toBe(10_000_000); + const prov = await new FieldProvenanceRepo().listByAccession("0000000000-26-000016"); + const promoteProv = prov.filter((p) => p.table_name === "spac_promote_terms"); + expect(promoteProv.length).toBeGreaterThan(0); + expect(promoteProv.some((p) => p.model_id === DETERMINISTIC_MODEL_ID)).toBe(false); }); it("extracts underwriters on a SPAC whose offering section is prose, not a unit table", async () => { diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts index 4809e05a..4a400d69 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts @@ -24,6 +24,18 @@ const HTML_PARSEABLE = [ "

LEGAL MATTERS

x

", ].join(""); +// A resale registration's table: the columns the SPAC table above does not have. +const HTML_RESALE = [ + "

MANAGEMENT

", + "

Eleanor Vasquez — Director

", + "

PRINCIPAL AND SELLING STOCKHOLDERS

", + "", + "", + "", + "
Name of Beneficial OwnerClass of SharesShares Beneficially Owned Before the OfferingShares OfferedShares Owned After the OfferingPercent After the Offering
Halyard Sponsor III LLCClass B4,312,5001,000,0003,312,50060.0%
", + "

LEGAL MATTERS

x

", +].join(""); + const NULL_HEADER = { sic: null, sicDescription: null, @@ -57,7 +69,7 @@ describe("processFormS1 beneficial ownership", () => { resetDependencyInjectionsForTesting(); }); - it("persists a parseable table as deterministic without calling the ownership model", async () => { + it("persists a SPAC ownership table with no offered/after columns as deterministic", async () => { const { unregister } = registerFakeStructuredProvider([MANAGEMENT_PAYLOAD]); cleanup = unregister; @@ -84,5 +96,64 @@ describe("processFormS1 beneficial ownership", () => { ]); const companies = await new CompanyObservationRepo().listAll(); expect(companies.some((c) => /Halyard Sponsor/i.test(c.name ?? ""))).toBe(true); + // The table prints one class, no offered/after columns and no selling + // stockholders, so the six columns the parse hardcodes null are what this + // filing actually discloses — nothing is lost by writing them. + expect(rows[0]!.security_class).toBeNull(); + expect(rows[0]!.shares_after).toBeNull(); + expect(rows[0]!.is_selling_stockholder).toBe(false); + }); + + it("does not preempt the ownership model on a resale table with Shares Offered / Shares After columns", async () => { + // The same parse, the same table shape, the opposite verdict: here the + // filing DOES state a class, an offered count and an after-offering + // position, so the parse's hardcoded nulls would delete three disclosed + // figures — and `is_selling_stockholder: false` would assert this holder + // registers no resale, which is the opposite of what the section says. + const { unregister } = registerFakeStructuredProvider([ + MANAGEMENT_PAYLOAD, + { + owners: [ + { + name: "Halyard Sponsor III LLC", + owner_kind: "company", + security_class: "Class B", + shares_owned: 4312500, + percent_owned: 100, + shares_offered: 1000000, + shares_after: 3312500, + percent_after: 60, + is_selling_stockholder: true, + footnote: null, + confidence: 0.9, + source_span: "Halyard Sponsor III LLC", + }, + ], + }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: 1018724, + file_number: "333-2", + accession_number: "acc-own-2", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { + header: NULL_HEADER, + html: HTML_RESALE, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const rows = await new BeneficialOwnershipRepo().queryByAccession("acc-own-2"); + expect(rows).toHaveLength(1); + expect(rows[0]!.security_class).toBe("Class B"); + expect(rows[0]!.shares_offered).toBe(1000000); + expect(rows[0]!.shares_after).toBe(3312500); + expect(rows[0]!.is_selling_stockholder).toBe(true); }); }); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 7cdb043c..bba923a3 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -48,7 +48,7 @@ import { import type { ExecutiveCompensationRow } from "./s1/executiveCompensationSchema"; import { hasSummaryCompensationTable } from "./s1/compensationHeuristic"; import { parseSummaryCompensationTable } from "./s1/parseSummaryCompensationTable"; -import { parseBeneficialOwnership } from "./s1/parseBeneficialOwnership"; +import { ownershipCoverage, parseBeneficialOwnership } from "./s1/parseBeneficialOwnership"; import { parseManagementRoster } from "./s1/parseManagementRoster"; import { parseRelatedPartyTables } from "./s1/parseRelatedPartyTables"; import { parseSpacSponsors } from "./s1/parseSpacSponsors"; @@ -770,17 +770,31 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident management rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident management rows had source_span not present in section text", - clears: new Set(["person_observation", "observation_provenance"]), + // `observePerson` UPSERTS the observation row, so every column of it that + // this section states is rewritten — `bio` included, and the roster table + // has no bio column. The officer's biography is the paragraphs BELOW the + // table, which the table walk never reads, so a table-granular claim here + // replaced a filed biography with null on every re-run. + // + // The gap is not closable by a better parse, either, which is why there is + // no `complete` here: the roster TABLE is not the roster POPULATION. A + // director named only in the prose beneath it is invisible to the walk, so + // even a parse that declined nothing could not assert it had enumerated + // everyone — and `s1:management` closure writes a departure from exactly + // that assertion. + clears: new Set([ + "person_observation.titles", + "person_observation.birth_year", + "person_observation.bio", + "observation_provenance", + ]), deterministic: { extract: parseManagementRoster, - covers: new Set(["person_observation", "observation_provenance"]), - // The roster parse is never a complete population, so it never closes a - // role. It drops rows it cannot read — a name that does not look like a - // person, a row with no title — before it returns, so its output cannot - // distinguish an officer the filing stopped naming from one it named in - // a shape the parser skipped. Closing on it writes a departure the - // filing does not disclose. - complete: () => false, + covers: new Set([ + "person_observation.titles", + "person_observation.birth_year", + "observation_provenance", + ]), }, ...modelExtractChain(models, (text, m) => extractManagement(text, m, args.context)), persist: async (rows, meta) => { @@ -849,20 +863,31 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident ownership rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident ownership rows had source_span not present in section text", + // Six of the nine ownership columns are hardcoded null by the table walk, + // and one of them — `is_selling_stockholder: false` — is a POSITIVE claim + // that this owner registers no resale, not an absent one. Whether those + // nulls are losses depends on the table: a SPAC's pre-IPO table prints one + // class and no offered/after columns, so null is the disclosure; a resale + // registration prints all of them, so the same nulls delete stated figures. + // `ownershipCoverage` answers that per filing off the same headers the + // parse walks. clears: new Set([ - "beneficial_ownership", + "beneficial_ownership.owner_kind", + "beneficial_ownership.security_class", + "beneficial_ownership.shares_owned", + "beneficial_ownership.percent_owned", + "beneficial_ownership.shares_offered", + "beneficial_ownership.shares_after", + "beneficial_ownership.percent_after", + "beneficial_ownership.is_selling_stockholder", + "beneficial_ownership.footnote", "person_observation", "company_observation", "observation_provenance", ]), deterministic: { extract: parseBeneficialOwnership, - covers: new Set([ - "beneficial_ownership", - "person_observation", - "company_observation", - "observation_provenance", - ]), + covers: ownershipCoverage, }, ...modelExtractChain(models, (text, m) => extractBeneficialOwnership(text, m, args.context)), persist: async (rows, meta) => { @@ -1085,10 +1110,43 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "all $T confident compensation rows had source_span not present in section text", unverifiedPartialDetail: "$N of $T confident compensation rows had source_span not present in section text", - clears: new Set(["executive_compensation", "person_observation", "observation_provenance"]), + // Every money column is read off the grid; `footnote` is not, and the + // prompt gives it nowhere else to go — it tells the model to STRIP + // footnote markers out of `person_name` and 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, so the table is named column by + // column and this parse does not stand in for the model. + clears: new Set([ + "executive_compensation.principal_position", + "executive_compensation.fiscal_year", + "executive_compensation.salary", + "executive_compensation.bonus", + "executive_compensation.stock_awards", + "executive_compensation.option_awards", + "executive_compensation.non_equity_incentive", + "executive_compensation.pension_and_nqdc", + "executive_compensation.all_other_compensation", + "executive_compensation.total", + "executive_compensation.footnote", + "person_observation", + "observation_provenance", + ]), deterministic: { extract: parseSummaryCompensationTable, - covers: new Set(["executive_compensation", "person_observation", "observation_provenance"]), + covers: new Set([ + "executive_compensation.principal_position", + "executive_compensation.fiscal_year", + "executive_compensation.salary", + "executive_compensation.bonus", + "executive_compensation.stock_awards", + "executive_compensation.option_awards", + "executive_compensation.non_equity_incentive", + "executive_compensation.pension_and_nqdc", + "executive_compensation.all_other_compensation", + "executive_compensation.total", + "person_observation", + "observation_provenance", + ]), }, ...modelExtractChain(models, (text, m) => extractExecutiveCompensation(text, m, args.context) diff --git a/src/sec/forms/registration-statements/s1/deterministicPass.test.ts b/src/sec/forms/registration-statements/s1/deterministicPass.test.ts new file mode 100644 index 00000000..25d51e49 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/deterministicPass.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import type { DeterministicPass } from "./deterministicPass"; +import { preempts } from "./deterministicPass"; + +interface Row { + readonly confidence: number; +} + +/** A pass whose `extract` is never reached — every case here is about `covers`. */ +function pass(covers: DeterministicPass["covers"]): DeterministicPass { + return { extract: () => [], covers }; +} + +const TEXT = "the section text"; + +describe("preempts", () => { + it("declines when clears names a column covers omits", () => { + // The underwriters shape: the syndicate table states the allocation, the + // role is prose beside it. Named column by column, the pass cannot claim + // the column it would otherwise overwrite with null. + const clears = new Set([ + "underwriter_link.role_detail", + "underwriter_link.shares_allocated", + "company_observation", + ]); + const covers = new Set(["underwriter_link.shares_allocated", "company_observation"]); + + expect(preempts(pass(covers), clears, TEXT)).toBe(false); + }); + + it("still preempts a table-granularity pair", () => { + // spac-sponsors: the parse fills every column persist writes, so naming the + // tables bare on both sides is the honest declaration and keeps working. + const both = new Set([ + "spac_sponsor_link", + "sponsor_family_membership", + "company_observation", + "observation_provenance", + ]); + + expect(preempts(pass(new Set(both)), both, TEXT)).toBe(true); + }); + + it("declines when the two sets mix granularity for one table", () => { + // Names are compared as plain strings and a bare table never expands to its + // columns, so a half-migrated declaration fails safe in BOTH directions + // rather than silently claiming (or silently losing) the whole table. + expect( + preempts( + pass(new Set(["beneficial_ownership"])), + new Set(["beneficial_ownership.shares_owned"]), + TEXT + ) + ).toBe(false); + expect( + preempts( + pass(new Set(["beneficial_ownership.shares_owned"])), + new Set(["beneficial_ownership"]), + TEXT + ) + ).toBe(false); + }); + + it("resolves a function-valued covers against the section text", () => { + // The promote/ownership shape: whether a null column is a loss or the + // filing's own answer is a property of THIS section's tables. + const covers = (text: string): ReadonlySet => + text.includes("Shares Offered") + ? new Set(["beneficial_ownership.shares_owned"]) + : new Set(["beneficial_ownership.shares_owned", "beneficial_ownership.shares_offered"]); + const clears = new Set([ + "beneficial_ownership.shares_owned", + "beneficial_ownership.shares_offered", + ]); + + expect(preempts(pass(covers), clears, "a pre-IPO table with no offered column")).toBe(true); + expect(preempts(pass(covers), clears, "a resale table with a Shares Offered column")).toBe( + false + ); + }); + + it("declines when the coverage function throws", () => { + // A coverage function reads the section, so it can fail the way any parse + // can. Declining costs one model call; propagating would abort a section + // the model could still have extracted. + const covers = (): ReadonlySet => { + throw new Error("unreadable table"); + }; + + expect(preempts(pass(covers), new Set(["use_of_proceeds"]), TEXT)).toBe(false); + }); + + it("declines an undefined clears", () => { + // A caller that never said what the section rewrites has not shown the + // parse can supply it. + expect(preempts(pass(new Set(["use_of_proceeds"])), undefined, TEXT)).toBe(false); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/deterministicPass.ts b/src/sec/forms/registration-statements/s1/deterministicPass.ts index 3c63e302..04cce505 100644 --- a/src/sec/forms/registration-statements/s1/deterministicPass.ts +++ b/src/sec/forms/registration-statements/s1/deterministicPass.ts @@ -18,6 +18,15 @@ * child (`field_provenance:issuer_ticker`); the two sets of one section simply * have to use the same spelling. * + * **Granularity is the whole contract.** A table is named BARE only when the + * parse fills every column `persist` writes for it. The moment one column is + * beyond the parse — hardcoded null, or read from prose the walk never sees — + * the whole table is named column by column in BOTH sets, and `covers` simply + * omits the columns the parse cannot supply. A bare name never expands to "all + * its columns": the two sets are compared as plain strings, so mixing + * granularities for one table (bare in one set, qualified in the other) makes + * the pass decline, which is the fail-safe answer in both directions. + * * Without that check a parse that hardcodes the columns it cannot read wins on * any non-empty result, and the section resolves clean: the destination is * emptied and refilled with a strict subset, no dead letter is recorded, and @@ -26,8 +35,20 @@ export interface DeterministicPass { /** Pure and synchronous — no model, no I/O. Returns `[]` when it reads nothing. */ readonly extract: (text: string) => readonly TRow[]; - /** Destinations this parse fills. See the type doc for the naming convention. */ - readonly covers: ReadonlySet; + /** + * Destinations this parse fills. See the type doc for the naming convention. + * + * A FUNCTION of the section text answers the question a fixed set cannot: + * whether a column the parse leaves null is a loss or the truth. An ownership + * table with no "Shares Offered" column has no shares offered, so `null` is + * what the filing says; the same null against a resale table deletes a + * disclosed figure. Such a function MUST be derived from the same walk + * {@link extract} performs — a coverage claim computed from anything else is + * a second implementation that can disagree with the parse it speaks for. + * A throw is treated as covering nothing, so the section falls through to the + * model rather than aborting. + */ + readonly covers: ReadonlySet | ((text: string) => ReadonlySet); /** * Whether the returned rows are the section's COMPLETE population, which is * what `SectionPersistMeta.complete` reports and what roster closure keys on. @@ -38,6 +59,8 @@ export interface DeterministicPass { readonly complete?: (rows: readonly TRow[], text: string) => boolean; } +const COVERS_NOTHING: ReadonlySet = new Set(); + /** * Whether `pass` may stand in for the model on a section that rewrites * `clears`. True only when `covers` is a superset of `clears`. @@ -48,11 +71,30 @@ export interface DeterministicPass { */ export function preempts( pass: DeterministicPass, - clears: ReadonlySet | undefined + clears: ReadonlySet | undefined, + text: string ): boolean { if (clears === undefined) return false; + const covers = resolveCovers(pass.covers, text); for (const destination of clears) { - if (!pass.covers.has(destination)) return false; + if (!covers.has(destination)) return false; } return true; } + +/** + * A coverage function reads the section itself, so it can fail the way any + * parse can. Declining is the only safe answer: throwing here would abort a + * section the model could still have extracted. + */ +function resolveCovers( + covers: ReadonlySet | ((text: string) => ReadonlySet), + text: string +): ReadonlySet { + if (typeof covers !== "function") return covers; + try { + return covers(text); + } catch { + return COVERS_NOTHING; + } +} diff --git a/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts b/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts index bf478431..26155c4a 100644 --- a/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts +++ b/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts @@ -68,6 +68,10 @@ export interface ExecutiveCompensationRow { footnote: string | null; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index 8ca3f931..753dc84f 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -45,6 +45,7 @@ import { DETERMINISTIC_MODEL_ID, parseSpacOfferingTerms, parseSpacPromoteTerms, + promoteCoverage, } from "./parseOfferingTables"; import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; @@ -442,13 +443,27 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { const det = parseSpacPromoteTerms(text); return det === null ? [] : [det]; }, - covers: new Set(["spac_promote_terms", "field_provenance:spac_promote_terms"]), + covers: promoteCoverage, }, ...modelExtractChain(models, async (text, m) => { const promote = await extractSponsorPromote(text, m, context); @@ -522,12 +537,33 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise extractUnderwriters(text, m, context)), @@ -627,6 +663,12 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise; - /** Persist-only. Set by the markdown-table parser; never part of the model schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts index 6ed2d39a..2c298c92 100644 --- a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts @@ -22,6 +22,60 @@ export function hasBeneficialOwnershipTable(text: string | undefined): boolean { return splitGfmTables(text).some((table) => findOwnershipHeader(table) !== undefined); } +const CLASS_HEADER = /\bclass\b|\bseries\b|title of (?:class|security)/i; +const OFFERED_HEADER = /\boffered\b|being offered|to be sold/i; +const AFTER_HEADER = /\bafter\b|\bfollowing\b/i; +const SELLING_TABLE = /selling (?:stockholder|shareholder|securityholder)/i; +const FOOTNOTE_MARKER = /\(\d+\)/; + +/** + * The ownership columns this parse fills for THIS table, as + * `beneficial_ownership.` destinations. + * + * Six of the nine columns are hardcoded null by {@link parseBeneficialOwnership} + * — but a null is only a loss when the table had something to say. A SPAC's + * pre-IPO ownership table prints one class, no "Shares Offered" and no "after + * the offering" columns, carries no selling stockholders and no footnote + * markers: there `null` IS the disclosure, and the parse is complete. A resale + * registration's table prints all of them, and there the same nulls delete + * figures the filing states. The question can only be answered by reading the + * table, so coverage is read off the same headers and rows the parse walks. + */ +export function ownershipCoverage(text: string): ReadonlySet { + const out = new Set([ + "beneficial_ownership.owner_kind", + "beneficial_ownership.shares_owned", + "beneficial_ownership.percent_owned", + "person_observation", + "company_observation", + "observation_provenance", + ]); + let headers = ""; + let footnoted = false; + for (const table of splitGfmTables(text)) { + const header = findOwnershipHeader(table); + if (header === undefined) continue; + headers += ` ${table + .slice(0, header.startIdx + 1) + .flat() + .join(" ")}`; + for (const row of table.slice(header.startIdx + 1)) { + if (row.some((cell) => FOOTNOTE_MARKER.test(cell))) footnoted = true; + } + } + if (!CLASS_HEADER.test(headers)) out.add("beneficial_ownership.security_class"); + if (!OFFERED_HEADER.test(headers)) out.add("beneficial_ownership.shares_offered"); + if (!AFTER_HEADER.test(headers)) { + out.add("beneficial_ownership.shares_after"); + out.add("beneficial_ownership.percent_after"); + } + // `is_selling_stockholder: false` is a positive claim, not an absent one, so + // it may only be asserted for a section that registers no resale at all. + if (!SELLING_TABLE.test(text)) out.add("beneficial_ownership.is_selling_stockholder"); + if (!footnoted) out.add("beneficial_ownership.footnote"); + return out; +} + function parseInner(text: string): BeneficialOwnerRow[] { const out: BeneficialOwnerRow[] = []; for (const table of splitGfmTables(text)) { diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts index 2415746a..20cdfc5e 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts @@ -107,6 +107,37 @@ export function parseSpacPromoteTerms(text: string): SponsorPromoteRow | null { }; } +/** + * The promote columns this walk actually reads out of `text`, as + * `spac_promote_terms.` destinations. + * + * `parseSpacPromoteTerms` returns a row on EITHER of two anchors, so a table + * stating founder shares and nothing else yields a row whose other five columns + * are null — and a fixed coverage set would let that row overwrite figures the + * model reads from the surrounding prose. Coverage is therefore computed from + * the same {@link walkFields} pass the parse runs: a column is claimed only when + * this filing's tables state it. A second walk over one section's text is a + * pure scan and costs nothing worth caching — and a cache keyed on text would + * outlive the filing it was computed for. + */ +export function promoteCoverage(text: string): ReadonlySet { + const fields = walkFields(text); + const out = new Set(["field_provenance:spac_promote_terms"]); + const columns: ReadonlyArray = [ + ["founder_shares", fields.founder_shares], + ["founder_percent", fields.founder_percent], + ["private_placement_warrants", fields.private_placement_warrants], + ["private_placement_warrant_price", fields.private_placement_warrant_price], + ["public_warrant_coverage", fields.warrant_fraction_per_unit], + ["trust_per_public_share", fields.trust_per_public_share], + ["trust_total", fields.trust_total], + ]; + for (const [column, value] of columns) { + if (value !== null) out.add(`spac_promote_terms.${column}`); + } + return out; +} + export function looksLikeUnitIpo(text: string): boolean { const fields = walkFields(text); return fields.price_per_unit !== null && fields.units_offered !== null; diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index e7d673a6..9d8f0bb6 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -296,7 +296,7 @@ export function makeRunSection(opts: { let source: "deterministic" | "model" = "model"; let deterministicComplete = false; const pass = sargs.deterministic; - if (pass !== undefined && preempts(pass, sargs.clears)) { + if (pass !== undefined && preempts(pass, sargs.clears, text)) { const detRaw = pass.extract(text); const detConfident = detRaw.filter((r) => r.confidence >= floor); const detRows = diff --git a/src/sec/forms/registration-statements/s1/sectionSchemas.ts b/src/sec/forms/registration-statements/s1/sectionSchemas.ts index 91340c05..897a41cf 100644 --- a/src/sec/forms/registration-statements/s1/sectionSchemas.ts +++ b/src/sec/forms/registration-statements/s1/sectionSchemas.ts @@ -132,7 +132,11 @@ export interface ManagementPersonRow { bio: string | null; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } export interface BeneficialOwnerRow { @@ -148,7 +152,11 @@ export interface BeneficialOwnerRow { footnote: string | null; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } export interface RelatedPartyRow { @@ -163,6 +171,10 @@ export interface RelatedPartyRow { period: string | null; footnote: string | null; }>; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts b/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts index e4d09bc7..2fe23fbb 100644 --- a/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts @@ -41,6 +41,10 @@ export interface SpacClassificationRow { entity_kind: SpacEntityKind; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/spacProfileSchema.ts b/src/sec/forms/registration-statements/s1/spacProfileSchema.ts index cb8c9a9a..453a8197 100644 --- a/src/sec/forms/registration-statements/s1/spacProfileSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacProfileSchema.ts @@ -128,6 +128,10 @@ export interface SpacProfileRow { url_spac: string | null; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts b/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts index b043f9c3..45fb0f06 100644 --- a/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts @@ -32,6 +32,10 @@ export interface SpacSponsorRow { legal_name: string; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/sponsorPromoteSchema.ts b/src/sec/forms/registration-statements/s1/sponsorPromoteSchema.ts index c467ed25..015f337b 100644 --- a/src/sec/forms/registration-statements/s1/sponsorPromoteSchema.ts +++ b/src/sec/forms/registration-statements/s1/sponsorPromoteSchema.ts @@ -45,6 +45,10 @@ export interface SponsorPromoteRow { trust_total: number | null; confidence: number; source_span: string; - /** Persist-only. Set by the markdown-table parser; never part of the model schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/underwriterSchema.ts b/src/sec/forms/registration-statements/s1/underwriterSchema.ts index f39446fe..e1464ae9 100644 --- a/src/sec/forms/registration-statements/s1/underwriterSchema.ts +++ b/src/sec/forms/registration-statements/s1/underwriterSchema.ts @@ -41,6 +41,10 @@ export interface UnderwriterRowOut { over_allotment_shares: number | null; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts b/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts index 935b5127..b97e831a 100644 --- a/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts +++ b/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts @@ -38,6 +38,10 @@ export interface UseOfProceedsLineRow { note: string | null; confidence: number; source_span: string; - /** Persist-only; never part of the model JSON schema. */ + /** + * Marks a row as produced by the model-free table parse — asserted by that + * parser's unit tests, and absent from the model's JSON schema. Persist does + * not read it: the provenance model id comes from `SectionPersistMeta.source`. + */ source?: "deterministic"; } diff --git a/src/storage/classification/S1ClassificationSchema.ts b/src/storage/classification/S1ClassificationSchema.ts index e5c62e7e..5f2ccdcf 100644 --- a/src/storage/classification/S1ClassificationSchema.ts +++ b/src/storage/classification/S1ClassificationSchema.ts @@ -20,7 +20,8 @@ export const S1ClassificationSchema = Type.Object({ is_spac: Type.Boolean(), classifier_source: Type.String({ maxLength: 32, - description: "sgml-header | sgml-header-rejected | sic-unknown | ai | newco-listing", + description: + "sgml-header | sgml-header-rejected | sic-unknown | ai | newco-listing | deterministic", }), created_at: Type.String({ description: "ISO 8601 timestamp" }), }); From 7d2b927b6aac2d7ed7e34f8215d0338577a03f63 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 08:42:47 +0000 Subject: [PATCH 12/29] Anchor the use-of-proceeds skip rules to the start of the label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKIP_PURPOSE matched its total/source phrases ANYWHERE in a row label, but those phrases are also how filers qualify a real line item. The underwriting-commission row is routinely written "Underwriting commissions (2.0% of gross proceeds from units offered to public)" and the residual trust row "Not held in trust account after offering expenses", so both were dropped. That is silent deletion, not a missed hit: `use-of-proceeds` declares `clears: {use_of_proceeds}` and the deterministic pass declares the identical bare `covers`, so it preempts the model on every SPAC filing, and the table has already been emptied by the time the parse runs. The section then resolves clean, with no dead letter and the same result on every replay. Measured against the committed golden labels: 13 filings, 16 line items — the largest expense row in each — and the same code path runs under extractor id `424` for the priced prospectus, i.e. the final deal figures. Every alternative is now anchored, or scoped to where it means what it says: - the total/source/ratio family is anchored at the start of the label, which is where a filer names a row that is the table's own arithmetic; - `reimbursed expenses` is dropped entirely — the golden labels record it as a real line item, and the parse now emits it (the label set for one Churchill fixture was missing the row its identically-tabled sibling carries, added here from the filing); - the per-share/per-unit metric rules are tested against the label with its parentheticals removed, so a metric row is still skipped while a line item qualified "($10.20 per unit)" survives. Anchoring exposed the other half of the shape: a filer who factors the sources into a block under a bare "Gross proceeds" heading writes its children as plain labels ("Offering", "Private Units"), which only the heading identifies as sources. Such a heading now opens a block that the matching expenses heading closes. `useOfProceedsIsComplete` reports whether the walk enumerated the table, from its own decline log rather than a second reading: a labelled row between the first and last line item, carrying no readable figure and matching no declared rule, is a row the parse could not represent. It errs toward incomplete, which costs a model call rather than a filed line item. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ske1Jwk7fDFxHykfZGEzce --- src/eval/goldenS1Labels.ts | 1 + .../s1/parseSpacUseOfProceeds.corpus.test.ts | 35 ++++- .../s1/parseSpacUseOfProceeds.test.ts | 79 +++++++++++- .../s1/parseSpacUseOfProceeds.ts | 120 +++++++++++++++--- 4 files changed, 215 insertions(+), 20 deletions(-) diff --git a/src/eval/goldenS1Labels.ts b/src/eval/goldenS1Labels.ts index bad260c9..446c5e19 100644 --- a/src/eval/goldenS1Labels.ts +++ b/src/eval/goldenS1Labels.ts @@ -847,6 +847,7 @@ export const GOLDEN_S1_LABELS: Readonly> = { purpose: "Nasdaq listing and filing fees", amount: 85000 }, { purpose: "Travel and roadshow expenses", amount: 10000 }, { purpose: "Miscellaneous", amount: 385641 }, + { purpose: "Reimbursed expenses", amount: 3000000 }, { purpose: "Held in trust account", amount: 300000000 }, { purpose: "Not held in trust account", amount: 1000000 }, { purpose: "Legal, accounting, due diligence, travel and other expenses in connection with business combination", amount: 100000 }, diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts index 57b42317..7e5c6ada 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts @@ -12,7 +12,7 @@ import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; import { S1_SECTIONS } from "./DocumentSegmenter"; -import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; +import { parseSpacUseOfProceeds, useOfProceedsIsComplete } from "./parseSpacUseOfProceeds"; const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); @@ -73,4 +73,37 @@ describe("parseSpacUseOfProceeds golden corpus", () => { expect(extras, filing).toEqual([]); } }); + + // Recall, not precision. A skip rule matching a phrase ANYWHERE in the label + // deleted the underwriting-commission row from 13 filings and 16 line items + // in this corpus — the largest expense in each — while every precision + // assertion above stayed green, because a dropped row invents nothing. + it("finds every golden line item on a filing it claims to have enumerated", () => { + const misses: string[] = []; + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "use-of-proceeds"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? ""; + if (!useOfProceedsIsComplete(text)) continue; + const found = new Set(parseSpacUseOfProceeds(text).map((r) => purposeKey(r.purpose ?? ""))); + for (const label of labels) { + const purpose = typeof label.purpose === "string" ? label.purpose : ""; + if (purpose !== "" && !found.has(purposeKey(purpose))) misses.push(`${filing}: ${purpose}`); + } + } + expect(misses).toEqual([]); + }); + + // The predicate above is only worth anything if it says yes to real filings. + it("claims a complete enumeration on most of the corpus it parses", () => { + const parsing = cases().filter( + ({ byName }) => + parseSpacUseOfProceeds(byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? "").length > 0 + ); + const complete = parsing.filter(({ byName }) => + useOfProceedsIsComplete(byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? "") + ); + expect(parsing.length).toBeGreaterThanOrEqual(20); + expect(complete.length).toBeGreaterThanOrEqual(16); + }); }); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts index 90119bc6..b02e2d92 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from "vitest"; -import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; +import { parseSpacUseOfProceeds, useOfProceedsIsComplete } from "./parseSpacUseOfProceeds"; function purposes(text: string): string[] { return parseSpacUseOfProceeds(text).map((r) => r.purpose ?? ""); @@ -42,6 +42,7 @@ describe("parseSpacUseOfProceeds", () => { "Underwriting discounts and commissions (excluding deferred portion)", "Legal fees and expenses", "Miscellaneous", + "Reimbursed expenses", "Held in trust account", "Not held in trust account", "Legal, accounting, due diligence, travel and other expenses in connection with business combination", @@ -75,4 +76,80 @@ describe("parseSpacUseOfProceeds", () => { "Held in trust account", ]); }); + + // The way most filers write the largest expense row in the table. A skip rule + // matching "gross proceeds" anywhere in the label deletes it — silently, and + // on every replay, because this parse replaces the model call for the section. + it("keeps an underwriting row whose parenthetical cites gross proceeds", () => { + const text = [ + "| Gross proceeds from units offered to public | $ | 100,000,000 |", + "| Underwriting commissions (2.0% of gross proceeds from units offered to public, excluding deferred portion) | $ | 2,000,000 |", + "| Held in trust account | $ | 100,000,000 |", + ].join("\n"); + expect(purposes(text)).toEqual([ + "Underwriting commissions (2.0% of gross proceeds from units offered to public, excluding deferred portion)", + "Held in trust account", + ]); + }); + + it("keeps the residual trust row that names offering expenses", () => { + const text = [ + "| Offering expenses | | |", + "| Underwriting discounts and commissions | $ | 4,500,000 |", + "| Not held in trust account after offering expenses | $ | 525,000 |", + ].join("\n"); + expect(purposes(text)).toContain("Not held in trust account after offering expenses"); + }); + + it("keeps a line item whose parenthetical states a per-unit rate", () => { + const text = [ + "| Underwriting discounts and commissions | $ | 4,500,000 |", + "| Held in trust account ($10.20 per unit) | $ | 306,000,000 |", + "| Amount held in trust per share | $ | 10.20 |", + ].join("\n"); + expect(purposes(text)).toEqual([ + "Underwriting discounts and commissions", + "Held in trust account ($10.20 per unit)", + ]); + }); + + // Some filers factor the sources into a block under a bare heading, so the + // rows beneath it read as ordinary labels ("Offering", "Private Units") and + // only the heading says they are where the money came from. + it("skips the children of a bare gross-proceeds heading but not of an expenses heading", () => { + const text = [ + "| Gross proceeds | | |", + "| Offering(1) | $ | 200,000,000 |", + "| Private Units(2) | | 6,500,000 |", + "| Total gross proceeds | $ | 206,500,000 |", + "| Offering expenses(3) | | |", + "| Underwriting discount | $ | 4,000,000 |", + "| Held in the trust account from this offering | $ | 200,000,000 |", + ].join("\n"); + expect(purposes(text)).toEqual([ + "Underwriting discount", + "Held in the trust account from this offering", + ]); + }); +}); + +describe("useOfProceedsIsComplete", () => { + it("is true when every labelled row of the table was represented", () => { + expect(useOfProceedsIsComplete(CHURCHILL)).toBe(true); + }); + + it("is false when a line item inside the table carries no readable figure", () => { + const text = [ + "| Underwriting discounts and commissions | $ | 4,500,000 |", + "| Miscellaneous | | 385,641 |", + "| Held in trust account(3) | | |", + "| Not held in trust account | $ | 750,000 |", + ].join("\n"); + expect(parseSpacUseOfProceeds(text).length).toBe(3); + expect(useOfProceedsIsComplete(text)).toBe(false); + }); + + it("is false when the parse read nothing", () => { + expect(useOfProceedsIsComplete("")).toBe(false); + }); }); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts index 364c592b..72502ba2 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts @@ -9,50 +9,128 @@ import type { UseOfProceedsLineRow } from "./useOfProceedsSchema"; const MIN_LINES = 2; const MIN_AMOUNT = 1_000; -const SKIP_PURPOSE = - /gross proceeds|^proceeds from\b|^from\b|^totals?\b|proceeds after|reimbursed expenses|% public offering|offering expenses\b(?! \()|per (?:public )?share|per unit|^(?:revenues?|cost of sales|gross profit|operating loss|net loss|ebitda|adjusted ebitda|net cash)\b/i; +/** + * Total, source, subtotal and ratio rows — the table's own arithmetic rather + * than a use of the proceeds. Every one is anchored to the START of the label: + * each of these phrases also occurs inside a real line item's parenthetical + * qualifier, which is how most filers write the largest expense row in the + * table ("Underwriting commissions (2.0% of gross proceeds from units offered + * to public)") and the residual trust row ("Not held in trust account after + * offering expenses"). A label opening with `%` states a ratio of the offering + * ("% of public offering size"), never a use of it. + */ +const SKIP_LEADING = + /^(?:gross proceeds|proceeds from\b|from\b|totals?\b|proceeds after|%|(?:estimated )?offering expenses\b(?! \()|revenues?\b|cost of sales\b|gross profit\b|operating loss\b|net loss\b|ebitda\b|adjusted ebitda\b|net cash\b)/i; + +/** + * Per-unit metric rows from a dilution or capitalization table that leaked into + * the section. Tested against the label with parentheticals removed, because a + * real line item states its rate as a qualifier ("Held in trust account ($10.20 + * per unit)") while a metric row names the rate as the row itself. + */ +const SKIP_METRIC = /per (?:public )?share\b|per unit\b/i; + +/** + * A label carrying no figure heads the block beneath it. A filer who factors + * the sources out into such a block writes its children bare — `Offering`, + * `Private Units` — so only the heading says they are where the money came + * from rather than where it goes. The matching `Offering expenses` heading + * closes the block again, and its children are the real line items. + */ +const SOURCE_BLOCK_HEADING = /^(?:gross proceeds|proceeds\b|sources? of (?:funds|proceeds)\b)/i; const DATE_PURPOSE = /^(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},\s+\d{4}$|^\d{4}$/i; const SPAC_USE = /held in trust|not held in trust|underwriting discounts?|deferred underwriting/i; export function parseSpacUseOfProceeds(text: string): UseOfProceedsLineRow[] { try { - return parseInner(text); + return parseInner(text).rows; } catch { return []; } } -function parseInner(text: string): UseOfProceedsLineRow[] { - const out = collectLines(text); - if (out.length < MIN_LINES) return []; - if (!out.some((r) => SPAC_USE.test(r.purpose ?? ""))) return []; - return out; +/** + * Whether {@link parseSpacUseOfProceeds} enumerated the whole table, which is + * what lets it stand in for the model on a destination the caller has already + * emptied. It is the walk's own decline log, not a second reading of the + * section: a labelled row BETWEEN the first and last line item, carrying no + * figure this walk could read and matching none of the declared total / source + * / ratio rules, is a row the parse could not represent. + * + * A filer who prints the trust amount only in prose leaves that row's figure + * cells empty ("Held in trust account(3)" against a "% of public offering size" + * of 100.0), and the disclosure is the row. A sub-table heading inside one grid + * reads identically, so this errs toward incomplete — which costs a model call + * rather than a filed line item. + */ +export function useOfProceedsIsComplete(text: string): boolean { + try { + const { rows, unrepresented } = parseInner(text); + return rows.length > 0 && unrepresented === 0; + } catch { + return false; + } +} + +interface UseOfProceedsParse { + readonly rows: UseOfProceedsLineRow[]; + /** Labelled rows inside the line-item span that the walk could not represent. */ + readonly unrepresented: number; +} + +const EMPTY_PARSE: UseOfProceedsParse = { rows: [], unrepresented: 0 }; + +function parseInner(text: string): UseOfProceedsParse { + const parsed = collectLines(text); + if (parsed.rows.length < MIN_LINES) return EMPTY_PARSE; + if (!parsed.rows.some((r) => SPAC_USE.test(r.purpose ?? ""))) return EMPTY_PARSE; + return parsed; } /** True when a SPAC expense/trust table is present, even if parse would return []. */ export function hasSpacUseOfProceedsTable(text: string): boolean { - return collectLines(text).some((r) => SPAC_USE.test(r.purpose ?? "")); + return collectLines(text).rows.some((r) => SPAC_USE.test(r.purpose ?? "")); } -function collectLines(text: string): UseOfProceedsLineRow[] { +function collectLines(text: string): UseOfProceedsParse { const out: UseOfProceedsLineRow[] = []; + let unrepresented = 0; for (const table of splitGfmTables(text)) { - for (const row of table) { - const cells = row.map(cleanCell).filter((c, i, arr) => !(c === "" && i > 0 && arr[0] === "")); + let inSourceBlock = false; + let firstItem = -1; + let lastItem = -1; + // Row index of each unclassified figure-less label, resolved against the + // line-item span only once the table has been walked to its end. + const declined: number[] = []; + for (let i = 0; i < table.length; i++) { + const row = table[i]!; + const cells = row.map(cleanCell).filter((c, j, arr) => !(c === "" && j > 0 && arr[0] === "")); const purposeRaw = cells.find((c) => c !== "" && c !== "$" && c !== "%") ?? ""; const purpose = tidyPurpose(purposeRaw); + if (purpose === "") continue; + const amount = firstAmount(cells); + if (amount === null) { + inSourceBlock = SOURCE_BLOCK_HEADING.test(purpose); + if (!isSkipPurpose(purpose) && !DATE_PURPOSE.test(purpose) && !isHeaderRow(cells)) { + declined.push(i); + } + continue; + } if ( - purpose === "" || - SKIP_PURPOSE.test(purpose) || + inSourceBlock || + isSkipPurpose(purpose) || DATE_PURPOSE.test(purpose) || isHeaderRow(cells) ) { continue; } - const amount = firstAmount(cells); - if (amount === null) continue; - if (!text.includes(purposeRaw) && !text.includes(purpose)) continue; + if (!text.includes(purposeRaw) && !text.includes(purpose)) { + declined.push(i); + continue; + } + if (firstItem < 0) firstItem = i; + lastItem = i; const percent = firstPercent(cells); out.push({ purpose, @@ -64,8 +142,14 @@ function collectLines(text: string): UseOfProceedsLineRow[] { source: "deterministic", }); } + unrepresented += declined.filter((i) => i > firstItem && i < lastItem).length; } - return out; + return { rows: out, unrepresented }; +} + +function isSkipPurpose(purpose: string): boolean { + if (SKIP_LEADING.test(purpose)) return true; + return SKIP_METRIC.test(purpose.replace(/\([^()]*\)/g, " ")); } function isHeaderRow(cells: readonly string[]): boolean { From fa62f14985994c82129bb78b4cbe994db53f5804 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 08:58:00 +0000 Subject: [PATCH 13/29] Require a row-completeness claim before a parse may preempt the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `preempts()` authorized a model-free parse to replace the model whenever `covers` was a superset of `clears`, and both sets name DESTINATIONS. For a destination holding many rows that says only "the parse fills every column persist writes" — never "the parse found every ROW". The caller has already cleared the destination, so a walk that reads N of M rows persists N and the section is marked resolved: no dead letter, no diagnostic, the same answer on every replay. The unanchored use-of-proceeds skip rules fixed in the previous commit were one instance of exactly this hole, and two more were live: - `spac-sponsors` covers `clears` exactly, so `parseSpacSponsors` preempted on its two prose patterns. Both require `our|the` immediately before `sponsor`, so a vehicle whose second sponsor is introduced as "our co-sponsor, Beta Holdings LLC, is …" rebuilt an already-cleared `spac_sponsor_link` with one of two sponsors. - `beneficial_ownership` under a full `ownershipCoverage`: rows failing `looksLikeOwner` are dropped and ones whose stub carries a street number are truncated by `peelName`, before persist and before the section resolves. `DeterministicPass.complete` already expressed the missing claim but was read only for roster closure, and no wired pass declared it. It is now the row half of the contract and a precondition of preempting at all, checked through `assertsCompletePopulation` (a missing or throwing claim declines, matching how a throwing `covers` is treated). A pass that cannot say its rows are the whole population costs a model call instead of losing filed rows. Declared per pass, from what each destination and walk can honestly support: - `spac-classification` and `sponsor-promote` write one row per filing, so producing that row IS enumerating the population — they keep preempting, and `promoteCoverage` keeps answering the column question; - `use-of-proceeds` claims completeness from the walk's own decline log (`useOfProceedsIsComplete`), which holds on 16 of the 20 committed SPAC fixtures it parses and correctly declines the one whose trust row carries no figure; - `spac-sponsors` and `beneficial-ownership` declare nothing and stop preempting: neither prose regexes nor a table walk that filters its own rows can report that the section named no one else. The corpus tests gain the recall side, which every existing assertion missed because a dropped row invents nothing: sponsors and classification must agree with the golden labels on every filing they answer for, the promote pass must be right about every column its coverage claims (80 field checks over 17 filings), and the ownership walk's four known drops are pinned as a list so a new one fails and closing one prompts revisiting the claim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ske1Jwk7fDFxHykfZGEzce --- .../Form_S_1.storage.ts | 16 +++++ .../s1/deterministicPass.test.ts | 39 +++++++++++- .../s1/deterministicPass.ts | 51 ++++++++++++++-- .../s1/offeringSections.ts | 11 +++- .../parseBeneficialOwnership.corpus.test.ts | 33 ++++++++++ .../s1/parseOfferingTables.corpus.test.ts | 35 ++++++++++- .../s1/parseSpacClassification.corpus.test.ts | 21 +++++++ .../s1/parseSpacSponsors.corpus.test.ts | 33 ++++++++++ .../s1/sectionRunner.deterministic.test.ts | 60 +++++++++++++++++-- .../s1/sectionRunner.ts | 18 ++++-- 10 files changed, 295 insertions(+), 22 deletions(-) diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index bba923a3..24519643 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -656,6 +656,9 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { return det === null ? [] : [det]; }, covers: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), + // A filing is classified once, so the row IS the population: there + // is no second verdict the walk could have missed. + complete: (rows) => rows.length === 1, }, extract: async (text) => { const c = await extractSpacClassification(text, classifierModelResolved, args.context); @@ -885,6 +888,12 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "company_observation", "observation_provenance", ]), + // `ownershipCoverage` answers the COLUMN question and nothing else. The + // walk also drops a data row whose stub fails `looksLikeOwner` (a + // single-token owner name) and truncates one whose stub carries a street + // number, so the rows it returns are not the table's roster and no + // `complete` can be derived from the same walk. It therefore does not + // preempt, whatever the coverage function answers. deterministic: { extract: parseBeneficialOwnership, covers: ownershipCoverage, @@ -1410,6 +1419,13 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "company_observation", "observation_provenance", ]), + // Never preempts: `spac_sponsor_link` holds one row per sponsor, and the + // parse is two prose patterns that both require `our|the` immediately + // before `sponsor`. A vehicle whose second sponsor is introduced as "our + // co-sponsor, Beta Holdings LLC, is …" matches neither, and the link table + // the caller has already cleared would be rebuilt with one of two sponsors + // and resolved clean. Nothing derived from those two patterns can say the + // prose named no other sponsor, so no `complete` is declared. deterministic: { extract: parseSpacSponsors, covers: new Set([ diff --git a/src/sec/forms/registration-statements/s1/deterministicPass.test.ts b/src/sec/forms/registration-statements/s1/deterministicPass.test.ts index 25d51e49..e7c3f22f 100644 --- a/src/sec/forms/registration-statements/s1/deterministicPass.test.ts +++ b/src/sec/forms/registration-statements/s1/deterministicPass.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import type { DeterministicPass } from "./deterministicPass"; -import { preempts } from "./deterministicPass"; +import { assertsCompletePopulation, preempts } from "./deterministicPass"; interface Row { readonly confidence: number; @@ -34,9 +34,10 @@ describe("preempts", () => { expect(preempts(pass(covers), clears, TEXT)).toBe(false); }); - it("still preempts a table-granularity pair", () => { + it("accepts a table-granularity pair as covering every column", () => { // spac-sponsors: the parse fills every column persist writes, so naming the - // tables bare on both sides is the honest declaration and keeps working. + // tables bare on both sides is the honest COLUMN declaration. Whether it + // found every sponsor ROW is `assertsCompletePopulation`'s question. const both = new Set([ "spac_sponsor_link", "sponsor_family_membership", @@ -102,3 +103,35 @@ describe("preempts", () => { expect(preempts(pass(new Set(["use_of_proceeds"])), undefined, TEXT)).toBe(false); }); }); + +describe("assertsCompletePopulation", () => { + const rows: readonly Row[] = [{ confidence: 1 }]; + + it("claims nothing for a pass that declares no completeness", () => { + expect(assertsCompletePopulation(pass(new Set(["use_of_proceeds"])), rows, TEXT)).toBe(false); + }); + + it("reads the claim against this filing's rows and text", () => { + const p: DeterministicPass = { + extract: () => [], + covers: new Set(["use_of_proceeds"]), + complete: (r, text) => r.length > 0 && !text.includes("unreadable row"), + }; + + expect(assertsCompletePopulation(p, rows, TEXT)).toBe(true); + expect(assertsCompletePopulation(p, rows, "a table with an unreadable row")).toBe(false); + expect(assertsCompletePopulation(p, [], TEXT)).toBe(false); + }); + + it("claims nothing when the completeness function throws", () => { + const p: DeterministicPass = { + extract: () => [], + covers: new Set(["use_of_proceeds"]), + complete: () => { + throw new Error("unreadable table"); + }, + }; + + expect(assertsCompletePopulation(p, rows, TEXT)).toBe(false); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/deterministicPass.ts b/src/sec/forms/registration-statements/s1/deterministicPass.ts index 04cce505..59dd3572 100644 --- a/src/sec/forms/registration-statements/s1/deterministicPass.ts +++ b/src/sec/forms/registration-statements/s1/deterministicPass.ts @@ -31,6 +31,16 @@ * any non-empty result, and the section resolves clean: the destination is * emptied and refilled with a strict subset, no dead letter is recorded, and * every replay takes the same path, so nothing ever self-corrects. + * + * **Columns are only half of it.** {@link covers} names destinations, so it can + * say the parse fills every column `persist` writes — never that the parse + * found every ROW. For a destination holding many rows (`use_of_proceeds`, + * `spac_sponsor_link`, `beneficial_ownership`) that is the same silent + * truncation one level up: the caller has already cleared the table, so a walk + * that reads N of M rows persists N and resolves the section as complete. + * {@link complete} is therefore required as well — a pass that cannot assert + * its rows are the whole population does not preempt, and the section costs a + * model call instead of losing filed rows. */ export interface DeterministicPass { /** Pure and synchronous — no model, no I/O. Returns `[]` when it reads nothing. */ @@ -50,11 +60,17 @@ export interface DeterministicPass { */ readonly covers: ReadonlySet | ((text: string) => ReadonlySet); /** - * Whether the returned rows are the section's COMPLETE population, which is - * what `SectionPersistMeta.complete` reports and what roster closure keys on. + * Whether the returned rows are the section's COMPLETE population — the ROW + * half of the contract, and a precondition of preempting the model at all. + * It is also what `SectionPersistMeta.complete` reports and what roster + * closure keys on. + * * Omitted means false: a parser that filters its own output cannot tell a row * it dropped from a row the section never had, so it must not be read as - * having enumerated everything. + * having enumerated everything. Like {@link covers}, an honest answer is + * derived from the same walk {@link extract} performs — a decline log, not a + * second reading of the section. A single-valued destination (one row per + * accession) answers it by the row being there at all. */ readonly complete?: (rows: readonly TRow[], text: string) => boolean; } @@ -62,8 +78,11 @@ export interface DeterministicPass { const COVERS_NOTHING: ReadonlySet = new Set(); /** - * Whether `pass` may stand in for the model on a section that rewrites - * `clears`. True only when `covers` is a superset of `clears`. + * Whether `pass` covers every column of every destination a section rewrites. + * The COLUMN half of the preemption test — {@link assertsCompletePopulation} + * is the row half, and both have to hold. Kept separate because this one is + * answerable before {@link DeterministicPass.extract} runs, so a pass that + * cannot supply the columns never walks the section at all. * * An undeclared `clears` is false, not vacuously true: a caller that never said * what the section rewrites has not shown the parse can supply it, and the @@ -82,6 +101,28 @@ export function preempts( return true; } +/** + * Whether `pass` claims `rows` are the section's whole population. A pass that + * declares no {@link DeterministicPass.complete} claims nothing, which is the + * fail-safe answer: the model runs. + * + * A throw is treated as no claim, for the same reason a throwing coverage + * function covers nothing — declining costs a model call, while aborting loses + * a section the model could still have extracted. + */ +export function assertsCompletePopulation( + pass: DeterministicPass, + rows: readonly TRow[], + text: string +): boolean { + if (pass.complete === undefined) return false; + try { + return pass.complete(rows, text) === true; + } catch { + return false; + } +} + /** * A coverage function reads the section itself, so it can fail the way any * parse can. Declining is the only safe answer: throwing here would abort a diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index 753dc84f..cd927e61 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -48,7 +48,7 @@ import { promoteCoverage, } from "./parseOfferingTables"; import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; -import { parseSpacUseOfProceeds } from "./parseSpacUseOfProceeds"; +import { parseSpacUseOfProceeds, useOfProceedsIsComplete } from "./parseSpacUseOfProceeds"; /** * Concatenate the sections production hands the offering-terms parser, so eval @@ -464,6 +464,10 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise rows.length === 1, }, ...modelExtractChain(models, async (text, m) => { const promote = await extractSponsorPromote(text, m, context); @@ -681,6 +685,11 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise= 2 ? det : []; }, covers: new Set(["use_of_proceeds"]), + // `use_of_proceeds` holds one row per line item, so covering its + // columns says nothing about the rows. The walk's own decline log + // does: a labelled row it could not represent means the table was + // not enumerated, and the model gets the section. + complete: (_rows, text) => useOfProceedsIsComplete(text), } : undefined, ...modelExtractChain(models, (text, m) => extractUseOfProceeds(text, m, context)), diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts index 4f231da4..9a129d02 100644 --- a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts @@ -91,4 +91,37 @@ describe("parseBeneficialOwnership golden corpus", () => { expect(garbage, filing).toEqual([]); } }); + + // Recall, which the precision assertions above cannot see: a dropped owner + // invents nothing. These four are the walk's own filters, not the table's + // contents — `looksLikeOwner` refuses a single-token stub, and `peelName` + // reads a street number in a stub as the start of an address and cuts there. + // + // They are pinned rather than fixed because the pass they back is wired with + // no completeness claim and therefore never stands in for the model: nothing + // is lost today. The list is the bar — a NEW gap fails here, and closing one + // of these fails here too, which is the prompt to reconsider the claim. + const KNOWN_RECALL_GAPS: readonly string[] = [ + "s1_1507957_000143774926010088: AIGH", + "s1_1602409_000152013826000232: Acuitas Group Holdings, LLC", + "s1_1602409_000152013826000232: Acuitas Capital LLC", + "s1_1602409_000152013826000232: Dorado Goose, LLC", + ]; + + it("misses only the owners its own filters are known to drop", () => { + const misses: string[] = []; + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "beneficial-ownership"); + if (!labels || labels.length === 0) continue; + const text = byName.get(S1_SECTIONS.BENEFICIAL_OWNERSHIP) ?? ""; + const parsed = parseBeneficialOwnership(text); + if (parsed.length === 0) continue; + const found = new Set(parsed.map((r) => nameKey(r.name))); + for (const label of labels) { + const name = typeof label.name === "string" ? label.name : ""; + if (name !== "" && !coveredName(name, found)) misses.push(`${filing}: ${name}`); + } + } + expect(misses.sort()).toEqual([...KNOWN_RECALL_GAPS].sort()); + }); }); diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts index 8f33d0d7..285a7788 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts @@ -12,7 +12,11 @@ import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; import { offeringParseText, promoteParseText } from "./offeringSections"; -import { parseSpacOfferingTerms, parseSpacPromoteTerms } from "./parseOfferingTables"; +import { + parseSpacOfferingTerms, + parseSpacPromoteTerms, + promoteCoverage, +} from "./parseOfferingTables"; const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); @@ -139,4 +143,33 @@ describe("parseOfferingTables golden corpus", () => { expectScoredFields(filing, got, expected, PROMOTE_FIELDS); } }); + + // `spac_promote_terms` holds one row per filing, so the promote pass answers + // the row question by producing that row and `promoteCoverage` carries the + // whole risk: a column it claims is a column the model will not be asked for + // and that persist will write. The assertion above forgives a null the walk + // returned; a CLAIMED column may not be null and may not disagree. + it("is right about every promote column its coverage claims", () => { + const wrong: string[] = []; + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "sponsor-promote"); + if (!labels || labels.length === 0) continue; + const text = promoteParseText(byName); + const parsed = parseSpacPromoteTerms(text); + if (parsed === null) continue; + const claimed = promoteCoverage(text); + const expected = scored(labels[0] as Record, PROMOTE_FIELDS); + const got = scored(parsed as unknown as Record, PROMOTE_FIELDS); + for (const field of PROMOTE_FIELDS) { + if (!claimed.has(`spac_promote_terms.${field}`)) continue; + if (expected[field] == null) continue; + if (got[field] !== expected[field]) { + wrong.push( + `${filing} ${field}: parsed ${String(got[field])}, golden ${String(expected[field])}` + ); + } + } + } + expect(wrong).toEqual([]); + }); }); diff --git a/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts index 4efc73ff..8df102aa 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts @@ -50,4 +50,25 @@ describe("parseSpacClassification golden corpus", () => { expect(parseSpacClassification(summary), filing).toBeNull(); } }); + + // This pass DOES stand in for the model — a filing is classified once, so the + // row it returns is the whole population — which makes it the one wired parse + // whose verdict is never checked against a model. It has to agree with the + // labels on every filing it answers for. + it("agrees with the golden classification on every filing it answers", () => { + const disagreements: string[] = []; + for (const { filing, summary } of cases()) { + const labels = getGoldenLabels(filing, "spac-classification"); + if (!labels || labels.length === 0) continue; + const parsed = parseSpacClassification(summary); + if (parsed === null) continue; + const label = labels[0]!; + if (parsed.is_spac !== label.is_spac || parsed.entity_kind !== label.entity_kind) { + disagreements.push( + `${filing}: parsed ${parsed.entity_kind}/${parsed.is_spac}, golden ${String(label.entity_kind)}/${String(label.is_spac)}` + ); + } + } + expect(disagreements).toEqual([]); + }); }); diff --git a/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts index a4045817..c9f880ac 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts @@ -91,4 +91,37 @@ describe("parseSpacSponsors golden corpus", () => { expect(extras, filing).toEqual([]); } }); + + // Recall, not precision. The precision assertion above passes on a parse that + // found one of two sponsors, and `spac_sponsor_link` is cleared per accession + // before the rows are rewritten — so a shortfall here would be a deleted + // sponsor, not a missing hit, if this parse were ever allowed to preempt. + it("finds every golden sponsor on a filing it hits", () => { + const misses: string[] = []; + for (const { filing, byName } of cases()) { + const labels = getGoldenLabels(filing, "spac-sponsors"); + if (!labels || labels.length === 0) continue; + const parsed = parseSpacSponsors(sponsorText(byName)); + if (parsed.length === 0) continue; + const found = new Set(parsed.map((r) => nameKey(r.legal_name))); + for (const label of labels) { + const name = typeof label.legal_name === "string" ? label.legal_name : ""; + if (name !== "" && !coveredName(name, found)) misses.push(`${filing}: ${name}`); + } + } + expect(misses).toEqual([]); + }); + + // The two patterns both require `our|the` immediately before `sponsor`, so a + // "our co-sponsor, Beta Holdings LLC, is …" introduction matches neither and + // nothing derived from them can report that the prose named no one else. + // Pinned because the pass is wired with no completeness claim for exactly + // this reason, and a stray `complete: () => true` would read as a tidy-up. + it("cannot see a sponsor the prose introduces as a co-sponsor", () => { + const text = + "Our sponsor, Alpha Sponsor LLC, is a Delaware limited liability company. " + + "Our co-sponsor, Beta Holdings LLC, is an affiliate of our chief executive officer."; + + expect(parseSpacSponsors(text).map((r) => r.legal_name)).toEqual(["Alpha Sponsor LLC"]); + }); }); diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts index dd460a7a..920aad2a 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts @@ -107,10 +107,11 @@ describe("makeRunSection deterministic pass", () => { expect(h.persisted[0]!.rows.map((r) => r.span)).toEqual(["bravo"]); }); - it("preempts the model when covers is a superset of clears", async () => { + it("preempts the model when covers is a superset of clears and the rows are complete", async () => { const h = harness({ clears: new Set(["person_observation"]), covers: new Set(["person_observation", "observation_provenance"]), + complete: () => true, }); await h.run(); @@ -120,6 +121,49 @@ describe("makeRunSection deterministic pass", () => { expect(h.persisted[0]!.rows.map((r) => r.span)).toEqual(["alpha"]); }); + // `covers` names destinations, so full coverage of a many-row table only says + // every COLUMN would be filled. The caller has already cleared that table, so + // a walk that found some of the rows would refill it with a subset and + // resolve the section clean. + it("does not preempt on full column coverage alone", async () => { + const h = harness({ + clears: new Set(["use_of_proceeds"]), + covers: new Set(["use_of_proceeds"]), + }); + await h.run(); + + expect(h.modelCalls()).toBe(1); + expect(h.persisted[0]!.meta.source).toBe("model"); + }); + + it("does not preempt when the completeness claim is false for this filing", async () => { + const h = harness({ + clears: new Set(["use_of_proceeds"]), + covers: new Set(["use_of_proceeds"]), + complete: () => false, + }); + await h.run(); + + expect(h.modelCalls()).toBe(1); + expect(h.persisted[0]!.meta.source).toBe("model"); + }); + + // Declining costs a model call; aborting would lose a section the model can + // still extract, which is how a throwing `covers` is already treated. + it("does not preempt when the completeness claim throws", async () => { + const h = harness({ + clears: new Set(["use_of_proceeds"]), + covers: new Set(["use_of_proceeds"]), + complete: () => { + throw new Error("walk failed"); + }, + }); + await h.run(); + + expect(h.modelCalls()).toBe(1); + expect(h.persisted[0]!.meta.source).toBe("model"); + }); + it("falls through to the model on a partial parse, once, with no dead letter", async () => { const h = harness({ clears: new Set(["person_observation"]), @@ -139,17 +183,21 @@ describe("makeRunSection deterministic pass", () => { expect(h.persisted[0]!.meta.source).toBe("model"); }); - it("reports an incomplete population when the pass declares no completeness", async () => { + // Every returned row surviving says nothing about the section's population: + // a parser that filters its own output cannot tell a row it dropped from a + // row the section never had. So the model runs, and its own filtering + // decides `meta.complete`. + it("falls through to the model when the pass declares no completeness", async () => { const h = harness({ clears: new Set(["person_observation"]), covers: new Set(["person_observation"]), }); await h.run(); - expect(h.persisted[0]!.meta.source).toBe("deterministic"); - // Every returned row survived, but the parser filters its own output, so - // "all of them survived" says nothing about the section's population. - expect(h.persisted[0]!.meta.complete).toBe(false); + expect(h.detCalls()).toBe(1); + expect(h.modelCalls()).toBe(1); + expect(h.letters).toEqual([]); + expect(h.persisted[0]!.meta.source).toBe("model"); }); it("reports a complete population only when the pass says so", async () => { diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index 9d8f0bb6..17592c61 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -15,7 +15,7 @@ import { } from "./sectionExtractors"; import type { SpanVerdict } from "./verifySourceSpan"; import type { DeterministicPass } from "./deterministicPass"; -import { preempts } from "./deterministicPass"; +import { assertsCompletePopulation, preempts } from "./deterministicPass"; /** * Parse a confidence-floor env value. Undefined, empty, or non-numeric input @@ -131,7 +131,8 @@ export interface RunSectionArgs { readonly clears?: ReadonlySet; /** * A model-free parse tried ONCE, before {@link extract}, and only when it - * covers everything {@link clears} names. + * covers every column {@link clears} names AND asserts its rows are the + * section's whole population. * * All-or-nothing: its rows persist only when every one of them clears the * confidence floor and {@link verifyRow}. A shortfall records nothing and @@ -306,14 +307,19 @@ export function makeRunSection(opts: { const verdict = verifyRow(text, r); return verdict === true || verdict === "ok"; }); - // All or nothing. A partial parse persists a subset of a section the - // caller has already cleared, and resolves it as complete. - if (detRaw.length > 0 && detRows.length === detRaw.length) { + // All or nothing, on two axes. Every row the parse returned has to + // survive filtering — a partial parse persists a subset of a section + // the caller has already cleared — and the parse has to claim those + // rows are the whole population. `covers` speaks only for columns, so + // without the second test a walk that found some of the rows fills a + // cleared table with them and resolves the section as complete. + const complete = assertsCompletePopulation(pass, detRows, text); + if (complete && detRaw.length > 0 && detRows.length === detRaw.length) { raw = [...detRaw]; confident = [...detConfident]; rows = [...detRows]; source = "deterministic"; - deterministicComplete = pass.complete?.(detRows, text) ?? false; + deterministicComplete = true; } } if (source === "model") { From bbb783741ea208b02190f7160f5ecc8987bc01e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 15:55:41 +0000 Subject: [PATCH 14/29] Re-express the two storage tests against the row-completeness contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pinned the preemption rule the previous commit replaced, and both were mirror images of what the rule now says. `preempts()` used to authorize a model-free parse on column coverage alone, so `spac-sponsors` and `beneficial-ownership` stood in for the model; requiring a row-completeness claim retires both, because two prose patterns cannot report that the section named no other sponsor and a walk that filters its own rows cannot tell a row it dropped from a row the table never had. The destination is cleared before persist, so a subset there is filed data lost with no dead letter — the model call is the cheaper side of that trade. So the assertions flip rather than relax. Each test now proves the section reaches the model and that the entity is still persisted through it: the section's prompt appears in the recorded calls, and the observation's provenance carries the model's id instead of `deterministic`. Each also opens by running the deterministic parse over the same section text and requiring it to read the filing outright, which is what makes the filing the case that demonstrates the rule — coverage is not the question, and a parse that handles the section perfectly still does not get to answer for it. The ownership resale test keeps every assertion and gains an accurate name: it no longer discriminates preemption from non-preemption, since no ownership section preempts now, and what it verifies is that the class, offered and after-offering figures the table states survive the model path — the three the walk would have written null. Verified on this branch: `bun run test` 428 files / 3885 tests passed, 3 files / 20 tests skipped, 0 failed; `bun run format-check` and `bun run build` clean. The two tests were confirmed to pass on `code-extractors` and fail at this branch's head before the change, and the replacements fail on `code-extractors` and pass here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ske1Jwk7fDFxHykfZGEzce --- .../Form_S_1.storage.ownership.test.ts | 89 +++++++++++++++++-- .../Form_S_1.storage.sponsors.test.ts | 41 +++++++-- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts index 4a400d69..97ac896c 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts @@ -9,7 +9,14 @@ import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; import { setupAllDatabases } from "../../../config/setupAllDatabases"; import { BeneficialOwnershipRepo } from "../../../storage/beneficial-ownership/BeneficialOwnershipRepo"; import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; +import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; +import { parseEdgarHtml } from "../../html/parseEdgarHtml"; import { processFormS1 } from "./Form_S_1.storage"; +import { S1_SECTIONS } from "./s1/DocumentSegmenter"; +import { DocumentTreeSegmenter } from "./s1/DocumentTreeSegmenter"; +import { parseBeneficialOwnership } from "./s1/parseBeneficialOwnership"; +import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; +import { resolveModelId } from "./s1/s1Model"; import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; const HTML_PARSEABLE = [ @@ -56,6 +63,44 @@ const MANAGEMENT_PAYLOAD = { ], }; +const SPAC_OWNERS_PAYLOAD = { + owners: [ + { + name: "Halyard Sponsor III LLC", + owner_kind: "company", + security_class: null, + shares_owned: 4312500, + percent_owned: 100, + shares_offered: null, + shares_after: null, + percent_after: null, + is_selling_stockholder: false, + footnote: null, + confidence: 0.9, + source_span: "Halyard Sponsor III LLC", + }, + { + name: "Eleanor Vasquez", + owner_kind: "person", + security_class: null, + shares_owned: 4312500, + percent_owned: 100, + shares_offered: null, + shares_after: null, + percent_after: null, + is_selling_stockholder: false, + footnote: null, + confidence: 0.9, + source_span: "Eleanor Vasquez", + }, + ], +}; + +function ownershipSectionText(html: string): string { + const segmented = new DocumentTreeSegmenter().segment(parseEdgarHtml(html, "s1.htm")); + return segmented.find((s) => s.name === S1_SECTIONS.BENEFICIAL_OWNERSHIP)?.text ?? ""; +} + let cleanup: (() => void) | undefined; describe("processFormS1 beneficial ownership", () => { @@ -69,8 +114,30 @@ describe("processFormS1 beneficial ownership", () => { resetDependencyInjectionsForTesting(); }); - it("persists a SPAC ownership table with no offered/after columns as deterministic", async () => { - const { unregister } = registerFakeStructuredProvider([MANAGEMENT_PAYLOAD]); + // The table walk covers every column this table prints — a SPAC's pre-IPO + // table states one class and no offered/after position, so the six columns + // the parse hardcodes null are the disclosure — and it still does not stand + // in for the model. `ownershipCoverage` answers the COLUMN question only; the + // walk drops a stub failing `looksLikeOwner` and truncates one carrying a + // street number, so it cannot report that these two are the whole roster, and + // `beneficial_ownership` is cleared before persist. This filing is the case + // that makes the rule visible: the parse reads the table outright and the + // model runs anyway. + it("sends a fully covered SPAC ownership table to the model, because the walk cannot claim it read every row", async () => { + expect( + parseBeneficialOwnership(ownershipSectionText(HTML_PARSEABLE)).map((r) => [ + r.owner_kind, + r.shares_owned, + ]) + ).toEqual([ + ["company", 4312500], + ["person", 4312500], + ]); + + const { calls, unregister } = registerFakeStructuredProvider([ + MANAGEMENT_PAYLOAD, + SPAC_OWNERS_PAYLOAD, + ]); cleanup = unregister; await processFormS1({ @@ -89,6 +156,8 @@ describe("processFormS1 beneficial ownership", () => { model: fakeS1Model(), }); + expect(calls.some((p) => /Extract every beneficial owner/.test(p))).toBe(true); + const rows = await new BeneficialOwnershipRepo().queryByAccession("acc-own-1"); expect(rows.map((r) => [r.owner_kind, r.shares_owned])).toEqual([ ["company", 4312500], @@ -96,16 +165,22 @@ describe("processFormS1 beneficial ownership", () => { ]); const companies = await new CompanyObservationRepo().listAll(); expect(companies.some((c) => /Halyard Sponsor/i.test(c.name ?? ""))).toBe(true); - // The table prints one class, no offered/after columns and no selling - // stockholders, so the six columns the parse hardcodes null are what this - // filing actually discloses — nothing is lost by writing them. expect(rows[0]!.security_class).toBeNull(); expect(rows[0]!.shares_after).toBeNull(); expect(rows[0]!.is_selling_stockholder).toBe(false); + // The rows are the model's, not the walk's, and provenance says so. + const provenance = await new ObservationProvenanceRepo().get( + "company", + rows[0]!.observation_id! + ); + expect(provenance?.model_id).not.toBe(DETERMINISTIC_MODEL_ID); + expect(provenance?.model_id).toBe(resolveModelId(fakeS1Model())); }); - it("does not preempt the ownership model on a resale table with Shares Offered / Shares After columns", async () => { - // The same parse, the same table shape, the opposite verdict: here the + it("persists the class / offered / after figures a resale table states, which the walk would have written null", async () => { + // The row half above already sends every ownership section to the model, so + // this filing gets there for a second, independent reason: `ownershipCoverage` + // declines the column half outright, and the walk never even runs. Here the // filing DOES state a class, an offered count and an after-offering // position, so the parse's hardcoded nulls would delete three disclosed // figures — and `is_selling_stockholder: false` would assert this holder diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts index 716f0724..d2d9cea6 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts @@ -9,14 +9,22 @@ import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; import { setupAllDatabases } from "../../../config/setupAllDatabases"; import { CompanyObservationRepo } from "../../../storage/observation/CompanyObservationRepo"; import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; +import { parseEdgarHtml } from "../../html/parseEdgarHtml"; import { processFormS1 } from "./Form_S_1.storage"; +import { S1_SECTIONS } from "./s1/DocumentSegmenter"; +import { DocumentTreeSegmenter } from "./s1/DocumentTreeSegmenter"; import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; +import { parseSpacSponsors } from "./s1/parseSpacSponsors"; +import { resolveModelId } from "./s1/s1Model"; import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; +const SPONSOR_SENTENCE = + "Our sponsor, Acme Sponsor LLC, is a Delaware limited liability company and was formed to invest in us."; + const HTML_PARSEABLE = [ "

MANAGEMENT

x

", "

THE SPONSOR

", - "

Our sponsor, Acme Sponsor LLC, is a Delaware limited liability company and was formed to invest in us.

", + `

${SPONSOR_SENTENCE}

`, "

LEGAL MATTERS

x

", ].join(""); @@ -28,6 +36,13 @@ const HEADER_6770 = { filingDate: null, }; +const SPONSOR_SPAN = "Our sponsor, Acme Sponsor LLC, is a Delaware limited liability company"; + +function sponsorSectionText(html: string): string { + const segmented = new DocumentTreeSegmenter().segment(parseEdgarHtml(html, "s1.htm")); + return segmented.find((s) => s.name === S1_SECTIONS.THE_SPONSOR)?.text ?? ""; +} + let cleanup: (() => void) | undefined; describe("processFormS1 spac-sponsors", () => { @@ -41,7 +56,18 @@ describe("processFormS1 spac-sponsors", () => { resetDependencyInjectionsForTesting(); }); - it("persists a parseable sponsor sentence as deterministic without calling the sponsor model", async () => { + // The sponsor pass is two prose patterns, both requiring `our|the` immediately + // before `sponsor`. Reading THIS sentence says nothing about whether the + // section introduced a second sponsor some other way ("our co-sponsor, Beta + // Holdings LLC, is …"), and `spac_sponsor_link` is cleared before persist — so + // the pass declares no completeness and never stands in for the model, however + // cleanly it reads the prose. This filing is the case that makes the rule + // visible: the parse handles the sentence outright and the model runs anyway. + it("sends a parseable sponsor sentence to the model, because the prose parse cannot claim the section named no one else", async () => { + expect(parseSpacSponsors(sponsorSectionText(HTML_PARSEABLE)).map((r) => r.legal_name)).toEqual([ + "Acme Sponsor LLC", + ]); + const { calls, unregister } = registerFakeStructuredProvider([ { focus: [], @@ -50,11 +76,14 @@ describe("processFormS1 spac-sponsors", () => { team: null, url_spac: null, confidence: 0.9, - source_span: "Our sponsor, Acme Sponsor LLC, is a Delaware limited liability company", + source_span: SPONSOR_SPAN, }, { people: [] }, { owners: [] }, { parties: [] }, + { + sponsors: [{ legal_name: "Acme Sponsor LLC", confidence: 0.9, source_span: SPONSOR_SPAN }], + }, ]); cleanup = unregister; @@ -74,13 +103,15 @@ describe("processFormS1 spac-sponsors", () => { model: fakeS1Model(), }); + expect(calls.some((p) => /Identify each sponsor entity/.test(p))).toBe(true); + const companies = (await new CompanyObservationRepo().listAll()).filter((c) => /s1:spac-sponsor/.test(c.source_context ?? "") ); expect(companies.some((c) => /Acme Sponsor/i.test(c.name ?? ""))).toBe(true); - expect(calls.some((p) => /Identify each sponsor entity/.test(p))).toBe(false); const party = companies.find((c) => /Acme Sponsor/i.test(c.name ?? "")); const provenance = await new ObservationProvenanceRepo().get("company", party!.observation_id); - expect(provenance?.model_id).toBe(DETERMINISTIC_MODEL_ID); + expect(provenance?.model_id).not.toBe(DETERMINISTIC_MODEL_ID); + expect(provenance?.model_id).toBe(resolveModelId(fakeS1Model())); }); }); From f5133c733df10383281caf8634edd6653228b7de Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 10:06:31 -0700 Subject: [PATCH 15/29] refactor(spac): enhance SPAC process handling with new sweeps and improved CIK management - Introduced `spacProcessSweeps` to manage SPAC filings more effectively, allowing for differentiated handling of known and unknown CIKs. - Updated `registerSecSyncLeaves` to utilize the new sweeps, improving the processing logic for SPAC filings. - Enhanced `runFormsSweep` to accept `eightKItems`, allowing for more granular control over which 8-K filings are processed based on item codes. - Added tests for the new functionality in `spacProcessSweeps` and updated existing tests to cover new CIK listing logic. - Refactored `listSpacProcessCiks` to leverage `listKnownSpacCiks`, ensuring accurate identification of known SPACs. --- src/cli/sync/registerSecSyncLeaves.ts | 21 +- src/cli/sync/runFormsSweep.ts | 5 +- src/cli/sync/spacProcessSweeps.test.ts | 52 +++++ src/cli/sync/spacProcessSweeps.ts | 71 +++++++ src/cli/sync/spacSyncCiks.test.ts | 13 +- src/cli/sync/spacSyncCiks.ts | 12 +- src/task/forms/ComputeFormsWorklistTask.ts | 210 +++++++++++++++---- src/task/forms/formsSweep.test.ts | 171 ++++++++++++++- src/task/forms/formsSweep.ts | 11 +- src/task/index/CatchUpDailyIndexTask.test.ts | 54 +++++ src/task/index/CatchUpDailyIndexTask.ts | 10 +- 11 files changed, 570 insertions(+), 60 deletions(-) create mode 100644 src/cli/sync/spacProcessSweeps.test.ts create mode 100644 src/cli/sync/spacProcessSweeps.ts diff --git a/src/cli/sync/registerSecSyncLeaves.ts b/src/cli/sync/registerSecSyncLeaves.ts index cbac9c29..03fe4688 100644 --- a/src/cli/sync/registerSecSyncLeaves.ts +++ b/src/cli/sync/registerSecSyncLeaves.ts @@ -10,7 +10,8 @@ import { IdentifySpacsTask } from "../../task/spac/IdentifySpacsTask"; import { UpdateAllSubmissionsTask } from "../../task/submissions/UpdateAllSubmissionsTask"; import { runWorkflowCli } from "../runWorkflow"; import { runFormsSweep } from "./runFormsSweep"; -import { listSpacProcessCiks } from "./spacSyncCiks"; +import { spacProcessSweeps } from "./spacProcessSweeps"; +import { listKnownSpacCiks, listSpacProcessCiks } from "./spacSyncCiks"; import { SYNC_FORM_DOMAINS, formsForExtractorIds } from "./syncFormDomains"; import { getSyncLeaf, registerSyncLeaf, type SyncRunContext } from "./syncLeaves"; @@ -159,16 +160,20 @@ export function registerSecSyncLeaves(): void { id: "process", title: "Process SPAC filings", run: async (ctx: SyncRunContext) => { - const ciks = await listSpacProcessCiks(); - if (ciks.length === 0) { + const processCiks = await listSpacProcessCiks(); + const knownCiks = await listKnownSpacCiks(); + if (processCiks.length === 0) { console.log("No known SPACs or high/medium candidates"); return; } - await runFormsSweep({ - formTypes: formsForExtractorIds([...SYNC_FORM_DOMAINS.spacs]), - shard: ctx.shard, - ciks, - }); + for (const sweep of spacProcessSweeps(processCiks, knownCiks)) { + await runFormsSweep({ + formTypes: sweep.formTypes, + shard: ctx.shard, + ciks: sweep.ciks, + eightKItems: sweep.eightKItems, + }); + } }, }, ], diff --git a/src/cli/sync/runFormsSweep.ts b/src/cli/sync/runFormsSweep.ts index 05980112..3b8a33b4 100644 --- a/src/cli/sync/runFormsSweep.ts +++ b/src/cli/sync/runFormsSweep.ts @@ -11,10 +11,13 @@ export async function runFormsSweep(options: { readonly formTypes: string[]; readonly shard?: FormsShard; readonly ciks?: number[]; + readonly eightKItems?: readonly string[]; }): Promise { await runWorkflowCli( [], undefined, - formsSweepLoop(newFormsWorklistTask(options.formTypes, options.shard, options.ciks)) + formsSweepLoop( + newFormsWorklistTask(options.formTypes, options.shard, options.ciks, options.eightKItems) + ) ); } diff --git a/src/cli/sync/spacProcessSweeps.test.ts b/src/cli/sync/spacProcessSweeps.test.ts new file mode 100644 index 00000000..96878395 --- /dev/null +++ b/src/cli/sync/spacProcessSweeps.test.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { MILESTONE_ITEM_CODES } from "../../sec/forms/miscellaneous-filings/Form_8_K.storage"; +import { LOI_TRIGGER_ITEMS } from "../../sec/forms/miscellaneous-filings/spac8kLoiTriggers"; +import { REDEMPTION_TRIGGER_ITEMS } from "../../sec/forms/miscellaneous-filings/spac8kRedemptionTriggers"; +import { formsForExtractorIds } from "../../storage/versioning/extractorIds"; +import { + SPAC_PROCESS_EIGHT_K_ITEMS, + SPAC_SHELF_424_FORMS, + spacProcessSweeps, +} from "./spacProcessSweeps"; + +describe("spacProcessSweeps", () => { + it("keeps eightKItems equal to the union of milestone, LOI, and redemption triggers", () => { + expect(new Set(SPAC_PROCESS_EIGHT_K_ITEMS)).toEqual( + new Set([...MILESTONE_ITEM_CODES, ...LOI_TRIGGER_ITEMS, ...REDEMPTION_TRIGGER_ITEMS]) + ); + }); + + it("runs registration for every process CIK and lifecycle only for known SPACs", () => { + const sweeps = spacProcessSweeps([1, 2], [1]); + expect(sweeps).toHaveLength(2); + + expect(sweeps[0]!.ciks).toEqual([1, 2]); + expect(sweeps[0]!.eightKItems).toBeUndefined(); + expect(sweeps[0]!.formTypes).toEqual(formsForExtractorIds(["S-1"])); + + expect(sweeps[1]!.ciks).toEqual([1]); + expect(sweeps[1]!.eightKItems).toEqual(SPAC_PROCESS_EIGHT_K_ITEMS); + expect(sweeps[1]!.formTypes).toContain("8-K"); + expect(sweeps[1]!.formTypes).toContain("424B4"); + expect(sweeps[1]!.formTypes).toContain("DEF 14A"); + expect(sweeps[1]!.formTypes).toContain("25-NSE"); + for (const form of SPAC_SHELF_424_FORMS) { + expect(sweeps[1]!.formTypes).not.toContain(form); + } + for (const form of formsForExtractorIds(["S-1"])) { + expect(sweeps[1]!.formTypes).not.toContain(form); + } + }); + + it("omits the lifecycle sweep when no spac row exists yet", () => { + const sweeps = spacProcessSweeps([2, 3], []); + expect(sweeps).toHaveLength(1); + expect(sweeps[0]!.formTypes).toEqual(formsForExtractorIds(["S-1"])); + }); +}); diff --git a/src/cli/sync/spacProcessSweeps.ts b/src/cli/sync/spacProcessSweeps.ts new file mode 100644 index 00000000..675e197c --- /dev/null +++ b/src/cli/sync/spacProcessSweeps.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { formsForExtractorIds } from "../../storage/versioning/extractorIds"; + +/** + * 424 variants that are shelf takedowns / supplements, not a SPAC IPO + * prospectus. `processForm424` returns after the deterministic XBRL pass for + * these; they do not mint `ipo` events. Including them in `sync spacs` queued + * every follow-on of a de-SPAC'd operating company. + */ +export const SPAC_SHELF_424_FORMS: ReadonlySet = new Set([ + "424A", + "424B2", + "424B5", + "424B7", +]); + +/** + * 8-K item codes that can carry SPAC lifecycle, LOI, or redemption content. + * Kept equal (by test) to the union of `MILESTONE_ITEM_CODES`, + * `LOI_TRIGGER_ITEMS`, and `REDEMPTION_TRIGGER_ITEMS`. + */ +export const SPAC_PROCESS_EIGHT_K_ITEMS: readonly string[] = [ + "1.01", + "1.02", + "2.01", + "5.03", + "5.07", + "7.01", + "8.01", +]; + +export interface SpacProcessSweep { + readonly formTypes: string[]; + readonly ciks: number[]; + readonly eightKItems: readonly string[] | undefined; +} + +/** + * Split the SPAC process worklist so candidates without a `spac` row only + * receive registration statements (which mint the row), and known SPACs skip + * shelf 424s plus 8-Ks whose item codes cannot carry a lifecycle event. + */ +export function spacProcessSweeps( + processCiks: readonly number[], + knownCiks: readonly number[] +): SpacProcessSweep[] { + const sweeps: SpacProcessSweep[] = []; + if (processCiks.length > 0) { + sweeps.push({ + formTypes: formsForExtractorIds(["S-1"]), + ciks: [...processCiks], + eightKItems: undefined, + }); + } + if (knownCiks.length > 0) { + sweeps.push({ + formTypes: [ + ...formsForExtractorIds(["424"]).filter((form) => !SPAC_SHELF_424_FORMS.has(form)), + ...formsForExtractorIds(["8-K", "merger-proxy", "25-15"]), + ], + ciks: [...knownCiks], + eightKItems: SPAC_PROCESS_EIGHT_K_ITEMS, + }); + } + return sweeps; +} diff --git a/src/cli/sync/spacSyncCiks.test.ts b/src/cli/sync/spacSyncCiks.test.ts index 0784ed38..25072e1c 100644 --- a/src/cli/sync/spacSyncCiks.test.ts +++ b/src/cli/sync/spacSyncCiks.test.ts @@ -13,7 +13,7 @@ import { type SpacCandidate, } from "../../storage/spac/SpacCandidateSchema"; import { SPAC_REPOSITORY_TOKEN, type Spac } from "../../storage/spac/SpacSchema"; -import { listSpacProcessCiks } from "./spacSyncCiks"; +import { listKnownSpacCiks, listSpacProcessCiks } from "./spacSyncCiks"; function minimalSpac(cik: number): Spac { return { @@ -111,4 +111,15 @@ describe("listSpacProcessCiks", () => { await expect(listSpacProcessCiks()).resolves.toEqual([2, 3]); }); + + it("listKnownSpacCiks is the spac table only", async () => { + const spacRepo = globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN); + const candidateRepo = globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN); + + await spacRepo.put(minimalSpac(1)); + await candidateRepo.putBulk([candidateRow(1, "high"), candidateRow(2, "high")]); + + await expect(listKnownSpacCiks()).resolves.toEqual([1]); + await expect(listSpacProcessCiks()).resolves.toEqual([1, 2]); + }); }); diff --git a/src/cli/sync/spacSyncCiks.ts b/src/cli/sync/spacSyncCiks.ts index d3a53994..81323e83 100644 --- a/src/cli/sync/spacSyncCiks.ts +++ b/src/cli/sync/spacSyncCiks.ts @@ -21,11 +21,10 @@ const PROCESS_CONFIDENCES: ReadonlySet = new Set { - const spacRepo = globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN); + const known = await listKnownSpacCiks(); + const ciks = new Set(known); const candidateRepo = globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN); - const ciks = new Set((await spacRepo.getAll())?.map((row) => row.cik) ?? []); - let candidates = await candidateRepo.query({ confidence: { value: ["high", "medium"], operator: "in" }, }); @@ -41,3 +40,10 @@ export async function listSpacProcessCiks(): Promise { return [...ciks].sort((a, b) => a - b); } + +/** CIKs that already have a `spac` row — the 8-K / proxy / 25-15 handlers' gate. */ +export async function listKnownSpacCiks(): Promise { + const spacRepo = globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN); + const ciks = (await spacRepo.getAll())?.map((row) => row.cik) ?? []; + return [...new Set(ciks)].sort((a, b) => a - b); +} diff --git a/src/task/forms/ComputeFormsWorklistTask.ts b/src/task/forms/ComputeFormsWorklistTask.ts index 11718cf7..ff2b0c4a 100644 --- a/src/task/forms/ComputeFormsWorklistTask.ts +++ b/src/task/forms/ComputeFormsWorklistTask.ts @@ -42,6 +42,14 @@ export type ComputeFormsWorklistTaskInput = { readonly shardCount?: number; /** When non-empty, only filings whose CIK is in this list are emitted. */ readonly ciks?: number[]; + /** + * When non-empty, `8-K` / `8-K/A` filings are emitted only if their + * submissions `items` string contains one of these codes. Other forms are + * unaffected. Used by the SPAC process sweep so earnings 2.02s of a + * de-SPAC'd operating company are not fetched as if they were lifecycle + * events. + */ + readonly eightKItems?: string[]; /** * Filings emitted per batch. Defaults to {@link WORKLIST_BATCH_SIZE}; exposed * mainly so tests can drive the batching/resume path with a handful of rows @@ -56,15 +64,25 @@ export type ComputeFormsWorklistTaskInput = { * The candidate set is far too large to materialize: form 4 alone is ~4.6M * filings and the full 55-form worklist ~6.4M, at a measured ~460 bytes per * 15-column row — ~3 GB per process, multiplied again by every `--shard` - * process, since each one scans the whole set. + * process, since each one scans the whole set. 10k holds ~5 MB in flight. * - * Must also stay above the largest single (form, cik) group, currently 4,628, - * so the last-key resume in {@link ComputeFormsWorklistTask.readPage} can - * always advance past a group. 10k gives ~2x headroom on that and holds ~5 MB - * in flight. + * Page size does not have to exceed a single (form, cik) group: + * {@link ComputeFormsWorklistTask.readPage} resumes with a keyset, so a CIK + * with tens of thousands of 424B2s (shelf takedowns) is several pages rather + * than a stall. When `ciks` is set, those pages are also narrowed to that + * allow-list (`cik IN (...)`), so a SPAC sweep never loads a non-SPAC + * issuer's forms. */ const FILING_PAGE_SIZE = 10_000; +/** + * CIKs per `in` list. SQLite binds one parameter per value and stays subject + * to `SQLITE_MAX_VARIABLE_NUMBER` (999 on older builds); Postgres binds the + * list as one array. 900 matches the other `in`-list callers (observation + * titles, SPAC download). The other bind in these queries is `form`. + */ +const WORKLIST_CIK_CHUNK = 900; + /** * Filings emitted per batch — the ceiling on what the producer holds and hands * to one fan-out iteration. @@ -96,6 +114,29 @@ function accessionShard(accession: string, shardCount: number): number { return (h >>> 0) % shardCount; } +/** True when a comma/semicolon-separated EDGAR `items` string names any code. */ +function filingHasAnyItem(items: string | null | undefined, codes: ReadonlySet): boolean { + if (!items) return false; + for (const raw of items.split(/[,;]/)) { + if (codes.has(raw.trim())) return true; + } + return false; +} + +/** + * When `eightKItems` is set, 8-Ks that do not carry one of those codes are + * consumed (resume advances past them) but not emitted. + */ +function skipEightKWithoutItems( + form: string | null | undefined, + items: string | null | undefined, + codes: ReadonlySet | undefined +): boolean { + if (codes === undefined) return false; + if (form !== "8-K" && form !== "8-K/A") return false; + return !filingHasAnyItem(items, codes); +} + export type ComputeFormsWorklistTaskOutput = { /** Parallel arrays, aligned by index — one entry per filing to process. */ accessionNumber: string[]; @@ -135,6 +176,7 @@ export class ComputeFormsWorklistTask extends Task< shardIndex: Type.Optional(Type.Integer({ minimum: 0 })), shardCount: Type.Optional(Type.Integer({ minimum: 1 })), ciks: Type.Optional(Type.Array(TypeSecCik())), + eightKItems: Type.Optional(Type.Array(Type.String())), batchSize: Type.Optional(Type.Integer({ minimum: 1 })), }); } @@ -201,6 +243,12 @@ export class ComputeFormsWorklistTask extends Task< const sharding = shardCount > 1; const cikAllowList = input.ciks !== undefined && input.ciks.length > 0 ? new Set(input.ciks) : undefined; + const allowCiks = + cikAllowList !== undefined ? [...cikAllowList].sort((a, b) => a - b) : undefined; + const eightKItemSet = + input.eightKItems !== undefined && input.eightKItems.length > 0 + ? new Set(input.eightKItems) + : undefined; const dryRun = isDryRun(); const batchSize = input.batchSize ?? WORKLIST_BATCH_SIZE; @@ -264,10 +312,11 @@ export class ComputeFormsWorklistTask extends Task< let from: number | undefined; let seen: string | undefined; for (;;) { - const { rows, full } = await this.readPage(filingRepo, form, from, seen); + const { rows, full } = await this.readPage(filingRepo, form, from, seen, allowCiks); for (const f of rows) { if (sharding && accessionShard(f.accession_number, shardCount) !== shardIndex) continue; if (cikAllowList !== undefined && !cikAllowList.has(f.cik)) continue; + if (skipEightKWithoutItems(f.form, f.items, eightKItemSet)) continue; if (keys.has(filingRunKey(f))) continue; total++; } @@ -319,7 +368,8 @@ export class ComputeFormsWorklistTask extends Task< filingRepo, form, this.lastCik, - this.lastAccession + this.lastAccession, + allowCiks ); if (rows.length === 0 && !full) { // Form drained — advance and reset its per-form resume state. @@ -346,6 +396,7 @@ export class ComputeFormsWorklistTask extends Task< // (shardCount-1)/shardCount of candidates before any other test. if (sharding && accessionShard(f.accession_number, shardCount) !== shardIndex) continue; if (cikAllowList !== undefined && !cikAllowList.has(f.cik)) continue; + if (skipEightKWithoutItems(f.form, f.items, eightKItemSet)) continue; if (this.successfulKeys.has(filingRunKey(f))) continue; accessionNumber.push(f.accession_number); cik.push(f.cik); @@ -389,47 +440,122 @@ export class ComputeFormsWorklistTask extends Task< * * `SearchCriteria` allows one condition per column and has no OR, so the * exact keyset predicate `(cik, accession) > (lastCik, lastAccession)` is - * not expressible. Resuming at `cik >= lastCik` and dropping the already- - * emitted head of that cik in memory is equivalent, and the re-read is - * bounded by the largest single (form, cik) group — 4,628 rows at the - * extreme, ~19 typical. + * two queries: remaining filings of this CIK after `afterAccession`, then + * later CIKs, concatenated up to {@link FILING_PAGE_SIZE}. That is what + * lets a single CIK hold more filings of one form than the page size + * (424B2 shelf takedowns) without stalling the scan. + * + * When `allowCiks` is set, later CIKs are `cik IN (remaining allow-list)` + * rather than `cik > lastCik`, so a SPAC process sweep never reads a + * non-SPAC issuer. The JS allow-list check on the caller is then only a + * belt; the database already scoped the page. */ private async readPage( filingRepo: FilingRepositoryStorage, form: string, fromCik: number | undefined, - afterAccession: string | undefined + afterAccession: string | undefined, + allowCiks: readonly number[] | undefined ): Promise<{ rows: Filing[]; full: boolean }> { - const criteria = - fromCik === undefined ? { form } : { form, cik: { value: fromCik, operator: ">=" as const } }; - const page = ((await filingRepo.query(criteria as never, { - orderBy: [ - { column: "cik", direction: "ASC" }, - { column: "accession_number", direction: "ASC" }, - ], - limit: FILING_PAGE_SIZE, - })) ?? []) as Filing[]; - - // `full` reports whether the DATABASE returned a full page, which is what - // says more rows may exist. It must not be derived from the returned row - // count: the resume head is trimmed below, so a full page routinely yields - // fewer rows and would otherwise read as "form drained" — silently ending - // the scan after a couple of pages. - const full = page.length === FILING_PAGE_SIZE; - - if (fromCik === undefined || afterAccession === undefined) return { rows: page, full }; - - const fresh = page.filter((f) => f.cik !== fromCik || f.accession_number > afterAccession); - // A full page consumed entirely by the resume head would leave the scan - // unable to advance. FILING_PAGE_SIZE is chosen to exceed the largest - // (form, cik) group precisely so this cannot happen; fail loudly rather - // than spin if that assumption ever stops holding. - if (fresh.length === 0 && full) { - throw new Error( - `Forms worklist cannot advance: form '${form}' has more than ${FILING_PAGE_SIZE} filings ` + - `for cik ${fromCik}. Raise FILING_PAGE_SIZE above that group's size.` - ); + const orderBy = [ + { column: "cik" as const, direction: "ASC" as const }, + { column: "accession_number" as const, direction: "ASC" as const }, + ]; + + if (allowCiks !== undefined) { + return this.readAllowlistedPage(filingRepo, form, fromCik, afterAccession, allowCiks, orderBy); } - return { rows: fresh, full }; + + if (fromCik === undefined || afterAccession === undefined) { + const page = ((await filingRepo.query({ form } as never, { + orderBy, + limit: FILING_PAGE_SIZE, + })) ?? []) as Filing[]; + return { rows: page, full: page.length === FILING_PAGE_SIZE }; + } + + const restOfCik = ((await filingRepo.query( + { + form, + cik: fromCik, + accession_number: { value: afterAccession, operator: ">" as const }, + } as never, + { + orderBy: [{ column: "accession_number", direction: "ASC" }], + limit: FILING_PAGE_SIZE, + } + )) ?? []) as Filing[]; + + if (restOfCik.length === FILING_PAGE_SIZE) { + return { rows: restOfCik, full: true }; + } + + const laterLimit = FILING_PAGE_SIZE - restOfCik.length; + const laterCiks = ((await filingRepo.query( + { + form, + cik: { value: fromCik, operator: ">" as const }, + } as never, + { + orderBy, + limit: laterLimit, + } + )) ?? []) as Filing[]; + + return { + rows: restOfCik.length === 0 ? laterCiks : [...restOfCik, ...laterCiks], + full: laterCiks.length === laterLimit, + }; + } + + /** + * Allow-listed variant of {@link readPage}: every query names the CIK set, + * chunked so an `in` list stays under SQLite's bind cap. + */ + private async readAllowlistedPage( + filingRepo: FilingRepositoryStorage, + form: string, + fromCik: number | undefined, + afterAccession: string | undefined, + allowCiks: readonly number[], + orderBy: ReadonlyArray<{ column: "cik" | "accession_number"; direction: "ASC" }> + ): Promise<{ rows: Filing[]; full: boolean }> { + const rows: Filing[] = []; + + if (fromCik !== undefined && afterAccession !== undefined && allowCiks.includes(fromCik)) { + const restOfCik = ((await filingRepo.query( + { + form, + cik: fromCik, + accession_number: { value: afterAccession, operator: ">" as const }, + } as never, + { + orderBy: [{ column: "accession_number", direction: "ASC" }], + limit: FILING_PAGE_SIZE, + } + )) ?? []) as Filing[]; + rows.push(...restOfCik); + if (rows.length === FILING_PAGE_SIZE) return { rows, full: true }; + } + + const remaining = + fromCik === undefined ? allowCiks : allowCiks.filter((cik) => cik > fromCik); + + for (let i = 0; i < remaining.length; ) { + const need = FILING_PAGE_SIZE - rows.length; + const chunk = remaining.slice(i, i + WORKLIST_CIK_CHUNK); + const part = ((await filingRepo.query( + { + form, + cik: { value: chunk, operator: "in" as const }, + } as never, + { orderBy, limit: need } + )) ?? []) as Filing[]; + rows.push(...part); + if (part.length === need) return { rows, full: true }; + i += chunk.length; + } + + return { rows, full: false }; } } diff --git a/src/task/forms/formsSweep.test.ts b/src/task/forms/formsSweep.test.ts index 1a406b26..d0ba9ed5 100644 --- a/src/task/forms/formsSweep.test.ts +++ b/src/task/forms/formsSweep.test.ts @@ -17,6 +17,7 @@ import { } from "workglow"; import { resetDependencyInjectionsForTesting } from "../../config/TestingDI"; import { setupAllDatabases } from "../../config/setupAllDatabases"; +import { SEC_DRY_RUN } from "../../config/tokens"; import { ExtractionDeadLetterRepo } from "../../storage/dead-letter/ExtractionDeadLetterRepo"; import { FILING_REPOSITORY_TOKEN } from "../../storage/filing/FilingSchema"; import { ExtractorRunRepo } from "../../storage/versioning/ExtractorRunRepo"; @@ -33,6 +34,7 @@ interface SeedFiling { accession_number: string; form: string; primary_doc: string; + items?: string | null; } async function seed(f: SeedFiling): Promise { @@ -51,7 +53,7 @@ async function seed(f: SeedFiling): Promise { size: null, is_xbrl: null, is_inline_xbrl: null, - items: null, + items: f.items ?? null, act: null, } as never); } @@ -162,6 +164,108 @@ describe("forms sweep wiring", () => { expect(emittedCiks).toEqual([1]); }); + it("does not read filings for CIKs outside the allow-list", async () => { + // `sync spacs` passes the known-SPAC CIK set, but the worklist used to + // page every filing of each SPAC form (every 424B2 in EDGAR) and only + // then drop other CIKs. Bank of America's 10k+ 424B2s are not SPACs and + // must never be loaded. + await seed({ + cik: 1, + accession_number: "0000000001-26-000001", + form: "D", + primary_doc: "a.xml", + }); + await seed({ + cik: 9631, + accession_number: "0000009631-26-000001", + form: "D", + primary_doc: "b.xml", + }); + + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + const criteria: unknown[] = []; + const realQuery = repo.query.bind(repo); + repo.query = ((c: unknown, options: unknown) => { + criteria.push(c); + return realQuery(c as never, options as never); + }) as typeof repo.query; + + try { + const producer = new ComputeFormsWorklistTask({ + defaults: { form: ["D"], ciks: [1], batchSize: 10 }, + }); + const emittedCiks: number[] = []; + while (!producer.exhausted) { + const out = await producer.run({}); + emittedCiks.push(...out.cik); + } + expect(emittedCiks).toEqual([1]); + } finally { + repo.query = realQuery; + } + + expect(criteria.length).toBeGreaterThan(0); + for (const c of criteria) { + expect(c).toMatchObject({ form: "D" }); + const cik = (c as { cik?: unknown }).cik; + expect(cik).toBeDefined(); + if (typeof cik === "number") { + expect(cik).toBe(1); + continue; + } + const cond = cik as { value?: unknown; operator?: string }; + if (cond.operator === "in") { + const values = Array.isArray(cond.value) ? cond.value : [cond.value]; + expect(values).toEqual([1]); + continue; + } + if (cond.operator === "=") { + expect(cond.value).toBe(1); + continue; + } + throw new Error(`unbounded cik constraint: ${JSON.stringify(cik)}`); + } + }); + + it("when eightKItems is set, emits only 8-Ks carrying one of those item codes", async () => { + await seed({ + cik: 1, + accession_number: "0000000001-26-000001", + form: "8-K", + primary_doc: "a.htm", + items: "2.02,9.01", + }); + await seed({ + cik: 1, + accession_number: "0000000001-26-000002", + form: "8-K", + primary_doc: "b.htm", + items: "5.07,9.01", + }); + await seed({ + cik: 1, + accession_number: "0000000001-26-000003", + form: "S-1", + primary_doc: "c.htm", + }); + + const producer = new ComputeFormsWorklistTask({ + defaults: { form: ["8-K", "S-1"], eightKItems: ["5.07", "2.01"], batchSize: 10 }, + }); + const emitted: Array<{ form: string; accession: string }> = []; + while (!producer.exhausted) { + const out = await producer.run({}); + for (let i = 0; i < out.count; i++) { + emitted.push({ form: out.form[i]!, accession: out.accessionNumber[i]! }); + } + } + + expect(emitted).toEqual([ + { form: "S-1", accession: "0000000001-26-000003" }, + { form: "8-K", accession: "0000000001-26-000002" }, + ]); + }); + it("includes all CIKs when ciks is omitted", async () => { await seed({ cik: 1, @@ -263,6 +367,71 @@ describe("forms sweep wiring", () => { } }); + it("advances past a (form, cik) group larger than the filing page", async () => { + // Resume used to re-read `cik >= lastCik` and drop the already-emitted head + // in memory, which requires the page to be larger than every (form, cik) + // group. CIK 9631 has >10k 424B2s, so a full page is consumed by that head + // and the scan throws rather than walking the rest of the form. + // `--dry-run` examines every row of each page, so the second page is the + // one that used to stall. + globalServiceRegistry.registerInstance(SEC_DRY_RUN, true); + const repo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); + const groupSize = 10_001; + const rows = []; + for (let i = 1; i <= groupSize; i++) { + rows.push({ + cik: 111, + accession_number: `0000000111-26-${String(i).padStart(6, "0")}`, + form: "3", + primary_doc: "a.xml", + file_number: "333-1", + filing_date: "2026-01-02", + acceptance_date: "2026-01-02T00:00:00.000Z", + report_date: null, + film_number: null, + primary_doc_description: null, + size: null, + is_xbrl: null, + is_inline_xbrl: null, + items: null, + act: null, + }); + } + rows.push({ + cik: 222, + accession_number: "0000000222-26-000001", + form: "3", + primary_doc: "b.xml", + file_number: "333-1", + filing_date: "2026-01-02", + acceptance_date: "2026-01-02T00:00:00.000Z", + report_date: null, + film_number: null, + primary_doc_description: null, + size: null, + is_xbrl: null, + is_inline_xbrl: null, + items: null, + act: null, + }); + await repo.putBulk(rows as never); + + const logs: string[] = []; + const originalLog = console.log; + console.log = (message?: unknown) => { + logs.push(String(message ?? "")); + }; + try { + await expect( + new ComputeFormsWorklistTask({ defaults: { form: ["3"] } }).run({}) + ).resolves.toMatchObject({ count: 0 }); + } finally { + console.log = originalLog; + } + + expect(logs.some((line) => line.includes(`Would process ${groupSize + 1}`))).toBe(true); + }); + it("emits bounded batches and resumes across them, covering every filing once", async () => { // The batch ceiling is what keeps the producer's memory independent of the // corpus: it must hand out at most `batchSize` per call and pick up exactly diff --git a/src/task/forms/formsSweep.ts b/src/task/forms/formsSweep.ts index 17cee7ee..e653fdaa 100644 --- a/src/task/forms/formsSweep.ts +++ b/src/task/forms/formsSweep.ts @@ -36,10 +36,17 @@ export interface FormsShard { export function newFormsWorklistTask( form?: string[], shard?: FormsShard, - ciks?: number[] + ciks?: number[], + eightKItems?: readonly string[] ): ComputeFormsWorklistTask { return new ComputeFormsWorklistTask({ - defaults: { form, shardIndex: shard?.index, shardCount: shard?.count, ciks }, + defaults: { + form, + shardIndex: shard?.index, + shardCount: shard?.count, + ciks, + eightKItems: eightKItems !== undefined ? [...eightKItems] : undefined, + }, }); } diff --git a/src/task/index/CatchUpDailyIndexTask.test.ts b/src/task/index/CatchUpDailyIndexTask.test.ts index 749e4cff..22340189 100644 --- a/src/task/index/CatchUpDailyIndexTask.test.ts +++ b/src/task/index/CatchUpDailyIndexTask.test.ts @@ -60,6 +60,37 @@ describe("CatchUpDailyIndexTask", () => { resetDependencyInjectionsForTesting(); }); + it("advances cursor through 403 on a completed Saturday (EDGAR's missing-day status) and finishes on 2xx days", async () => { + const runSpy = vi + .spyOn(FetchDailyIndexTask.prototype, "run") + .mockImplementation(async (input) => { + const date = input?.date; + if (date === "2026-08-16" || date === TODAY) { + throw httpError(403); + } + return { updateList: [[1018724, date!] as [number, string]] }; + }); + + const result = await new CatchUpDailyIndexTask().execute({}, ctx()); + + expect(runSpy).toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + skipped404: 1, + todayFetched: false, + lastSuccess: "2026-08-17", + }); + expect(result.fetched).toBe(2); + + const cursor = await globalServiceRegistry + .get(DAILY_INDEX_CURSOR_REPOSITORY_TOKEN) + .get({ id: DAILY_INDEX_CURSOR_ID }); + expect(cursor?.last_success).toBe("2026-08-17"); + + const cikRepo = globalServiceRegistry.get(CIK_LAST_UPDATE_REPOSITORY_TOKEN); + expect((await cikRepo.get({ cik: 1018724 }))?.last_update).toBe("2026-08-17"); + }); + it("advances cursor through 404 on a completed Saturday and finishes on 2xx days", async () => { const runSpy = vi .spyOn(FetchDailyIndexTask.prototype, "run") @@ -133,6 +164,29 @@ describe("CatchUpDailyIndexTask", () => { expect((await cikRepo.get({ cik: 320193 }))?.last_update).toBe(TODAY); }); + it("treats 403 today as success without changing the cursor", async () => { + vi.spyOn(FetchDailyIndexTask.prototype, "run").mockImplementation(async (input) => { + const date = input?.date; + if (date === TODAY) { + throw httpError(403); + } + return { updateList: [] }; + }); + + const result = await new CatchUpDailyIndexTask().execute({}, ctx()); + + expect(result).toMatchObject({ + success: true, + todayFetched: false, + lastSuccess: "2026-08-17", + }); + + const cursor = await globalServiceRegistry + .get(DAILY_INDEX_CURSOR_REPOSITORY_TOKEN) + .get({ id: DAILY_INDEX_CURSOR_ID }); + expect(cursor?.last_success).toBe("2026-08-17"); + }); + it("treats 404 today as success without changing the cursor", async () => { vi.spyOn(FetchDailyIndexTask.prototype, "run").mockImplementation(async (input) => { const date = input?.date; diff --git a/src/task/index/CatchUpDailyIndexTask.ts b/src/task/index/CatchUpDailyIndexTask.ts index b11c9b3a..33baf2e3 100644 --- a/src/task/index/CatchUpDailyIndexTask.ts +++ b/src/task/index/CatchUpDailyIndexTask.ts @@ -18,6 +18,12 @@ import { import { CIK_LAST_UPDATE_REPOSITORY_TOKEN } from "../../storage/processing/CikLastUpdateSchema"; import { TypeSecDate } from "../../util/parseDate"; import { getHttpErrorStatus } from "../fetch/SecFetchJob"; + +/** EDGAR's daily-index bucket 403s unpublished days (weekends/holidays); some paths still 404. */ +function isUnpublishedDailyIndex(err: unknown): boolean { + const status = getHttpErrorStatus(err); + return status === 404 || status === 403; +} import { dailyIndexCacheRelPath, DEFAULT_DAILY_INDEX_LOOKBACK, @@ -149,7 +155,7 @@ export class CatchUpDailyIndexTask extends Task< await cursorRepo.put({ id: DAILY_INDEX_CURSOR_ID, last_success: date }); fetched++; } catch (err) { - if (getHttpErrorStatus(err) === 404) { + if (isUnpublishedDailyIndex(err)) { skipped404++; lastSuccess = date; await cursorRepo.put({ id: DAILY_INDEX_CURSOR_ID, last_success: date }); @@ -167,7 +173,7 @@ export class CatchUpDailyIndexTask extends Task< todayFetched = true; fetched++; } catch (err) { - if (getHttpErrorStatus(err) !== 404) { + if (!isUnpublishedDailyIndex(err)) { throw err; } } From 901de6b632352ed12fccbed85f6d947dd480a6bd Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 10:50:42 -0700 Subject: [PATCH 16/29] feat(spac): enhance SPAC processing with new command options and improved handling - Added `--only` option to filter CIKs based on processing history (never-processed or updates). - Introduced `--step` option to specify which step to run for multi-step SPAC commands. - Enhanced `runSpacTimelineIssuers` to support concurrency and filing date filtering. - Updated `ComputeFormsWorklistTask` to handle filings based on the `filedOnOrAfter` date. - Added tests to validate new functionality and ensure correct behavior of filtering and command options. --- src/cli/groups/sync.test.ts | 34 +++- src/cli/groups/sync.ts | 20 ++- src/cli/sync/registerSecSyncLeaves.ts | 43 +++-- src/cli/sync/runFormsSweep.ts | 9 +- src/cli/sync/runSpacTimelineIssuers.ts | 108 ++++++++++++ src/cli/sync/spacSyncCiks.test.ts | 104 +++++++++++- src/cli/sync/spacSyncCiks.ts | 105 ++++++++++++ src/cli/sync/syncLeaves.ts | 8 + src/commands/spac.test.ts | 32 ++-- src/commands/spac.ts | 156 +++++------------- src/task/forms/ComputeFormsWorklistTask.ts | 39 ++++- src/task/forms/formsSweep.test.ts | 33 +++- src/task/forms/formsSweep.ts | 4 +- src/task/spac/ProcessSpacTimelineTask.test.ts | 56 +++++++ src/task/spac/ProcessSpacTimelineTask.ts | 73 +++++--- 15 files changed, 643 insertions(+), 181 deletions(-) create mode 100644 src/cli/sync/runSpacTimelineIssuers.ts diff --git a/src/cli/groups/sync.test.ts b/src/cli/groups/sync.test.ts index 80cad6d0..4f87ea21 100644 --- a/src/cli/groups/sync.test.ts +++ b/src/cli/groups/sync.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from "vitest"; -import { validateLookback } from "./sync"; +import { Command } from "commander"; +import { afterEach, describe, expect, it } from "vitest"; +import { addSyncCommand, validateLookback } from "./sync"; +import { clearSyncLeavesForTesting, listSyncLeaves } from "../sync/syncLeaves"; describe("validateLookback", () => { it("rejects lookback below 1", () => { @@ -16,3 +18,31 @@ describe("validateLookback", () => { expect(validateLookback(3)).toBe(3); }); }); + +describe("sync --step help", () => { + afterEach(() => { + clearSyncLeavesForTesting(); + }); + + it("lists each multi-step leaf's step ids on the --step option", () => { + const program = new Command(); + addSyncCommand(program); + const sync = program.commands.find((cmd) => cmd.name() === "sync"); + expect(sync).toBeDefined(); + + for (const leaf of listSyncLeaves()) { + const cmd = sync!.commands.find((c) => c.name() === leaf.id); + expect(cmd, `sync ${leaf.id} command`).toBeDefined(); + const help = cmd!.helpInformation(); + const stepLine = help.split("\n").find((line) => line.includes("--step ")); + + if (leaf.steps.length <= 1) { + expect(stepLine, `sync ${leaf.id} is single-step`).toBeUndefined(); + continue; + } + + expect(stepLine, `sync ${leaf.id} --step`).toBeDefined(); + expect(stepLine).toContain(leaf.steps.map((step) => step.id).join(" | ")); + } + }); +}); diff --git a/src/cli/groups/sync.ts b/src/cli/groups/sync.ts index 121bf08e..1266c15d 100644 --- a/src/cli/groups/sync.ts +++ b/src/cli/groups/sync.ts @@ -3,6 +3,8 @@ import { parseShardOption } from "../../task/forms/formsSweep"; import { parseIntOption } from "../GlobalOptions"; import { runCommand } from "../runCommand"; import { registerSecSyncLeaves } from "../sync/registerSecSyncLeaves"; +import { DEFAULT_SPAC_ISSUER_CONCURRENCY } from "../sync/runSpacTimelineIssuers"; +import { parseSpacProcessOnly } from "../sync/spacSyncCiks"; import { EMPTY_SYNC_CONTEXT, listSyncLeaves, @@ -57,7 +59,8 @@ function addOneLeafCommand(sync: Command, leaf: SyncLeaf): void { const cmd = sync.command(leaf.id).description(leaf.description); if (leaf.steps.length > 1) { - cmd.option("--step ", "Run only this step"); + const names = leaf.steps.map((step) => step.id).join(" | "); + cmd.option("--step ", `Run only this step (${names})`); } if (leaf.id === "submissions") { @@ -86,6 +89,17 @@ function addOneLeafCommand(sync: Command, leaf: SyncLeaf): void { .option( "--shard ", "Process only shard i of N (1-based) — run N processes with distinct shards to fan out across cores" + ) + .option( + "--only ", + "never-processed = SPACs with no successful run yet; updates = already-processed SPACs, filings since the last SPAC process run (default: both, including historical leftover)", + parseSpacProcessOnly + ) + .option( + "-c, --concurrency ", + "How many ISSUERS to process at once (default 3). Filings within an issuer are always serial.", + parseIntOption, + DEFAULT_SPAC_ISSUER_CONCURRENCY ); } @@ -105,6 +119,8 @@ function addOneLeafCommand(sync: Command, leaf: SyncLeaf): void { lookback?: number; full?: boolean; shard?: string; + only?: ReturnType; + concurrency?: number; }) => { await runCommand( async () => { @@ -132,6 +148,8 @@ function addOneLeafCommand(sync: Command, leaf: SyncLeaf): void { ctx = { ...ctx, full: opts.full ?? false, + only: opts.only, + concurrency: Math.max(1, opts.concurrency ?? DEFAULT_SPAC_ISSUER_CONCURRENCY), }; } diff --git a/src/cli/sync/registerSecSyncLeaves.ts b/src/cli/sync/registerSecSyncLeaves.ts index 03fe4688..84adea38 100644 --- a/src/cli/sync/registerSecSyncLeaves.ts +++ b/src/cli/sync/registerSecSyncLeaves.ts @@ -4,14 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { reportSpacProcessRows, spacProcessFailureCount } from "../../commands/spac"; import { UpdateAllCompanyFactsTask } from "../../task/facts/UpdateAllCompanyFactsTask"; import { CatchUpDailyIndexTask } from "../../task/index/CatchUpDailyIndexTask"; import { IdentifySpacsTask } from "../../task/spac/IdentifySpacsTask"; import { UpdateAllSubmissionsTask } from "../../task/submissions/UpdateAllSubmissionsTask"; +import { isDryRun } from "../isDryRun"; import { runWorkflowCli } from "../runWorkflow"; import { runFormsSweep } from "./runFormsSweep"; -import { spacProcessSweeps } from "./spacProcessSweeps"; -import { listKnownSpacCiks, listSpacProcessCiks } from "./spacSyncCiks"; +import { runSpacTimelineIssuers } from "./runSpacTimelineIssuers"; +import { + filterSpacCiksByHistory, + listSpacProcessCiks, + shardCiks, + spacUpdatesFiledOnOrAfter, +} from "./spacSyncCiks"; import { SYNC_FORM_DOMAINS, formsForExtractorIds } from "./syncFormDomains"; import { getSyncLeaf, registerSyncLeaf, type SyncRunContext } from "./syncLeaves"; @@ -160,19 +167,31 @@ export function registerSecSyncLeaves(): void { id: "process", title: "Process SPAC filings", run: async (ctx: SyncRunContext) => { - const processCiks = await listSpacProcessCiks(); - const knownCiks = await listKnownSpacCiks(); + const processCiks = shardCiks( + await filterSpacCiksByHistory(await listSpacProcessCiks(), ctx.only), + ctx.shard + ); if (processCiks.length === 0) { - console.log("No known SPACs or high/medium candidates"); + if (ctx.only === "never-processed") { + console.log("No never-processed SPACs"); + } else if (ctx.only === "updates") { + console.log("No previously processed SPACs"); + } else { + console.log("No known SPACs or high/medium candidates"); + } return; } - for (const sweep of spacProcessSweeps(processCiks, knownCiks)) { - await runFormsSweep({ - formTypes: sweep.formTypes, - shard: ctx.shard, - ciks: sweep.ciks, - eightKItems: sweep.eightKItems, - }); + const filedOnOrAfter = + ctx.only === "updates" ? await spacUpdatesFiledOnOrAfter() : undefined; + const rows = await runSpacTimelineIssuers({ + ciks: processCiks, + concurrency: ctx.concurrency, + filedOnOrAfter, + }); + reportSpacProcessRows(rows, { dryRun: isDryRun() }); + const failed = spacProcessFailureCount(rows); + if (failed > 0) { + throw new Error(`${failed} of ${processCiks.length} issuer(s) had failed filings`); } }, }, diff --git a/src/cli/sync/runFormsSweep.ts b/src/cli/sync/runFormsSweep.ts index 3b8a33b4..eb579f04 100644 --- a/src/cli/sync/runFormsSweep.ts +++ b/src/cli/sync/runFormsSweep.ts @@ -12,12 +12,19 @@ export async function runFormsSweep(options: { readonly shard?: FormsShard; readonly ciks?: number[]; readonly eightKItems?: readonly string[]; + readonly filedOnOrAfter?: string; }): Promise { await runWorkflowCli( [], undefined, formsSweepLoop( - newFormsWorklistTask(options.formTypes, options.shard, options.ciks, options.eightKItems) + newFormsWorklistTask( + options.formTypes, + options.shard, + options.ciks, + options.eightKItems, + options.filedOnOrAfter + ) ) ); } diff --git a/src/cli/sync/runSpacTimelineIssuers.ts b/src/cli/sync/runSpacTimelineIssuers.ts new file mode 100644 index 00000000..5a935412 --- /dev/null +++ b/src/cli/sync/runSpacTimelineIssuers.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type DataPorts, type ITask } from "workglow"; +import { + ProcessSpacTimelineTask, + type ProcessSpacTimelineTaskOutput, +} from "../../task/spac/ProcessSpacTimelineTask"; +import { runWorkflowCli } from "../runWorkflow"; + +/** + * How many ISSUERS one `spac process` / `sync spacs --step process` process + * replays at once. Filings within an issuer stay serial — that ordering is + * what makes the timeline correct. + */ +export const DEFAULT_SPAC_ISSUER_CONCURRENCY = 3; + +/** + * The issuer fan-out's merged output: one column per + * {@link ProcessSpacTimelineTask} output port, index-aligned across columns. + */ +export type SpacProcessColumns = { + readonly [K in keyof ProcessSpacTimelineTaskOutput]?: ReadonlyArray< + ProcessSpacTimelineTaskOutput[K] + >; +}; + +/** + * Transposes the fan-out's column arrays back into one row per issuer. + * + * The map merges each output port into an array across iterations — always an + * array, including for the one-issuer run that is the commonest invocation, so + * there is no scalar shape to unwrap. `cik` is echoed by the task rather than + * zipped from the input list, so a row can never be reported under the wrong + * issuer. + */ +export function spacProcessRows( + columns: SpacProcessColumns +): readonly ProcessSpacTimelineTaskOutput[] { + const column = ( + key: K + ): ReadonlyArray => columns[key] ?? []; + const ciks = column("cik"); + const matched = column("matched"); + const processed = column("processed"); + const partial = column("partial"); + const failed = column("failed"); + const nonfatal = column("nonfatal"); + const triage = column("triage"); + const skipped = column("skipped"); + const triageExtractors = column("triageExtractors"); + const firstDate = column("firstDate"); + const lastDate = column("lastDate"); + const error = column("error"); + const rows: ProcessSpacTimelineTaskOutput[] = []; + for (let i = 0; i < ciks.length; i++) { + const cik = ciks[i]; + if (cik === undefined) continue; + rows.push({ + cik, + matched: matched[i] ?? 0, + processed: processed[i] ?? 0, + partial: partial[i] ?? 0, + failed: failed[i] ?? 0, + nonfatal: nonfatal[i] ?? 0, + triage: triage[i] ?? 0, + skipped: skipped[i] ?? 0, + triageExtractors: triageExtractors[i] ?? "", + firstDate: firstDate[i] ?? "", + lastDate: lastDate[i] ?? "", + error: error[i] ?? "", + }); + } + return rows; +} + +/** + * Replay each issuer's filings in filing-date order. Issuers run in parallel + * up to `concurrency`; one issuer's filings always run serially. + */ +export async function runSpacTimelineIssuers(args: { + readonly ciks: readonly number[]; + readonly concurrency: number; + readonly filedOnOrAfter?: string | undefined; + readonly force?: string | undefined; +}): Promise { + if (args.ciks.length === 0) return []; + const results = await runWorkflowCli([], { cik: [...args.ciks] }, (wf) => { + const loop = wf.map({ + concurrencyLimit: Math.min(Math.max(1, args.concurrency), args.ciks.length), + maxIterations: args.ciks.length, + preserveOrder: true, + }); + loop.pipe( + new ProcessSpacTimelineTask({ + defaults: { + ...(args.force !== undefined ? { force: args.force } : {}), + ...(args.filedOnOrAfter !== undefined ? { filedOnOrAfter: args.filedOnOrAfter } : {}), + }, + }) as ITask + ); + loop.endMap(); + }); + return spacProcessRows(results); +} diff --git a/src/cli/sync/spacSyncCiks.test.ts b/src/cli/sync/spacSyncCiks.test.ts index 25072e1c..72155161 100644 --- a/src/cli/sync/spacSyncCiks.test.ts +++ b/src/cli/sync/spacSyncCiks.test.ts @@ -13,7 +13,17 @@ import { type SpacCandidate, } from "../../storage/spac/SpacCandidateSchema"; import { SPAC_REPOSITORY_TOKEN, type Spac } from "../../storage/spac/SpacSchema"; -import { listKnownSpacCiks, listSpacProcessCiks } from "./spacSyncCiks"; +import { ExtractorRunRepo } from "../../storage/versioning/ExtractorRunRepo"; +import { EXTRACTOR_RUN_REPOSITORY_TOKEN } from "../../storage/versioning/ExtractorRunSchema"; +import { + dayBeforeUtc, + filterSpacCiksByHistory, + listKnownSpacCiks, + listSpacProcessCiks, + parseSpacProcessOnly, + shardCiks, + spacUpdatesFiledOnOrAfter, +} from "./spacSyncCiks"; function minimalSpac(cik: number): Spac { return { @@ -123,3 +133,95 @@ describe("listSpacProcessCiks", () => { await expect(listSpacProcessCiks()).resolves.toEqual([1, 2]); }); }); + +async function recordRun(args: { + cik: number; + extractor_id: string; + success?: boolean; + outcome?: "success" | "partial" | "failure"; + extractor_version?: string; +}): Promise { + const repo = new ExtractorRunRepo(globalServiceRegistry.get(EXTRACTOR_RUN_REPOSITORY_TOKEN)); + await repo.recordRun({ + cik: args.cik, + accession_number: `${String(args.cik).padStart(10, "0")}-26-000001`, + form: args.extractor_id, + extractor_id: args.extractor_id, + extractor_version: args.extractor_version ?? "1.0.0", + slot_at_run: "current", + success: args.success ?? true, + outcome: args.outcome, + error: null, + }); +} + +describe("parseSpacProcessOnly", () => { + it("accepts never-processed and updates", () => { + expect(parseSpacProcessOnly("never-processed")).toBe("never-processed"); + expect(parseSpacProcessOnly("updates")).toBe("updates"); + }); + + it("rejects other values", () => { + expect(() => parseSpacProcessOnly("both")).toThrow(/Invalid --only/); + }); +}); + +describe("filterSpacCiksByHistory", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + + it("returns the input list when only is omitted", async () => { + await expect(filterSpacCiksByHistory([1, 2], undefined)).resolves.toEqual([1, 2]); + }); + + it("never-processed is CIKs with no successful SPAC extractor run", async () => { + await recordRun({ cik: 1, extractor_id: "S-1" }); + await recordRun({ cik: 2, extractor_id: "S-1", success: false, outcome: "failure" }); + await recordRun({ cik: 3, extractor_id: "D" }); + + await expect(filterSpacCiksByHistory([1, 2, 3, 4], "never-processed")).resolves.toEqual([ + 2, 3, 4, + ]); + }); + + it("updates is CIKs with at least one successful SPAC run, including an older version", async () => { + await recordRun({ cik: 1, extractor_id: "8-K", extractor_version: "0.9.0" }); + await recordRun({ cik: 2, extractor_id: "S-1", outcome: "partial" }); + + await expect(filterSpacCiksByHistory([1, 2, 3], "updates")).resolves.toEqual([1]); + }); +}); + +describe("shardCiks", () => { + it("returns the full list when sharding is off", () => { + expect(shardCiks([1, 2, 3], undefined)).toEqual([1, 2, 3]); + expect(shardCiks([1, 2, 3], { index: 0, count: 1 })).toEqual([1, 2, 3]); + }); + + it("partitions issuers disjointly and completely so one CIK never splits", () => { + const ciks = [10, 11, 12, 13, 14]; + const shards = [0, 1, 2].map((index) => shardCiks(ciks, { index, count: 3 })); + expect(shards.flat().toSorted((a, b) => a - b)).toEqual(ciks); + expect(new Set(shards.flat()).size).toBe(ciks.length); + for (const shard of shards) { + expect(new Set(shard).size).toBe(shard.length); + } + }); +}); + +describe("spacUpdatesFiledOnOrAfter", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + + it("is the UTC day before the latest successful SPAC extractor run", async () => { + expect(dayBeforeUtc("2026-08-20T22:33:50.884Z")).toBe("2026-08-19"); + await recordRun({ cik: 1, extractor_id: "S-1" }); + const repo = new ExtractorRunRepo(globalServiceRegistry.get(EXTRACTOR_RUN_REPOSITORY_TOKEN)); + const row = await repo.findRun(1, "0000000001-26-000001", "S-1", "1.0.0"); + await expect(spacUpdatesFiledOnOrAfter()).resolves.toBe(dayBeforeUtc(row!.ran_at)); + }); +}); diff --git a/src/cli/sync/spacSyncCiks.ts b/src/cli/sync/spacSyncCiks.ts index 81323e83..5140832d 100644 --- a/src/cli/sync/spacSyncCiks.ts +++ b/src/cli/sync/spacSyncCiks.ts @@ -10,6 +10,8 @@ import { type SpacCandidateConfidence, } from "../../storage/spac/SpacCandidateSchema"; import { SPAC_REPOSITORY_TOKEN } from "../../storage/spac/SpacSchema"; +import { EXTRACTOR_RUN_REPOSITORY_TOKEN } from "../../storage/versioning/ExtractorRunSchema"; +import { SYNC_FORM_DOMAINS } from "./syncFormDomains"; // Typed `ReadonlySet` for lookup — the stored column is a plain string // (`TypeStringEnum` surfaces `string`, not the union) — while the literals are @@ -19,6 +21,109 @@ const PROCESS_CONFIDENCES: ReadonlySet = new Set = new Set(SYNC_FORM_DOMAINS.spacs); + +/** CIKs per `in` list — same bound as the forms worklist. */ +const TOUCHED_CIK_CHUNK = 900; + +export const SPAC_PROCESS_ONLY_VALUES = ["never-processed", "updates"] as const; +export type SpacProcessOnly = (typeof SPAC_PROCESS_ONLY_VALUES)[number]; + +export function parseSpacProcessOnly(value: string): SpacProcessOnly { + if ((SPAC_PROCESS_ONLY_VALUES as readonly string[]).includes(value)) { + return value as SpacProcessOnly; + } + throw new Error(`Invalid --only "${value}". Expected: ${SPAC_PROCESS_ONLY_VALUES.join(", ")}`); +} + +/** + * Split issuers across `--shard i/N` processes by CIK. Accession hashing would + * put two filings of one SPAC on different shards, which is the ordering the + * timeline replay exists to prevent. + */ +export function shardCiks( + ciks: readonly number[], + shard: { readonly index: number; readonly count: number } | undefined +): number[] { + if (shard === undefined || shard.count <= 1) return [...ciks]; + return ciks.filter((cik) => cik % shard.count === shard.index); +} + +function isSuccessfulSpacRun(row: { + readonly success: boolean; + readonly outcome: string; +}): boolean { + if (row.outcome) return row.outcome === "success"; + return row.success; +} + +const MS_PER_DAY = 86_400_000; + +/** UTC calendar day before `isoTimestamp`'s date. `ran_at` is date-granular for this gate. */ +export function dayBeforeUtc(isoTimestamp: string): string { + const day = isoTimestamp.slice(0, 10); + const t = Date.parse(`${day}T00:00:00.000Z`); + return new Date(t - MS_PER_DAY).toISOString().slice(0, 10); +} + +/** + * Inclusive filing-date floor for `--only updates`: the day before the latest + * successful SPAC extractor run. Matches identify's "take the watermark back + * one day" so a CIK processed later on the same date is not skipped. + */ +export async function spacUpdatesFiledOnOrAfter(): Promise { + const storage = globalServiceRegistry.get(EXTRACTOR_RUN_REPOSITORY_TOKEN); + let latest: string | undefined; + for (const extractor_id of SPAC_EXTRACTOR_IDS) { + const rows = (await storage.query({ extractor_id, success: true })) ?? []; + for (const row of rows) { + if (!isSuccessfulSpacRun(row)) continue; + if (latest === undefined || row.ran_at > latest) latest = row.ran_at; + } + } + if (latest === undefined) return undefined; + return dayBeforeUtc(latest); +} + +/** CIKs in `ciks` that have any successful SPAC-extractor run, at any version. */ +export async function listTouchedSpacCiks(ciks: readonly number[]): Promise> { + const want = new Set(ciks); + const touched = new Set(); + if (want.size === 0) return touched; + + const storage = globalServiceRegistry.get(EXTRACTOR_RUN_REPOSITORY_TOKEN); + const sorted = [...want].sort((a, b) => a - b); + for (let i = 0; i < sorted.length; i += TOUCHED_CIK_CHUNK) { + const chunk = sorted.slice(i, i + TOUCHED_CIK_CHUNK); + for (const extractor_id of SPAC_EXTRACTOR_IDS) { + const rows = + (await storage.query({ + extractor_id, + success: true, + cik: { value: chunk, operator: "in" }, + })) ?? []; + for (const row of rows) { + if (!want.has(row.cik)) continue; + if (!isSuccessfulSpacRun(row)) continue; + touched.add(row.cik); + } + } + } + return touched; +} + +export async function filterSpacCiksByHistory( + ciks: readonly number[], + only: SpacProcessOnly | undefined +): Promise { + if (only === undefined || ciks.length === 0) return [...ciks]; + const touched = await listTouchedSpacCiks(ciks); + if (only === "never-processed") { + return ciks.filter((cik) => !touched.has(cik)); + } + return ciks.filter((cik) => touched.has(cik)); +} + /** Known spac rows ∪ spac_candidate rows with confidence high|medium. */ export async function listSpacProcessCiks(): Promise { const known = await listKnownSpacCiks(); diff --git a/src/cli/sync/syncLeaves.ts b/src/cli/sync/syncLeaves.ts index 282e4057..7b281322 100644 --- a/src/cli/sync/syncLeaves.ts +++ b/src/cli/sync/syncLeaves.ts @@ -5,6 +5,8 @@ */ import type { FormsShard } from "../../task/forms/formsSweep"; +import { DEFAULT_SPAC_ISSUER_CONCURRENCY } from "./runSpacTimelineIssuers"; +import type { SpacProcessOnly } from "./spacSyncCiks"; export interface SyncRunContext { readonly force: boolean; @@ -14,6 +16,10 @@ export interface SyncRunContext { readonly lookback: number; readonly shard: FormsShard | undefined; readonly formTypes: string[] | undefined; + /** `sync spacs --only`: restrict process CIKs. Undefined means both. */ + readonly only: SpacProcessOnly | undefined; + /** How many SPAC issuers to replay at once. Filings within an issuer stay serial. */ + readonly concurrency: number; } export interface SyncStep { @@ -38,6 +44,8 @@ export const EMPTY_SYNC_CONTEXT: SyncRunContext = { lookback: 3, shard: undefined, formTypes: undefined, + only: undefined, + concurrency: DEFAULT_SPAC_ISSUER_CONCURRENCY, }; const syncLeaves = new Map(); diff --git a/src/commands/spac.test.ts b/src/commands/spac.test.ts index e4dad37c..d58f0d7f 100644 --- a/src/commands/spac.test.ts +++ b/src/commands/spac.test.ts @@ -5,15 +5,11 @@ */ import { beforeEach, describe, expect, it } from "vitest"; -import type { DataPorts, ITask } from "workglow"; -import { runWorkflowCli } from "../cli/runWorkflow"; +import { runSpacTimelineIssuers } from "../cli/sync/runSpacTimelineIssuers"; import { resetDependencyInjectionsForTesting } from "../config/TestingDI"; import { setupAllDatabases } from "../config/setupAllDatabases"; import { SpacReportWriter } from "../storage/spac/SpacReportWriter"; -import { - ProcessSpacTimelineTask, - type ProcessSpacTimelineTaskOutput, -} from "../task/spac/ProcessSpacTimelineTask"; +import type { ProcessSpacTimelineTaskOutput } from "../task/spac/ProcessSpacTimelineTask"; import { assembleSpacReport, formatSpacProcessDeadLetterHint, @@ -124,25 +120,19 @@ describe("spacProcessRows", () => { ])( "renders what the real %s fan-out actually merges to", async (_label, ciks: readonly number[]) => { - // The shape `spacProcessRows` consumes is produced by `runWorkflowCli`, - // not asserted anywhere else — so build the graph `sec spac process` - // builds and read the sink. Notably a ONE-iteration map still merges to a - // one-element array per port rather than a bare scalar, which is the - // commonest invocation and was previously assumed to be the other way. - const merged = await runWorkflowCli>([], { cik: [...ciks] }, (wf) => { - const loop = wf.map({ - concurrencyLimit: ciks.length, - maxIterations: ciks.length, - preserveOrder: true, - }); - loop.pipe(new ProcessSpacTimelineTask() as ITask); - loop.endMap(); + // The shape `spacProcessRows` consumes is produced by `runSpacTimelineIssuers`, + // not asserted anywhere else — so run the same graph `sec spac process` and + // `sync spacs --step process` build. Notably a ONE-iteration map still + // merges to a one-element array per port rather than a bare scalar. + const rows = await runSpacTimelineIssuers({ + ciks, + concurrency: ciks.length, }); - expect(merged.cik).toEqual([...ciks]); + expect(rows.map((row) => row.cik)).toEqual([...ciks]); // None of these CIKs has a filing, so every issuer reports an empty // timeline — the point here is the shape and the per-issuer labelling. - expect(spacProcessRows(merged as never)).toEqual( + expect(rows).toEqual( ciks.map((cik) => ({ cik, matched: 0, diff --git a/src/commands/spac.ts b/src/commands/spac.ts index 81160a08..d50355da 100644 --- a/src/commands/spac.ts +++ b/src/commands/spac.ts @@ -5,13 +5,17 @@ */ import { Command } from "commander"; -import { globalServiceRegistry, type DataPorts, type ITask } from "workglow"; +import { globalServiceRegistry } from "workglow"; import { parseIntOption, parseOutputFormat, type OutputFormat } from "../cli/GlobalOptions"; import { isDryRun } from "../cli/isDryRun"; import { statusMessage } from "../cli/output/Progress"; import { renderTable, type ColumnDef } from "../cli/output/TableRenderer"; import { runCommand } from "../cli/runCommand"; import { runWorkflowCli } from "../cli/runWorkflow"; +import { + DEFAULT_SPAC_ISSUER_CONCURRENCY, + runSpacTimelineIssuers, +} from "../cli/sync/runSpacTimelineIssuers"; import { SPAC_CANDIDATE_CONFIDENCES, type SpacCandidateConfidence, @@ -29,10 +33,7 @@ import { type SpacDownloadSet, } from "../task/spac/spacCandidateDownload"; import { SpacRepo } from "../storage/spac/SpacRepo"; -import { - ProcessSpacTimelineTask, - type ProcessSpacTimelineTaskOutput, -} from "../task/spac/ProcessSpacTimelineTask"; +import { type ProcessSpacTimelineTaskOutput } from "../task/spac/ProcessSpacTimelineTask"; import { parseSpacProcessForce } from "../task/spac/parseSpacProcessForce"; import { SPAC_SPONSOR_LINK_REPOSITORY_TOKEN } from "../storage/canonical/SpacSponsorLinkSchema"; import { UNDERWRITER_LINK_REPOSITORY_TOKEN } from "../storage/canonical/UnderwriterLinkSchema"; @@ -95,64 +96,7 @@ const SPAC_CANDIDATE_COLUMNS: ReadonlyArray = [ { key: "signal_renamed_from", header: "Was", width: 28 }, ]; -/** - * The `spac process` fan-out's merged output: one column per - * {@link ProcessSpacTimelineTask} output port, index-aligned across columns. - */ -type SpacProcessColumns = { - readonly [K in keyof ProcessSpacTimelineTaskOutput]?: ReadonlyArray< - ProcessSpacTimelineTaskOutput[K] - >; -}; - -/** - * Transposes the fan-out's column arrays back into one row per issuer. - * - * The map merges each output port into an array across iterations — always an - * array, including for the one-issuer run that is the commonest invocation, so - * there is no scalar shape to unwrap. `cik` is echoed by the task rather than - * zipped from the input list, so a row can never be reported under the wrong - * issuer. - */ -export function spacProcessRows( - columns: SpacProcessColumns -): readonly ProcessSpacTimelineTaskOutput[] { - const column = ( - key: K - ): ReadonlyArray => columns[key] ?? []; - const ciks = column("cik"); - const matched = column("matched"); - const processed = column("processed"); - const partial = column("partial"); - const failed = column("failed"); - const nonfatal = column("nonfatal"); - const triage = column("triage"); - const skipped = column("skipped"); - const triageExtractors = column("triageExtractors"); - const firstDate = column("firstDate"); - const lastDate = column("lastDate"); - const error = column("error"); - const rows: ProcessSpacTimelineTaskOutput[] = []; - for (let i = 0; i < ciks.length; i++) { - const cik = ciks[i]; - if (cik === undefined) continue; - rows.push({ - cik, - matched: matched[i] ?? 0, - processed: processed[i] ?? 0, - partial: partial[i] ?? 0, - failed: failed[i] ?? 0, - nonfatal: nonfatal[i] ?? 0, - triage: triage[i] ?? 0, - skipped: skipped[i] ?? 0, - triageExtractors: triageExtractors[i] ?? "", - firstDate: firstDate[i] ?? "", - lastDate: lastDate[i] ?? "", - error: error[i] ?? "", - }); - } - return rows; -} +export { spacProcessRows } from "../cli/sync/runSpacTimelineIssuers"; /** * One issuer's replay summary. Partial/failed/triage are omitted when zero so @@ -204,6 +148,30 @@ export function formatSpacProcessDeadLetterHint( return `Some sections did not extract. Inspect them with: ${inspect}`; } +export function reportSpacProcessRows( + rows: readonly ProcessSpacTimelineTaskOutput[], + opts?: { readonly dryRun?: boolean; readonly rebuild?: boolean } +): void { + for (const row of rows) { + if (row.error) { + console.error(`${row.cik}: ${row.error}`); + } else if (row.matched === 0) { + console.log(`${row.cik}: no processable filings`); + } else { + console.log(formatSpacProcessSummary(row, opts)); + if (row.partial > 0 || row.failed > 0) { + console.error( + statusMessage("warn", formatSpacProcessDeadLetterHint(row.triageExtractors, "partial")) + ); + } else if (row.triage > 0) { + console.error( + statusMessage("info", formatSpacProcessDeadLetterHint(row.triageExtractors, "dropped")) + ); + } + } + } +} + /** * Issuers whose replay actually failed — what the command's exit code reports. * @@ -249,7 +217,7 @@ export function registerSpacCommands(program: Command): void { "How many ISSUERS to process at once (default 3). Filings within an issuer are " + "always serial — that ordering is what makes the timeline correct.", parseIntOption, - 3 + DEFAULT_SPAC_ISSUER_CONCURRENCY ) .option( "--force [extractors]", @@ -266,59 +234,15 @@ export function registerSpacCommands(program: Command): void { // typo does not abandon the rest of the batch. const parsed = ciks.map((c) => parseCikArg(c)).filter((c): c is number => c !== null); if (parsed.length === 0) throw new Error("no valid CIKs given"); - const limit = Math.max(1, opts.concurrency); - // ONE workflow over all issuers, fanned out by a map. The previous - // hand-rolled pool ran a separate `runWorkflowCli` per issuer, which on - // a TTY started a second Ink renderer while the first still owned the - // terminal, and — because the workflow renderer answers a thrown error - // with `process.exit(1)` — let one issuer's failure kill the whole - // batch mid-flight, which is exactly what the pool existed to prevent. - // The task now reports a failure on its `error` port instead of - // throwing, so nothing in the graph raises. - const results = await runWorkflowCli([], { cik: [...parsed] }, (wf) => { - const loop = wf.map({ - concurrencyLimit: Math.min(limit, parsed.length), - maxIterations: parsed.length, - preserveOrder: true, - }); - loop.pipe( - new ProcessSpacTimelineTask({ defaults: { force: forceInput } }) as ITask< - DataPorts, - DataPorts - > - ); - loop.endMap(); + const rows = await runSpacTimelineIssuers({ + ciks: parsed, + concurrency: opts.concurrency, + force: forceInput, + }); + reportSpacProcessRows(rows, { + dryRun: isDryRun(), + rebuild: force.kind === "all", }); - const rows = spacProcessRows(results); - for (const row of rows) { - if (row.error) { - console.error(`${row.cik}: ${row.error}`); - } else if (row.matched === 0) { - console.log(`${row.cik}: no processable filings`); - } else { - console.log( - formatSpacProcessSummary(row, { - dryRun: isDryRun(), - rebuild: force.kind === "all", - }) - ); - if (row.partial > 0 || row.failed > 0) { - console.error( - statusMessage( - "warn", - formatSpacProcessDeadLetterHint(row.triageExtractors, "partial") - ) - ); - } else if (row.triage > 0) { - console.error( - statusMessage( - "info", - formatSpacProcessDeadLetterHint(row.triageExtractors, "dropped") - ) - ); - } - } - } const failed = spacProcessFailureCount(rows); if (failed > 0) { throw new Error(`${failed} of ${parsed.length} issuer(s) had failed filings`); diff --git a/src/task/forms/ComputeFormsWorklistTask.ts b/src/task/forms/ComputeFormsWorklistTask.ts index ff2b0c4a..d1edae8f 100644 --- a/src/task/forms/ComputeFormsWorklistTask.ts +++ b/src/task/forms/ComputeFormsWorklistTask.ts @@ -50,6 +50,13 @@ export type ComputeFormsWorklistTaskInput = { * events. */ readonly eightKItems?: string[]; + /** + * When set, filings whose `filing_date` is strictly before this YYYY-MM-DD + * are consumed but not emitted. Used by `sync spacs --only updates` so a + * daily run is new filings, not the historical leftover on already-touched + * SPACs. An empty filing_date is kept. + */ + readonly filedOnOrAfter?: string; /** * Filings emitted per batch. Defaults to {@link WORKLIST_BATCH_SIZE}; exposed * mainly so tests can drive the batching/resume path with a handful of rows @@ -137,6 +144,16 @@ function skipEightKWithoutItems( return !filingHasAnyItem(items, codes); } +/** True when `filedOnOrAfter` is set and this filing is dated strictly earlier. */ +function skipFiledBefore( + filingDate: string | null | undefined, + onOrAfter: string | undefined +): boolean { + if (onOrAfter === undefined) return false; + if (!filingDate) return false; + return filingDate < onOrAfter; +} + export type ComputeFormsWorklistTaskOutput = { /** Parallel arrays, aligned by index — one entry per filing to process. */ accessionNumber: string[]; @@ -177,6 +194,7 @@ export class ComputeFormsWorklistTask extends Task< shardCount: Type.Optional(Type.Integer({ minimum: 1 })), ciks: Type.Optional(Type.Array(TypeSecCik())), eightKItems: Type.Optional(Type.Array(Type.String())), + filedOnOrAfter: Type.Optional(Type.String()), batchSize: Type.Optional(Type.Integer({ minimum: 1 })), }); } @@ -249,6 +267,7 @@ export class ComputeFormsWorklistTask extends Task< input.eightKItems !== undefined && input.eightKItems.length > 0 ? new Set(input.eightKItems) : undefined; + const filedOnOrAfter = input.filedOnOrAfter; const dryRun = isDryRun(); const batchSize = input.batchSize ?? WORKLIST_BATCH_SIZE; @@ -317,6 +336,7 @@ export class ComputeFormsWorklistTask extends Task< if (sharding && accessionShard(f.accession_number, shardCount) !== shardIndex) continue; if (cikAllowList !== undefined && !cikAllowList.has(f.cik)) continue; if (skipEightKWithoutItems(f.form, f.items, eightKItemSet)) continue; + if (skipFiledBefore(f.filing_date, filedOnOrAfter)) continue; if (keys.has(filingRunKey(f))) continue; total++; } @@ -328,8 +348,10 @@ export class ComputeFormsWorklistTask extends Task< } this.exhausted = true; const shardNote = sharding ? ` (shard ${shardIndex + 1}/${shardCount})` : ""; + const sinceNote = + filedOnOrAfter !== undefined ? ` (filed on or after ${filedOnOrAfter})` : ""; console.log( - `Would process ${total} unprocessed filings for forms: ${[...formSet].join(", ")}${shardNote}` + `Would process ${total} unprocessed filings for forms: ${[...formSet].join(", ")}${shardNote}${sinceNote}` ); return { accessionNumber: [], cik: [], form: [], fileName: [], count: 0 }; } @@ -397,6 +419,7 @@ export class ComputeFormsWorklistTask extends Task< if (sharding && accessionShard(f.accession_number, shardCount) !== shardIndex) continue; if (cikAllowList !== undefined && !cikAllowList.has(f.cik)) continue; if (skipEightKWithoutItems(f.form, f.items, eightKItemSet)) continue; + if (skipFiledBefore(f.filing_date, filedOnOrAfter)) continue; if (this.successfulKeys.has(filingRunKey(f))) continue; accessionNumber.push(f.accession_number); cik.push(f.cik); @@ -463,7 +486,14 @@ export class ComputeFormsWorklistTask extends Task< ]; if (allowCiks !== undefined) { - return this.readAllowlistedPage(filingRepo, form, fromCik, afterAccession, allowCiks, orderBy); + return this.readAllowlistedPage( + filingRepo, + form, + fromCik, + afterAccession, + allowCiks, + orderBy + ); } if (fromCik === undefined || afterAccession === undefined) { @@ -538,10 +568,9 @@ export class ComputeFormsWorklistTask extends Task< if (rows.length === FILING_PAGE_SIZE) return { rows, full: true }; } - const remaining = - fromCik === undefined ? allowCiks : allowCiks.filter((cik) => cik > fromCik); + const remaining = fromCik === undefined ? allowCiks : allowCiks.filter((cik) => cik > fromCik); - for (let i = 0; i < remaining.length; ) { + for (let i = 0; i < remaining.length;) { const need = FILING_PAGE_SIZE - rows.length; const chunk = remaining.slice(i, i + WORKLIST_CIK_CHUNK); const part = ((await filingRepo.query( diff --git a/src/task/forms/formsSweep.test.ts b/src/task/forms/formsSweep.test.ts index d0ba9ed5..6a48ecf2 100644 --- a/src/task/forms/formsSweep.test.ts +++ b/src/task/forms/formsSweep.test.ts @@ -35,6 +35,7 @@ interface SeedFiling { form: string; primary_doc: string; items?: string | null; + filing_date?: string; } async function seed(f: SeedFiling): Promise { @@ -45,7 +46,7 @@ async function seed(f: SeedFiling): Promise { form: f.form, primary_doc: f.primary_doc, file_number: "333-1", - filing_date: "2026-01-02", + filing_date: f.filing_date ?? "2026-01-02", acceptance_date: "2026-01-02T00:00:00.000Z", report_date: null, film_number: null, @@ -266,6 +267,36 @@ describe("forms sweep wiring", () => { ]); }); + it("when filedOnOrAfter is set, emits only filings on or after that date", async () => { + await seed({ + cik: 1, + accession_number: "0000000001-26-000001", + form: "8-K", + primary_doc: "old.htm", + items: "5.07", + filing_date: "2026-01-02", + }); + await seed({ + cik: 1, + accession_number: "0000000001-26-000002", + form: "8-K", + primary_doc: "new.htm", + items: "5.07", + filing_date: "2026-08-20", + }); + + const producer = new ComputeFormsWorklistTask({ + defaults: { form: ["8-K"], filedOnOrAfter: "2026-08-19", batchSize: 10 }, + }); + const emitted: string[] = []; + while (!producer.exhausted) { + const out = await producer.run({}); + emitted.push(...out.accessionNumber); + } + + expect(emitted).toEqual(["0000000001-26-000002"]); + }); + it("includes all CIKs when ciks is omitted", async () => { await seed({ cik: 1, diff --git a/src/task/forms/formsSweep.ts b/src/task/forms/formsSweep.ts index e653fdaa..ef68fa85 100644 --- a/src/task/forms/formsSweep.ts +++ b/src/task/forms/formsSweep.ts @@ -37,7 +37,8 @@ export function newFormsWorklistTask( form?: string[], shard?: FormsShard, ciks?: number[], - eightKItems?: readonly string[] + eightKItems?: readonly string[], + filedOnOrAfter?: string ): ComputeFormsWorklistTask { return new ComputeFormsWorklistTask({ defaults: { @@ -46,6 +47,7 @@ export function newFormsWorklistTask( shardCount: shard?.count, ciks, eightKItems: eightKItems !== undefined ? [...eightKItems] : undefined, + filedOnOrAfter, }, }); } diff --git a/src/task/spac/ProcessSpacTimelineTask.test.ts b/src/task/spac/ProcessSpacTimelineTask.test.ts index 25cd3bc3..80329591 100644 --- a/src/task/spac/ProcessSpacTimelineTask.test.ts +++ b/src/task/spac/ProcessSpacTimelineTask.test.ts @@ -585,6 +585,36 @@ describe("ProcessSpacTimelineTask", () => { expect(out.matched).toBe(2); }); + it("owns each filing as a child of the issuer so the CLI can nest them", async () => { + // An owned inner Workflow+Map sits two layers down; the CLI stops + // recursing there, so the issuer map row had no filing children. Directly + // owned form tasks are the issuer's subgraph. + await seedFiling("0000000000-26-000001", "D", "2021-01-04"); + await seedFiling("0000000000-26-000002", "D", "2021-02-04"); + vi.spyOn(ProcessAccessionDocFormTask.prototype, "execute").mockResolvedValue({ + success: true, + }); + + const task = new ProcessSpacTimelineTask(); + await task.run({ cik: CIK }); + + const children = task.subGraph?.getTasks() ?? []; + expect(children.map((child) => child.type)).toEqual([ + "ProcessAccessionDocFormTask", + "ProcessAccessionDocFormTask", + ]); + expect(children.map((child) => child.title)).toEqual([ + "D 0000000000-26-000001", + "D 0000000000-26-000002", + ]); + }); + + it("labels the issuer row with the CIK so map iterations are distinguishable", async () => { + const task = new ProcessSpacTimelineTask(); + await task.run({ cik: CIK }); + expect(task.title).toContain(String(CIK)); + }); + it("echoes the cik so a fan-out's result columns are self-labelling", async () => { const out = await new ProcessSpacTimelineTask().run({ cik: CIK }); // No filings at all is still an answer about THIS issuer. @@ -597,4 +627,30 @@ describe("ProcessSpacTimelineTask", () => { error: "", }); }); + + it("filedOnOrAfter skips older unprocessed filings, keeping date order for the rest", async () => { + await seedFiling("0000000000-26-000001", "D", "2021-01-04"); + await seedFiling("0000000000-26-000002", "D", "2026-08-20"); + const spy = vi.spyOn(ProcessAccessionDocFormTask.prototype, "execute"); + + const out = await new ProcessSpacTimelineTask().run({ + cik: CIK, + filedOnOrAfter: "2026-08-19", + }); + + expect(spy.mock.calls.map((c) => c[0]?.accessionNumber)).toEqual(["0000000000-26-000002"]); + expect(out.matched).toBe(2); + expect(out.skipped).toBe(1); + }); + + it("filedOnOrAfter also keeps the repair pass from replaying older gated 8-Ks", async () => { + await seedFiling("0000000000-26-000001", "S-1", "2026-08-20", "s1.htm"); + await seedFiling("0000000000-26-000002", "8-K", "2021-02-04", "d8k.htm", "5.07"); + await seedSuccessfulRun("0000000000-26-000002", "8-K", "8-K"); + const spy = mockFormProcessor(); + + await new ProcessSpacTimelineTask().run({ cik: CIK, filedOnOrAfter: "2026-08-19" }); + + expect(spy.mock.calls.map((c) => c[0]?.accessionNumber)).toEqual(["0000000000-26-000001"]); + }); }); diff --git a/src/task/spac/ProcessSpacTimelineTask.ts b/src/task/spac/ProcessSpacTimelineTask.ts index 4c64af00..0e3087ad 100644 --- a/src/task/spac/ProcessSpacTimelineTask.ts +++ b/src/task/spac/ProcessSpacTimelineTask.ts @@ -11,7 +11,6 @@ import { Task, TaskAbortedError, TaskError, - Workflow, } from "workglow"; import { isDryRun } from "../../cli/isDryRun"; import { SecCliConfigurationError } from "../../config/EnvToDI"; @@ -38,6 +37,11 @@ const InputSchema = () => Type.Object({ cik: TypeSecCik(), force: Type.Optional(Type.String()), + // Inclusive filing-date floor. `sync spacs --only updates` uses this so a + // daily delta does not replay leftover historical filings of an already + // processed issuer. Undated filings are never excluded: they sort last on + // the timeline and dropping them would hide work with no date to compare. + filedOnOrAfter: Type.Optional(Type.String()), }); export type ProcessSpacTimelineTaskInput = Static>; @@ -155,8 +159,14 @@ export class ProcessSpacTimelineTask extends Task< ): Promise { const { cik } = input; if (!cik) throw new TaskError("Invalid input"); + this.setTitle(`CIK ${cik}`); try { - return await this.replay(cik, parseSpacProcessForce(input.force), context); + return await this.replay( + cik, + parseSpacProcessForce(input.force), + emptyToUndefined(input.filedOnOrAfter), + context + ); } catch (e) { // Cooperative cancellation and a misconfigured CLI are wrong for the // whole batch, not for this issuer, so they keep escaping. Checked in @@ -176,6 +186,7 @@ export class ProcessSpacTimelineTask extends Task< private async replay( cik: number, force: SpacProcessForce, + filedOnOrAfter: string | undefined, context: IExecuteContext ): Promise { const filingRepo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN); @@ -211,6 +222,7 @@ export class ProcessSpacTimelineTask extends Task< const toProcess = timeline.filter( (f) => f.form !== null && + filingMeetsDateFloor(f.filing_date, filedOnOrAfter) && shouldReplaySpacFiling({ form: f.form, items: f.items, @@ -286,7 +298,9 @@ export class ProcessSpacTimelineTask extends Task< // ordering the two-invocation workaround already produced. const repair = timeline.filter( (f) => - !processedAccessions.has(f.accession_number) && gatedAfterReplay.has(f.accession_number) + !processedAccessions.has(f.accession_number) && + gatedAfterReplay.has(f.accession_number) && + filingMeetsDateFloor(f.filing_date, filedOnOrAfter) ); if (repair.length > 0) { await this.replayFilings(repair, cik, context); @@ -319,29 +333,30 @@ export class ProcessSpacTimelineTask extends Task< } /** - * One serial pass over `filings`, in the order given. `concurrencyLimit: 1` - * is the whole point: this is a replay, not a batch. + * One serial pass over `filings`, in the order given. Each filing is an owned + * child of this issuer so the CLI nests `form accession` rows under the CIK + * — an inner Workflow+Map sat below the renderer's recursion cap and the + * issuer row had no children. */ private async replayFilings( filings: readonly Filing[], cik: number, context: IExecuteContext ): Promise { - const wf = context.own(new Workflow(), { - title: `Replay ${filings.length} filings for CIK ${cik} in date order`, - }); - const loop = wf.map({ concurrencyLimit: 1, maxIterations: filings.length }); - loop.pipe(new ProcessAccessionDocFormTask()); - loop.endMap(); - await wf.run({ - cik: filings.map(() => cik), - form: filings.map((f) => f.form), - accessionNumber: filings.map((f) => f.accession_number), - // `primary_doc` is nullable and `stripXslPrefix` is not: one filing - // without a primary document threw out of the whole issuer's replay. - // Left absent, the form task resolves it or dead-letters that one filing. - fileName: filings.map((f) => resolvePrimaryDocName(f.primary_doc)), - }); + for (const filing of filings) { + const form = filing.form ?? ""; + const child = context.own( + new ProcessAccessionDocFormTask({ + title: `${form} ${filing.accession_number}`, + }) + ); + await child.run({ + cik, + form, + accessionNumber: filing.accession_number, + fileName: resolvePrimaryDocName(filing.primary_doc), + }); + } } } @@ -382,6 +397,24 @@ async function loadSuccessfulKeys( return successfulKeys; } +function emptyToUndefined(value: string | undefined): string | undefined { + if (value === undefined || value === "") return undefined; + return value; +} + +/** + * Inclusive `filing_date` floor. An undated filing sorts last on the timeline + * and is kept: dropping it would hide work that has no date to compare. + */ +function filingMeetsDateFloor( + filingDate: string | null | undefined, + filedOnOrAfter: string | undefined +): boolean { + if (filedOnOrAfter === undefined) return true; + if (filingDate === null || filingDate === undefined || filingDate === "") return true; + return filingDate >= filedOnOrAfter; +} + function emptyOutcome(cik: number, error: string): ProcessSpacTimelineTaskOutput { return { cik, From d076e94a1797c2979f2e30976fde7ef23783715a Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:07:39 -0700 Subject: [PATCH 17/29] feat(sec): register deterministic as a reserved model id Stub ModelRecord with no provider so CSV / --models can name the sync walk without a cloud key. Co-authored-by: Cursor --- src/config/Constants.ts | 3 ++ src/config/listPricing.test.ts | 11 ++++++ src/config/listPricing.ts | 1 + src/config/registerModels.test.ts | 35 +++++++++++++++++-- src/config/registerModels.ts | 20 +++++++++-- .../s1/parseOfferingTables.ts | 3 +- .../model/EnsureModelDownloadedTask.test.ts | 4 +++ 7 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/config/Constants.ts b/src/config/Constants.ts index e4e62881..bbcaf2b0 100644 --- a/src/config/Constants.ts +++ b/src/config/Constants.ts @@ -84,6 +84,9 @@ export const SecFetchMaxConcurrent = ((): number => { */ export const DEFAULT_SEC_MODEL = "claude-sonnet-5"; +/** Reserved extract id for the sync section walk. Same string stored as provenance `model_id`. */ +export const DETERMINISTIC_MODEL_ID = "deterministic"; + /** * Split a model env value into distinct ids. A scalar is a one-element list. * Empty / unset input falls back to `fallback` as a single id. diff --git a/src/config/listPricing.test.ts b/src/config/listPricing.test.ts index 3cddf78d..6cf89d20 100644 --- a/src/config/listPricing.test.ts +++ b/src/config/listPricing.test.ts @@ -8,6 +8,17 @@ import { describe, expect, it } from "vitest"; import { listPricingForModelId } from "./listPricing"; describe("listPricingForModelId", () => { + it("prices deterministic at $0", () => { + expect(listPricingForModelId("deterministic")).toEqual({ + currency: "USD", + input: 0, + output: 0, + cached: 0, + cacheWrite: 0, + cacheStoragePerHour: undefined, + }); + }); + it("prices Anthropic families by name", () => { // Sonnet 5's $2/$10 is permanent and distinct from 4.x at $3/$15. expect(listPricingForModelId("claude-sonnet-5")).toEqual({ diff --git a/src/config/listPricing.ts b/src/config/listPricing.ts index 55eaadab..12f19540 100644 --- a/src/config/listPricing.ts +++ b/src/config/listPricing.ts @@ -92,6 +92,7 @@ const DEEPSEEK_PRICING: ReadonlyArray = * cloud gateways (`hfi:`, `open-router:`) and unrecognized ids are unpriced. */ export function listPricingForModelId(modelId: string): ModelPricing | undefined { + if (modelId === "deterministic") return FREE_LOCAL; if (modelId.startsWith("onnx:")) return FREE_LOCAL; if (/^(gguf:|llama:|node-llama:)/.test(modelId)) return FREE_LOCAL; if (modelId.startsWith("hfi:") || modelId.startsWith("open-router:")) return undefined; diff --git a/src/config/registerModels.test.ts b/src/config/registerModels.test.ts index 1c9c146d..962a22bb 100644 --- a/src/config/registerModels.test.ts +++ b/src/config/registerModels.test.ts @@ -11,7 +11,12 @@ import { InMemoryModelRepository, setGlobalModelRepository, } from "workglow"; -import { DEFAULT_SEC_MODEL, SecHftModelDefault, SecModelDefault } from "./Constants"; +import { + DEFAULT_SEC_MODEL, + DETERMINISTIC_MODEL_ID, + SecHftModelDefault, + SecModelDefault, +} from "./Constants"; import { SecCliConfigurationError } from "./EnvToDI"; import { anthropicModelRecord, @@ -22,6 +27,8 @@ import { llamaCppModelRecord, openAiModelRecord, openRouterModelRecord, + KNOWN_MODEL_ID_SHAPES, + modelApiKeyEnvVar, registerModelIds, registerSecModels, secModelRecord, @@ -57,6 +64,28 @@ describe("registerSecModels", () => { } }); + it("mints a stub record for deterministic with no provider and $0 pricing", () => { + const record = secModelRecord(DETERMINISTIC_MODEL_ID); + expect(record.model_id).toBe("deterministic"); + expect(record.provider).toBeFalsy(); + expect(record.pricing?.input).toBe(0); + expect(record.pricing?.output).toBe(0); + }); + + it("lists deterministic among known id shapes", () => { + expect(KNOWN_MODEL_ID_SHAPES).toContain("deterministic"); + }); + + it("does not require an API key for deterministic", () => { + expect(modelApiKeyEnvVar(DETERMINISTIC_MODEL_ID)).toBeUndefined(); + }); + + it("registerSecModels always registers deterministic", async () => { + await registerSecModels(); + const found = await getGlobalModelRepository().findByName(DETERMINISTIC_MODEL_ID); + expect(found?.model_id).toBe(DETERMINISTIC_MODEL_ID); + }); + it("builds a routable Anthropic record", () => { const record = anthropicModelRecord("claude-sonnet-5"); expect(record.model_id).toBe("claude-sonnet-5"); @@ -388,8 +417,8 @@ describe("registerSecModels", () => { await registerSecModels(); const repo = getGlobalModelRepository(); expect((await repo.findByName("claude-haiku-4-5"))?.provider).toBe("ANTHROPIC"); - // The two always-registered defaults (cloud + local HFT) plus the override. - expect(await repo.size()).toBe(3); + // Always-registered: cloud default + local HFT + deterministic, plus the override. + expect(await repo.size()).toBe(4); }); }); diff --git a/src/config/registerModels.ts b/src/config/registerModels.ts index f41c23e4..e1ef2d07 100644 --- a/src/config/registerModels.ts +++ b/src/config/registerModels.ts @@ -12,6 +12,7 @@ import { parseModelIdList, SecHftModelDefault, DEFAULT_SEC_MODEL, + DETERMINISTIC_MODEL_ID, } from "./Constants"; import { SecCliConfigurationError } from "./EnvToDI"; import { listPricingForModelId } from "./listPricing"; @@ -588,7 +589,21 @@ export const KNOWN_MODEL_ID_SHAPES = "onnx:org/name (local HuggingFace ONNX), " + "llama:… / node-llama:… / gguf:… (local node-llama-cpp), " + "hfi:[provider:]org/name (HuggingFace Inference), " + - "open-router:[provider:]vendor/model (OpenRouter)"; + "open-router:[provider:]vendor/model (OpenRouter), " + + "deterministic (sync parser, no provider)"; + +export function deterministicModelRecord(): ModelRecord { + return { + model_id: DETERMINISTIC_MODEL_ID, + provider: "", + title: "Deterministic parser", + description: "Sync table/prose walk; no provider", + capabilities: [], + provider_config: {}, + metadata: {}, + pricing: listPricingForModelId(DETERMINISTIC_MODEL_ID), + }; +} /** * {@link secModelRecord} without the unknown-id throw: `undefined` when no @@ -599,6 +614,7 @@ export const KNOWN_MODEL_ID_SHAPES = * simply isn't ours to route, so those callers must not treat it as a failure. */ export function trySecModelRecord(modelId: string): ModelRecord | undefined { + if (modelId === DETERMINISTIC_MODEL_ID) return deterministicModelRecord(); if (isLlamaCppModelId(modelId)) return llamaCppModelRecord(modelId); if (isHftModelId(modelId)) return hftModelRecord(modelId); if (isHfInferenceModelId(modelId)) return hfInferenceModelRecord(modelId); @@ -678,7 +694,7 @@ export function secModelRecord(modelId: string): ModelRecord { * this config module decoupled from `src/sec/`. */ function secModelIds(): string[] { - const ids = new Set([...defaultModelIds(), SecHftModelDefault]); + const ids = new Set([...defaultModelIds(), SecHftModelDefault, DETERMINISTIC_MODEL_ID]); for (const key of [ "SEC_S1_MODEL", "SEC_S1_CLASSIFIER_MODEL", diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts index 20cdfc5e..988e700f 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts @@ -5,11 +5,12 @@ */ import { parseNumeric } from "../../../html/parseNumeric"; +import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; import { anchorFieldSpan } from "./anchorFieldSpan"; import type { OfferingTermsRow } from "./offeringTermsSchema"; import type { SponsorPromoteRow } from "./sponsorPromoteSchema"; -export const DETERMINISTIC_MODEL_ID = "deterministic"; +export { DETERMINISTIC_MODEL_ID }; const PRICE_MIN = 8; const PRICE_MAX = 12; diff --git a/src/task/model/EnsureModelDownloadedTask.test.ts b/src/task/model/EnsureModelDownloadedTask.test.ts index 89785845..97ce90f9 100644 --- a/src/task/model/EnsureModelDownloadedTask.test.ts +++ b/src/task/model/EnsureModelDownloadedTask.test.ts @@ -53,6 +53,10 @@ describe("EnsureModelDownloadedTask / ensureModelDownloaded", () => { await expect(ensureModelDownloaded("grok-4.6", ctx())).rejects.toThrow(/model\.info/i); }); + it("no-ops for the deterministic reserved id", async () => { + await expect(ensureModelDownloaded("deterministic", ctx())).resolves.toBeUndefined(); + }); + it("is a no-op for an id whose shape sec does not route", async () => { // Such an id is legal — a record registered straight into the model // repository by an operator, a harness, or a test fixture. It is simply not From 9c093065adc1c3468d52ccf082cf319c1ae2a3ba Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:09:49 -0700 Subject: [PATCH 18/29] feat(sec): dispatch deterministic slots inside modelExtractChain A list id of deterministic runs the section pass (or []) instead of StructuredGenerationTask. Co-authored-by: Cursor --- .../s1/s1Model.test.ts | 118 +++++++++++------- .../registration-statements/s1/s1Model.ts | 38 +++++- .../s1/sectionRunner.ts | 8 ++ 3 files changed, 113 insertions(+), 51 deletions(-) diff --git a/src/sec/forms/registration-statements/s1/s1Model.test.ts b/src/sec/forms/registration-statements/s1/s1Model.test.ts index 8872f5fd..509be83d 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.test.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.test.ts @@ -4,60 +4,86 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, describe, expect, it } from "vitest"; -import { DEFAULT_SEC_MODEL, parseModelIdList, SecModelDefault } from "../../../../config/Constants"; -import { getS1ModelId, getS1ModelIds } from "./s1Model"; +import { describe, expect, it } from "vitest"; +import type { ModelConfig } from "workglow"; +import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; +import { deterministicModelConfig, modelExtractChain, resolveModelId } from "./s1Model"; -const ORIG_S1 = process.env.SEC_S1_MODEL; -const ORIG_DEFAULT = process.env.SEC_MODEL_DEFAULT; -afterEach(() => { - if (ORIG_S1 === undefined) delete process.env.SEC_S1_MODEL; - else process.env.SEC_S1_MODEL = ORIG_S1; - if (ORIG_DEFAULT === undefined) delete process.env.SEC_MODEL_DEFAULT; - else process.env.SEC_MODEL_DEFAULT = ORIG_DEFAULT; -}); +function ai(id: string): ModelConfig { + return { + model_id: id, + provider: "fake", + capabilities: [], + provider_config: {}, + metadata: {}, + } as ModelConfig; +} -describe("parseModelIdList", () => { - it("treats a scalar as a one-element list", () => { - expect(parseModelIdList("claude-opus-5", DEFAULT_SEC_MODEL)).toEqual(["claude-opus-5"]); - }); - it("splits a CSV, trims, and drops duplicates and empty parts", () => { - expect( - parseModelIdList(" claude-sonnet-5, , claude-haiku-4-5, claude-sonnet-5 ", DEFAULT_SEC_MODEL) - ).toEqual(["claude-sonnet-5", "claude-haiku-4-5"]); - }); - it("falls back when the value is unset or blank", () => { - expect(parseModelIdList(undefined, DEFAULT_SEC_MODEL)).toEqual([DEFAULT_SEC_MODEL]); - expect(parseModelIdList(" ", DEFAULT_SEC_MODEL)).toEqual([DEFAULT_SEC_MODEL]); +describe("deterministicModelConfig", () => { + it("exposes the reserved id", () => { + expect(resolveModelId(deterministicModelConfig())).toBe(DETERMINISTIC_MODEL_ID); }); }); -describe("getS1ModelId", () => { - it("returns the configured model id from SEC_S1_MODEL", () => { - process.env.SEC_S1_MODEL = "claude-opus-5"; - expect(getS1ModelId()).toBe("claude-opus-5"); - }); - it("returns the first id when SEC_S1_MODEL is a CSV list", () => { - process.env.SEC_S1_MODEL = "claude-sonnet-5,claude-haiku-4-5"; - expect(getS1ModelId()).toBe("claude-sonnet-5"); +describe("modelExtractChain", () => { + it("runs the pass at the deterministic slot and the AI extract at others", async () => { + const seen: string[] = []; + const chain = modelExtractChain( + [deterministicModelConfig(), ai("claude-haiku-4-5")], + async (_text, m) => { + seen.push(resolveModelId(m) ?? "none"); + return [{ confidence: 1 }]; + }, + { + deterministic: { + extract: () => [{ confidence: 1, via: "walk" }], + covers: new Set(["t"]), + }, + clears: new Set(["t"]), + } + ); + expect(chain.modelIds).toEqual(["deterministic", "claude-haiku-4-5"]); + const walk = await chain.extract("hello"); + expect(walk).toEqual([{ confidence: 1, via: "walk" }]); + expect(seen).toEqual([]); + const fallback = await chain.emptyExtracts![0]!("hello"); + expect(fallback).toEqual([{ confidence: 1 }]); + expect(seen).toEqual(["claude-haiku-4-5"]); }); - // Asserts the fallback *wiring* — that an unset override defers to the shared - // default — not which model that default happens to name. Pinning the literal - // made every change to `DEFAULT_SEC_MODEL` fail this and its two siblings. - it("falls back to the shared default model id when unset", () => { - delete process.env.SEC_S1_MODEL; - expect(getS1ModelId()).toBe(SecModelDefault); + + it("returns [] for a deterministic slot when no pass is provided", async () => { + const chain = modelExtractChain([deterministicModelConfig()], async () => [{ confidence: 1 }]); + expect(await chain.extract("x")).toEqual([]); }); -}); -describe("getS1ModelIds", () => { - it("returns the full CSV list from SEC_S1_MODEL", () => { - process.env.SEC_S1_MODEL = "claude-sonnet-5,claude-haiku-4-5"; - expect(getS1ModelIds()).toEqual(["claude-sonnet-5", "claude-haiku-4-5"]); + it("returns [] when covers does not preempt clears", async () => { + const chain = modelExtractChain( + [deterministicModelConfig()], + async () => [{ confidence: 1 }], + { + deterministic: { + extract: () => [{ confidence: 1, via: "walk" }], + covers: new Set(["a"]), + }, + clears: new Set(["a", "b"]), + } + ); + expect(await chain.extract("x")).toEqual([]); }); - it("inherits the full SEC_MODEL_DEFAULT list when the override is unset", () => { - delete process.env.SEC_S1_MODEL; - process.env.SEC_MODEL_DEFAULT = "claude-sonnet-5,claude-haiku-4-5"; - expect(getS1ModelIds()).toEqual(["claude-sonnet-5", "claude-haiku-4-5"]); + + it("places the walk last when deterministic is last in the list", async () => { + const chain = modelExtractChain( + [ai("claude-haiku-4-5"), deterministicModelConfig()], + async () => [{ confidence: 0.9, via: "ai" }], + { + deterministic: { + extract: () => [{ confidence: 1, via: "walk" }], + covers: new Set(["t"]), + }, + clears: new Set(["t"]), + } + ); + expect(await chain.extract("x")).toEqual([{ confidence: 0.9, via: "ai" }]); + expect(await chain.emptyExtracts![0]!("x")).toEqual([{ confidence: 1, via: "walk" }]); }); }); diff --git a/src/sec/forms/registration-statements/s1/s1Model.ts b/src/sec/forms/registration-statements/s1/s1Model.ts index f173b080..bb8d3596 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.ts @@ -6,7 +6,10 @@ import type { ModelConfig } from "workglow"; import { getGlobalModelRepository } from "workglow"; -import { modelIdsFromEnv } from "../../../../config/Constants"; +import { DETERMINISTIC_MODEL_ID, modelIdsFromEnv } from "../../../../config/Constants"; +import { deterministicModelRecord } from "../../../../config/registerModels"; +import type { DeterministicPass } from "./deterministicPass"; +import { preempts } from "./deterministicPass"; import type { RunSectionArgs } from "./sectionRunner"; /** The model ids used for S-1 extraction; overridable via SEC_S1_MODEL (CSV). */ @@ -80,26 +83,51 @@ export function persistModelId(models: readonly ModelConfig[], modelIndex: numbe return resolveModelId(models[modelIndex] ?? models[0]!); } +export function deterministicModelConfig(): ModelConfig { + return deterministicModelRecord() as ModelConfig; +} + +export function isDeterministicModel(model: ModelConfig): boolean { + return resolveModelId(model) === DETERMINISTIC_MODEL_ID; +} + /** * Primary extract plus fallbacks for {@link makeRunSection}. Later models run * when the previous extract returned `[]` **or threw** a provider/extraction * error (abort, config, and mixed-shape re-asks stay on the throwing model). * Pass `{ fallbackOnEmpty: false }` for detectors where `[]` is the expected * negative (redemption / LOI) so a later model only runs on a throw. + * A list id of {@link DETERMINISTIC_MODEL_ID} runs {@link options.deterministic} + * (or `[]` if omitted / if it does not cover `clears`). */ export function modelExtractChain( models: readonly ModelConfig[], extract: (text: string, model: ModelConfig) => Promise, - options?: { readonly fallbackOnEmpty?: boolean } -): Pick, "extract" | "emptyExtracts" | "modelIds" | "fallbackOnEmpty"> { + options?: { + readonly fallbackOnEmpty?: boolean; + readonly deterministic?: DeterministicPass; + readonly clears?: ReadonlySet; + } +): Pick< + RunSectionArgs, + "extract" | "emptyExtracts" | "modelIds" | "fallbackOnEmpty" | "deterministicComplete" +> { const primary = models[0]; if (primary === undefined) { throw new Error("modelExtractChain requires at least one model"); } + const slot = (model: ModelConfig) => async (text: string): Promise => { + if (!isDeterministicModel(model)) return extract(text, model); + const pass = options?.deterministic; + if (pass === undefined) return []; + if (!preempts(pass, options.clears, text)) return []; + return [...pass.extract(text)]; + }; return { - extract: (text) => extract(text, primary), - emptyExtracts: models.slice(1).map((m) => (text: string) => extract(text, m)), + extract: slot(primary), + emptyExtracts: models.slice(1).map((m) => slot(m)), modelIds: models.map((m) => resolveModelId(m)).filter((id): id is string => id !== null), + deterministicComplete: options?.deterministic?.complete, ...(options?.fallbackOnEmpty === false ? { fallbackOnEmpty: false as const } : {}), }; } diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index 17592c61..65febb5f 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -159,6 +159,14 @@ export interface RunSectionArgs { readonly fallbackOnEmpty?: boolean; /** Ids tried for this section; named in the MODEL_EMPTY detail when length > 1. */ readonly modelIds?: readonly string[]; + /** + * When the persisted rows came from a `deterministic` list slot, + * `SectionPersistMeta.complete` is this callback (or false if omitted). + */ + readonly deterministicComplete?: ( + rows: readonly NoInfer[], + text: string + ) => boolean; readonly persist: (rows: TRow[], meta: SectionPersistMeta) => Promise; } From 0fcec880bf2ec181645c9ecdf4703ab0a117503d Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:12:41 -0700 Subject: [PATCH 19/29] feat(sec): one-shot the deterministic walk at its list index runSection no longer always walks first; a miss falls through without re-asking a pure function. Co-authored-by: Cursor --- .../s1/sectionRunner.deterministic.test.ts | 71 ++++-- .../s1/sectionRunner.ts | 204 +++++++++--------- 2 files changed, 161 insertions(+), 114 deletions(-) diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts index 920aad2a..8d64b627 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts @@ -5,7 +5,9 @@ */ import { describe, expect, it } from "vitest"; +import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; import type { ExtractionDeadLetterRepo } from "../../../../storage/dead-letter/ExtractionDeadLetterRepo"; +import { preempts } from "./deterministicPass"; import { makeRunSection } from "./sectionRunner"; import type { SectionPersistMeta } from "./sectionRunner"; @@ -47,6 +49,9 @@ function harness(overrides: { readonly complete?: (rows: readonly Row[], text: string) => boolean; readonly modelRows?: readonly Row[]; readonly verify?: boolean; + readonly modelIds?: readonly string[]; + readonly walkLast?: boolean; + readonly omitWalk?: boolean; }): { readonly run: () => Promise; readonly modelCalls: () => number; @@ -65,6 +70,27 @@ function harness(overrides: { let detCalls = 0; const persisted: Array<{ rows: Row[]; meta: SectionPersistMeta }> = []; const detRows = overrides.detRows ?? [{ confidence: 1, span: "alpha" }]; + const covers = overrides.covers ?? new Set(["person_observation"]); + const walk = async (text: string): Promise => { + detCalls++; + if ( + !preempts({ extract: () => detRows, covers }, overrides.clears, text) + ) { + return []; + } + return [...detRows]; + }; + const model = async (): Promise => { + modelCalls++; + return [...(overrides.modelRows ?? [{ confidence: 1, span: "bravo" }])]; + }; + const modelIds = + overrides.modelIds ?? + (overrides.omitWalk + ? ["fake-s1-model"] + : overrides.walkLast + ? ["fake-s1-model", DETERMINISTIC_MODEL_ID] + : [DETERMINISTIC_MODEL_ID, "fake-s1-model"]); const run = () => runSection({ sectionName: "management", @@ -73,18 +99,13 @@ function harness(overrides: { lowConfidenceDetail: "low", ...(overrides.verify === false ? {} : { verifyRow: (text, r) => text.includes(r.span) }), clears: overrides.clears, - deterministic: { - extract: () => { - detCalls++; - return detRows; - }, - covers: overrides.covers ?? new Set(["person_observation"]), - ...(overrides.complete === undefined ? {} : { complete: overrides.complete }), - }, - extract: async () => { - modelCalls++; - return [...(overrides.modelRows ?? [{ confidence: 1, span: "bravo" }])]; - }, + modelIds, + deterministicComplete: overrides.complete, + ...(overrides.omitWalk + ? { extract: model } + : overrides.walkLast + ? { extract: model, emptyExtracts: [walk] } + : { extract: walk, emptyExtracts: [model] }), persist: async (rows, meta) => { persisted.push({ rows, meta }); return rows.length; @@ -168,6 +189,7 @@ describe("makeRunSection deterministic pass", () => { const h = harness({ clears: new Set(["person_observation"]), covers: new Set(["person_observation"]), + complete: () => true, detRows: [ { confidence: 1, span: "alpha" }, { confidence: 1, span: "bravo" }, @@ -212,4 +234,29 @@ describe("makeRunSection deterministic pass", () => { expect(h.persisted[0]!.meta.source).toBe("deterministic"); expect(h.persisted[0]!.meta.complete).toBe(true); }); + + it("does not run the walk when deterministic is omitted from modelIds", async () => { + const h = harness({ omitWalk: true }); + await h.run(); + + expect(h.detCalls()).toBe(0); + expect(h.modelCalls()).toBe(1); + expect(h.persisted[0]!.meta.source).toBe("model"); + }); + + it("runs the walk only after an empty primary when deterministic is last", async () => { + const h = harness({ + walkLast: true, + modelRows: [], + clears: new Set(["person_observation"]), + covers: new Set(["person_observation"]), + complete: () => true, + }); + await h.run(); + + expect(h.modelCalls()).toBe(1); + expect(h.detCalls()).toBe(1); + expect(h.persisted[0]!.meta.source).toBe("deterministic"); + expect(h.persisted[0]!.rows.map((r) => r.span)).toEqual(["alpha"]); + }); }); diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index 65febb5f..f8b4a7ac 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -5,6 +5,7 @@ */ import { TaskAbortedError } from "workglow"; +import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; import type { ExtractionDeadLetterRepo } from "../../../../storage/dead-letter/ExtractionDeadLetterRepo"; import type { DeadLetterReasonCode } from "../../../../storage/dead-letter/ExtractionDeadLetterSchema"; import { SecCliConfigurationError } from "../../../../config/EnvToDI"; @@ -15,7 +16,6 @@ import { } from "./sectionExtractors"; import type { SpanVerdict } from "./verifySourceSpan"; import type { DeterministicPass } from "./deterministicPass"; -import { assertsCompletePopulation, preempts } from "./deterministicPass"; /** * Parse a confidence-floor env value. Undefined, empty, or non-numeric input @@ -29,6 +29,19 @@ export function parseConfidenceFloor(raw: string | undefined, fallback: number): return Number.isFinite(n) ? n : fallback; } +function walkClaimsComplete( + fn: ((rows: readonly TRow[], text: string) => boolean) | undefined, + rows: readonly TRow[], + text: string +): boolean { + if (fn === undefined) return false; + try { + return fn(rows, text) === true; + } catch { + return false; + } +} + const REJECTED_SPAN_DETAIL_CHARS = 300; /** @@ -268,127 +281,114 @@ export function makeRunSection(opts: { // own, smaller budget, and an attempt spent on one question must not // spend the other's. let mixedShapeAttempts = 0; - let extractFn = sargs.extract; let modelIndex = 0; - let triedEmptyFallbacks = false; - const fallbacks = sargs.emptyExtracts; + const slots: ReadonlyArray<(text: string) => Promise> = [ + sargs.extract, + ...(sargs.emptyExtracts ?? []), + ]; const fallbackOnEmpty = sargs.fallbackOnEmpty !== false; const isImmediateExtractFailure = (e: unknown): boolean => e instanceof TaskAbortedError || e instanceof SecCliConfigurationError || e instanceof MixedRiskCaptionShapeError || opts.signal?.aborted === true; - const runEmptyFallbacks = async (priorError: unknown | undefined): Promise => { - triedEmptyFallbacks = true; - let lastError: unknown = priorError; - let lastRaw: TRow[] = []; - for (let i = 0; i < fallbacks!.length; i++) { - extractFn = fallbacks![i]!; - modelIndex = i + 1; - try { - lastRaw = await extractFn(text); - lastError = undefined; - if (lastRaw.length > 0) return lastRaw; - } catch (fe) { - if (isImmediateExtractFailure(fe)) throw fe; - lastError = fe; - } + const isWalkSlot = (i: number): boolean => + sargs.modelIds?.[i] === DETERMINISTIC_MODEL_ID; + const applyRowFilters = (incoming: TRow[]): void => { + raw = incoming; + confident = raw.filter((r) => r.confidence >= floor); + droppedUnverified = 0; + droppedTooLong = 0; + if (verifyRow !== undefined && confident.length > 0) { + rows = confident.filter((r) => { + const verdict = verifyRow(text, r); + if (verdict === true || verdict === "ok") return true; + if (verdict === "too-long") droppedTooLong++; + return false; + }); + droppedUnverified = confident.length - rows.length; + } else { + rows = confident; } - if (lastError !== undefined) throw lastError; - return lastRaw; }; - // The deterministic pass runs ONCE, ahead of the retry loop and outside - // it. It is a pure function of the section text, so a second identical - // call cannot produce a different answer; re-asking it would only burn - // attempts, and dead-lettering its shortfall would record the model as - // having failed a section it was never given. + const clearSlot = (): void => { + raw = []; + confident = []; + rows = []; + droppedUnverified = 0; + droppedTooLong = 0; + }; let source: "deterministic" | "model" = "model"; - let deterministicComplete = false; - const pass = sargs.deterministic; - if (pass !== undefined && preempts(pass, sargs.clears, text)) { - const detRaw = pass.extract(text); - const detConfident = detRaw.filter((r) => r.confidence >= floor); - const detRows = - verifyRow === undefined - ? detConfident - : detConfident.filter((r) => { - const verdict = verifyRow(text, r); - return verdict === true || verdict === "ok"; - }); - // All or nothing, on two axes. Every row the parse returned has to - // survive filtering — a partial parse persists a subset of a section - // the caller has already cleared — and the parse has to claim those - // rows are the whole population. `covers` speaks only for columns, so - // without the second test a walk that found some of the rows fills a - // cleared table with them and resolves the section as complete. - const complete = assertsCompletePopulation(pass, detRows, text); - if (complete && detRaw.length > 0 && detRows.length === detRaw.length) { - raw = [...detRaw]; - confident = [...detConfident]; - rows = [...detRows]; - source = "deterministic"; - deterministicComplete = true; + let walkComplete = false; + let lastError: unknown; + for (let i = 0; i < slots.length; i++) { + const extractFn = slots[i]!; + modelIndex = i; + if (isWalkSlot(i)) { + try { + applyRowFilters(await extractFn(text)); + lastError = undefined; + } catch (e) { + if (isImmediateExtractFailure(e)) throw e; + lastError = e; + clearSlot(); + continue; + } + const complete = walkClaimsComplete(sargs.deterministicComplete, rows, text); + if (complete && raw.length > 0 && rows.length === raw.length) { + source = "deterministic"; + walkComplete = true; + lastError = undefined; + break; + } + clearSlot(); + continue; } - } - if (source === "model") { + mixedShapeAttempts = 0; + let slotFailed = false; for (let attempt = 1; attempt <= VERIFICATION_ATTEMPTS; attempt++) { try { - try { - raw = await extractFn(text); - if ( - raw.length === 0 && - fallbackOnEmpty && - !triedEmptyFallbacks && - fallbacks !== undefined && - fallbacks.length > 0 - ) { - raw = await runEmptyFallbacks(undefined); - } - } catch (e) { - if (isImmediateExtractFailure(e)) throw e; - if (!triedEmptyFallbacks && fallbacks !== undefined && fallbacks.length > 0) { - raw = await runEmptyFallbacks(e); - } else { + applyRowFilters(await extractFn(text)); + lastError = undefined; + slotFailed = false; + } catch (e) { + if (e instanceof MixedRiskCaptionShapeError) { + mixedShapeAttempts++; + if (mixedShapeAttempts >= MIXED_SHAPE_REASK_ATTEMPTS) { + e.message = `${e.message} (unchanged after ${mixedShapeAttempts} attempt(s))`; throw e; } + continue; } - } catch (e) { - // A mixed caption shape is a property of ONE generation, not a verdict - // about the section: the model echoed a category heading back as a - // row, and the next call usually does not. Without this the throw - // escapes the loop entirely and the section gets zero re-asks, unlike - // every other recoverable response-shape failure here. - if (!(e instanceof MixedRiskCaptionShapeError)) throw e; - mixedShapeAttempts++; - if (mixedShapeAttempts >= MIXED_SHAPE_REASK_ATTEMPTS) { - // Say what the re-ask cost, so the dead-letter detail records it - // rather than reading as a single unlucky generation. - e.message = `${e.message} (unchanged after ${mixedShapeAttempts} attempt(s))`; - throw e; - } - continue; - } - confident = raw.filter((r) => r.confidence >= floor); - droppedUnverified = 0; - droppedTooLong = 0; - if (verifyRow !== undefined && confident.length > 0) { - rows = confident.filter((r) => { - const verdict = verifyRow(text, r); - if (verdict === true || verdict === "ok") return true; - if (verdict === "too-long") droppedTooLong++; - return false; - }); - droppedUnverified = confident.length - rows.length; - } else { - rows = confident; + if (isImmediateExtractFailure(e)) throw e; + lastError = e; + slotFailed = true; + clearSlot(); + break; } - // Only a total verification wipeout is worth re-asking. An empty or - // all-low-confidence response is a judgement about the text rather - // than a malformed citation, and re-rolling it just burns calls. if (rows.length > 0 || droppedUnverified !== confident.length || confident.length === 0) { break; } } + if (rows.length > 0) { + source = "model"; + lastError = undefined; + break; + } + if (droppedUnverified > 0 && droppedUnverified === confident.length) { + lastError = undefined; + break; + } + if (raw.length > 0) { + lastError = undefined; + break; + } + if (!fallbackOnEmpty && !slotFailed) { + break; + } + } + if (lastError !== undefined && rows.length === 0 && raw.length === 0) { + throw lastError; } if (rows.length === 0) { const allDroppedUnverified = @@ -422,7 +422,7 @@ export function makeRunSection(opts: { // On the deterministic path `raw` is already the parser's surviving // output, so counting it would report every parse as complete. The // pass says so itself, or it does not say so at all. - complete: source === "deterministic" ? deterministicComplete : rows.length === raw.length, + complete: source === "deterministic" ? walkComplete : rows.length === raw.length, modelIndex, source, }); From 1427eb8a6b17b23974a686fac8adfe093c98e14f Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:16:01 -0700 Subject: [PATCH 20/29] feat(sec): take the section walk from the model list Production wraps pass DeterministicPass into modelExtractChain; omit the id and the walk does not run. Co-authored-by: Cursor --- .../Form_424.storage.ts | 3 +- .../Form_S_1.storage.ts | 233 +++++++++++------- .../s1/offeringSections.ts | 161 +++++++----- .../s1/s1Model.test.ts | 18 +- .../registration-statements/s1/s1Model.ts | 16 +- .../s1/sectionRunner.deterministic.test.ts | 4 +- .../s1/sectionRunner.ts | 33 +-- .../s1/spacClassifierModel.ts | 34 +-- 8 files changed, 285 insertions(+), 217 deletions(-) diff --git a/src/sec/forms/registration-statements/Form_424.storage.ts b/src/sec/forms/registration-statements/Form_424.storage.ts index f1bcbd85..000d3f84 100644 --- a/src/sec/forms/registration-statements/Form_424.storage.ts +++ b/src/sec/forms/registration-statements/Form_424.storage.ts @@ -146,6 +146,7 @@ export interface ProcessForm424Args { readonly form: string; readonly form424: FormS1Parsed; readonly model?: ModelConfig; + readonly models?: readonly ModelConfig[]; readonly context?: IExecuteContext; } @@ -308,7 +309,7 @@ export async function processForm424(args: ProcessForm424Args): Promise { // still records, mirroring the "XBRL failures never abort the filing" contract. let models: ModelConfig[] = []; try { - models = args.model ? [args.model] : await getS1Models(); + models = args.models ? [...args.models] : args.model ? [args.model] : await getS1Models(); } catch (err) { const detail = err instanceof Error ? err.message : String(err); for (const section of offeringSectionNames(isSpac)) { diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 24519643..05923f4c 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -54,7 +54,6 @@ import { parseRelatedPartyTables } from "./s1/parseRelatedPartyTables"; import { parseSpacSponsors } from "./s1/parseSpacSponsors"; import { parseSpacProfile } from "./s1/parseSpacProfile"; import { parseSpacClassification } from "./s1/parseSpacClassification"; -import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; import { looksLikePartIIOnlyAmendment } from "./s1/partIIOnlyAmendment"; import { issuerHasCombinationListing } from "./s1/newcoListing"; import { MAX_RISK_FACTORS_CHARS } from "./s1/riskFactorChunks"; @@ -69,7 +68,10 @@ import type { RiskFactorRow } from "./s1/riskFactorSchema"; import { getRiskFactorsConfidenceFloor, getRiskFactorsModels } from "./s1/riskFactorsModel"; import type { SpacClassificationRow } from "./s1/spacClassifierSchema"; import { looksLikeBlankCheck } from "./s1/spacContentHeuristic"; -import { getSpacClassifierConfidenceFloor, getSpacClassifierModel } from "./s1/spacClassifierModel"; +import { + getSpacClassifierConfidenceFloor, + getSpacClassifierModels, +} from "./s1/spacClassifierModel"; import type { SpacProfileRow } from "./s1/spacProfileSchema"; import type { BeneficialOwnerRow, ManagementPersonRow, RelatedPartyRow } from "./s1/sectionSchemas"; import { makeRunSection } from "./s1/sectionRunner"; @@ -182,6 +184,7 @@ export interface ProcessFormS1Args { readonly form: string; readonly formS1: FormS1Parsed; readonly model?: ModelConfig; + readonly models?: readonly ModelConfig[]; readonly context?: IExecuteContext; readonly extractRiskFactors?: boolean; } @@ -196,7 +199,7 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { let models: ModelConfig[] = []; let modelError: string | null = null; try { - models = args.model ? [args.model] : await getS1Models(); + models = args.models ? [...args.models] : args.model ? [args.model] : await getS1Models(); } catch (err) { modelError = err instanceof Error ? err.message : String(err); } @@ -618,17 +621,20 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { // (markResolved no-ops when no entry exists.) await deadLetters.markResolved(EXTRACTOR_ID, accession_number, "spac-classification"); } else { - let classifierModel: ModelConfig | null = null; + let classifierModels: ModelConfig[] = []; let classifierError: string | null = null; try { - classifierModel = args.model ?? (await getSpacClassifierModel()); + classifierModels = args.models + ? [...args.models] + : args.model + ? [args.model] + : await getSpacClassifierModels(); } catch (err) { classifierError = err instanceof Error ? err.message : String(err); } - if (classifierModel === null) { + if (classifierModels.length === 0) { await recordFail("spac-classification", "MODEL_RESOLUTION_ERROR", classifierError); } else { - const classifierModelResolved = classifierModel; const classifierHolder: { upgraded: boolean; source: "ai" | "deterministic" } = { upgraded: false, source: "ai", @@ -650,20 +656,26 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { unverifiedAllDetail: "the confident SPAC classification had source_span not present in section text", clears: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), - deterministic: { - extract: (text) => { - const det = parseSpacClassification(text); - return det === null ? [] : [det]; + ...modelExtractChain( + classifierModels, + async (text, m) => { + const c = await extractSpacClassification(text, m, args.context); + return c === null ? [] : [c]; }, - covers: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), - // A filing is classified once, so the row IS the population: there - // is no second verdict the walk could have missed. - complete: (rows) => rows.length === 1, - }, - extract: async (text) => { - const c = await extractSpacClassification(text, classifierModelResolved, args.context); - return c === null ? [] : [c]; - }, + { + deterministic: { + extract: (text) => { + const det = parseSpacClassification(text); + return det === null ? [] : [det]; + }, + covers: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), + // A filing is classified once, so the row IS the population: there + // is no second verdict the walk could have missed. + complete: (rows) => rows.length === 1, + }, + clears: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), + } + ), persist: async (_rows, meta) => { classifierHolder.upgraded = true; if (meta.source === "deterministic") classifierHolder.source = "deterministic"; @@ -725,17 +737,23 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { // sentences and nothing else, so the narrative fields would be handed to // `recordRegistration` as nulls on this filing and on every replay, with // the section resolving clean and nothing flagging the gap. - deterministic: { - extract: (text) => { - const det = parseSpacProfile(text); - return det === null ? [] : [det]; + ...modelExtractChain( + models, + async (text, m) => { + const p = await extractSpacProfile(text, m, args.context); + return p === null ? [] : [p]; }, - covers: new Set(["spac.focus", "spac.focus_location"]), - }, - ...modelExtractChain(models, async (text, m) => { - const p = await extractSpacProfile(text, m, args.context); - return p === null ? [] : [p]; - }), + { + deterministic: { + extract: (text) => { + const det = parseSpacProfile(text); + return det === null ? [] : [det]; + }, + covers: new Set(["spac.focus", "spac.focus_location"]), + }, + clears: new Set(["spac.focus", "spac.focus_location", "spac.description", "spac.team"]), + } + ), persist: async (rows) => { profileHolder.row = rows[0]; return 1; @@ -791,20 +809,24 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "person_observation.bio", "observation_provenance", ]), - deterministic: { - extract: parseManagementRoster, - covers: new Set([ + ...modelExtractChain(models, (text, m) => extractManagement(text, m, args.context), { + deterministic: { + extract: parseManagementRoster, + covers: new Set([ + "person_observation.titles", + "person_observation.birth_year", + "observation_provenance", + ]), + }, + clears: new Set([ "person_observation.titles", "person_observation.birth_year", + "person_observation.bio", "observation_provenance", ]), - }, - ...modelExtractChain(models, (text, m) => extractManagement(text, m, args.context)), + }), persist: async (rows, meta) => { - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); for (const r of rows) { const name = splitPersonName(r.full_name); const { observation_id } = await observer.observePerson({ @@ -894,16 +916,26 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { // number, so the rows it returns are not the table's roster and no // `complete` can be derived from the same walk. It therefore does not // preempt, whatever the coverage function answers. - deterministic: { - extract: parseBeneficialOwnership, - covers: ownershipCoverage, - }, - ...modelExtractChain(models, (text, m) => extractBeneficialOwnership(text, m, args.context)), + ...modelExtractChain(models, (text, m) => extractBeneficialOwnership(text, m, args.context), { + deterministic: { + extract: parseBeneficialOwnership, + covers: ownershipCoverage, + }, + clears: new Set([ + "beneficial_ownership.shares_owned", + "beneficial_ownership.percent_owned", + "beneficial_ownership.shares_offered", + "beneficial_ownership.shares_after", + "beneficial_ownership.percent_after", + "beneficial_ownership.is_selling_stockholder", + "beneficial_ownership.footnote", + "person_observation", + "company_observation", + "observation_provenance", + ]), + }), persist: async (rows, meta) => { - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); for (const r of rows) { if (r.owner_kind === "company" && isUnnamedCompanyName(r.name)) continue; const observation_index = idx++; @@ -980,16 +1012,20 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { // transaction, and `related_party_transaction` is cleared above. The // disclosure IS the transaction — a party with no dollar figure, period or // nature records that someone was mentioned, not what they were paid. - deterministic: { - extract: parseRelatedPartyTables, - covers: new Set(["person_observation", "company_observation", "observation_provenance"]), - }, - ...modelExtractChain(models, (text, m) => extractRelatedParty(text, m, args.context)), + ...modelExtractChain(models, (text, m) => extractRelatedParty(text, m, args.context), { + deterministic: { + extract: parseRelatedPartyTables, + covers: new Set(["person_observation", "company_observation", "observation_provenance"]), + }, + clears: new Set([ + "related_party_transaction", + "person_observation", + "company_observation", + "observation_provenance", + ]), + }), persist: async (rows, meta) => { - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); // Check every row against the storage schema's own declared bounds BEFORE // writing any of them. This persist spans three storages (observations, // provenance, transactions) and `withTransaction` is scoped to a single @@ -1140,31 +1176,46 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "person_observation", "observation_provenance", ]), - deterministic: { - extract: parseSummaryCompensationTable, - covers: new Set([ - "executive_compensation.principal_position", - "executive_compensation.fiscal_year", - "executive_compensation.salary", - "executive_compensation.bonus", - "executive_compensation.stock_awards", - "executive_compensation.option_awards", - "executive_compensation.non_equity_incentive", - "executive_compensation.pension_and_nqdc", - "executive_compensation.all_other_compensation", - "executive_compensation.total", - "person_observation", - "observation_provenance", - ]), - }, - ...modelExtractChain(models, (text, m) => - extractExecutiveCompensation(text, m, args.context) + ...modelExtractChain( + models, + (text, m) => extractExecutiveCompensation(text, m, args.context), + { + deterministic: { + extract: parseSummaryCompensationTable, + covers: new Set([ + "executive_compensation.principal_position", + "executive_compensation.fiscal_year", + "executive_compensation.salary", + "executive_compensation.bonus", + "executive_compensation.stock_awards", + "executive_compensation.option_awards", + "executive_compensation.non_equity_incentive", + "executive_compensation.pension_and_nqdc", + "executive_compensation.all_other_compensation", + "executive_compensation.total", + "person_observation", + "observation_provenance", + ]), + }, + clears: new Set([ + "executive_compensation.principal_position", + "executive_compensation.fiscal_year", + "executive_compensation.salary", + "executive_compensation.bonus", + "executive_compensation.stock_awards", + "executive_compensation.option_awards", + "executive_compensation.non_equity_incentive", + "executive_compensation.pension_and_nqdc", + "executive_compensation.all_other_compensation", + "executive_compensation.total", + "executive_compensation.footnote", + "person_observation", + "observation_provenance", + ]), + } ), persist: async (rows, meta) => { - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); // An officer shown for two fiscal years is two table rows but ONE // mention of that person, so the observation is minted once and reused; // the row key is positional and independent of it. @@ -1426,21 +1477,25 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { // the caller has already cleared would be rebuilt with one of two sponsors // and resolved clean. Nothing derived from those two patterns can say the // prose named no other sponsor, so no `complete` is declared. - deterministic: { - extract: parseSpacSponsors, - covers: new Set([ + ...modelExtractChain(models, (text, m) => extractSpacSponsors(text, m, args.context), { + deterministic: { + extract: parseSpacSponsors, + covers: new Set([ + "spac_sponsor_link", + "sponsor_family_membership", + "company_observation", + "observation_provenance", + ]), + }, + clears: new Set([ "spac_sponsor_link", "sponsor_family_membership", "company_observation", "observation_provenance", ]), - }, - ...modelExtractChain(models, (text, m) => extractSpacSponsors(text, m, args.context)), + }), persist: async (rows, meta) => { - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); let wrote = 0; const splits = rows.map((r) => splitParentClause(r.legal_name?.trim() ?? "")); const extractedNames = splits.map((s) => s.observationName); diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index cd927e61..de82ed30 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -42,7 +42,6 @@ import { verifyNumericObjectSpan } from "./verifyNumericObjectSpan"; import { anchorFieldSpan } from "./anchorFieldSpan"; import { FieldProvenanceRepo } from "../../../../storage/provenance/FieldProvenanceRepo"; import { - DETERMINISTIC_MODEL_ID, parseSpacOfferingTerms, parseSpacPromoteTerms, promoteCoverage, @@ -286,25 +285,33 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { - const det = parseSpacOfferingTerms(text); - return det === null ? [] : [det]; - }, - covers: new Set([termsTable, `field_provenance:${termsTable}`]), - } - : undefined, - ...modelExtractChain(models, async (text, m) => { - const terms = await extractOfferingTerms(text, m, context); - return terms === null ? [] : [terms]; - }), + ...modelExtractChain( + models, + async (text, m) => { + const terms = await extractOfferingTerms(text, m, context); + return terms === null ? [] : [terms]; + }, + { + deterministic: isSpac + ? { + extract: (text) => { + const det = parseSpacOfferingTerms(text); + return det === null ? [] : [det]; + }, + covers: new Set([termsTable, `field_provenance:${termsTable}`]), + } + : undefined, + clears: new Set([ + termsTable, + `field_provenance:${termsTable}`, + "issuer_ticker", + "field_provenance:issuer_ticker", + ]), + } + ), persist: async (rows, meta) => { const terms = rows[0]; - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); const now = new Date().toISOString(); if (isSpac) { await spacUnitTermsRepo.save({ @@ -458,27 +465,39 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { - const det = parseSpacPromoteTerms(text); - return det === null ? [] : [det]; + ...modelExtractChain( + models, + async (text, m) => { + const promote = await extractSponsorPromote(text, m, context); + return promote === null ? [] : [promote]; }, - covers: promoteCoverage, - // `spac_promote_terms` is keyed by (extractor, accession): one row per - // filing, so producing it IS enumerating the population. Which of its - // columns that row may state is `promoteCoverage`'s question. - complete: (rows) => rows.length === 1, - }, - ...modelExtractChain(models, async (text, m) => { - const promote = await extractSponsorPromote(text, m, context); - return promote === null ? [] : [promote]; - }), + { + deterministic: { + extract: (text) => { + const det = parseSpacPromoteTerms(text); + return det === null ? [] : [det]; + }, + covers: promoteCoverage, + // `spac_promote_terms` is keyed by (extractor, accession): one row per + // filing, so producing it IS enumerating the population. Which of its + // columns that row may state is `promoteCoverage`'s question. + complete: (rows) => rows.length === 1, + }, + clears: new Set([ + "spac_promote_terms.founder_shares", + "spac_promote_terms.founder_percent", + "spac_promote_terms.private_placement_warrants", + "spac_promote_terms.private_placement_warrant_price", + "spac_promote_terms.public_warrant_coverage", + "spac_promote_terms.trust_per_public_share", + "spac_promote_terms.trust_total", + "field_provenance:spac_promote_terms", + ]), + } + ), persist: async (rows, meta) => { const promote = rows[0]; - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); await spacPromoteTermsRepo.save({ extractor_id, accession_number, @@ -559,23 +578,29 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise extractUnderwriters(text, m, context)), + ...modelExtractChain(models, (text, m) => extractUnderwriters(text, m, context), { + deterministic: isSpac + ? { + extract: parseSpacUnderwriters, + covers: new Set([ + "underwriter_link.shares_allocated", + "underwriter_family_membership", + "company_observation", + "observation_provenance", + ]), + } + : undefined, + clears: new Set([ + "underwriter_link.role_detail", + "underwriter_link.shares_allocated", + "underwriter_link.over_allotment_shares", + "underwriter_family_membership", + "company_observation", + "observation_provenance", + ]), + }), persist: async (rows, meta) => { - const model_id = - meta.source === "deterministic" - ? DETERMINISTIC_MODEL_ID - : persistModelId(models, meta.modelIndex); + const model_id = persistModelId(models, meta.modelIndex); let wrote = 0; // One underwriter, one link row. The model repeats an underwriter across // rows more often than not — a sole-underwriter filing came back with the @@ -678,21 +703,23 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { - const det = parseSpacUseOfProceeds(text); - return det.length >= 2 ? det : []; - }, - covers: new Set(["use_of_proceeds"]), - // `use_of_proceeds` holds one row per line item, so covering its - // columns says nothing about the rows. The walk's own decline log - // does: a labelled row it could not represent means the table was - // not enumerated, and the model gets the section. - complete: (_rows, text) => useOfProceedsIsComplete(text), - } - : undefined, - ...modelExtractChain(models, (text, m) => extractUseOfProceeds(text, m, context)), + ...modelExtractChain(models, (text, m) => extractUseOfProceeds(text, m, context), { + deterministic: isSpac + ? { + extract: (text) => { + const det = parseSpacUseOfProceeds(text); + return det.length >= 2 ? det : []; + }, + covers: new Set(["use_of_proceeds"]), + // `use_of_proceeds` holds one row per line item, so covering its + // columns says nothing about the rows. The walk's own decline log + // does: a labelled row it could not represent means the table was + // not enumerated, and the model gets the section. + complete: (_rows, text) => useOfProceedsIsComplete(text), + } + : undefined, + clears: new Set(["use_of_proceeds"]), + }), persist: async (rows) => { const now = new Date().toISOString(); let lineIndex = 0; diff --git a/src/sec/forms/registration-statements/s1/s1Model.test.ts b/src/sec/forms/registration-statements/s1/s1Model.test.ts index 509be83d..469d9df0 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.test.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.test.ts @@ -57,17 +57,13 @@ describe("modelExtractChain", () => { }); it("returns [] when covers does not preempt clears", async () => { - const chain = modelExtractChain( - [deterministicModelConfig()], - async () => [{ confidence: 1 }], - { - deterministic: { - extract: () => [{ confidence: 1, via: "walk" }], - covers: new Set(["a"]), - }, - clears: new Set(["a", "b"]), - } - ); + const chain = modelExtractChain([deterministicModelConfig()], async () => [{ confidence: 1 }], { + deterministic: { + extract: () => [{ confidence: 1, via: "walk" }], + covers: new Set(["a"]), + }, + clears: new Set(["a", "b"]), + }); expect(await chain.extract("x")).toEqual([]); }); diff --git a/src/sec/forms/registration-statements/s1/s1Model.ts b/src/sec/forms/registration-statements/s1/s1Model.ts index bb8d3596..1ccd9607 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.ts @@ -116,13 +116,15 @@ export function modelExtractChain( if (primary === undefined) { throw new Error("modelExtractChain requires at least one model"); } - const slot = (model: ModelConfig) => async (text: string): Promise => { - if (!isDeterministicModel(model)) return extract(text, model); - const pass = options?.deterministic; - if (pass === undefined) return []; - if (!preempts(pass, options.clears, text)) return []; - return [...pass.extract(text)]; - }; + const slot = + (model: ModelConfig) => + async (text: string): Promise => { + if (!isDeterministicModel(model)) return extract(text, model); + const pass = options?.deterministic; + if (pass === undefined) return []; + if (!preempts(pass, options.clears, text)) return []; + return [...pass.extract(text)]; + }; return { extract: slot(primary), emptyExtracts: models.slice(1).map((m) => slot(m)), diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts index 8d64b627..aedbb1ef 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts @@ -73,9 +73,7 @@ function harness(overrides: { const covers = overrides.covers ?? new Set(["person_observation"]); const walk = async (text: string): Promise => { detCalls++; - if ( - !preempts({ extract: () => detRows, covers }, overrides.clears, text) - ) { + if (!preempts({ extract: () => detRows, covers }, overrides.clears, text)) { return []; } return [...detRows]; diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index f8b4a7ac..3f4fecc2 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -15,7 +15,6 @@ import { RateLimitExhaustedError, } from "./sectionExtractors"; import type { SpanVerdict } from "./verifySourceSpan"; -import type { DeterministicPass } from "./deterministicPass"; /** * Parse a confidence-floor env value. Undefined, empty, or non-numeric input @@ -137,22 +136,10 @@ export interface RunSectionArgs { readonly extract: (text: string) => Promise; /** * Every destination {@link persist} rewrites for this section: rows cleared - * before the run, or overwritten in place. Only read to decide whether - * {@link deterministic} may stand in for {@link extract}; see - * {@link DeterministicPass}. + * before the run, or overwritten in place. Copied into the extract chain so a + * `deterministic` list slot can test coverage against the same set. */ readonly clears?: ReadonlySet; - /** - * A model-free parse tried ONCE, before {@link extract}, and only when it - * covers every column {@link clears} names AND asserts its rows are the - * section's whole population. - * - * All-or-nothing: its rows persist only when every one of them clears the - * confidence floor and {@link verifyRow}. A shortfall records nothing and - * falls through to the model — re-asking a pure function cannot change its - * answer, and dead-lettering here would blame the model for a parser miss. - */ - readonly deterministic?: DeterministicPass; /** * Tried in order when {@link extract} (and any earlier fallback) returns `[]` * **or throws** a provider/extraction error. Abort, an already-aborted @@ -176,10 +163,7 @@ export interface RunSectionArgs { * When the persisted rows came from a `deterministic` list slot, * `SectionPersistMeta.complete` is this callback (or false if omitted). */ - readonly deterministicComplete?: ( - rows: readonly NoInfer[], - text: string - ) => boolean; + readonly deterministicComplete?: (rows: readonly NoInfer[], text: string) => boolean; readonly persist: (rows: TRow[], meta: SectionPersistMeta) => Promise; } @@ -194,10 +178,10 @@ export interface SectionPersistMeta { /** 0 = primary {@link RunSectionArgs.extract}; 1+ = {@link RunSectionArgs.emptyExtracts} index + 1. */ readonly modelIndex: number; /** - * Which path produced the rows. `"deterministic"` means - * {@link RunSectionArgs.deterministic} supplied them and no model was called, - * so `modelIndex` names nothing — persist callbacks record the provenance - * model id from this, never from a field on a row. + * Which path produced the rows. `"deterministic"` means the winning list + * slot was the reserved `deterministic` id and no model was called, so persist + * callbacks record the provenance model id from this, never from a field on a + * row. */ readonly source: "deterministic" | "model"; } @@ -292,8 +276,7 @@ export function makeRunSection(opts: { e instanceof SecCliConfigurationError || e instanceof MixedRiskCaptionShapeError || opts.signal?.aborted === true; - const isWalkSlot = (i: number): boolean => - sargs.modelIds?.[i] === DETERMINISTIC_MODEL_ID; + const isWalkSlot = (i: number): boolean => sargs.modelIds?.[i] === DETERMINISTIC_MODEL_ID; const applyRowFilters = (incoming: TRow[]): void => { raw = incoming; confident = raw.filter((r) => r.confidence >= floor); diff --git a/src/sec/forms/registration-statements/s1/spacClassifierModel.ts b/src/sec/forms/registration-statements/s1/spacClassifierModel.ts index 89a59653..ccbc40f2 100644 --- a/src/sec/forms/registration-statements/s1/spacClassifierModel.ts +++ b/src/sec/forms/registration-statements/s1/spacClassifierModel.ts @@ -5,29 +5,35 @@ */ import type { ModelConfig } from "workglow"; -import { getGlobalModelRepository } from "workglow"; import { modelIdsFromEnv } from "../../../../config/Constants"; -import { resolveModelId } from "./s1Model"; +import { resolveConfiguredModels, resolveModelId } from "./s1Model"; import { CONFIDENCE_FLOOR, parseConfidenceFloor } from "./sectionRunner"; export { resolveModelId }; -/** The model id used for the SPAC content classifier; overridable via SEC_S1_CLASSIFIER_MODEL. */ +/** The model ids used for the SPAC content classifier; overridable via SEC_S1_CLASSIFIER_MODEL. */ +export function getSpacClassifierModelIds(): string[] { + return modelIdsFromEnv(process.env.SEC_S1_CLASSIFIER_MODEL); +} + +/** First id of {@link getSpacClassifierModelIds}. */ export function getSpacClassifierModelId(): string { - return modelIdsFromEnv(process.env.SEC_S1_CLASSIFIER_MODEL)[0]!; + return getSpacClassifierModelIds()[0]!; +} + +/** Resolves the configured SPAC-classifier model list. */ +export async function getSpacClassifierModels(): Promise { + return resolveConfiguredModels( + getSpacClassifierModelIds(), + "SPAC classifier", + "SEC_S1_CLASSIFIER_MODEL" + ); } -/** Resolves the configured SPAC-classifier model into a ModelConfig. */ +/** Primary (first) configured SPAC-classifier model. */ export async function getSpacClassifierModel(): Promise { - const id = getSpacClassifierModelId(); - const record = await getGlobalModelRepository().findByName(id); - if (!record) { - throw new Error( - `SPAC classifier model '${id}' is not registered. Register it or set ` + - `SEC_S1_CLASSIFIER_MODEL to a known model id.` - ); - } - return record as ModelConfig; + const [model] = await getSpacClassifierModels(); + return model!; } /** From a3eeec55ea6e64f99e97050e453f7ff286a48f09 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:18:12 -0700 Subject: [PATCH 21/29] test(sec): opt storage tests into the deterministic walk Tests that need the parser pass deterministic in the model list; AI-only tests keep a single fake model. Co-authored-by: Cursor --- .../Form_S_1.storage.classification.test.ts | 7 +++++-- .../Form_S_1.storage.offering.test.ts | 12 ++++++++---- .../s1/testing/fakeStructuredProvider.ts | 6 ++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts index 4357a2f9..fe650797 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts @@ -9,7 +9,10 @@ import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; import { setupAllDatabases } from "../../../config/setupAllDatabases"; import { S1ClassificationRepo } from "../../../storage/classification/S1ClassificationRepo"; import { processFormS1 } from "./Form_S_1.storage"; -import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; +import { + s1ModelsWithWalk, + registerFakeStructuredProvider, +} from "./s1/testing/fakeStructuredProvider"; const HTML_PARSEABLE = [ "

PROSPECTUS SUMMARY

", @@ -60,7 +63,7 @@ describe("processFormS1 spac-classification", () => { xbrlInstanceXml: null, feeExhibitHtml: null, }, - model: fakeS1Model(), + models: s1ModelsWithWalk(), }); const row = await new S1ClassificationRepo().get("S-1", "acc-cls-1"); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts index c469dde8..c92f5549 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.offering.test.ts @@ -19,7 +19,11 @@ import { UnderwriterLinkRepo } from "../../../storage/canonical/UnderwriterLinkR import { UseOfProceedsRepo } from "../../../storage/use-of-proceeds/UseOfProceedsRepo"; import { processFormS1 } from "./Form_S_1.storage"; import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; -import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; +import { + fakeS1Model, + registerFakeStructuredProvider, + s1ModelsWithWalk, +} from "./s1/testing/fakeStructuredProvider"; const OFFERING_HTML = [ "

THE OFFERING

We are offering 5,000,000 shares.

", @@ -438,7 +442,7 @@ describe("processFormS1 offering terms", () => { xbrlInstanceXml: null, feeExhibitHtml: null, }, - model: fakeS1Model(), + models: s1ModelsWithWalk(), }); const unit = await new SpacUnitTermsRepo().get("S-1", "0000000000-26-000010"); @@ -808,7 +812,7 @@ describe("processFormS1 offering terms", () => { xbrlInstanceXml: null, feeExhibitHtml: null, }, - model: fakeS1Model(), + models: s1ModelsWithWalk(), }); const rows = await new UseOfProceedsRepo().queryByAccession("0000000000-26-000013"); @@ -880,7 +884,7 @@ describe("processFormS1 offering terms", () => { xbrlInstanceXml: null, feeExhibitHtml: null, }, - model: fakeS1Model(), + models: s1ModelsWithWalk(), }); const rows = await new UseOfProceedsRepo().queryByAccession("0000000000-26-000014"); diff --git a/src/sec/forms/registration-statements/s1/testing/fakeStructuredProvider.ts b/src/sec/forms/registration-statements/s1/testing/fakeStructuredProvider.ts index e016e335..ca74d9c9 100644 --- a/src/sec/forms/registration-statements/s1/testing/fakeStructuredProvider.ts +++ b/src/sec/forms/registration-statements/s1/testing/fakeStructuredProvider.ts @@ -11,6 +11,7 @@ import type { ModelConfig, } from "workglow"; import { AiProvider, getAiProviderRegistry } from "workglow"; +import { deterministicModelConfig } from "../s1Model"; const JSON_MODE = ["text.generation", "json-mode"] as const satisfies Capability[]; const FAKE_PROVIDER = "fake-structured"; @@ -40,6 +41,11 @@ export function fakeS1Model(): ModelConfig { } as ModelConfig; } +/** Walk first, then the fake AI model — today's production wrap, opt-in for tests. */ +export function s1ModelsWithWalk(ai: ModelConfig = fakeS1Model()): ModelConfig[] { + return [deterministicModelConfig(), ai]; +} + /** * A model on a *local* provider (node-llama-cpp GBNF). The section extractors * omit the per-call nonce for local providers ({@link isLocalProvider}), so From d8b65949a096e78f98e458186a202d0f57a7203b Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:22:34 -0700 Subject: [PATCH 22/29] feat(sec): eval --models deterministic runs the sync walk Same reserved id as production; $0; no prompt; extractors with no parse return []. Co-authored-by: Cursor --- src/cli/groups/eval.ts | 22 ++++++++- src/eval/fixtures.test.ts | 19 ++++++++ src/eval/fixtures.ts | 74 +++++++++++++++++++------------ src/eval/modelPricing.test.ts | 6 +++ src/eval/printEvalPrompts.test.ts | 13 ++++++ src/eval/printEvalPrompts.ts | 11 +++++ 6 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index 5d40edda..ea02e25f 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -144,6 +144,18 @@ function parseModels(ids: readonly string[] | undefined): string[] { return [...new Set(ids ?? availableDefaultModels())]; } +/** + * `--models` as given, or `undefined` when the flag was omitted. Print-prompts + * must not default the list: a bare `--print-prompts` still dumps AI prompts, + * and only an explicit `deterministic` skips that dump. + */ +function printPromptModelIds( + modelsOpt: string | boolean | undefined, + defaults: readonly string[] +): readonly string[] | undefined { + return csvOptionValue("--models", modelsOpt, () => modelIdsHint(defaults)); +} + /** * What `--models` / `--reference` accept. Model ids are not a closed set — any * id whose shape a provider claims works — so the hint names the defaults and @@ -814,7 +826,11 @@ export function addEvalCommands(program: Command): void { extractor: name, label: name, })); - printEvalPrompts({ mode: printMode, items }); + printEvalPrompts({ + mode: printMode, + items, + modelIds: printPromptModelIds(opts.models, DEFAULT_MODELS), + }); return; } const format = requireFormat(opts.format); @@ -985,11 +1001,13 @@ export function addEvalCommands(program: Command): void { label: `${s.filing} [${s.extractor}]`, sectionText: preparedSectionText(s.extractor, s.text), })), + modelIds: printPromptModelIds(opts.models, [ORACLE_DEFAULT_CANDIDATE]), }); } else { printEvalPrompts({ mode: printMode, items: extractors.map((extractor) => ({ extractor, label: extractor })), + modelIds: printPromptModelIds(opts.models, [ORACLE_DEFAULT_CANDIDATE]), }); } return; @@ -1101,11 +1119,13 @@ export function addEvalCommands(program: Command): void { label: s.filing, sectionText: s.text, })), + modelIds: printPromptModelIds(opts.models, DEFAULT_MODELS), }); } else { printEvalPrompts({ mode: printMode, items: [{ extractor: "offering-terms", label: "offering-terms" }], + modelIds: printPromptModelIds(opts.models, DEFAULT_MODELS), }); } return; diff --git a/src/eval/fixtures.test.ts b/src/eval/fixtures.test.ts index 10adaa58..52537283 100644 --- a/src/eval/fixtures.test.ts +++ b/src/eval/fixtures.test.ts @@ -129,3 +129,22 @@ describe("EVAL_FIXTURES expected rows", () => { expect(offenders).toEqual([]); }); }); + +describe("EVAL_EXTRACTORS deterministic dispatch", () => { + it("deterministic id calls the parse function, not the AI extractor", async () => { + const { deterministicModelConfig } = + await import("../sec/forms/registration-statements/s1/s1Model"); + const rows = await EVAL_EXTRACTORS.management.run( + "# Directors\n| Name | Age | Title |\n| --- | --- | --- |\n| Jane Doe | 40 | CEO |\n", + deterministicModelConfig() + ); + expect(Array.isArray(rows)).toBe(true); + }); + + it("deterministic id on an extractor with no parse returns []", async () => { + const { deterministicModelConfig } = + await import("../sec/forms/registration-statements/s1/s1Model"); + const rows = await EVAL_EXTRACTORS["risk-factors"]!.run("any", deterministicModelConfig()); + expect(rows).toEqual([]); + }); +}); diff --git a/src/eval/fixtures.ts b/src/eval/fixtures.ts index dd13f9a0..c6e5b375 100644 --- a/src/eval/fixtures.ts +++ b/src/eval/fixtures.ts @@ -5,6 +5,20 @@ */ import type { IExecuteContext, ModelConfig } from "workglow"; +import { isDeterministicModel } from "../sec/forms/registration-statements/s1/s1Model"; +import { parseBeneficialOwnership } from "../sec/forms/registration-statements/s1/parseBeneficialOwnership"; +import { parseManagementRoster } from "../sec/forms/registration-statements/s1/parseManagementRoster"; +import { parseRelatedPartyTables } from "../sec/forms/registration-statements/s1/parseRelatedPartyTables"; +import { parseSpacClassification } from "../sec/forms/registration-statements/s1/parseSpacClassification"; +import { + parseSpacOfferingTerms, + parseSpacPromoteTerms, +} from "../sec/forms/registration-statements/s1/parseOfferingTables"; +import { parseSpacProfile } from "../sec/forms/registration-statements/s1/parseSpacProfile"; +import { parseSpacSponsors } from "../sec/forms/registration-statements/s1/parseSpacSponsors"; +import { parseSpacUnderwriters } from "../sec/forms/registration-statements/s1/parseSpacUnderwriters"; +import { parseSpacUseOfProceeds } from "../sec/forms/registration-statements/s1/parseSpacUseOfProceeds"; +import { parseSummaryCompensationTable } from "../sec/forms/registration-statements/s1/parseSummaryCompensationTable"; import { ExecutiveCompensationOutputSchema } from "../sec/forms/registration-statements/s1/executiveCompensationSchema"; import { LoiOutputSchema } from "../sec/forms/registration-statements/s1/loiSchema"; import { OfferingTermsOutputSchema } from "../sec/forms/registration-statements/s1/offeringTermsSchema"; @@ -124,6 +138,25 @@ export interface EvalExtractor { readonly disabled?: boolean; } +function asRows(value: readonly T[] | T | null): T[] { + if (value === null) return []; + return Array.isArray(value) ? [...value] : [value]; +} + +function runWithDeterministic( + parse: ((text: string) => readonly T[] | T | null) | undefined, + extract: ( + text: string, + model: ModelConfig, + context?: IExecuteContext + ) => Promise +): EvalExtractor["run"] { + return async (text, model, context) => { + if (isDeterministicModel(model)) return asRows(parse === undefined ? [] : parse(text)); + return asRows(await extract(text, model, context)); + }; +} + /** * Registry of extractors the `sec eval` harness can exercise, keyed by the name * a fixture (and `--extractor`) references. Extend by adding an entry and a @@ -131,7 +164,7 @@ export interface EvalExtractor { */ export const EVAL_EXTRACTORS: Record = { management: { - run: (text, model, context) => extractManagement(text, model, context), + run: runWithDeterministic(parseManagementRoster, extractManagement), instructions: managementInstructions, schema: () => ManagementOutputSchema, keyField: "full_name", @@ -139,7 +172,7 @@ export const EVAL_EXTRACTORS: Record = { personNameFields: ["full_name"], }, "beneficial-ownership": { - run: (text, model, context) => extractBeneficialOwnership(text, model, context), + run: runWithDeterministic(parseBeneficialOwnership, extractBeneficialOwnership), instructions: beneficialOwnershipInstructions, schema: () => BeneficialOwnershipOutputSchema, keyField: "name", @@ -157,7 +190,7 @@ export const EVAL_EXTRACTORS: Record = { // must NOT produce rows, so emitting one costs precision. // Disabled from default eval sweeps: low priority / may be dropped. "risk-factors": { - run: (text, model, context) => extractRiskFactors(text, model, context), + run: runWithDeterministic(undefined, extractRiskFactors), instructions: riskFactorsInstructions, schema: () => RiskFactorsOutputSchema, keyField: "headline", @@ -165,7 +198,7 @@ export const EVAL_EXTRACTORS: Record = { disabled: true, }, "related-party": { - run: (text, model, context) => extractRelatedParty(text, model, context), + run: runWithDeterministic(parseRelatedPartyTables, extractRelatedParty), instructions: relatedPartyInstructions, schema: () => RelatedPartyOutputSchema, keyField: "name", @@ -174,10 +207,7 @@ export const EVAL_EXTRACTORS: Record = { // Single-object extractor over an S-1/424 "The Offering" section; positional // alignment (no keyField). Scored on the objective numeric unit terms. "offering-terms": { - run: async (text, model, context) => { - const row = await extractOfferingTerms(text, model, context); - return row === null ? [] : [row]; - }, + run: runWithDeterministic(parseSpacOfferingTerms, extractOfferingTerms), instructions: offeringTermsInstructions, schema: () => OfferingTermsOutputSchema, compareFields: [ @@ -190,10 +220,7 @@ export const EVAL_EXTRACTORS: Record = { // Single-object extractor over a SPAC "The Offering" / "The Sponsor" section; // positional alignment (no keyField). Scored on the objective promote figures. "sponsor-promote": { - run: async (text, model, context) => { - const row = await extractSponsorPromote(text, model, context); - return row === null ? [] : [row]; - }, + run: runWithDeterministic(parseSpacPromoteTerms, extractSponsorPromote), instructions: sponsorPromoteInstructions, schema: () => SponsorPromoteOutputSchema, compareFields: [ @@ -208,10 +235,7 @@ export const EVAL_EXTRACTORS: Record = { // true SPAC yields one row; a shell or operating company yields none, so a // fixture with `expected: []` scores a false positive as lost precision. "spac-classification": { - run: async (text, model, context) => { - const row = await extractSpacClassification(text, model, context); - return row === null ? [] : [row]; - }, + run: runWithDeterministic(parseSpacClassification, extractSpacClassification), instructions: spacClassificationInstructions, schema: () => SpacClassificationOutputSchema, keyField: "entity_kind", @@ -224,7 +248,7 @@ export const EVAL_EXTRACTORS: Record = { // for. Scored on the cells every table has, whichever disclosure regime the // registrant reports under. "executive-compensation": { - run: (text, model, context) => extractExecutiveCompensation(text, model, context), + run: runWithDeterministic(parseSummaryCompensationTable, extractExecutiveCompensation), instructions: executiveCompensationInstructions, schema: () => ExecutiveCompensationOutputSchema, compareFields: ["person_name", "fiscal_year", "salary", "total"], @@ -234,7 +258,7 @@ export const EVAL_EXTRACTORS: Record = { // Keyed on the bank's legal name — the field the persist path dedupes on, so // a model that repeats a syndicate member should not be rewarded for it. underwriters: { - run: (text, model, context) => extractUnderwriters(text, model, context), + run: runWithDeterministic(parseSpacUnderwriters, extractUnderwriters), instructions: underwritersInstructions, schema: () => UnderwriterOutputSchema, keyField: "legal_name", @@ -242,7 +266,7 @@ export const EVAL_EXTRACTORS: Record = { }, // Multi-row extractor over the Use of Proceeds section: one row per line item. "use-of-proceeds": { - run: (text, model, context) => extractUseOfProceeds(text, model, context), + run: runWithDeterministic(parseSpacUseOfProceeds, extractUseOfProceeds), instructions: useOfProceedsInstructions, schema: () => UseOfProceedsOutputSchema, keyField: "purpose", @@ -252,17 +276,14 @@ export const EVAL_EXTRACTORS: Record = { // controlled-vocabulary focus rather than the free-text description, which no // two models phrase alike. "spac-profile": { - run: async (text, model, context) => { - const row = await extractSpacProfile(text, model, context); - return row === null ? [] : [row]; - }, + run: runWithDeterministic(parseSpacProfile, extractSpacProfile), instructions: spacProfileInstructions, schema: () => SpacProfileOutputSchema, compareFields: ["focus", "focus_location"], }, // Multi-row extractor naming the sponsor entities behind a blank-check issuer. "spac-sponsors": { - run: (text, model, context) => extractSpacSponsors(text, model, context), + run: runWithDeterministic(parseSpacSponsors, extractSpacSponsors), instructions: spacSponsorsInstructions, schema: () => SpacSponsorOutputSchema, keyField: "legal_name", @@ -273,10 +294,7 @@ export const EVAL_EXTRACTORS: Record = { // agreements, vote results, LOI terminations) yields none, so a fixture with // `expected: []` scores a false positive as lost precision. loi: { - run: async (text, model, context) => { - const row = await extractLoi(text, model, context); - return row === null ? [] : [row]; - }, + run: runWithDeterministic(undefined, extractLoi), instructions: loiInstructions, schema: () => LoiOutputSchema, keyField: "target_name", diff --git a/src/eval/modelPricing.test.ts b/src/eval/modelPricing.test.ts index 87ee9bbf..0d4119ed 100644 --- a/src/eval/modelPricing.test.ts +++ b/src/eval/modelPricing.test.ts @@ -150,3 +150,9 @@ describe("Gemini pricing", () => { expect(estimateCost("gemini-2.5-pro", 4_000_000, 4_000_000).usd).toBeCloseTo(1.25 + 10, 5); }); }); + +describe("deterministic pricing", () => { + it("prices deterministic at $0 without estimating a prompt", () => { + expect(estimateCost("deterministic", 40_000, 2_000).usd).toBe(0); + }); +}); diff --git a/src/eval/printEvalPrompts.test.ts b/src/eval/printEvalPrompts.test.ts index 39a986aa..974d9e73 100644 --- a/src/eval/printEvalPrompts.test.ts +++ b/src/eval/printEvalPrompts.test.ts @@ -189,4 +189,17 @@ describe("printEvalPrompts", () => { }) ).not.toThrow(); }); + + it("notes the sync parser instead of dumping instructions for deterministic", () => { + const lines: string[] = []; + printEvalPrompts({ + mode: "instructions", + items: [{ extractor: "management", label: "management" }], + modelIds: ["deterministic"], + write: (l) => lines.push(l), + }); + const out = lines.join("\n"); + expect(out).toContain("sync parser"); + expect(out).not.toContain(EVAL_EXTRACTORS.management.instructions().slice(0, 40)); + }); }); diff --git a/src/eval/printEvalPrompts.ts b/src/eval/printEvalPrompts.ts index 0585d479..f31eb07f 100644 --- a/src/eval/printEvalPrompts.ts +++ b/src/eval/printEvalPrompts.ts @@ -9,6 +9,7 @@ import { isNonceEnabled, stripNonceSeen, } from "../sec/forms/registration-statements/s1/sectionExtractors"; +import { DETERMINISTIC_MODEL_ID } from "../config/Constants"; import { EVAL_EXTRACTORS } from "./fixtures"; export type PrintPromptsMode = "instructions" | "template" | "document" | "schema" | "full"; @@ -42,9 +43,19 @@ function writeSchema(write: (line: string) => void, extractor: string, schema: o export function printEvalPrompts(args: { readonly mode: PrintPromptsMode; readonly items: readonly PrintPromptItem[]; + readonly modelIds?: readonly string[] | undefined; readonly write?: (line: string) => void; }): void { const write = args.write ?? ((line: string) => console.log(line)); + const namedDeterministic = args.modelIds?.includes(DETERMINISTIC_MODEL_ID) === true; + const promptModels = args.modelIds?.filter((id) => id !== DETERMINISTIC_MODEL_ID); + if (namedDeterministic) { + write("deterministic: sync parser (no prompt)"); + write(""); + } + if (args.modelIds !== undefined && (promptModels?.length ?? 0) === 0) { + return; + } if (args.items.length === 0) { throw new Error("nothing to print — no extractors/sections matched the selection"); } From bd065ed2cadc2b7b831da731471426025b37ed69 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:23:11 -0700 Subject: [PATCH 23/29] docs(sec): deterministic is an opt-in model-list slot Walk-then-model is SEC_S1_MODEL=deterministic,; the built-in default stays cloud-only. Co-authored-by: Cursor --- CLAUDE.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 50328e3b..89e750f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,8 +222,17 @@ override the inference — the enum is per-model, so an unsupported value like `minimal` on `gpt-5.6-luna` fails loudly rather than degrading. All extractors share a general default model (`SecModelDefault` in `src/config/Constants.ts`); set `SEC_MODEL_DEFAULT` to change every extractor at once, and a per-extractor -env var (e.g. `SEC_S1_MODEL`) to override just one. CLI startup registers these -model ids (the default plus any set overrides, plus the local HFT default +env var (e.g. `SEC_S1_MODEL`) to override just one. Each of these variables is a +CSV list. The reserved id `deterministic` is the sync table/prose walk for that +extractor. Position is attempt order (`deterministic,claude-haiku-4-5` walks +first; omit it and the walk does not run). The built-in `SEC_MODEL_DEFAULT` +stays a cloud id — to restore walk-then-model after this change, set +`SEC_S1_MODEL=deterministic,` and, independently, +`SEC_S1_CLASSIFIER_MODEL` if the content classifier should walk too. `sec eval +extract --models deterministic` / `sec eval s1 --models deterministic` score the +walk in the same table as cloud ids (`$0`, no API key). + +CLI startup registers these model ids (the default plus any set overrides, plus the local HFT default `SecHftModelDefault`) into the global model repository via `registerSecModels` (`src/config/registerModels.ts`). `secModelRecord` dispatches on id shape, and the full list is `KNOWN_MODEL_ID_SHAPES` in that file — the string the @@ -240,6 +249,7 @@ unknown-id error prints, so it cannot drift from the dispatch: | `gemini-*` | `GOOGLE_GEMINI` | | `grok-*` | `XAI` | | `deepseek-*` | `DEEPSEEK` | +| `deterministic` | sync walk (no provider) | Every record explicitly declares the `json-mode` capability `StructuredGenerationTask` gates on (the installed provider's From 2baf223bdfec9f0f99c04d6621b3702a767f6523 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 11:24:54 -0700 Subject: [PATCH 24/29] docs(sec): align the model id-shape table for prettier The new deterministic row widened the provider column. Co-authored-by: Cursor --- CLAUDE.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 89e750f5..54a29a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,17 +238,17 @@ CLI startup registers these model ids (the default plus any set overrides, plus the full list is `KNOWN_MODEL_ID_SHAPES` in that file — the string the unknown-id error prints, so it cannot drift from the dispatch: -| id shape | provider | -| -------------------------------------------- | ---------------------- | -| `llama:…` / `node-llama:…` / `gguf:…` | `LOCAL_LLAMACPP` | -| `onnx:org/name` | `HF_TRANSFORMERS_ONNX` | -| `hfi:[provider:]org/name` | `HF_INFERENCE` | -| `open-router:[provider:]vendor/model` | `OPENROUTER` | -| `claude-*` | `ANTHROPIC` | -| `gpt-*` / `chatgpt-*` / `o1-*`/`o3-*`/`o4-*` | `OPENAI` | -| `gemini-*` | `GOOGLE_GEMINI` | -| `grok-*` | `XAI` | -| `deepseek-*` | `DEEPSEEK` | +| id shape | provider | +| -------------------------------------------- | ----------------------- | +| `llama:…` / `node-llama:…` / `gguf:…` | `LOCAL_LLAMACPP` | +| `onnx:org/name` | `HF_TRANSFORMERS_ONNX` | +| `hfi:[provider:]org/name` | `HF_INFERENCE` | +| `open-router:[provider:]vendor/model` | `OPENROUTER` | +| `claude-*` | `ANTHROPIC` | +| `gpt-*` / `chatgpt-*` / `o1-*`/`o3-*`/`o4-*` | `OPENAI` | +| `gemini-*` | `GOOGLE_GEMINI` | +| `grok-*` | `XAI` | +| `deepseek-*` | `DEEPSEEK` | | `deterministic` | sync walk (no provider) | Every record explicitly declares the `json-mode` capability From ebb932df940f92c1541cc5f4ab551b29cbf9bab3 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 13:15:49 -0700 Subject: [PATCH 25/29] refactor(sec): streamline parser evaluation and reporting interfaces - Introduced `ParserEvalCase` and `ParserEvalReport` interfaces to standardize the structure of evaluation reports. - Removed redundant print functions for various report types, consolidating logic to improve maintainability. - Enhanced error handling in parsing functions to ensure robustness during evaluation runs. --- src/cli/groups/eval.ts | 752 ++++-------------- src/eval/fixtures.ts | 4 +- .../Form_S_1.storage.ts | 7 + .../s1/deterministicPass.ts | 18 +- .../s1/parseOfferingTables.ts | 15 +- .../s1/parseSpacClassification.ts | 7 +- .../s1/parseSpacProfile.ts | 3 +- .../s1/parseSpacSponsors.ts | 8 +- .../s1/parseSpacUnderwriters.ts | 15 +- .../s1/parseSpacUseOfProceeds.ts | 12 +- .../registration-statements/s1/s1Model.ts | 9 +- .../s1/sectionRunner.ts | 16 +- 12 files changed, 248 insertions(+), 618 deletions(-) diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index ea02e25f..9ee45525 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -214,190 +214,35 @@ function truncate(s: string, max = 60): string { return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } -function printSpacClassificationReport(report: SpacClassificationReport): void { - const { counts } = report; - console.log( - `spac-classification parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } -} - -function printSpacProfileReport(report: SpacProfileReport): void { - const { counts } = report; - console.log( - `spac-profile parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } -} - -function printSpacSponsorsReport(report: SpacSponsorsReport): void { - const { counts } = report; - console.log( - `spac-sponsors parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } -} - -function printRelatedPartyReport(report: RelatedPartyReport): void { - const { counts } = report; - console.log( - `related-party parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } -} - -function printManagementReport(report: ManagementReport): void { - const { counts } = report; - console.log( - `management parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } -} - -function printBeneficialOwnershipReport(report: BeneficialOwnershipReport): void { - const { counts } = report; - console.log( - `beneficial-ownership parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } +/** + * One scored case from a deterministic-parser eval. Every `run*Eval` report + * shares this shape; `kind` is present only where one command scores two + * destinations (offering terms vs sponsor promote). + */ +interface ParserEvalCase { + readonly bucket: string; + readonly accession_number: string; + readonly cik: number | null; + readonly cachePath: string | undefined; + readonly kind?: string; + readonly parsed?: unknown; + readonly stored?: unknown; } -function printExecutiveCompensationReport(report: ExecutiveCompensationReport): void { - const { counts } = report; - console.log( - `executive-compensation parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } +interface ParserEvalReport { + readonly cases: readonly ParserEvalCase[]; + readonly counts: Record; } -function printUseOfProceedsReport(report: UseOfProceedsReport): void { - const { counts } = report; - console.log( - `use-of-proceeds parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } -} - -function printUnderwritersReport(report: UnderwritersReport): void { - const { counts } = report; - console.log( - `underwriters parser vs stored rows ` + - `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + - `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` - ); - const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree"); - if (flagged.length === 0) return; - console.log("\nmiss / hit-disagree:"); - for (const c of flagged) { - const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); - if (c.bucket === "hit-disagree") { - console.log(` parsed ${JSON.stringify(c.parsed)}`); - console.log(` stored ${JSON.stringify(c.stored)}`); - } - } -} - -function printOfferingTablesReport(report: OfferingTablesReport): void { +/** + * The parser-vs-stored-rows table every `sec eval ` command prints. + * One implementation: the ten commands differed only in the label, so ten + * copies of it could only drift. + */ +function printParserEvalReport(label: string, report: ParserEvalReport): void { const { counts } = report; console.log( - `offering/promote parser vs stored rows ` + + `${label} parser vs stored rows ` + `hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` + `miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}` ); @@ -406,7 +251,8 @@ function printOfferingTablesReport(report: OfferingTablesReport): void { console.log("\nmiss / hit-disagree:"); for (const c of flagged) { const cik = c.cik === null ? "" : ` cik=${c.cik}`; - console.log(` ${c.bucket} ${c.kind} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); + const kind = c.kind === undefined ? "" : ` ${c.kind}`; + console.log(` ${c.bucket}${kind} ${c.accession_number}${cik} ${c.cachePath ?? ""}`); if (c.bucket === "hit-disagree") { console.log(` parsed ${JSON.stringify(c.parsed)}`); console.log(` stored ${JSON.stringify(c.stored)}`); @@ -1167,413 +1013,145 @@ export function addEvalCommands(program: Command): void { } ); - cmd - .command("offering-tables") - .description( - "Score the deterministic SPAC offering/promote table parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalOfferingTablesTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printOfferingTablesReport(report); - }); - } - ); - - cmd - .command("underwriters") - .description( - "Score the deterministic SPAC underwriter table parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalUnderwritersTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printUnderwritersReport(report); - }); - } - ); - - cmd - .command("use-of-proceeds") - .description( - "Score the deterministic SPAC use-of-proceeds table parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalUseOfProceedsTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printUseOfProceedsReport(report); - }); - } - ); - - cmd - .command("executive-compensation") - .description( - "Score the deterministic Summary Compensation Table parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalExecutiveCompensationTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printExecutiveCompensationReport(report); - }); - } - ); - - cmd - .command("beneficial-ownership") - .description( - "Score the deterministic beneficial-ownership parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalBeneficialOwnershipTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printBeneficialOwnershipReport(report); - }); - } - ); - - cmd - .command("management") - .description( - "Score the deterministic management roster parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalManagementTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printManagementReport(report); - }); - } - ); - - cmd - .command("related-party") - .description( - "Score the deterministic related-party table parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalRelatedPartyTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printRelatedPartyReport(report); - }); - } - ); - - cmd - .command("spac-sponsors") - .description( - "Score the deterministic SPAC sponsor parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalSpacSponsorsTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printSpacSponsorsReport(report); - }); - } - ); + for (const spec of PARSER_EVAL_COMMANDS) { + cmd + .command(spec.name) + .description(spec.description) + .option("--extractor-id [id]", "limit to S-1 or 424") + .option("--limit [n]", "max stored rows to score") + .option("--cik [n]", "limit to one issuer CIK") + .option("--format [fmt]", "table | json (default: table)") + .action( + async (opts: { + extractorId?: string | boolean; + limit?: string | boolean; + cik?: string | boolean; + format: string | boolean; + }) => { + await runCommand(async () => { + const format = requireFormat(opts.format); + const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); + if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { + throw new Error("--extractor-id needs a value — S-1, 424"); + } + const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); + const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); + const input: ParserEvalInput = { + ...(extractorRaw ? { extractorId: extractorRaw } : {}), + ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), + ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), + }; + const report = await spec.run(input); + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + printParserEvalReport(spec.label, report); + }); + } + ); + } +} - cmd - .command("spac-profile") - .description( - "Score the deterministic SPAC profile parser against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalSpacProfileTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printSpacProfileReport(report); - }); - } - ); +/** + * The three narrowing options every deterministic-parser eval command takes. + * A type alias, not an interface: each task's `defaults` is a `DataPorts` + * subtype, and only an alias picks up the implicit string index signature that + * assignment needs. + */ +type ParserEvalInput = { + readonly extractorId?: string; + readonly limit?: number; + readonly cik?: number; +}; - cmd - .command("spac-classification") - .description( - "Score the deterministic SPAC classifier against stored rows (on-disk cache only; no EDGAR fetch)" - ) - .option("--extractor-id [id]", "limit to S-1 or 424") - .option("--limit [n]", "max stored rows to score") - .option("--cik [n]", "limit to one issuer CIK") - .option("--format [fmt]", "table | json (default: table)") - .action( - async (opts: { - extractorId?: string | boolean; - limit?: string | boolean; - cik?: string | boolean; - format: string | boolean; - }) => { - await runCommand(async () => { - const format = requireFormat(opts.format); - const extractorRaw = optionValue("--extractor-id", opts.extractorId, () => "S-1, 424"); - if (extractorRaw !== undefined && extractorRaw !== "S-1" && extractorRaw !== "424") { - throw new Error("--extractor-id needs a value — S-1, 424"); - } - const limitRaw = optionValue("--limit", opts.limit, () => "a non-negative integer"); - const cikRaw = optionValue("--cik", opts.cik, () => "an issuer CIK"); - const input = { - ...(extractorRaw ? { extractorId: extractorRaw } : {}), - ...(limitRaw !== undefined ? { limit: parseIntOption(limitRaw) } : {}), - ...(cikRaw !== undefined ? { cik: parseIntOption(cikRaw) } : {}), - }; - const report = await runWorkflowCli([ - new EvalSpacClassificationTask({ defaults: input }), - ]); - if (format === "json") { - console.log(JSON.stringify(report, null, 2)); - return; - } - printSpacClassificationReport(report); - }); - } - ); -} +/** + * The `sec eval ` commands that score a deterministic parser against + * the rows already stored for a filing. They differ only in which task runs and + * how the table is labelled, so they are declared rather than repeated: ten + * copies of the same option parsing drifted apart the moment one was edited. + */ +const PARSER_EVAL_COMMANDS: ReadonlyArray<{ + readonly name: string; + readonly label: string; + readonly description: string; + readonly run: (defaults: ParserEvalInput) => Promise; +}> = [ + { + name: "offering-tables", + label: "offering/promote", + description: + "Score the deterministic SPAC offering/promote table parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => + runWorkflowCli([new EvalOfferingTablesTask({ defaults })]), + }, + { + name: "underwriters", + label: "underwriters", + description: + "Score the deterministic SPAC underwriter table parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => runWorkflowCli([new EvalUnderwritersTask({ defaults })]), + }, + { + name: "use-of-proceeds", + label: "use-of-proceeds", + description: + "Score the deterministic SPAC use-of-proceeds table parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => + runWorkflowCli([new EvalUseOfProceedsTask({ defaults })]), + }, + { + name: "executive-compensation", + label: "executive-compensation", + description: + "Score the deterministic Summary Compensation Table parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => + runWorkflowCli([ + new EvalExecutiveCompensationTask({ defaults }), + ]), + }, + { + name: "beneficial-ownership", + label: "beneficial-ownership", + description: + "Score the deterministic beneficial-ownership parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => + runWorkflowCli([new EvalBeneficialOwnershipTask({ defaults })]), + }, + { + name: "management", + label: "management", + description: + "Score the deterministic management roster parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => runWorkflowCli([new EvalManagementTask({ defaults })]), + }, + { + name: "related-party", + label: "related-party", + description: + "Score the deterministic related-party table parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => runWorkflowCli([new EvalRelatedPartyTask({ defaults })]), + }, + { + name: "spac-sponsors", + label: "spac-sponsors", + description: + "Score the deterministic SPAC sponsor parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => runWorkflowCli([new EvalSpacSponsorsTask({ defaults })]), + }, + { + name: "spac-profile", + label: "spac-profile", + description: + "Score the deterministic SPAC profile parser against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => runWorkflowCli([new EvalSpacProfileTask({ defaults })]), + }, + { + name: "spac-classification", + label: "spac-classification", + description: + "Score the deterministic SPAC classifier against stored rows (on-disk cache only; no EDGAR fetch)", + run: (defaults) => + runWorkflowCli([new EvalSpacClassificationTask({ defaults })]), + }, +]; diff --git a/src/eval/fixtures.ts b/src/eval/fixtures.ts index c6e5b375..9499d6e3 100644 --- a/src/eval/fixtures.ts +++ b/src/eval/fixtures.ts @@ -140,7 +140,9 @@ export interface EvalExtractor { function asRows(value: readonly T[] | T | null): T[] { if (value === null) return []; - return Array.isArray(value) ? [...value] : [value]; + // `Array.isArray` cannot narrow `readonly T[] | T` — `T` may itself be an + // array — so the branch is asserted rather than inferred. + return Array.isArray(value) ? [...(value as readonly T[])] : [value as T]; } function runWithDeterministic( diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 05923f4c..897b24ef 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -921,7 +921,14 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { extract: parseBeneficialOwnership, covers: ownershipCoverage, }, + // Must name exactly what the section-level `clears` above names — this is + // the copy `preempts` actually tests, and the two had drifted: it was + // missing `owner_kind` and `security_class`, so a filing whose coverage + // function happened to claim the rest would have preempted on a set that + // understated what persist rewrites. clears: new Set([ + "beneficial_ownership.owner_kind", + "beneficial_ownership.security_class", "beneficial_ownership.shares_owned", "beneficial_ownership.percent_owned", "beneficial_ownership.shares_offered", diff --git a/src/sec/forms/registration-statements/s1/deterministicPass.ts b/src/sec/forms/registration-statements/s1/deterministicPass.ts index 59dd3572..e0a9849c 100644 --- a/src/sec/forms/registration-statements/s1/deterministicPass.ts +++ b/src/sec/forms/registration-statements/s1/deterministicPass.ts @@ -115,9 +115,23 @@ export function assertsCompletePopulation( rows: readonly TRow[], text: string ): boolean { - if (pass.complete === undefined) return false; + return claimsCompletePopulation(pass.complete, rows, text); +} + +/** + * {@link assertsCompletePopulation} against a bare callback, for the section + * runner — which is handed `DeterministicPass.complete` through the extract + * chain rather than the pass itself. One implementation, so the runner and the + * pass cannot disagree about what an omitted or throwing claim means. + */ +export function claimsCompletePopulation( + complete: ((rows: readonly TRow[], text: string) => boolean) | undefined, + rows: readonly TRow[], + text: string +): boolean { + if (complete === undefined) return false; try { - return pass.complete(rows, text) === true; + return complete(rows, text) === true; } catch { return false; } diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts index 988e700f..3570356e 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts @@ -47,8 +47,15 @@ interface WalkedFields { source_span: string; } +/** The unit-IPO anchor: a per-unit price and a unit count, both off one walk. */ +function isUnitIpoWalk(fields: WalkedFields): boolean { + return fields.price_per_unit !== null && fields.units_offered !== null; +} + export function parseSpacOfferingTerms(text: string): OfferingTermsRow | null { const fields = walkFields(text); + // Spelled out rather than via `isUnitIpoWalk` so the two fields narrow to + // `number` for the `locates` calls below. if (fields.price_per_unit === null || fields.units_offered === null) return null; if (!locates(text, fields.price_per_unit, "per unit")) return null; if (!locates(text, fields.units_offered, "units")) return null; @@ -80,8 +87,11 @@ export function parseSpacOfferingTerms(text: string): OfferingTermsRow | null { } export function parseSpacPromoteTerms(text: string): SponsorPromoteRow | null { - if (!looksLikeUnitIpo(text)) return null; + // One walk, not two: `looksLikeUnitIpo` re-scans the whole section to ask a + // question this walk already answers, and this parse is run per filing behind + // a coverage function that walks it a second time. const fields = walkFields(text); + if (!isUnitIpoWalk(fields)) return null; if (fields.founder_shares === null && fields.trust_per_public_share === null) return null; if (fields.founder_shares !== null && !locates(text, fields.founder_shares, "founder")) { return null; @@ -140,8 +150,7 @@ export function promoteCoverage(text: string): ReadonlySet { } export function looksLikeUnitIpo(text: string): boolean { - const fields = walkFields(text); - return fields.price_per_unit !== null && fields.units_offered !== null; + return isUnitIpoWalk(walkFields(text)); } function emptyWalk(): WalkedFields { diff --git a/src/sec/forms/registration-statements/s1/parseSpacClassification.ts b/src/sec/forms/registration-statements/s1/parseSpacClassification.ts index fcda8b49..5f08ee5a 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacClassification.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.ts @@ -19,17 +19,18 @@ export function parseSpacClassification(text: string): SpacClassificationRow | n } } +/** Asks the parser, so a throw is a "no" here exactly as it is there. */ export function hasSpacFormationIdentification(text: string | undefined): boolean { if (text === undefined || text.trim() === "") return false; - return findClassification(text) !== null; + return parseSpacClassification(text) !== null; } function findClassification(text: string): SpacClassificationRow | null { const vehicle = VEHICLE.exec(text); const purpose = PURPOSE.exec(text); if (vehicle === null || purpose === null) return null; - const source_span = text.includes(vehicle[0]!) ? vehicle[0]! : purpose[0]!; - if (!text.includes(source_span)) return null; + // `exec` returned it out of `text`, so it is verbatim by construction. + const source_span = vehicle[0]!; return { is_spac: true, entity_kind: "spac", diff --git a/src/sec/forms/registration-statements/s1/parseSpacProfile.ts b/src/sec/forms/registration-statements/s1/parseSpacProfile.ts index 7dfa15a2..895ee5d3 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacProfile.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacProfile.ts @@ -84,9 +84,10 @@ export function parseSpacProfile(text: string): SpacProfileRow | null { } } +/** Asks the parser, so a throw is a "no" here exactly as it is there. */ export function hasProfileIdentification(text: string | undefined): boolean { if (text === undefined || text.trim() === "") return false; - return findProfile(text) !== null; + return parseSpacProfile(text) !== null; } function findProfile(text: string): SpacProfileRow | null { diff --git a/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts b/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts index 9ced67d8..05ae54c7 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts @@ -18,9 +18,15 @@ export function parseSpacSponsors(text: string): SpacSponsorRow[] { } } +/** + * Whether {@link parseSpacSponsors} would name a sponsor. Asks the parser + * itself: `findCandidates` skips both the source-span presence filter and the + * try/catch, so it answered "there is a sponsor here" for text the parser + * returns `[]` for, and threw out of an eval run instead of bucketing one case. + */ export function hasSponsorIdentification(text: string | undefined): boolean { if (text === undefined || text.trim() === "") return false; - return findCandidates(text).length > 0; + return parseSpacSponsors(text).length > 0; } const APPOSITIVE = /(? looksLikeFirmName(tidyName(row[0] ?? ""))); } -/** True when a syndicate allocation table contains at least one firm-like name. */ +/** + * True when a syndicate allocation table contains at least one firm-like name. + * Declines on a throw, like {@link parseSpacUnderwriters}: it is called from the + * eval bucketer, where a throw would abort the whole run. + */ export function hasSpacSyndicateTable(text: string): boolean { + try { + return findSyndicateTable(text); + } catch { + return false; + } +} + +function findSyndicateTable(text: string): boolean { for (const table of splitGfmTables(text)) { const blob = table.flat().join(" "); if (SELLING_HEADER.test(blob)) continue; @@ -215,7 +227,6 @@ function dedupe(rows: readonly Candidate[]): Candidate[] { function cleanCell(raw: string): string { return raw .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") - .replace(/​/g, "") .replace(/\s+/g, " ") .trim(); } diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts index 72502ba2..a104ce95 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts @@ -88,9 +88,17 @@ function parseInner(text: string): UseOfProceedsParse { return parsed; } -/** True when a SPAC expense/trust table is present, even if parse would return []. */ +/** + * True when a SPAC expense/trust table is present, even if parse would return + * []. Declines on a throw, like every other reader of this walk: it is called + * from the eval bucketer, where a throw would abort the whole run. + */ export function hasSpacUseOfProceedsTable(text: string): boolean { - return collectLines(text).rows.some((r) => SPAC_USE.test(r.purpose ?? "")); + try { + return collectLines(text).rows.some((r) => SPAC_USE.test(r.purpose ?? "")); + } catch { + return false; + } } function collectLines(text: string): UseOfProceedsParse { diff --git a/src/sec/forms/registration-statements/s1/s1Model.ts b/src/sec/forms/registration-statements/s1/s1Model.ts index 1ccd9607..5d7ae1d5 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.ts @@ -122,13 +122,18 @@ export function modelExtractChain( if (!isDeterministicModel(model)) return extract(text, model); const pass = options?.deterministic; if (pass === undefined) return []; - if (!preempts(pass, options.clears, text)) return []; + if (!preempts(pass, options?.clears, text)) return []; return [...pass.extract(text)]; }; return { extract: slot(primary), emptyExtracts: models.slice(1).map((m) => slot(m)), - modelIds: models.map((m) => resolveModelId(m)).filter((id): id is string => id !== null), + // Index-aligned with `models` (and therefore with the extract slots the + // runner builds): `sectionRunner` identifies the deterministic slot by + // `modelIds[i]`, and `persistModelId` reads `models[modelIndex]`, so + // dropping an unresolvable id here would shift every later slot onto the + // wrong model. An id that does not resolve becomes "" rather than a hole. + modelIds: models.map((m) => resolveModelId(m) ?? ""), deterministicComplete: options?.deterministic?.complete, ...(options?.fallbackOnEmpty === false ? { fallbackOnEmpty: false as const } : {}), }; diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index 3f4fecc2..e0d8a421 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -9,6 +9,7 @@ import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; import type { ExtractionDeadLetterRepo } from "../../../../storage/dead-letter/ExtractionDeadLetterRepo"; import type { DeadLetterReasonCode } from "../../../../storage/dead-letter/ExtractionDeadLetterSchema"; import { SecCliConfigurationError } from "../../../../config/EnvToDI"; +import { claimsCompletePopulation } from "./deterministicPass"; import { MixedRiskCaptionShapeError, NonceMismatchError, @@ -28,19 +29,6 @@ export function parseConfidenceFloor(raw: string | undefined, fallback: number): return Number.isFinite(n) ? n : fallback; } -function walkClaimsComplete( - fn: ((rows: readonly TRow[], text: string) => boolean) | undefined, - rows: readonly TRow[], - text: string -): boolean { - if (fn === undefined) return false; - try { - return fn(rows, text) === true; - } catch { - return false; - } -} - const REJECTED_SPAN_DETAIL_CHARS = 300; /** @@ -317,7 +305,7 @@ export function makeRunSection(opts: { clearSlot(); continue; } - const complete = walkClaimsComplete(sargs.deterministicComplete, rows, text); + const complete = claimsCompletePopulation(sargs.deterministicComplete, rows, text); if (complete && raw.length > 0 && rows.length === raw.length) { source = "deterministic"; walkComplete = true; From 792bd7c010fb52444bc54518d8184ec0da5e3385 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 14:53:03 -0700 Subject: [PATCH 26/29] refactor: remove eightKItems from forms sweep logic - Eliminated the `eightKItems` parameter from `runFormsSweep` and related functions to simplify the workflow. - Removed associated tests and logic that filtered 8-K filings based on item codes, streamlining the forms processing task. - Updated `ComputeFormsWorklistTask` to reflect the removal of `eightKItems` handling. --- src/cli/sync/runFormsSweep.ts | 9 +- src/cli/sync/spacProcessSweeps.test.ts | 52 ------- src/cli/sync/spacProcessSweeps.ts | 71 --------- .../s1/gfmTables.test.ts | 127 +++++++++++++++ .../registration-statements/s1/gfmTables.ts | 140 +++++++++++++++++ .../s1/offeringSections.ts | 11 +- .../s1/parseBeneficialOwnership.test.ts | 29 ++++ .../s1/parseBeneficialOwnership.ts | 88 +++-------- .../s1/parseManagementRoster.ts | 76 +-------- .../s1/parseOfferingTables.ts | 30 +--- .../s1/parseRelatedPartyTables.ts | 76 +-------- .../s1/parseSpacUnderwriters.ts | 53 +------ .../s1/parseSpacUseOfProceeds.test.ts | 39 ++++- .../s1/parseSpacUseOfProceeds.ts | 82 ++++------ .../s1/parseSummaryCompensationTable.ts | 79 +--------- src/task/forms/ComputeFormsWorklistTask.ts | 38 ----- src/task/forms/formsSweep.test.ts | 39 ----- src/task/forms/formsSweep.ts | 2 - src/task/index/CatchUpDailyIndexTask.test.ts | 92 +++++++++++ src/task/index/CatchUpDailyIndexTask.ts | 88 +++++++++-- src/task/index/dailyIndexPublication.test.ts | 144 ++++++++++++++++++ src/task/index/dailyIndexPublication.ts | 108 +++++++++++++ src/task/spac/ProcessSpacTimelineTask.test.ts | 22 ++- src/task/spac/ProcessSpacTimelineTask.ts | 42 ++++- 24 files changed, 893 insertions(+), 644 deletions(-) delete mode 100644 src/cli/sync/spacProcessSweeps.test.ts delete mode 100644 src/cli/sync/spacProcessSweeps.ts create mode 100644 src/sec/forms/registration-statements/s1/gfmTables.test.ts create mode 100644 src/sec/forms/registration-statements/s1/gfmTables.ts create mode 100644 src/task/index/dailyIndexPublication.test.ts create mode 100644 src/task/index/dailyIndexPublication.ts diff --git a/src/cli/sync/runFormsSweep.ts b/src/cli/sync/runFormsSweep.ts index eb579f04..ac2c3169 100644 --- a/src/cli/sync/runFormsSweep.ts +++ b/src/cli/sync/runFormsSweep.ts @@ -11,20 +11,13 @@ export async function runFormsSweep(options: { readonly formTypes: string[]; readonly shard?: FormsShard; readonly ciks?: number[]; - readonly eightKItems?: readonly string[]; readonly filedOnOrAfter?: string; }): Promise { await runWorkflowCli( [], undefined, formsSweepLoop( - newFormsWorklistTask( - options.formTypes, - options.shard, - options.ciks, - options.eightKItems, - options.filedOnOrAfter - ) + newFormsWorklistTask(options.formTypes, options.shard, options.ciks, options.filedOnOrAfter) ) ); } diff --git a/src/cli/sync/spacProcessSweeps.test.ts b/src/cli/sync/spacProcessSweeps.test.ts deleted file mode 100644 index 96878395..00000000 --- a/src/cli/sync/spacProcessSweeps.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright 2026 Steven Roussey - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from "vitest"; -import { MILESTONE_ITEM_CODES } from "../../sec/forms/miscellaneous-filings/Form_8_K.storage"; -import { LOI_TRIGGER_ITEMS } from "../../sec/forms/miscellaneous-filings/spac8kLoiTriggers"; -import { REDEMPTION_TRIGGER_ITEMS } from "../../sec/forms/miscellaneous-filings/spac8kRedemptionTriggers"; -import { formsForExtractorIds } from "../../storage/versioning/extractorIds"; -import { - SPAC_PROCESS_EIGHT_K_ITEMS, - SPAC_SHELF_424_FORMS, - spacProcessSweeps, -} from "./spacProcessSweeps"; - -describe("spacProcessSweeps", () => { - it("keeps eightKItems equal to the union of milestone, LOI, and redemption triggers", () => { - expect(new Set(SPAC_PROCESS_EIGHT_K_ITEMS)).toEqual( - new Set([...MILESTONE_ITEM_CODES, ...LOI_TRIGGER_ITEMS, ...REDEMPTION_TRIGGER_ITEMS]) - ); - }); - - it("runs registration for every process CIK and lifecycle only for known SPACs", () => { - const sweeps = spacProcessSweeps([1, 2], [1]); - expect(sweeps).toHaveLength(2); - - expect(sweeps[0]!.ciks).toEqual([1, 2]); - expect(sweeps[0]!.eightKItems).toBeUndefined(); - expect(sweeps[0]!.formTypes).toEqual(formsForExtractorIds(["S-1"])); - - expect(sweeps[1]!.ciks).toEqual([1]); - expect(sweeps[1]!.eightKItems).toEqual(SPAC_PROCESS_EIGHT_K_ITEMS); - expect(sweeps[1]!.formTypes).toContain("8-K"); - expect(sweeps[1]!.formTypes).toContain("424B4"); - expect(sweeps[1]!.formTypes).toContain("DEF 14A"); - expect(sweeps[1]!.formTypes).toContain("25-NSE"); - for (const form of SPAC_SHELF_424_FORMS) { - expect(sweeps[1]!.formTypes).not.toContain(form); - } - for (const form of formsForExtractorIds(["S-1"])) { - expect(sweeps[1]!.formTypes).not.toContain(form); - } - }); - - it("omits the lifecycle sweep when no spac row exists yet", () => { - const sweeps = spacProcessSweeps([2, 3], []); - expect(sweeps).toHaveLength(1); - expect(sweeps[0]!.formTypes).toEqual(formsForExtractorIds(["S-1"])); - }); -}); diff --git a/src/cli/sync/spacProcessSweeps.ts b/src/cli/sync/spacProcessSweeps.ts deleted file mode 100644 index 675e197c..00000000 --- a/src/cli/sync/spacProcessSweeps.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @license - * Copyright 2026 Steven Roussey - * SPDX-License-Identifier: Apache-2.0 - */ - -import { formsForExtractorIds } from "../../storage/versioning/extractorIds"; - -/** - * 424 variants that are shelf takedowns / supplements, not a SPAC IPO - * prospectus. `processForm424` returns after the deterministic XBRL pass for - * these; they do not mint `ipo` events. Including them in `sync spacs` queued - * every follow-on of a de-SPAC'd operating company. - */ -export const SPAC_SHELF_424_FORMS: ReadonlySet = new Set([ - "424A", - "424B2", - "424B5", - "424B7", -]); - -/** - * 8-K item codes that can carry SPAC lifecycle, LOI, or redemption content. - * Kept equal (by test) to the union of `MILESTONE_ITEM_CODES`, - * `LOI_TRIGGER_ITEMS`, and `REDEMPTION_TRIGGER_ITEMS`. - */ -export const SPAC_PROCESS_EIGHT_K_ITEMS: readonly string[] = [ - "1.01", - "1.02", - "2.01", - "5.03", - "5.07", - "7.01", - "8.01", -]; - -export interface SpacProcessSweep { - readonly formTypes: string[]; - readonly ciks: number[]; - readonly eightKItems: readonly string[] | undefined; -} - -/** - * Split the SPAC process worklist so candidates without a `spac` row only - * receive registration statements (which mint the row), and known SPACs skip - * shelf 424s plus 8-Ks whose item codes cannot carry a lifecycle event. - */ -export function spacProcessSweeps( - processCiks: readonly number[], - knownCiks: readonly number[] -): SpacProcessSweep[] { - const sweeps: SpacProcessSweep[] = []; - if (processCiks.length > 0) { - sweeps.push({ - formTypes: formsForExtractorIds(["S-1"]), - ciks: [...processCiks], - eightKItems: undefined, - }); - } - if (knownCiks.length > 0) { - sweeps.push({ - formTypes: [ - ...formsForExtractorIds(["424"]).filter((form) => !SPAC_SHELF_424_FORMS.has(form)), - ...formsForExtractorIds(["8-K", "merger-proxy", "25-15"]), - ], - ciks: [...knownCiks], - eightKItems: SPAC_PROCESS_EIGHT_K_ITEMS, - }); - } - return sweeps; -} diff --git a/src/sec/forms/registration-statements/s1/gfmTables.test.ts b/src/sec/forms/registration-statements/s1/gfmTables.test.ts new file mode 100644 index 00000000..7f1caf55 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/gfmTables.test.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { + cleanCell, + collapseRow, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; +import { parseBeneficialOwnership } from "./parseBeneficialOwnership"; +import { parseManagementRoster } from "./parseManagementRoster"; + +describe("splitPipeRow", () => { + it("splits a plain row", () => { + expect(splitPipeRow("| a | b | c |")).toEqual([" a ", " b ", " c "]); + }); + + it("returns cells verbatim, leaving whitespace to cleanCell", () => { + // Six of the seven private copies did not trim, and every caller that + // cares runs cleanCell. Trimming here would silently change the seventh. + expect(splitPipeRow("| padded |")).toEqual([" padded "]); + }); + + it("keeps an escaped pipe inside its own cell", () => { + // The divergence this module exists to end: `end.split(\"|\")` yielded FOUR + // cells here, shifting every value right of the escape into the wrong + // column — so the same rendered table parsed differently depending on + // which walk read it. + expect(splitPipeRow("| Class A \\| Class B | 1,000 | 5% |")).toEqual([ + " Class A | Class B ", + " 1,000 ", + " 5% ", + ]); + }); + + it("handles a row with no delimiters at all", () => { + expect(splitPipeRow("bare")).toEqual(["bare"]); + }); +}); + +describe("isSeparatorRow", () => { + it("recognizes the rule under a header, in either alignment form", () => { + expect(isSeparatorRow("| --- | --- |")).toBe(true); + expect(isSeparatorRow("|:---|---:|")).toBe(true); + expect(isSeparatorRow("| Name | Shares |")).toBe(false); + }); +}); + +describe("cleanCell", () => { + it("folds zero-width and non-breaking spaces to a space rather than deleting them", () => { + // Filer agents use them as spacing; deleting joins two words into one. + expect(cleanCell("Jane Doe")).toBe("Jane Doe"); + expect(cleanCell("A​B")).toBe("A B"); + expect(cleanCell(" lots of space ")).toBe("lots of space"); + }); +}); + +describe("splitGfmTables", () => { + it("groups consecutive pipe lines, dropping separators, and ends a table on prose", () => { + const text = [ + "Some prose.", + "| Name | Shares |", + "| --- | --- |", + "| Jane Doe | 1,000 |", + "", + "More prose.", + "| Other | Table |", + "| x | y |", + ].join("\n"); + expect(splitGfmTables(text)).toEqual([ + [ + ["Name", "Shares"], + ["Jane Doe", "1,000"], + ], + [ + ["Other", "Table"], + ["x", "y"], + ], + ]); + }); +}); + +describe("collapseRow", () => { + it("drops empty cells and immediate repeats", () => { + // Spacer columns carry the `$`; a colspan caption repeats across its span. + expect(collapseRow(["Total", "", "Total", "$", "1,000"])).toEqual(["Total", "$", "1,000"]); + }); +}); + +describe("mergeHeaderRows", () => { + it("folds a two-line header into one caption per column", () => { + expect(mergeHeaderRows(["Name", "Shares", ""], ["", "Owned", "Percent"])).toEqual([ + "Name", + "Shares Owned", + "Percent", + ]); + }); +}); + +describe("the parsers agree on one table grammar", () => { + // Before extraction, four parsers used `end.split("|")` and three a scan that + // honoured `\|`. This is the case that told them apart. + const ESCAPED = [ + "| Name | Class A \\| Class B | Percent |", + "| --- | --- | --- |", + "| Jane Doe | 1,000 | 5% |", + ].join("\n"); + + it("reads an escaped pipe as one cell in every walk", () => { + for (const table of splitGfmTables(ESCAPED)) { + for (const row of table) { + expect(row).toHaveLength(3); + } + } + }); + + it("does not throw in the walks that previously split naively", () => { + expect(() => parseBeneficialOwnership(ESCAPED)).not.toThrow(); + expect(() => parseManagementRoster(ESCAPED)).not.toThrow(); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/gfmTables.ts b/src/sec/forms/registration-statements/s1/gfmTables.ts new file mode 100644 index 00000000..ccd59399 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/gfmTables.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Reading GFM pipe tables back out of a rendered section. + * + * {@link ../../../html/TableExtractor} turns a filing's `` into a + * `TableNode`, which the renderer prints as pipe rows; every deterministic + * section walk then has to parse those rows back. Seven of them each carried a + * private copy of these six functions, and the copies had diverged on escaped + * pipes — so the same rendered table split into a different number of columns + * depending on which walk read it, silently shifting every field to the right + * of the escape. + * + * What belongs here is the table grammar, and nothing above it. A parser's own + * name tidying, stub tests and column vocabulary stay in the parser: those read + * differently per section on purpose (an ownership row strips a `(our sponsor)` + * parenthetical that an underwriter row must keep), and pulling them up here + * would be the same over-merge in the other direction. + */ + +/** + * Split one rendered row into its cells, honouring the `\|` escape the renderer + * writes for a pipe INSIDE a cell. + * + * That escape is why this is a scan rather than `split("|")`. A filer who + * prints `Class A | Class B` in a single cell renders as one escaped pipe, and + * splitting on it naively yields an extra column — so every value after it + * lands one position right, into a neighbouring field, with nothing failing. + * + * Cells are returned **untrimmed**; callers that care run them through + * {@link cleanCell}, which also folds the zero-width and non-breaking spaces + * EDGAR markup is full of. + */ +export function splitPipeRow(line: string): string[] { + const inner = line.replace(/^\|/, "").replace(/\|$/, ""); + const cells: string[] = []; + let cur = ""; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === "\\" && inner[i + 1] === "|") { + cur += "|"; + i += 1; + continue; + } + if (inner[i] === "|") { + cells.push(cur); + cur = ""; + continue; + } + cur += inner[i]; + } + cells.push(cur); + return cells; +} + +/** The `|---|:--:|` rule under a header, which carries no data. */ +export function isSeparatorRow(line: string): boolean { + return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); +} + +/** + * Normalize one cell's whitespace. The zero-width characters and the + * non-breaking space are folded to a plain space rather than deleted: EDGAR + * filer agents use them as spacing, so removing them joins two words. + */ +export function cleanCell(raw: string): string { + return raw + .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Every pipe table in a rendered section, as rows of cleaned cells. A run of + * consecutive pipe lines is one table; any other line ends it. + */ +export function splitGfmTables(text: string): string[][][] { + const tables: string[][][] = []; + let current: string[][] = []; + const flush = (): void => { + if (current.length > 0) tables.push(current); + current = []; + }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + flush(); + continue; + } + if (isSeparatorRow(trimmed)) continue; + current.push(splitPipeRow(trimmed).map(cleanCell)); + } + flush(); + return tables; +} + +/** + * Drop a row's empty cells and its immediate repeats. Filer markup pads a grid + * with spacer columns carrying the `$` sign and footnote markers, and a + * colspan-stretched caption expands to the same text in every column it spans; + * neither is a value. + */ +export function collapseRow(row: readonly string[]): string[] { + const out: string[] = []; + for (const raw of row) { + const cell = cleanCell(raw); + if (cell === "") continue; + if (out[out.length - 1] === cell) continue; + out.push(cell); + } + return out; +} + +/** + * Fold a two-line header into one caption per column. A blank upper cell takes + * the lower one; a stretched upper caption (repeating the stub column's text) + * yields to the lower; otherwise the two are joined. + */ +export function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { + const n = Math.max(a.length, b.length); + const a0 = cleanCell(a[0] ?? ""); + const out: string[] = []; + for (let i = 0; i < n; i++) { + const left = cleanCell(a[i] ?? ""); + const right = cleanCell(b[i] ?? ""); + if (left === "" || (i > 0 && left === a0)) { + out.push(right); + continue; + } + if (right === "" || right === left) { + out.push(left); + continue; + } + out.push(`${left} ${right}`); + } + return out; +} diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index de82ed30..d84fd1e3 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -706,15 +706,16 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise extractUseOfProceeds(text, m, context), { deterministic: isSpac ? { - extract: (text) => { - const det = parseSpacUseOfProceeds(text); - return det.length >= 2 ? det : []; - }, + extract: (text) => parseSpacUseOfProceeds(text), covers: new Set(["use_of_proceeds"]), // `use_of_proceeds` holds one row per line item, so covering its // columns says nothing about the rows. The walk's own decline log // does: a labelled row it could not represent means the table was - // not enumerated, and the model gets the section. + // not enumerated, and the model gets the section. It reads the + // section rather than `rows` because the count of rows the walk + // DECLINED is not recoverable from the ones it returned — and it + // is the same walk, not a second reading: `parseInner` is cached on + // the text `extract` just passed it. complete: (_rows, text) => useOfProceedsIsComplete(text), } : undefined, diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts index 8f45ac77..ae7efa67 100644 --- a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts @@ -92,4 +92,33 @@ describe("parseBeneficialOwnership", () => { ["Peter McKellar", 479167], ]); }); + it("never reads a cell that states a percentage as a share count", () => { + // `parseNumeric` strips a trailing `%`, so an integral "10%" satisfied the + // share test and a 10% holder was recorded as owning ten shares. The + // magnitude is not the discriminator — the `%` is. + const text = [ + "| Name and Address of Beneficial Owner | Shares | Percent |", + "| --- | --- | --- |", + "| Bellweather Sponsor LLC | — | 10% |", + "| Ordinary Holder LLC | 1,000 | 12% |", + ].join("\n"); + const rows = parseBeneficialOwnership(text); + const bell = rows.find((r) => r.name === "Bellweather Sponsor LLC"); + expect(bell?.shares_owned).toBeNull(); + // The row that DOES state a share count still reads both figures. + const ordinary = rows.find((r) => r.name === "Ordinary Holder LLC"); + expect(ordinary?.shares_owned).toBe(1000); + expect(ordinary?.percent_owned).toBe(12); + }); + + it("still reads a percentage split into its own column", () => { + const text = [ + "| Name and Address of Beneficial Owner | Shares | Percent |", + "| --- | --- | --- |", + "| Split Column Sponsor LLC | 2,820,000 | 98.09 | % |", + ].join("\n"); + const rows = parseBeneficialOwnership(text); + expect(rows[0]?.shares_owned).toBe(2820000); + expect(rows[0]?.percent_owned).toBeCloseTo(98.09); + }); }); diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts index 2c298c92..0f835beb 100644 --- a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts @@ -8,6 +8,14 @@ import { parseNumeric } from "../../../html/parseNumeric"; import { hasCompanyEnding } from "../../../../storage/company/CompanyNormalization"; import { isOwnershipGroupSubtotal } from "./sectionExtractors"; import type { BeneficialOwnerRow } from "./sectionSchemas"; +import { + cleanCell, + collapseRow, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; export function parseBeneficialOwnership(text: string): BeneficialOwnerRow[] { try { @@ -167,8 +175,18 @@ function parseFigures(cells: readonly string[]): { pending = null; continue; } - const n = parseNumeric(cell.replace(/,/g, "")); + const stripped = cell.replace(/,/g, ""); + const n = parseNumeric(stripped); if (n === undefined || !Number.isFinite(n)) continue; + // `parseNumeric` strips a trailing `%` before returning, so by the time the + // value arrives the sign is gone: an integral "10%" satisfied the share + // test below and recorded a 10% holder as owning ten shares. A cell that + // states a percentage is a percentage whatever its magnitude — the + // split-column case (a bare figure, then a lone "%") is handled above. + if (stripped.includes("%")) { + pending = n; + continue; + } if (shares_owned === null && Number.isInteger(n) && (n === 0 || Math.abs(n) >= 1)) { shares_owned = n; continue; @@ -233,71 +251,3 @@ function isFootnoteOnly(cell: string): boolean { function isBlankMoney(cell: string): boolean { return cell === "" || cell === "$" || cell === "—" || cell === "–" || cell === "-"; } - -function collapseRow(row: readonly string[]): string[] { - const out: string[] = []; - for (const raw of row) { - const cell = cleanCell(raw); - if (cell === "") continue; - if (out[out.length - 1] === cell) continue; - out.push(cell); - } - return out; -} - -function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { - const n = Math.max(a.length, b.length); - const a0 = cleanCell(a[0] ?? ""); - const out: string[] = []; - for (let i = 0; i < n; i++) { - const left = cleanCell(a[i] ?? ""); - const right = cleanCell(b[i] ?? ""); - if (left === "" || (i > 0 && left === a0)) { - out.push(right); - continue; - } - if (right === "" || right === left) { - out.push(left); - continue; - } - out.push(`${left} ${right}`); - } - return out; -} - -function cleanCell(raw: string): string { - return raw - .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function splitGfmTables(text: string): string[][][] { - const tables: string[][][] = []; - let current: string[][] = []; - const flush = (): void => { - if (current.length > 0) tables.push(current); - current = []; - }; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("|")) { - flush(); - continue; - } - if (isSeparatorRow(trimmed)) continue; - current.push(splitPipeRow(trimmed).map(cleanCell)); - } - flush(); - return tables; -} - -function isSeparatorRow(line: string): boolean { - return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); -} - -function splitPipeRow(line: string): string[] { - const inner = line.startsWith("|") ? line.slice(1) : line; - const end = inner.endsWith("|") ? inner.slice(0, -1) : inner; - return end.split("|"); -} diff --git a/src/sec/forms/registration-statements/s1/parseManagementRoster.ts b/src/sec/forms/registration-statements/s1/parseManagementRoster.ts index 0d85f5fa..d48e90da 100644 --- a/src/sec/forms/registration-statements/s1/parseManagementRoster.ts +++ b/src/sec/forms/registration-statements/s1/parseManagementRoster.ts @@ -9,6 +9,14 @@ import { hasCompanyEnding } from "../../../../storage/company/CompanyNormalizati import { normalizeManagementTitles } from "./normalizeTitle"; import { isCollectivePartyName, isCompensationPositionLabel } from "./sectionExtractors"; import type { ManagementPersonRow } from "./sectionSchemas"; +import { + cleanCell, + collapseRow, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; export function parseManagementRoster(text: string): ManagementPersonRow[] { try { @@ -177,71 +185,3 @@ function tidyName(raw: string): string { .replace(/\s+/g, " ") .trim(); } - -function collapseRow(row: readonly string[]): string[] { - const out: string[] = []; - for (const raw of row) { - const cell = cleanCell(raw); - if (cell === "") continue; - if (out[out.length - 1] === cell) continue; - out.push(cell); - } - return out; -} - -function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { - const n = Math.max(a.length, b.length); - const a0 = cleanCell(a[0] ?? ""); - const out: string[] = []; - for (let i = 0; i < n; i++) { - const left = cleanCell(a[i] ?? ""); - const right = cleanCell(b[i] ?? ""); - if (left === "" || (i > 0 && left === a0)) { - out.push(right); - continue; - } - if (right === "" || right === left) { - out.push(left); - continue; - } - out.push(`${left} ${right}`); - } - return out; -} - -function cleanCell(raw: string): string { - return raw - .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function splitGfmTables(text: string): string[][][] { - const tables: string[][][] = []; - let current: string[][] = []; - const flush = (): void => { - if (current.length > 0) tables.push(current); - current = []; - }; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("|")) { - flush(); - continue; - } - if (isSeparatorRow(trimmed)) continue; - current.push(splitPipeRow(trimmed).map(cleanCell)); - } - flush(); - return tables; -} - -function isSeparatorRow(line: string): boolean { - return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); -} - -function splitPipeRow(line: string): string[] { - const inner = line.startsWith("|") ? line.slice(1) : line; - const end = inner.endsWith("|") ? inner.slice(0, -1) : inner; - return end.split("|"); -} diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts index 3570356e..89deff1c 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts @@ -9,6 +9,7 @@ import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; import { anchorFieldSpan } from "./anchorFieldSpan"; import type { OfferingTermsRow } from "./offeringTermsSchema"; import type { SponsorPromoteRow } from "./sponsorPromoteSchema"; +import { isSeparatorRow, splitPipeRow } from "./gfmTables"; export { DETERMINISTIC_MODEL_ID }; @@ -350,7 +351,9 @@ function iterTableRows(text: string): TableRow[] { const trimmed = line.trim(); if (!trimmed.startsWith("|")) continue; if (isSeparatorRow(trimmed)) continue; - const cells = splitPipeRow(trimmed); + // The shared split returns cells verbatim; this walk has no `cleanCell` + // pass of its own, so it trims here exactly where its private copy did. + const cells = splitPipeRow(trimmed).map((cell) => cell.trim()); const picked = pickLabelValue(cells); if (picked === null) continue; const { label, value } = picked; @@ -395,31 +398,6 @@ function pickLabelValue(cells: string[]): TableRow | null { return { label, value, cells: c }; } -function isSeparatorRow(line: string): boolean { - return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); -} - -function splitPipeRow(line: string): string[] { - const inner = line.replace(/^\|/, "").replace(/\|$/, ""); - const cells: string[] = []; - let cur = ""; - for (let i = 0; i < inner.length; i++) { - if (inner[i] === "\\" && inner[i + 1] === "|") { - cur += "|"; - i += 1; - continue; - } - if (inner[i] === "|") { - cells.push(cur.trim()); - cur = ""; - continue; - } - cur += inner[i]; - } - cells.push(cur.trim()); - return cells; -} - function normalizeLabel(raw: string): string { const lowered = raw.toLowerCase().replace(/\s+/g, " ").trim(); const stripped = lowered diff --git a/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts index 7505ae19..f4bd6875 100644 --- a/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts +++ b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts @@ -8,6 +8,14 @@ import { hasCompanyEnding } from "../../../../storage/company/CompanyNormalizati import { legalFormTrailingCanonical } from "../../../../util/legalForms"; import { isCollectivePartyName } from "./sectionExtractors"; import type { RelatedPartyRow } from "./sectionSchemas"; +import { + cleanCell, + collapseRow, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; export function parseRelatedPartyTables(text: string): RelatedPartyRow[] { try { @@ -121,71 +129,3 @@ function tidyName(raw: string): string { .replace(/\s+/g, " ") .trim(); } - -function collapseRow(row: readonly string[]): string[] { - const out: string[] = []; - for (const raw of row) { - const cell = cleanCell(raw); - if (cell === "") continue; - if (out[out.length - 1] === cell) continue; - out.push(cell); - } - return out; -} - -function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { - const n = Math.max(a.length, b.length); - const a0 = cleanCell(a[0] ?? ""); - const out: string[] = []; - for (let i = 0; i < n; i++) { - const left = cleanCell(a[i] ?? ""); - const right = cleanCell(b[i] ?? ""); - if (left === "" || (i > 0 && left === a0)) { - out.push(right); - continue; - } - if (right === "" || right === left) { - out.push(left); - continue; - } - out.push(`${left} ${right}`); - } - return out; -} - -function cleanCell(raw: string): string { - return raw - .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function splitGfmTables(text: string): string[][][] { - const tables: string[][][] = []; - let current: string[][] = []; - const flush = (): void => { - if (current.length > 0) tables.push(current); - current = []; - }; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("|")) { - flush(); - continue; - } - if (isSeparatorRow(trimmed)) continue; - current.push(splitPipeRow(trimmed).map(cleanCell)); - } - flush(); - return tables; -} - -function isSeparatorRow(line: string): boolean { - return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); -} - -function splitPipeRow(line: string): string[] { - const inner = line.startsWith("|") ? line.slice(1) : line; - const end = inner.endsWith("|") ? inner.slice(0, -1) : inner; - return end.split("|"); -} diff --git a/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts index 7539da86..955e2442 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts @@ -8,6 +8,7 @@ import { parseNumeric } from "../../../html/parseNumeric"; import { isCompanyFamilyPrefixEcho } from "../../../../storage/company/CompanyFamilyName"; import { isUnnamedCompanyName } from "../../../../storage/company/CompanyNormalization"; import type { UnderwriterRowOut } from "./underwriterSchema"; +import { cleanCell, isSeparatorRow, splitGfmTables, splitPipeRow } from "./gfmTables"; function nameKey(name: string): string { return name @@ -223,55 +224,3 @@ function dedupe(rows: readonly Candidate[]): Candidate[] { } return kept; } - -function cleanCell(raw: string): string { - return raw - .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function splitGfmTables(text: string): string[][][] { - const tables: string[][][] = []; - let current: string[][] = []; - const flush = (): void => { - if (current.length > 0) tables.push(current); - current = []; - }; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("|")) { - flush(); - continue; - } - if (isSeparatorRow(trimmed)) continue; - current.push(splitPipeRow(trimmed).map(cleanCell)); - } - flush(); - return tables; -} - -function isSeparatorRow(line: string): boolean { - return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); -} - -function splitPipeRow(line: string): string[] { - const inner = line.replace(/^\|/, "").replace(/\|$/, ""); - const cells: string[] = []; - let cur = ""; - for (let i = 0; i < inner.length; i++) { - if (inner[i] === "\\" && inner[i + 1] === "|") { - cur += "|"; - i += 1; - continue; - } - if (inner[i] === "|") { - cells.push(cur); - cur = ""; - continue; - } - cur += inner[i]; - } - cells.push(cur); - return cells; -} diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts index b02e2d92..8782f6ed 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts @@ -5,7 +5,11 @@ */ import { describe, expect, it } from "vitest"; -import { parseSpacUseOfProceeds, useOfProceedsIsComplete } from "./parseSpacUseOfProceeds"; +import { + parseSpacUseOfProceeds, + resetUseOfProceedsParseCacheForTesting, + useOfProceedsIsComplete, +} from "./parseSpacUseOfProceeds"; function purposes(text: string): string[] { return parseSpacUseOfProceeds(text).map((r) => r.purpose ?? ""); @@ -153,3 +157,36 @@ describe("useOfProceedsIsComplete", () => { expect(useOfProceedsIsComplete("")).toBe(false); }); }); + +describe("the extract and complete readers share one walk", () => { + // The runner calls both for the same section on the same pass. The + // completeness verdict counts the rows the walk could NOT represent, which + // never reach the returned array — so `complete` re-read the section, both + // doubling the work and leaving two answers that could disagree. + const TABLE = [ + "| Use of Proceeds | Amount |", + "| --- | --- |", + "| Held in trust account | $100,000,000 |", + "| Underwriting discounts | $2,000,000 |", + ].join("\n"); + + it("answers identically whichever reader asks first", () => { + resetUseOfProceedsParseCacheForTesting(); + const completeFirst = useOfProceedsIsComplete(TABLE); + const rowsAfter = parseSpacUseOfProceeds(TABLE); + resetUseOfProceedsParseCacheForTesting(); + const rowsFirst = parseSpacUseOfProceeds(TABLE); + const completeAfter = useOfProceedsIsComplete(TABLE); + expect(completeFirst).toBe(completeAfter); + expect(rowsFirst).toEqual(rowsAfter); + }); + + it("is not a stale cache — a different section gets its own answer", () => { + resetUseOfProceedsParseCacheForTesting(); + expect(parseSpacUseOfProceeds(TABLE).length).toBeGreaterThan(0); + expect(parseSpacUseOfProceeds("no table here at all")).toEqual([]); + expect(useOfProceedsIsComplete("no table here at all")).toBe(false); + // ...and asking for the first one again still reads it. + expect(parseSpacUseOfProceeds(TABLE).length).toBeGreaterThan(0); + }); +}); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts index a104ce95..4acc9681 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts @@ -6,6 +6,7 @@ import { parseNumeric } from "../../../html/parseNumeric"; import type { UseOfProceedsLineRow } from "./useOfProceedsSchema"; +import { cleanCell, isSeparatorRow, splitGfmTables, splitPipeRow } from "./gfmTables"; const MIN_LINES = 2; const MIN_AMOUNT = 1_000; @@ -81,7 +82,36 @@ interface UseOfProceedsParse { const EMPTY_PARSE: UseOfProceedsParse = { rows: [], unrepresented: 0 }; +/** + * One walk per section text, shared by every reader of it. + * + * {@link parseSpacUseOfProceeds} and {@link useOfProceedsIsComplete} are both + * called for the same section on the same pass — the runner's `extract` slot + * and its `complete` callback — and the completeness verdict is not derivable + * from the rows: it counts the labelled rows the walk could NOT represent, + * which by definition never reach the returned array. So the second reader had + * to walk the section again, which both doubled the work and left two answers + * that could disagree about one filing. + * + * A single-entry cache is enough because the two calls are adjacent: the entry + * is replaced by the next section, so nothing accumulates and the section text + * is held no longer than the caller already holds it. The walk is pure, so a + * hit is indistinguishable from a re-walk apart from not doing it twice. + */ +let lastParse: { readonly text: string; readonly parse: UseOfProceedsParse } | undefined; + function parseInner(text: string): UseOfProceedsParse { + if (lastParse !== undefined && lastParse.text === text) return lastParse.parse; + const parse = parseUncached(text); + lastParse = { text, parse }; + return parse; +} + +export function resetUseOfProceedsParseCacheForTesting(): void { + lastParse = undefined; +} + +function parseUncached(text: string): UseOfProceedsParse { const parsed = collectLines(text); if (parsed.rows.length < MIN_LINES) return EMPTY_PARSE; if (!parsed.rows.some((r) => SPAC_USE.test(r.purpose ?? ""))) return EMPTY_PARSE; @@ -192,55 +222,3 @@ function firstPercent(cells: readonly string[]): number | null { } return null; } - -function cleanCell(raw: string): string { - return raw - .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function splitGfmTables(text: string): string[][][] { - const tables: string[][][] = []; - let current: string[][] = []; - const flush = (): void => { - if (current.length > 0) tables.push(current); - current = []; - }; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("|")) { - flush(); - continue; - } - if (isSeparatorRow(trimmed)) continue; - current.push(splitPipeRow(trimmed).map(cleanCell)); - } - flush(); - return tables; -} - -function isSeparatorRow(line: string): boolean { - return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); -} - -function splitPipeRow(line: string): string[] { - const inner = line.replace(/^\|/, "").replace(/\|$/, ""); - const cells: string[] = []; - let cur = ""; - for (let i = 0; i < inner.length; i++) { - if (inner[i] === "\\" && inner[i + 1] === "|") { - cur += "|"; - i += 1; - continue; - } - if (inner[i] === "|") { - cells.push(cur); - cur = ""; - continue; - } - cur += inner[i]; - } - cells.push(cur); - return cells; -} diff --git a/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts index 83351302..b19e72c4 100644 --- a/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts +++ b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts @@ -11,6 +11,13 @@ import { normalizeFiscalYear, } from "./sectionExtractors"; import type { ExecutiveCompensationRow } from "./executiveCompensationSchema"; +import { + cleanCell, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; const MONEY_KINDS = [ "salary", @@ -248,26 +255,6 @@ function findSctHeader( return undefined; } -function mergeHeaderRows(a: readonly string[], b: readonly string[]): string[] { - const n = Math.max(a.length, b.length); - const a0 = cleanCell(a[0] ?? ""); - const out: string[] = []; - for (let i = 0; i < n; i++) { - const left = cleanCell(a[i] ?? ""); - const right = cleanCell(b[i] ?? ""); - if (left === "" || (i > 0 && left === a0)) { - out.push(right); - continue; - } - if (right === "" || right === left) { - out.push(left); - continue; - } - out.push(`${left} ${right}`); - } - return out; -} - function isSctHeader(row: readonly string[]): boolean { if (row.some((c) => /^term(?:s|\(s\))?$/i.test(cleanCell(c)))) return false; const kinds = headerKinds(row); @@ -389,55 +376,3 @@ function isHeaderish(stub: string): boolean { function isFootnoteOnly(cell: string): boolean { return /^\(\d+\)$/.test(cell.trim()); } - -function cleanCell(raw: string): string { - return raw - .replace(/[\u200b\u200c\u200d\ufeff\u00a0]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function splitGfmTables(text: string): string[][][] { - const tables: string[][][] = []; - let current: string[][] = []; - const flush = (): void => { - if (current.length > 0) tables.push(current); - current = []; - }; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("|")) { - flush(); - continue; - } - if (isSeparatorRow(trimmed)) continue; - current.push(splitPipeRow(trimmed).map(cleanCell)); - } - flush(); - return tables; -} - -function isSeparatorRow(line: string): boolean { - return /^\|[\s:|-]+\|$/.test(line) || /^[\s|:-]+$/.test(line); -} - -function splitPipeRow(line: string): string[] { - const inner = line.replace(/^\|/, "").replace(/\|$/, ""); - const cells: string[] = []; - let cur = ""; - for (let i = 0; i < inner.length; i++) { - if (inner[i] === "\\" && inner[i + 1] === "|") { - cur += "|"; - i += 1; - continue; - } - if (inner[i] === "|") { - cells.push(cur); - cur = ""; - continue; - } - cur += inner[i]; - } - cells.push(cur); - return cells; -} diff --git a/src/task/forms/ComputeFormsWorklistTask.ts b/src/task/forms/ComputeFormsWorklistTask.ts index d1edae8f..651456bb 100644 --- a/src/task/forms/ComputeFormsWorklistTask.ts +++ b/src/task/forms/ComputeFormsWorklistTask.ts @@ -42,14 +42,6 @@ export type ComputeFormsWorklistTaskInput = { readonly shardCount?: number; /** When non-empty, only filings whose CIK is in this list are emitted. */ readonly ciks?: number[]; - /** - * When non-empty, `8-K` / `8-K/A` filings are emitted only if their - * submissions `items` string contains one of these codes. Other forms are - * unaffected. Used by the SPAC process sweep so earnings 2.02s of a - * de-SPAC'd operating company are not fetched as if they were lifecycle - * events. - */ - readonly eightKItems?: string[]; /** * When set, filings whose `filing_date` is strictly before this YYYY-MM-DD * are consumed but not emitted. Used by `sync spacs --only updates` so a @@ -121,29 +113,6 @@ function accessionShard(accession: string, shardCount: number): number { return (h >>> 0) % shardCount; } -/** True when a comma/semicolon-separated EDGAR `items` string names any code. */ -function filingHasAnyItem(items: string | null | undefined, codes: ReadonlySet): boolean { - if (!items) return false; - for (const raw of items.split(/[,;]/)) { - if (codes.has(raw.trim())) return true; - } - return false; -} - -/** - * When `eightKItems` is set, 8-Ks that do not carry one of those codes are - * consumed (resume advances past them) but not emitted. - */ -function skipEightKWithoutItems( - form: string | null | undefined, - items: string | null | undefined, - codes: ReadonlySet | undefined -): boolean { - if (codes === undefined) return false; - if (form !== "8-K" && form !== "8-K/A") return false; - return !filingHasAnyItem(items, codes); -} - /** True when `filedOnOrAfter` is set and this filing is dated strictly earlier. */ function skipFiledBefore( filingDate: string | null | undefined, @@ -193,7 +162,6 @@ export class ComputeFormsWorklistTask extends Task< shardIndex: Type.Optional(Type.Integer({ minimum: 0 })), shardCount: Type.Optional(Type.Integer({ minimum: 1 })), ciks: Type.Optional(Type.Array(TypeSecCik())), - eightKItems: Type.Optional(Type.Array(Type.String())), filedOnOrAfter: Type.Optional(Type.String()), batchSize: Type.Optional(Type.Integer({ minimum: 1 })), }); @@ -263,10 +231,6 @@ export class ComputeFormsWorklistTask extends Task< input.ciks !== undefined && input.ciks.length > 0 ? new Set(input.ciks) : undefined; const allowCiks = cikAllowList !== undefined ? [...cikAllowList].sort((a, b) => a - b) : undefined; - const eightKItemSet = - input.eightKItems !== undefined && input.eightKItems.length > 0 - ? new Set(input.eightKItems) - : undefined; const filedOnOrAfter = input.filedOnOrAfter; const dryRun = isDryRun(); const batchSize = input.batchSize ?? WORKLIST_BATCH_SIZE; @@ -335,7 +299,6 @@ export class ComputeFormsWorklistTask extends Task< for (const f of rows) { if (sharding && accessionShard(f.accession_number, shardCount) !== shardIndex) continue; if (cikAllowList !== undefined && !cikAllowList.has(f.cik)) continue; - if (skipEightKWithoutItems(f.form, f.items, eightKItemSet)) continue; if (skipFiledBefore(f.filing_date, filedOnOrAfter)) continue; if (keys.has(filingRunKey(f))) continue; total++; @@ -418,7 +381,6 @@ export class ComputeFormsWorklistTask extends Task< // (shardCount-1)/shardCount of candidates before any other test. if (sharding && accessionShard(f.accession_number, shardCount) !== shardIndex) continue; if (cikAllowList !== undefined && !cikAllowList.has(f.cik)) continue; - if (skipEightKWithoutItems(f.form, f.items, eightKItemSet)) continue; if (skipFiledBefore(f.filing_date, filedOnOrAfter)) continue; if (this.successfulKeys.has(filingRunKey(f))) continue; accessionNumber.push(f.accession_number); diff --git a/src/task/forms/formsSweep.test.ts b/src/task/forms/formsSweep.test.ts index 6a48ecf2..c6795706 100644 --- a/src/task/forms/formsSweep.test.ts +++ b/src/task/forms/formsSweep.test.ts @@ -228,45 +228,6 @@ describe("forms sweep wiring", () => { } }); - it("when eightKItems is set, emits only 8-Ks carrying one of those item codes", async () => { - await seed({ - cik: 1, - accession_number: "0000000001-26-000001", - form: "8-K", - primary_doc: "a.htm", - items: "2.02,9.01", - }); - await seed({ - cik: 1, - accession_number: "0000000001-26-000002", - form: "8-K", - primary_doc: "b.htm", - items: "5.07,9.01", - }); - await seed({ - cik: 1, - accession_number: "0000000001-26-000003", - form: "S-1", - primary_doc: "c.htm", - }); - - const producer = new ComputeFormsWorklistTask({ - defaults: { form: ["8-K", "S-1"], eightKItems: ["5.07", "2.01"], batchSize: 10 }, - }); - const emitted: Array<{ form: string; accession: string }> = []; - while (!producer.exhausted) { - const out = await producer.run({}); - for (let i = 0; i < out.count; i++) { - emitted.push({ form: out.form[i]!, accession: out.accessionNumber[i]! }); - } - } - - expect(emitted).toEqual([ - { form: "S-1", accession: "0000000001-26-000003" }, - { form: "8-K", accession: "0000000001-26-000002" }, - ]); - }); - it("when filedOnOrAfter is set, emits only filings on or after that date", async () => { await seed({ cik: 1, diff --git a/src/task/forms/formsSweep.ts b/src/task/forms/formsSweep.ts index ef68fa85..130ab347 100644 --- a/src/task/forms/formsSweep.ts +++ b/src/task/forms/formsSweep.ts @@ -37,7 +37,6 @@ export function newFormsWorklistTask( form?: string[], shard?: FormsShard, ciks?: number[], - eightKItems?: readonly string[], filedOnOrAfter?: string ): ComputeFormsWorklistTask { return new ComputeFormsWorklistTask({ @@ -46,7 +45,6 @@ export function newFormsWorklistTask( shardIndex: shard?.index, shardCount: shard?.count, ciks, - eightKItems: eightKItems !== undefined ? [...eightKItems] : undefined, filedOnOrAfter, }, }); diff --git a/src/task/index/CatchUpDailyIndexTask.test.ts b/src/task/index/CatchUpDailyIndexTask.test.ts index 22340189..9e9ed589 100644 --- a/src/task/index/CatchUpDailyIndexTask.test.ts +++ b/src/task/index/CatchUpDailyIndexTask.test.ts @@ -19,10 +19,12 @@ import { DAILY_INDEX_CURSOR_REPOSITORY_TOKEN, } from "../../storage/processing/DailyIndexCursorSchema"; import * as dailyIndexDates from "./dailyIndexDates"; +import * as dailyIndexPublication from "./dailyIndexPublication"; import { dailyIndexCacheRelPath, planIndexDays } from "./dailyIndexDates"; import { CatchUpDailyIndexTask } from "./CatchUpDailyIndexTask"; import { FetchDailyIndexTask } from "./FetchDailyIndexTask"; +// A Tuesday. 2026-08-16 is the Sunday before it, 2026-08-15 the Saturday. const TODAY = "2026-08-18"; let rawRoot: string | undefined; @@ -32,6 +34,7 @@ function ctx(): IExecuteContext { signal: new AbortController().signal, updateProgress: () => {}, own: (v: T): T => v, + disown: () => {}, } as unknown as IExecuteContext; } @@ -44,6 +47,10 @@ describe("CatchUpDailyIndexTask", () => { resetDependencyInjectionsForTesting(); await setupAllDatabases(); vi.spyOn(dailyIndexDates, "todayEtYYYYdMMdDD").mockReturnValue(TODAY); + // Default for the weekday probe: EDGAR has not published that day. Today's + // index really has not been published for most of the day, and it keeps the + // pre-existing cases (which 403 on TODAY, a Tuesday) reading as before. + vi.spyOn(dailyIndexPublication, "dailyIndexWasPublished").mockResolvedValue(false); await globalServiceRegistry.get(DAILY_INDEX_CURSOR_REPOSITORY_TOKEN).put({ id: DAILY_INDEX_CURSOR_ID, last_success: "2026-08-14", @@ -284,4 +291,89 @@ describe("CatchUpDailyIndexTask", () => { .get({ id: DAILY_INDEX_CURSOR_ID }); expect(cursor?.last_success).toBe("2026-08-17"); }); + // --- 403 is ambiguous: unpublished day, or a client EDGAR is refusing? --- + // + // Accepting every 403 as "unpublished" walks `last_success` over every real + // trading day in the lookback and reports `success: true`, and nothing ever + // goes back for those days. These four pin the discrimination. + + it("does not probe a weekend 403 — EDGAR never published one", async () => { + vi.spyOn(FetchDailyIndexTask.prototype, "run").mockImplementation(async (input) => { + const date = input?.date; + // The Saturday and the Sunday of the lookback window. + if (date === "2026-08-15" || date === "2026-08-16") throw httpError(403); + return { updateList: [] }; + }); + const probe = vi.mocked(dailyIndexPublication.dailyIndexWasPublished); + probe.mockClear(); + + const result = await new CatchUpDailyIndexTask().execute({ lookback: 4 }, ctx()); + + expect(result.success).toBe(true); + expect(result.skipped404).toBe(2); + // Only TODAY (a Tuesday) reaches the probe; neither weekend day does. + for (const call of probe.mock.calls) { + expect(call[0]).toBe(TODAY); + } + }); + + it("throws on a weekday 403 the quarter listing says WAS published, leaving the cursor put", async () => { + vi.spyOn(FetchDailyIndexTask.prototype, "run").mockImplementation(async (input) => { + // A blocked client: every day 403s, including real trading days. + throw httpError(403); + }); + vi.mocked(dailyIndexPublication.dailyIndexWasPublished).mockImplementation( + async (date) => date !== TODAY + ); + + await expect(new CatchUpDailyIndexTask().execute({}, ctx())).rejects.toThrow( + /being refused|Refusing to advance/ + ); + + // The Saturday and Sunday are skipped without a probe and do advance the + // cursor — EDGAR published nothing on them, so nothing is lost. It stops + // dead at the Monday, which is the day whose filings would have been. + const cursor = await globalServiceRegistry + .get(DAILY_INDEX_CURSOR_REPOSITORY_TOKEN) + .get({ id: DAILY_INDEX_CURSOR_ID }); + expect(cursor?.last_success).toBe("2026-08-16"); + }); + + it("skips a weekday 403 the quarter listing does not name — a market holiday", async () => { + vi.spyOn(FetchDailyIndexTask.prototype, "run").mockImplementation(async (input) => { + const date = input?.date; + if (date === "2026-08-17" || date === TODAY) throw httpError(403); + return { updateList: [] }; + }); + vi.mocked(dailyIndexPublication.dailyIndexWasPublished).mockResolvedValue(false); + + const result = await new CatchUpDailyIndexTask().execute({}, ctx()); + + expect(result.success).toBe(true); + expect(result.lastSuccess).toBe("2026-08-17"); + const cursor = await globalServiceRegistry + .get(DAILY_INDEX_CURSOR_REPOSITORY_TOKEN) + .get({ id: DAILY_INDEX_CURSOR_ID }); + expect(cursor?.last_success).toBe("2026-08-17"); + }); + + it("throws when the probe itself cannot answer, rather than assuming unpublished", async () => { + vi.spyOn(FetchDailyIndexTask.prototype, "run").mockImplementation(async () => { + throw httpError(403); + }); + vi.mocked(dailyIndexPublication.dailyIndexWasPublished).mockRejectedValue( + new Error("listing fetch failed: 403") + ); + + await expect(new CatchUpDailyIndexTask().execute({}, ctx())).rejects.toThrow( + /could not be read either/ + ); + + // Stops at the last weekend day, before the first weekday it could not + // classify — an unanswerable probe never advances past a trading day. + const cursor = await globalServiceRegistry + .get(DAILY_INDEX_CURSOR_REPOSITORY_TOKEN) + .get({ id: DAILY_INDEX_CURSOR_ID }); + expect(cursor?.last_success).toBe("2026-08-16"); + }); }); diff --git a/src/task/index/CatchUpDailyIndexTask.ts b/src/task/index/CatchUpDailyIndexTask.ts index 33baf2e3..77ce785a 100644 --- a/src/task/index/CatchUpDailyIndexTask.ts +++ b/src/task/index/CatchUpDailyIndexTask.ts @@ -18,12 +18,7 @@ import { import { CIK_LAST_UPDATE_REPOSITORY_TOKEN } from "../../storage/processing/CikLastUpdateSchema"; import { TypeSecDate } from "../../util/parseDate"; import { getHttpErrorStatus } from "../fetch/SecFetchJob"; - -/** EDGAR's daily-index bucket 403s unpublished days (weekends/holidays); some paths still 404. */ -function isUnpublishedDailyIndex(err: unknown): boolean { - const status = getHttpErrorStatus(err); - return status === 404 || status === 403; -} +import { dailyIndexWasPublished, isWeekendDate } from "./dailyIndexPublication"; import { dailyIndexCacheRelPath, DEFAULT_DAILY_INDEX_LOOKBACK, @@ -33,6 +28,55 @@ import { import { FetchDailyIndexTask } from "./FetchDailyIndexTask"; import { StoreCikLastUpdatedTask } from "./StoreCikLastUpdatedTask"; +/** + * Whether a failed day's error means "EDGAR published no index for this day", + * as opposed to "EDGAR refused this client". + * + * Both are 403 (some paths still 404), and the difference is the whole ballgame: + * the unpublished branch advances `last_success` past the day, and nothing ever + * goes back for it. A blocked client — rejected User-Agent, rate limit, an + * egress EDGAR does not serve — would otherwise walk the cursor over every real + * trading day in the lookback and report `success: true`. + * + * Weekends are free: EDGAR has never published one, so no request is made. A + * weekday is the ambiguous case (Friday 2026-07-03, Independence Day observed, + * is a genuine 403) and is settled against the quarter's own file listing, + * which distinguishes the two directly. A probe that cannot answer throws, + * because "unknown" must not be recorded as "unpublished". + */ +async function isUnpublishedDailyIndex( + err: unknown, + date: string, + context: IExecuteContext +): Promise { + const status = getHttpErrorStatus(err); + if (status !== 404 && status !== 403) return false; + if (isWeekendDate(date)) return true; + let published: boolean; + try { + published = await dailyIndexWasPublished(date, context); + } catch (probeErr) { + const detail = probeErr instanceof Error ? probeErr.message : String(probeErr); + throw new Error( + `EDGAR returned ${status} for the ${date} daily index and its quarter listing could ` + + `not be read either (${detail}). That is the signature of a blocked client, not an ` + + `unpublished day, so the cursor is left at ${date} rather than advanced past it. ` + + `Check the SEC User-Agent and request rate, then re-run.`, + { cause: err } + ); + } + if (published) { + throw new Error( + `EDGAR returned ${status} for the ${date} daily index, but its quarter listing names ` + + `that day's master index — the day published and this client is being refused. ` + + `Refusing to advance the cursor past ${date}. Check the SEC User-Agent and request ` + + `rate, then re-run.`, + { cause: err } + ); + } + return true; +} + export type CatchUpDailyIndexTaskInput = { readonly from?: string; readonly lookback?: number; @@ -144,18 +188,35 @@ export class CatchUpDailyIndexTask extends Task< await unlinkCacheFile(date); }; + // Owned per day and released per day: a cursor seeded from an old + // `cik_last_update` plans one entry per calendar day since, so holding + // every child for the whole of `execute()` is unbounded in the length of + // the catch-up. `disown` is how a loop hands them back. + const runDay = async (date: string): Promise => { + const fetchTask = context.own(new FetchDailyIndexTask()); + let updateList; + try { + ({ updateList } = await fetchTask.run({ date })); + } finally { + context.disown(fetchTask); + } + const storeTask = context.own(new StoreCikLastUpdatedTask()); + try { + await storeTask.run({ updateList }); + } finally { + context.disown(storeTask); + } + }; + for (const date of plan.completed) { await bypassCacheIfNeeded(date); try { - const fetchResult = await context.own(new FetchDailyIndexTask()).run({ date }); - await context - .own(new StoreCikLastUpdatedTask()) - .run({ updateList: fetchResult.updateList }); + await runDay(date); lastSuccess = date; await cursorRepo.put({ id: DAILY_INDEX_CURSOR_ID, last_success: date }); fetched++; } catch (err) { - if (isUnpublishedDailyIndex(err)) { + if (await isUnpublishedDailyIndex(err, date, context)) { skipped404++; lastSuccess = date; await cursorRepo.put({ id: DAILY_INDEX_CURSOR_ID, last_success: date }); @@ -168,12 +229,11 @@ export class CatchUpDailyIndexTask extends Task< let todayFetched = false; await unlinkCacheFile(plan.today); try { - const fetchResult = await context.own(new FetchDailyIndexTask()).run({ date: plan.today }); - await context.own(new StoreCikLastUpdatedTask()).run({ updateList: fetchResult.updateList }); + await runDay(plan.today); todayFetched = true; fetched++; } catch (err) { - if (!isUnpublishedDailyIndex(err)) { + if (!(await isUnpublishedDailyIndex(err, plan.today, context))) { throw err; } } diff --git a/src/task/index/dailyIndexPublication.test.ts b/src/task/index/dailyIndexPublication.test.ts new file mode 100644 index 00000000..dd1da357 --- /dev/null +++ b/src/task/index/dailyIndexPublication.test.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IExecuteContext } from "workglow"; +import { SecFetchTask } from "../fetch/SecFetchTask"; +import { + dailyIndexWasPublished, + isWeekendDate, + quarterListingUrl, + resetDailyIndexListingCacheForTesting, +} from "./dailyIndexPublication"; + +function ctx(): IExecuteContext { + return { + signal: new AbortController().signal, + updateProgress: () => {}, + own: (v: T): T => v, + disown: () => {}, + } as unknown as IExecuteContext; +} + +/** The shape EDGAR's `.../daily-index//QTR/index.json` really returns. */ +function listing(dates: readonly string[]): string { + return JSON.stringify({ + directory: { + item: [ + { name: "index.json", type: "file" }, + ...dates.flatMap((d) => [ + { name: `company.${d}.idx`, type: "file" }, + { name: `master.${d}.idx`, type: "file" }, + ]), + ], + }, + }); +} + +describe("isWeekendDate", () => { + it("separates the weekend from the trading week", () => { + // 2026-08-15 Sat, 08-16 Sun, 08-17 Mon .. 08-21 Fri. + expect(isWeekendDate("2026-08-15")).toBe(true); + expect(isWeekendDate("2026-08-16")).toBe(true); + for (const d of ["2026-08-17", "2026-08-18", "2026-08-19", "2026-08-20", "2026-08-21"]) { + expect(isWeekendDate(d)).toBe(false); + } + }); + + it("reads the date as a calendar date, not a local instant", () => { + // A date-only value must not shift a day under any host time zone; these + // straddle a DST boundary in America/New_York. + expect(isWeekendDate("2026-03-07")).toBe(true); + expect(isWeekendDate("2026-03-09")).toBe(false); + expect(isWeekendDate("2026-11-07")).toBe(true); + expect(isWeekendDate("2026-11-09")).toBe(false); + }); +}); + +describe("dailyIndexWasPublished", () => { + beforeEach(() => { + resetDailyIndexListingCacheForTesting(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetDailyIndexListingCacheForTesting(); + }); + + it("reports a day the quarter listing names, and one it does not", async () => { + vi.spyOn(SecFetchTask.prototype, "run").mockResolvedValue({ + text: listing(["20260701", "20260702", "20260706"]), + } as never); + + // Friday 2026-07-03 is Independence Day observed: a real weekday 403. + expect(await dailyIndexWasPublished("2026-07-02", ctx())).toBe(true); + expect(await dailyIndexWasPublished("2026-07-03", ctx())).toBe(false); + }); + + it("addresses each date's own quarter listing", () => { + expect(quarterListingUrl(2026, 1)).toBe( + "https://www.sec.gov/Archives/edgar/daily-index/2026/QTR1/index.json" + ); + expect(quarterListingUrl(2026, 4)).toBe( + "https://www.sec.gov/Archives/edgar/daily-index/2026/QTR4/index.json" + ); + }); + + it("asks a different listing per quarter", async () => { + const runSpy = vi + .spyOn(SecFetchTask.prototype, "run") + .mockResolvedValue({ text: listing([]) } as never); + + await dailyIndexWasPublished("2026-02-17", ctx()); + await dailyIndexWasPublished("2026-11-26", ctx()); + + expect(runSpy).toHaveBeenCalledTimes(2); + }); + + it("fetches one listing per quarter, however many days ask", async () => { + const runSpy = vi + .spyOn(SecFetchTask.prototype, "run") + .mockResolvedValue({ text: listing(["20260706"]) } as never); + + // A catch-up crossing several holidays in one quarter must not re-fetch. + await Promise.all([ + dailyIndexWasPublished("2026-07-03", ctx()), + dailyIndexWasPublished("2026-08-14", ctx()), + dailyIndexWasPublished("2026-09-07", ctx()), + ]); + + expect(runSpy).toHaveBeenCalledTimes(1); + }); + + it("propagates a failed probe instead of answering 'unpublished'", async () => { + vi.spyOn(SecFetchTask.prototype, "run").mockRejectedValue( + Object.assign(new Error("fetch failed: 403"), { status: 403 }) + ); + + await expect(dailyIndexWasPublished("2026-07-03", ctx())).rejects.toThrow(/403/); + }); + + it("does not cache a failed probe — the next day asks again", async () => { + const runSpy = vi + .spyOn(SecFetchTask.prototype, "run") + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue({ text: listing(["20260703"]) } as never); + + await expect(dailyIndexWasPublished("2026-07-03", ctx())).rejects.toThrow(/boom/); + expect(await dailyIndexWasPublished("2026-07-03", ctx())).toBe(true); + expect(runSpy).toHaveBeenCalledTimes(2); + }); + + it("throws on a body that is not the listing, rather than reading it as empty", async () => { + // An EDGAR error page or a truncated body must not read as "no index for + // any day", which would skip every day in the quarter. + vi.spyOn(SecFetchTask.prototype, "run").mockResolvedValue({ + text: "Your Request Originates from an Undeclared Automated Tool", + } as never); + + await expect(dailyIndexWasPublished("2026-07-03", ctx())).rejects.toThrow(); + }); +}); diff --git a/src/task/index/dailyIndexPublication.ts b/src/task/index/dailyIndexPublication.ts new file mode 100644 index 00000000..42e1db59 --- /dev/null +++ b/src/task/index/dailyIndexPublication.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IExecuteContext } from "workglow"; +import { parseDate } from "../../util/parseDate"; +import { SecFetchTask } from "../fetch/SecFetchTask"; + +/** + * EDGAR answers 403 — not 404 — for a daily index it never published, and it + * answers 403 for a client it is refusing (rejected User-Agent, rate limit, + * blocked egress). The status alone cannot tell those apart, and guessing + * wrong in the "unpublished" direction advances the cursor past a day whose + * filings are then never ingested, permanently. + * + * Weekends need no request: EDGAR has never published a Saturday or Sunday + * index. A weekday 403 is the ambiguous case — market holidays are real + * (Friday 2026-07-03 is a 403) but so is being blocked — and + * {@link dailyIndexWasPublished} settles it against the quarter listing. + */ +export function isWeekendDate(date: string): boolean { + const { year, month, day } = parseDate(date); + // `parseDate` returns a numeric year and zero-padded month/day strings. + const dow = new Date(Date.UTC(year, parseInt(month, 10) - 1, parseInt(day, 10))).getUTCDay(); + return dow === 0 || dow === 6; +} + +function quarterOf(month: string): number { + return Math.ceil(parseInt(month, 10) / 3); +} + +/** `.../daily-index/2026/QTR3/index.json` — the bucket's own file listing. */ +export function quarterListingUrl(year: number, quarter: number): string { + return `https://www.sec.gov/Archives/edgar/daily-index/${year}/QTR${quarter}/index.json`; +} + +interface QuarterListing { + readonly directory?: { readonly item?: ReadonlyArray<{ readonly name?: string }> }; +} + +/** + * Dates (`YYYYMMDD`) whose master index the quarter listing names. Memoized per + * `(year, quarter)` for the life of the process: a catch-up run that crosses + * two holidays in one quarter asks once, and the listing is small. + * + * Not cached to disk — a listing for the CURRENT quarter grows every trading + * day, and a stale copy would report today's index missing. + */ +const listingCache = new Map>>(); + +export function resetDailyIndexListingCacheForTesting(): void { + listingCache.clear(); +} + +async function publishedDatesInQuarter( + year: number, + quarter: number, + context: IExecuteContext +): Promise> { + const key = `${year}-QTR${quarter}`; + const hit = listingCache.get(key); + if (hit !== undefined) return hit; + const pending = (async () => { + const fetchTask = context.own( + new SecFetchTask( + { url: quarterListingUrl(year, quarter), response_type: "text" }, + { title: `Daily-index listing ${key}` } + ) + ); + let body: string; + try { + const result = await fetchTask.run(); + body = result.text ?? ""; + } finally { + context.disown(fetchTask); + } + const listing = JSON.parse(body) as QuarterListing; + const dates = new Set(); + for (const item of listing.directory?.item ?? []) { + const match = /^master\.(\d{8})\.idx$/.exec(item.name ?? ""); + if (match !== null) dates.add(match[1]!); + } + return dates; + })(); + listingCache.set(key, pending); + // A failed probe must not poison the run: the next weekday 403 asks again. + pending.catch(() => listingCache.delete(key)); + return pending; +} + +/** + * Whether EDGAR published a daily index for `date`, per the quarter listing. + * + * Authoritative in both directions, which is the point: the listing loading at + * all proves this client is not being refused, and the file's absence from a + * listing that loaded proves the day genuinely has no index. A throw here means + * neither could be established — the caller must not treat that as "unpublished". + */ +export async function dailyIndexWasPublished( + date: string, + context: IExecuteContext +): Promise { + const { year, month, day } = parseDate(date); + const published = await publishedDatesInQuarter(year, quarterOf(month), context); + return published.has(`${year}${month}${day}`); +} diff --git a/src/task/spac/ProcessSpacTimelineTask.test.ts b/src/task/spac/ProcessSpacTimelineTask.test.ts index 80329591..90466014 100644 --- a/src/task/spac/ProcessSpacTimelineTask.test.ts +++ b/src/task/spac/ProcessSpacTimelineTask.test.ts @@ -19,7 +19,7 @@ import { SpacRepo } from "../../storage/spac/SpacRepo"; import { ExtractorRunRepo } from "../../storage/versioning/ExtractorRunRepo"; import { EXTRACTOR_RUN_REPOSITORY_TOKEN } from "../../storage/versioning/ExtractorRunSchema"; import { ProcessAccessionDocFormTask } from "../forms/ProcessAccessionDocFormTask"; -import { ProcessSpacTimelineTask } from "./ProcessSpacTimelineTask"; +import { MAX_RETAINED_FILING_ROWS, ProcessSpacTimelineTask } from "./ProcessSpacTimelineTask"; const CIK = 1800001; @@ -609,6 +609,26 @@ describe("ProcessSpacTimelineTask", () => { ]); }); + it("bounds the retained filing rows so a long timeline cannot grow the graph without limit", async () => { + // `own` is add-only, so an issuer with thousands of filings held one live + // task node per filing for the whole run. The nesting above is kept for + // the timelines a person watches; past the cap the finished row is + // released and the in-flight filing is still owned while it runs. + const total = MAX_RETAINED_FILING_ROWS + 5; + for (let i = 0; i < total; i++) { + await seedFiling(`0000000000-26-${String(i).padStart(6, "0")}`, "D", "2021-01-04"); + } + vi.spyOn(ProcessAccessionDocFormTask.prototype, "execute").mockResolvedValue({ + success: true, + }); + + const task = new ProcessSpacTimelineTask(); + const out = await task.run({ cik: CIK }); + + expect(out.matched).toBe(total); + expect(task.subGraph?.getTasks().length ?? 0).toBe(MAX_RETAINED_FILING_ROWS); + }); + it("labels the issuer row with the CIK so map iterations are distinguishable", async () => { const task = new ProcessSpacTimelineTask(); await task.run({ cik: CIK }); diff --git a/src/task/spac/ProcessSpacTimelineTask.ts b/src/task/spac/ProcessSpacTimelineTask.ts index 0e3087ad..ab424ec7 100644 --- a/src/task/spac/ProcessSpacTimelineTask.ts +++ b/src/task/spac/ProcessSpacTimelineTask.ts @@ -136,6 +136,13 @@ export type ProcessSpacTimelineTaskOutput = Static { + // `own` is add-only and the subgraph is cleared only between graph runs, so + // one child per filing retains every child — and everything each child + // owned in turn — for the whole of `execute()`. That is unbounded in the + // length of the timeline: a de-SPAC'd operating company runs to thousands + // of filings (the 424B2 shelf-takedown case), multiplied by `--concurrency` + // issuers in flight. + // + // The completed rows are not merely debris, though — they are why these are + // owned directly rather than through the inner Workflow+Map this replaced, + // which the CLI stops recursing into, leaving the issuer row with no filing + // children at all. So the cap keeps the nesting for the timelines a person + // actually watches and releases the tail, rather than trading one defect + // for the other. Past it the in-flight filing is still owned while it runs; + // only its finished row is dropped. + let retained = 0; for (const filing of filings) { const form = filing.form ?? ""; const child = context.own( @@ -350,12 +372,20 @@ export class ProcessSpacTimelineTask extends Task< title: `${form} ${filing.accession_number}`, }) ); - await child.run({ - cik, - form, - accessionNumber: filing.accession_number, - fileName: resolvePrimaryDocName(filing.primary_doc), - }); + try { + await child.run({ + cik, + form, + accessionNumber: filing.accession_number, + fileName: resolvePrimaryDocName(filing.primary_doc), + }); + } finally { + if (retained < MAX_RETAINED_FILING_ROWS) { + retained++; + } else { + context.disown(child); + } + } } } } From beb768c1b660c43557794aaa779233d936da7aec Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 15:51:10 -0700 Subject: [PATCH 27/29] refactor(sec): remove redundant clears from form processing logic - Eliminated multiple `clears` sets across various sections in `Form_S_1.storage.ts` and `offeringSections.ts` to reduce redundancy and improve clarity. - Updated related tests to reflect the changes in the handling of clears, ensuring consistency in the extraction process. - Enhanced the `modelExtractChain` and `sectionRunner` to streamline the preemption logic, allowing for more efficient processing of deterministic passes. --- .../Form_S_1.storage.ts | 49 ------------------- .../s1/offeringSections.ts | 25 ---------- .../s1/s1Model.test.ts | 27 ++++++---- .../registration-statements/s1/s1Model.ts | 15 ++++-- .../s1/sectionRunner.deterministic.test.ts | 33 ++++++++++--- .../s1/sectionRunner.ts | 38 +++++++++++--- 6 files changed, 86 insertions(+), 101 deletions(-) diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 897b24ef..9393d953 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -673,7 +673,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { // is no second verdict the walk could have missed. complete: (rows) => rows.length === 1, }, - clears: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), } ), persist: async (_rows, meta) => { @@ -751,7 +750,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { }, covers: new Set(["spac.focus", "spac.focus_location"]), }, - clears: new Set(["spac.focus", "spac.focus_location", "spac.description", "spac.team"]), } ), persist: async (rows) => { @@ -818,12 +816,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "observation_provenance", ]), }, - clears: new Set([ - "person_observation.titles", - "person_observation.birth_year", - "person_observation.bio", - "observation_provenance", - ]), }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); @@ -926,20 +918,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { // missing `owner_kind` and `security_class`, so a filing whose coverage // function happened to claim the rest would have preempted on a set that // understated what persist rewrites. - clears: new Set([ - "beneficial_ownership.owner_kind", - "beneficial_ownership.security_class", - "beneficial_ownership.shares_owned", - "beneficial_ownership.percent_owned", - "beneficial_ownership.shares_offered", - "beneficial_ownership.shares_after", - "beneficial_ownership.percent_after", - "beneficial_ownership.is_selling_stockholder", - "beneficial_ownership.footnote", - "person_observation", - "company_observation", - "observation_provenance", - ]), }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); @@ -1024,12 +1002,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { extract: parseRelatedPartyTables, covers: new Set(["person_observation", "company_observation", "observation_provenance"]), }, - clears: new Set([ - "related_party_transaction", - "person_observation", - "company_observation", - "observation_provenance", - ]), }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); @@ -1204,21 +1176,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "observation_provenance", ]), }, - clears: new Set([ - "executive_compensation.principal_position", - "executive_compensation.fiscal_year", - "executive_compensation.salary", - "executive_compensation.bonus", - "executive_compensation.stock_awards", - "executive_compensation.option_awards", - "executive_compensation.non_equity_incentive", - "executive_compensation.pension_and_nqdc", - "executive_compensation.all_other_compensation", - "executive_compensation.total", - "executive_compensation.footnote", - "person_observation", - "observation_provenance", - ]), } ), persist: async (rows, meta) => { @@ -1494,12 +1451,6 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { "observation_provenance", ]), }, - clears: new Set([ - "spac_sponsor_link", - "sponsor_family_membership", - "company_observation", - "observation_provenance", - ]), }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); diff --git a/src/sec/forms/registration-statements/s1/offeringSections.ts b/src/sec/forms/registration-statements/s1/offeringSections.ts index d84fd1e3..c05e7ee8 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -301,12 +301,6 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { @@ -483,16 +477,6 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise rows.length === 1, }, - clears: new Set([ - "spac_promote_terms.founder_shares", - "spac_promote_terms.founder_percent", - "spac_promote_terms.private_placement_warrants", - "spac_promote_terms.private_placement_warrant_price", - "spac_promote_terms.public_warrant_coverage", - "spac_promote_terms.trust_per_public_share", - "spac_promote_terms.trust_total", - "field_provenance:spac_promote_terms", - ]), } ), persist: async (rows, meta) => { @@ -590,14 +574,6 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { const model_id = persistModelId(models, meta.modelIndex); @@ -719,7 +695,6 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise useOfProceedsIsComplete(text), } : undefined, - clears: new Set(["use_of_proceeds"]), }), persist: async (rows) => { const now = new Date().toISOString(); diff --git a/src/sec/forms/registration-statements/s1/s1Model.test.ts b/src/sec/forms/registration-statements/s1/s1Model.test.ts index 469d9df0..e8348da2 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.test.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.test.ts @@ -39,7 +39,6 @@ describe("modelExtractChain", () => { extract: () => [{ confidence: 1, via: "walk" }], covers: new Set(["t"]), }, - clears: new Set(["t"]), } ); expect(chain.modelIds).toEqual(["deterministic", "claude-haiku-4-5"]); @@ -56,15 +55,26 @@ describe("modelExtractChain", () => { expect(await chain.extract("x")).toEqual([]); }); - it("returns [] when covers does not preempt clears", async () => { + it("hands the pass to the runner rather than testing coverage itself", async () => { + // The chain does not know `clears` — the section that declares what + // `persist` rewrites does, and it states it once, on `runSection`. Stating + // it here too meant the copy that gated preemption was the one nobody read. + const pass = { + extract: () => [{ confidence: 1, via: "walk" }], + covers: new Set(["a"]), + }; const chain = modelExtractChain([deterministicModelConfig()], async () => [{ confidence: 1 }], { - deterministic: { - extract: () => [{ confidence: 1, via: "walk" }], - covers: new Set(["a"]), - }, - clears: new Set(["a", "b"]), + deterministic: pass, }); - expect(await chain.extract("x")).toEqual([]); + expect(chain.deterministic).toBe(pass); + // The slot runs the parse unconditionally; the runner is what decides + // whether to call it. + expect(await chain.extract("x")).toEqual([{ confidence: 1, via: "walk" }]); + }); + + it("omits `deterministic` entirely when the section declares no pass", async () => { + const chain = modelExtractChain([ai("claude-haiku-4-5")], async () => [{ confidence: 1 }]); + expect(chain.deterministic).toBeUndefined(); }); it("places the walk last when deterministic is last in the list", async () => { @@ -76,7 +86,6 @@ describe("modelExtractChain", () => { extract: () => [{ confidence: 1, via: "walk" }], covers: new Set(["t"]), }, - clears: new Set(["t"]), } ); expect(await chain.extract("x")).toEqual([{ confidence: 0.9, via: "ai" }]); diff --git a/src/sec/forms/registration-statements/s1/s1Model.ts b/src/sec/forms/registration-statements/s1/s1Model.ts index 5d7ae1d5..616beb90 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.ts @@ -9,7 +9,6 @@ import { getGlobalModelRepository } from "workglow"; import { DETERMINISTIC_MODEL_ID, modelIdsFromEnv } from "../../../../config/Constants"; import { deterministicModelRecord } from "../../../../config/registerModels"; import type { DeterministicPass } from "./deterministicPass"; -import { preempts } from "./deterministicPass"; import type { RunSectionArgs } from "./sectionRunner"; /** The model ids used for S-1 extraction; overridable via SEC_S1_MODEL (CSV). */ @@ -106,11 +105,10 @@ export function modelExtractChain( options?: { readonly fallbackOnEmpty?: boolean; readonly deterministic?: DeterministicPass; - readonly clears?: ReadonlySet; } ): Pick< RunSectionArgs, - "extract" | "emptyExtracts" | "modelIds" | "fallbackOnEmpty" | "deterministicComplete" + "extract" | "emptyExtracts" | "modelIds" | "fallbackOnEmpty" | "deterministic" > { const primary = models[0]; if (primary === undefined) { @@ -120,9 +118,13 @@ export function modelExtractChain( (model: ModelConfig) => async (text: string): Promise => { if (!isDeterministicModel(model)) return extract(text, model); + // Whether the pass may stand in for the model is the RUNNER's question: + // it is the side that knows `clears`, the set of destinations `persist` + // rewrites. Asking it here meant every section declared that set twice — + // once on `runSection`, once here — with only this copy read, so the two + // could disagree about what the section rewrites and nothing would say so. const pass = options?.deterministic; if (pass === undefined) return []; - if (!preempts(pass, options?.clears, text)) return []; return [...pass.extract(text)]; }; return { @@ -134,7 +136,10 @@ export function modelExtractChain( // dropping an unresolvable id here would shift every later slot onto the // wrong model. An id that does not resolve becomes "" rather than a hole. modelIds: models.map((m) => resolveModelId(m) ?? ""), - deterministicComplete: options?.deterministic?.complete, + // Handed to the runner whole rather than reduced to its `complete` + // callback: the runner tests `covers` against its own `clears` before the + // walk runs, and claims completeness from the same pass afterwards. + ...(options?.deterministic !== undefined ? { deterministic: options.deterministic } : {}), ...(options?.fallbackOnEmpty === false ? { fallbackOnEmpty: false as const } : {}), }; } diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts index aedbb1ef..f1034a70 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts @@ -7,7 +7,6 @@ import { describe, expect, it } from "vitest"; import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; import type { ExtractionDeadLetterRepo } from "../../../../storage/dead-letter/ExtractionDeadLetterRepo"; -import { preempts } from "./deterministicPass"; import { makeRunSection } from "./sectionRunner"; import type { SectionPersistMeta } from "./sectionRunner"; @@ -71,11 +70,11 @@ function harness(overrides: { const persisted: Array<{ rows: Row[]; meta: SectionPersistMeta }> = []; const detRows = overrides.detRows ?? [{ confidence: 1, span: "alpha" }]; const covers = overrides.covers ?? new Set(["person_observation"]); - const walk = async (text: string): Promise => { + // The slot closure `modelExtractChain` builds: it runs the parse and nothing + // else. Whether the parse MAY run — `covers` against `clears` — is the + // runner's call now, so a pass that cannot supply the columns never gets here. + const walk = async (): Promise => { detCalls++; - if (!preempts({ extract: () => detRows, covers }, overrides.clears, text)) { - return []; - } return [...detRows]; }; const model = async (): Promise => { @@ -98,7 +97,15 @@ function harness(overrides: { ...(overrides.verify === false ? {} : { verifyRow: (text, r) => text.includes(r.span) }), clears: overrides.clears, modelIds, - deterministicComplete: overrides.complete, + ...(overrides.omitWalk + ? {} + : { + deterministic: { + extract: () => detRows, + covers, + ...(overrides.complete !== undefined ? { complete: overrides.complete } : {}), + }, + }), ...(overrides.omitWalk ? { extract: model } : overrides.walkLast @@ -124,6 +131,20 @@ describe("makeRunSection deterministic pass", () => { expect(h.persisted).toHaveLength(1); expect(h.persisted[0]!.meta.source).toBe("model"); expect(h.persisted[0]!.rows.map((r) => r.span)).toEqual(["bravo"]); + // The column test is answerable before the walk runs, so a parse that + // cannot supply the destinations never reads the section at all. + expect(h.detCalls()).toBe(0); + }); + + it("declines every pass when the section never said what it rewrites", async () => { + // An undeclared `clears` is false, not vacuously true: a caller that has + // not said what `persist` rewrites has not shown a parse can supply it. + const h = harness({ covers: new Set(["person_observation"]), complete: () => true }); + await h.run(); + + expect(h.detCalls()).toBe(0); + expect(h.modelCalls()).toBe(1); + expect(h.persisted[0]!.meta.source).toBe("model"); }); it("preempts the model when covers is a superset of clears and the rows are complete", async () => { diff --git a/src/sec/forms/registration-statements/s1/sectionRunner.ts b/src/sec/forms/registration-statements/s1/sectionRunner.ts index e0d8a421..6dfc2343 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -9,7 +9,8 @@ import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; import type { ExtractionDeadLetterRepo } from "../../../../storage/dead-letter/ExtractionDeadLetterRepo"; import type { DeadLetterReasonCode } from "../../../../storage/dead-letter/ExtractionDeadLetterSchema"; import { SecCliConfigurationError } from "../../../../config/EnvToDI"; -import { claimsCompletePopulation } from "./deterministicPass"; +import type { DeterministicPass } from "./deterministicPass"; +import { assertsCompletePopulation, preempts } from "./deterministicPass"; import { MixedRiskCaptionShapeError, NonceMismatchError, @@ -124,8 +125,16 @@ export interface RunSectionArgs { readonly extract: (text: string) => Promise; /** * Every destination {@link persist} rewrites for this section: rows cleared - * before the run, or overwritten in place. Copied into the extract chain so a - * `deterministic` list slot can test coverage against the same set. + * before the run, or overwritten in place. + * + * This is the set a {@link deterministic} pass must cover before it may stand + * in for the model, and it is declared HERE because this is the side that + * knows what `persist` writes. It used to be stated twice — once here and + * once on `modelExtractChain` — with only the chain's copy read, so a section + * could describe two different sets of destinations and the one that gated + * preemption was the one nobody was reading. Undeclared means no pass may + * preempt: a caller that has not said what the section rewrites has not shown + * a parse can supply it. */ readonly clears?: ReadonlySet; /** @@ -148,10 +157,15 @@ export interface RunSectionArgs { /** Ids tried for this section; named in the MODEL_EMPTY detail when length > 1. */ readonly modelIds?: readonly string[]; /** - * When the persisted rows came from a `deterministic` list slot, - * `SectionPersistMeta.complete` is this callback (or false if omitted). + * The model-free parse behind a `deterministic` slot in {@link modelIds}. + * + * The runner owns both halves of the preemption test: `covers` against + * {@link clears} before the walk runs, and {@link DeterministicPass.complete} + * against the rows it produced afterwards — which is also what + * `SectionPersistMeta.complete` reports. Absent, a `deterministic` slot + * yields nothing and the section falls through to the next model. */ - readonly deterministicComplete?: (rows: readonly NoInfer[], text: string) => boolean; + readonly deterministic?: DeterministicPass>; readonly persist: (rows: TRow[], meta: SectionPersistMeta) => Promise; } @@ -296,6 +310,14 @@ export function makeRunSection(opts: { const extractFn = slots[i]!; modelIndex = i; if (isWalkSlot(i)) { + const pass = sargs.deterministic; + // The COLUMN half of the preemption test, and it is answerable before + // the walk runs — a parse that cannot supply every destination + // `persist` rewrites never reads the section at all. + if (pass === undefined || !preempts(pass, sargs.clears, text)) { + clearSlot(); + continue; + } try { applyRowFilters(await extractFn(text)); lastError = undefined; @@ -305,7 +327,9 @@ export function makeRunSection(opts: { clearSlot(); continue; } - const complete = claimsCompletePopulation(sargs.deterministicComplete, rows, text); + // The ROW half: covering the columns says nothing about having found + // every row, and the caller has already cleared the destination. + const complete = assertsCompletePopulation(pass, rows, text); if (complete && raw.length > 0 && rows.length === raw.length) { source = "deterministic"; walkComplete = true; From 66a60f8228b8b448922fc8f19c0cbc8261a6ce76 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 17:28:24 -0700 Subject: [PATCH 28/29] chore: update bun type deps --- bun.lock | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 6be47852..809546cd 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "xml2js": "^0.6.2", }, "devDependencies": { - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/pg": "^8.23.1", "@types/xml2js": "^0.4.14", "better-sqlite3": "^13.0.3", @@ -298,7 +298,7 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -428,7 +428,7 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bunset": ["bunset@1.0.13", "", { "peerDependencies": { "typescript": "^6.0.3" }, "bin": { "bunset": "src/index.ts" } }, "sha512-9j/bN+V7Nx164Qj2flqil8Mfcia0QKmP7sjKU9gZMHJ62Ve85aF8g1/2ozMz0o6yW+jT8Jze/bGLEhBJzmRJGA=="], diff --git a/package.json b/package.json index fcd1e19d..eb338e17 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "xml2js": "^0.6.2" }, "devDependencies": { - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/pg": "^8.23.1", "@types/xml2js": "^0.4.14", "better-sqlite3": "^13.0.3", From 09acc1517f8bc8ed9ca5dbba8c148a278ed9efc9 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Fri, 21 Aug 2026 17:36:36 -0700 Subject: [PATCH 29/29] feat(sec): implement global setup for S-1 corpus tests - Added a global setup script to share a parsed S-1 corpus across multiple test files, reducing redundant parsing and improving test efficiency. - Updated test files to utilize the new `loadS1Corpus` function, which caches the corpus for faster access. - Refactored test cases to ensure they correctly reference the new corpus loading mechanism, enhancing maintainability and performance. --- .../parseBeneficialOwnership.corpus.test.ts | 47 +++----- .../s1/parseManagementRoster.corpus.test.ts | 45 +++----- .../s1/parseOfferingTables.corpus.test.ts | 49 +++----- .../s1/parseRelatedPartyTables.corpus.test.ts | 45 +++----- .../s1/parseSpacClassification.corpus.test.ts | 49 +++----- .../s1/parseSpacProfile.corpus.test.ts | 49 +++----- .../s1/parseSpacSponsors.corpus.test.ts | 47 +++----- .../s1/parseSpacUnderwriters.corpus.test.ts | 45 +++----- .../s1/parseSpacUseOfProceeds.corpus.test.ts | 49 +++----- ...rseSummaryCompensationTable.corpus.test.ts | 45 +++----- .../s1/testing/s1Corpus.ts | 109 ++++++++++++++++++ .../s1/testing/s1CorpusGlobalSetup.ts | 41 +++++++ vitest.config.ts | 6 + 13 files changed, 308 insertions(+), 318 deletions(-) create mode 100644 src/sec/forms/registration-statements/s1/testing/s1Corpus.ts create mode 100644 src/sec/forms/registration-statements/s1/testing/s1CorpusGlobalSetup.ts diff --git a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts index 9a129d02..01f01737 100644 --- a/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseBeneficialOwnership } from "./parseBeneficialOwnership"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function nameKey(s: string): string { return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); } @@ -36,34 +30,23 @@ function looksLikeCaption(n: string): boolean { ); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseBeneficialOwnership golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty beneficial-ownership label", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "beneficial-ownership"); if (!labels || labels.length !== 0) continue; const text = byName.get(S1_SECTIONS.BENEFICIAL_OWNERSHIP) ?? ""; @@ -72,7 +55,7 @@ describe("parseBeneficialOwnership golden corpus", () => { }); it("does not invent owners outside the golden set when it hits", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "beneficial-ownership"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.BENEFICIAL_OWNERSHIP) ?? ""; @@ -110,7 +93,7 @@ describe("parseBeneficialOwnership golden corpus", () => { it("misses only the owners its own filters are known to drop", () => { const misses: string[] = []; - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "beneficial-ownership"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.BENEFICIAL_OWNERSHIP) ?? ""; diff --git a/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts index 470898d9..6ac1a046 100644 --- a/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseManagementRoster } from "./parseManagementRoster"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function nameKey(s: string): string { return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); } @@ -36,34 +30,23 @@ function looksLikeCaption(n: string): boolean { ); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseManagementRoster golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty management label", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "management"); if (!labels || labels.length !== 0) continue; const text = byName.get(S1_SECTIONS.MANAGEMENT) ?? ""; @@ -72,7 +55,7 @@ describe("parseManagementRoster golden corpus", () => { }); it("does not invent caption-like names outside the golden set when it hits", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "management"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.MANAGEMENT) ?? ""; diff --git a/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts index 285a7788..bfae55f0 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.corpus.test.ts @@ -4,13 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { offeringParseText, promoteParseText } from "./offeringSections"; import { parseSpacOfferingTerms, @@ -18,8 +14,6 @@ import { promoteCoverage, } from "./parseOfferingTables"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - const OFFERING_FIELDS = [ "price_per_unit", "warrant_fraction_per_unit", @@ -77,34 +71,23 @@ function isAllNull(row: Record, fields: readonly string[]): boo return fields.every((f) => row[f] == null); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseOfferingTables golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never claims a required-set hit against an all-null offering golden or empty promote golden", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const offeringLabels = getGoldenLabels(filing, "offering-terms"); if (offeringLabels && offeringLabels.length === 1) { const expected = scored(offeringLabels[0] as Record, OFFERING_FIELDS); @@ -120,7 +103,7 @@ describe("parseOfferingTables golden corpus", () => { }); it("matches scored offering fields when it hits a labelled SPAC table", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "offering-terms"); if (!labels || labels.length !== 1) continue; const expected = scored(labels[0] as Record, OFFERING_FIELDS); @@ -133,7 +116,7 @@ describe("parseOfferingTables golden corpus", () => { }); it("matches scored promote fields when it hits a labelled SPAC table", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "sponsor-promote"); if (!labels || labels.length === 0) continue; const expected = scored(labels[0] as Record, PROMOTE_FIELDS); @@ -151,7 +134,7 @@ describe("parseOfferingTables golden corpus", () => { // returned; a CLAIMED column may not be null and may not disagree. it("is right about every promote column its coverage claims", () => { const wrong: string[] = []; - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "sponsor-promote"); if (!labels || labels.length === 0) continue; const text = promoteParseText(byName); diff --git a/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts index ca0def5e..b3ba4a57 100644 --- a/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseRelatedPartyTables } from "./parseRelatedPartyTables"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function nameKey(s: string): string { return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); } @@ -33,34 +27,23 @@ function looksLikeCaption(n: string): boolean { return /table of contents|^\d+$|participants?\(\d+\)|^stockholders?$/i.test(n); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseRelatedPartyTables golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty related-party label", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "related-party"); if (!labels || labels.length !== 0) continue; const text = byName.get(S1_SECTIONS.RELATED_PARTY) ?? ""; @@ -69,7 +52,7 @@ describe("parseRelatedPartyTables golden corpus", () => { }); it("does not invent caption-like names outside the golden set when it hits", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "related-party"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.RELATED_PARTY) ?? ""; diff --git a/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts index 8df102aa..3ee1f2b9 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts @@ -4,47 +4,32 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseSpacClassification } from "./parseSpacClassification"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - -function fixtures(): Array<{ filing: string; summary: string }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; summary: string }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - const byName = new Map(segmented.map((s) => [s.name as string, s.text])); - out.push({ - filing: file.replace(/\.htm$/, ""), - summary: byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? "", - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: Array<{ filing: string; summary: string }> = []; describe("parseSpacClassification golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus().map((f: S1CorpusFiling) => ({ + filing: f.filing, + summary: f.byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? "", + })); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty spac-classification label", () => { - for (const { filing, summary } of cases()) { + for (const { filing, summary } of cases) { const labels = getGoldenLabels(filing, "spac-classification"); if (!labels || labels.length !== 0) continue; expect(parseSpacClassification(summary), filing).toBeNull(); @@ -57,7 +42,7 @@ describe("parseSpacClassification golden corpus", () => { // labels on every filing it answers for. it("agrees with the golden classification on every filing it answers", () => { const disagreements: string[] = []; - for (const { filing, summary } of cases()) { + for (const { filing, summary } of cases) { const labels = getGoldenLabels(filing, "spac-classification"); if (!labels || labels.length === 0) continue; const parsed = parseSpacClassification(summary); diff --git a/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts index ae76b4de..5a949160 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseSpacProfile } from "./parseSpacProfile"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function nameKey(s: string): string { return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); } @@ -28,35 +22,26 @@ function emptyProfile(labels: readonly Record[] | undefined): b return focus.length === 0 && loc.length === 0; } -function fixtures(): Array<{ filing: string; summary: string }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; summary: string }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - const byName = new Map(segmented.map((s) => [s.name as string, s.text])); - out.push({ - filing: file.replace(/\.htm$/, ""), - summary: byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? "", - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: Array<{ filing: string; summary: string }> = []; describe("parseSpacProfile golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus().map((f: S1CorpusFiling) => ({ + filing: f.filing, + summary: f.byName.get(S1_SECTIONS.PROSPECTUS_SUMMARY) ?? "", + })); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty spac-profile label", () => { - for (const { filing, summary } of cases()) { + for (const { filing, summary } of cases) { const labels = getGoldenLabels(filing, "spac-profile"); if (!emptyProfile(labels)) continue; expect(parseSpacProfile(summary), filing).toBeNull(); @@ -64,7 +49,7 @@ describe("parseSpacProfile golden corpus", () => { }); it("does not invent tags outside the golden set when it hits a labelled filing", () => { - for (const { filing, summary } of cases()) { + for (const { filing, summary } of cases) { const labels = getGoldenLabels(filing, "spac-profile"); if (emptyProfile(labels)) continue; const parsed = parseSpacProfile(summary); diff --git a/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts index c9f880ac..980dea7f 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseSpacSponsors } from "./parseSpacSponsors"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function nameKey(s: string): string { return s.replace(/[^a-z0-9]+/gi, "").toLowerCase(); } @@ -39,34 +33,23 @@ function sponsorText(byName: Map): string { ); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseSpacSponsors golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty spac-sponsors label", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "spac-sponsors"); if (!labels || labels.length !== 0) continue; expect(parseSpacSponsors(sponsorText(byName)), filing).toEqual([]); @@ -74,7 +57,7 @@ describe("parseSpacSponsors golden corpus", () => { }); it("does not invent names outside the golden set when it hits a labelled filing", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "spac-sponsors"); if (!labels || labels.length === 0) continue; const parsed = parseSpacSponsors(sponsorText(byName)); @@ -98,7 +81,7 @@ describe("parseSpacSponsors golden corpus", () => { // sponsor, not a missing hit, if this parse were ever allowed to preempt. it("finds every golden sponsor on a filing it hits", () => { const misses: string[] = []; - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "spac-sponsors"); if (!labels || labels.length === 0) continue; const parsed = parseSpacSponsors(sponsorText(byName)); diff --git a/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts index fe0c9f5e..bcec38f2 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts @@ -4,18 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function nameKey(name: string): string { return name .normalize("NFKC") @@ -25,34 +19,23 @@ function nameKey(name: string): string { .toLowerCase(); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseSpacUnderwriters golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty underwriters label", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "underwriters"); if (!labels || labels.length !== 0) continue; const text = byName.get(S1_SECTIONS.UNDERWRITING) ?? ""; @@ -64,7 +47,7 @@ describe("parseSpacUnderwriters golden corpus", () => { }); it("does not invent names outside the golden set when it hits", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "underwriters"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.UNDERWRITING) ?? ""; diff --git a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts index 7e5c6ada..89f25861 100644 --- a/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts @@ -4,50 +4,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseSpacUseOfProceeds, useOfProceedsIsComplete } from "./parseSpacUseOfProceeds"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function purposeKey(s: string): string { return s.replace(/\s+/g, "").toLowerCase(); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseSpacUseOfProceeds golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty use-of-proceeds label", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "use-of-proceeds"); if (!labels || labels.length !== 0) continue; const text = byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? ""; @@ -56,7 +39,7 @@ describe("parseSpacUseOfProceeds golden corpus", () => { }); it("does not invent purposes outside the golden set when it hits", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "use-of-proceeds"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? ""; @@ -80,7 +63,7 @@ describe("parseSpacUseOfProceeds golden corpus", () => { // assertion above stayed green, because a dropped row invents nothing. it("finds every golden line item on a filing it claims to have enumerated", () => { const misses: string[] = []; - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "use-of-proceeds"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? ""; @@ -96,7 +79,7 @@ describe("parseSpacUseOfProceeds golden corpus", () => { // The predicate above is only worth anything if it says yes to real filings. it("claims a complete enumeration on most of the corpus it parses", () => { - const parsing = cases().filter( + const parsing = cases.filter( ({ byName }) => parseSpacUseOfProceeds(byName.get(S1_SECTIONS.USE_OF_PROCEEDS) ?? "").length > 0 ); diff --git a/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts index be3c611e..832ddb2e 100644 --- a/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts +++ b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts @@ -4,50 +4,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; -import { parseEdgarHtml } from "../../../html/parseEdgarHtml"; -import { DocumentTreeSegmenter } from "./DocumentTreeSegmenter"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; import { S1_SECTIONS } from "./DocumentSegmenter"; import { parseSummaryCompensationTable } from "./parseSummaryCompensationTable"; -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); - function nameKey(s: string): string { return s.replace(/\s+/g, "").toLowerCase(); } -function fixtures(): Array<{ filing: string; byName: Map }> { - const files = readdirSync(MOCK_DIR).filter((f) => f.endsWith(".htm")); - const out: Array<{ filing: string; byName: Map }> = []; - for (const file of files.sort()) { - const html = readFileSync(join(MOCK_DIR, file), "utf8"); - const doc = parseEdgarHtml(html, file); - const segmented = new DocumentTreeSegmenter().segment(doc); - out.push({ - filing: file.replace(/\.htm$/, ""), - byName: new Map(segmented.map((s) => [s.name as string, s.text])), - }); - } - return out; -} - -let corpus: ReturnType | undefined; -function cases(): ReturnType { - corpus ??= fixtures(); - return corpus; -} +let cases: readonly S1CorpusFiling[] = []; describe("parseSummaryCompensationTable golden corpus", () => { + // Building the corpus is ~100 MB of HTML through the converter and the + // segmenter — that is the work, not a hang. It lives in `beforeAll` with its + // own budget so the cost is attributed to setup rather than charged to + // whichever assertion happened to touch it first. + beforeAll(() => { + cases = loadS1Corpus(); + }, S1_CORPUS_TIMEOUT_MS); + it("loads committed S-1 fixtures", () => { - expect(cases().length).toBeGreaterThan(0); + expect(cases.length).toBeGreaterThan(0); }); it("never false-hits a golden empty executive-compensation label", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "executive-compensation"); if (!labels || labels.length !== 0) continue; const text = byName.get(S1_SECTIONS.EXECUTIVE_COMPENSATION) ?? ""; @@ -56,7 +39,7 @@ describe("parseSummaryCompensationTable golden corpus", () => { }); it("does not invent officers outside the golden set when it hits", () => { - for (const { filing, byName } of cases()) { + for (const { filing, byName } of cases) { const labels = getGoldenLabels(filing, "executive-compensation"); if (!labels || labels.length === 0) continue; const text = byName.get(S1_SECTIONS.EXECUTIVE_COMPENSATION) ?? ""; diff --git a/src/sec/forms/registration-statements/s1/testing/s1Corpus.ts b/src/sec/forms/registration-statements/s1/testing/s1Corpus.ts new file mode 100644 index 00000000..864b42ca --- /dev/null +++ b/src/sec/forms/registration-statements/s1/testing/s1Corpus.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseEdgarHtml } from "../../../../html/parseEdgarHtml"; +import { DocumentTreeSegmenter } from "../DocumentTreeSegmenter"; +import { S1_CORPUS_CACHE_DIR_ENV } from "./s1CorpusGlobalSetup"; + +const MOCK_DIR = join( + fileURLToPath(new URL(".", import.meta.url)), + "../../../../html/mock_data/s1" +); + +export interface S1CorpusFiling { + /** The fixture's basename with `.htm` stripped — the key golden labels use. */ + readonly filing: string; + /** Every segmented section of that filing, by {@link S1_SECTIONS} name. */ + readonly byName: ReadonlyMap; +} + +/** + * Budget for building the corpus, for the `beforeAll` that does it. + * + * Reading and segmenting 42 committed prospectuses is ~100 MB of HTML through + * `parseEdgarHtml` plus a tree walk each, which is genuinely tens of seconds — + * it is the work, not a hang. The suite-wide `testTimeout` is 30s, tuned for + * the CLI integration tests that spawn `sec` subprocesses, and a slower CI + * runner crosses it: the corpus build used to be charged lazily to whichever + * `it()` touched it first, so CI failed on an assertion reading + * `expect(cases().length).toBeGreaterThan(0)` — a test that does no work and + * named nothing about what was slow. + * + * Doing it in `beforeAll` with its own budget puts the cost where it belongs + * and leaves each test's own timeout tight enough to still catch a real hang. + * + * 120s matches the `CORPUS_PARSE_TIMEOUT_MS` the older corpus-reading tests in + * `src/eval` already use for the same reason — those were written with an + * explicit budget; these ten were not, which is the whole of this bug. + */ +export const S1_CORPUS_TIMEOUT_MS = 120_000; + +let cached: readonly S1CorpusFiling[] | undefined; + +/** `[filing, [[sectionName, text], ...]]` — a Map does not survive JSON. */ +type SerializedCorpus = ReadonlyArray]>; + +function segmentCorpus(): S1CorpusFiling[] { + const out: S1CorpusFiling[] = []; + for (const file of readdirSync(MOCK_DIR) + .filter((f) => f.endsWith(".htm")) + .sort()) { + const doc = parseEdgarHtml(readFileSync(join(MOCK_DIR, file), "utf8"), file); + const segmented = new DocumentTreeSegmenter().segment(doc); + out.push({ + filing: file.replace(/\.htm$/, ""), + byName: new Map(segmented.map((s) => [s.name as string, s.text])), + }); + } + return out; +} + +/** + * Every committed S-1 fixture, parsed and segmented. + * + * Memoized in-process, and — when the global setup provided a run-scoped + * directory — shared across processes through it. Vitest runs each test file in + * its own fork (`isolate: true`), so without that the ten `*.corpus.test.ts` + * files each re-read and re-segment the same ~100 MB of HTML. Reading the cache + * back is a JSON parse of ~19 MB, which is milliseconds against the seconds the + * segmentation costs. + * + * A miss is never an error: with no cache directory (a bare `vitest` run of one + * file, or any non-vitest caller) this simply segments, exactly as before. + */ +export function loadS1Corpus(): readonly S1CorpusFiling[] { + if (cached !== undefined) return cached; + const dir = process.env[S1_CORPUS_CACHE_DIR_ENV]; + const cacheFile = dir === undefined ? undefined : join(dir, "corpus.json"); + + if (cacheFile !== undefined && existsSync(cacheFile)) { + const rows = JSON.parse(readFileSync(cacheFile, "utf8")) as SerializedCorpus; + cached = rows.map(([filing, entries]) => ({ filing, byName: new Map(entries) })); + return cached; + } + + const built = segmentCorpus(); + if (cacheFile !== undefined) { + // Several forks can miss at once and all build; each writes its own temp + // file and renames it over the target, so a reader never observes a partial + // one and the duplicate work is bounded by however many raced. + const serialized: SerializedCorpus = built.map((f) => [f.filing, [...f.byName]]); + const tmp = `${cacheFile}.${process.pid}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(serialized)); + renameSync(tmp, cacheFile); + } catch { + // A cache that cannot be written is a missed optimization, never a + // failed test — the corpus in hand is already correct. + rmSync(tmp, { force: true }); + } + } + cached = built; + return cached; +} diff --git a/src/sec/forms/registration-statements/s1/testing/s1CorpusGlobalSetup.ts b/src/sec/forms/registration-statements/s1/testing/s1CorpusGlobalSetup.ts new file mode 100644 index 00000000..fd789949 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/testing/s1CorpusGlobalSetup.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Names the run-scoped directory {@link loadS1Corpus} caches into. */ +export const S1_CORPUS_CACHE_DIR_ENV = "SEC_S1_CORPUS_CACHE_DIR"; + +/** + * Hands every test worker one directory to share a parsed S-1 corpus through. + * + * The ten `*.corpus.test.ts` files all read the same 42 committed fixtures, and + * vitest runs each file in its own fork (`isolate: true`), so each one paid to + * re-read and re-segment ~100 MB of HTML — the same work ten times over. + * + * This only creates the directory; nothing is parsed here. The first worker + * that actually needs the corpus builds it and writes it, and the rest read it + * back, so a single-file run of an unrelated test still pays nothing. + * + * **The cache is scoped to one vitest run, deliberately.** A cache that + * outlived the run would have to invalidate whenever `parseEdgarHtml` or + * `DocumentTreeSegmenter` changed, and there is no cheap, honest way to detect + * that — the alternative is a corpus test quietly asserting against + * segmentation the current code no longer produces, which is a worse failure + * than a slow test. Scoping it to the run makes staleness impossible. It also + * costs nothing in CI, where every run is a fresh checkout and a persistent + * cache would never hit anyway. + */ +export default function setup(): () => void { + const dir = mkdtempSync(join(tmpdir(), "sec-s1-corpus-")); + process.env[S1_CORPUS_CACHE_DIR_ENV] = dir; + return () => { + delete process.env[S1_CORPUS_CACHE_DIR_ENV]; + rmSync(dir, { recursive: true, force: true }); + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index ef9dae15..e255178f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -62,6 +62,12 @@ export default defineConfig({ test: { include: ["src/**/*.test.ts"], environment: "node", + // Hands the ten `*.corpus.test.ts` files one run-scoped directory to share + // a parsed S-1 corpus through. Each test file runs in its own fork, so + // without it each re-segments the same ~100 MB of committed HTML. Creating + // the directory is all this does — the first worker that needs the corpus + // builds it. See `testing/s1CorpusGlobalSetup.ts`. + globalSetup: ["./src/sec/forms/registration-statements/s1/testing/s1CorpusGlobalSetup.ts"], // Multi-spawn CLI integration tests fire several sequential `sec` subprocess // invocations; keep the generous timeout the bun runner used. testTimeout: 30_000,