diff --git a/CLAUDE.md b/CLAUDE.md index 50328e3b..54a29a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,24 +222,34 @@ 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 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 `StructuredGenerationTask` gates on (the installed provider's 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", diff --git a/src/cli/groups/eval.ts b/src/cli/groups/eval.ts index 37a9bb8d..9ee45525 100644 --- a/src/cli/groups/eval.ts +++ b/src/cli/groups/eval.ts @@ -39,7 +39,27 @@ import { EVAL_S1_CONCURRENCY_DEFAULTS, } from "../../task/eval/evalS1Concurrency"; 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 { 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 @@ -124,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 @@ -182,6 +214,52 @@ function truncate(s: string, max = 60): string { return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } +/** + * 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; +} + +interface ParserEvalReport { + readonly cases: readonly ParserEvalCase[]; + readonly counts: Record; +} + +/** + * 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( + `${label} 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}`; + 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)}`); + } + } +} + function hasDiff(d: ExtractionDiff): boolean { return d.missing.length > 0 || d.extra.length > 0 || d.mismatches.length > 0; } @@ -594,7 +672,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); @@ -765,11 +847,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; @@ -881,11 +965,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; @@ -926,4 +1012,146 @@ export function addEvalCommands(program: Command): void { }); } ); + + 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); + }); + } + ); + } } + +/** + * 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; +}; + +/** + * 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/cli/groups/evalOptionValues.test.ts b/src/cli/groups/evalOptionValues.test.ts index e529497d..63dabf33 100644 --- a/src/cli/groups/evalOptionValues.test.ts +++ b/src/cli/groups/evalOptionValues.test.ts @@ -102,6 +102,56 @@ 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("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("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("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("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/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 cbac9c29..84adea38 100644 --- a/src/cli/sync/registerSecSyncLeaves.ts +++ b/src/cli/sync/registerSecSyncLeaves.ts @@ -4,13 +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 { 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"; @@ -159,16 +167,32 @@ export function registerSecSyncLeaves(): void { id: "process", title: "Process SPAC filings", run: async (ctx: SyncRunContext) => { - const ciks = await listSpacProcessCiks(); - if (ciks.length === 0) { - console.log("No known SPACs or high/medium candidates"); + const processCiks = shardCiks( + await filterSpacCiksByHistory(await listSpacProcessCiks(), ctx.only), + ctx.shard + ); + if (processCiks.length === 0) { + 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; } - await runFormsSweep({ - formTypes: formsForExtractorIds([...SYNC_FORM_DOMAINS.spacs]), - shard: ctx.shard, - ciks, + 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 05980112..ac2c3169 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 filedOnOrAfter?: string; }): Promise { await runWorkflowCli( [], undefined, - formsSweepLoop(newFormsWorklistTask(options.formTypes, options.shard, options.ciks)) + formsSweepLoop( + newFormsWorklistTask(options.formTypes, options.shard, options.ciks, 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 0784ed38..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 { 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 { @@ -111,4 +121,107 @@ 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]); + }); +}); + +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 d3a53994..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,13 +21,115 @@ 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 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 +145,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/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/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/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..9499d6e3 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,27 @@ export interface EvalExtractor { readonly disabled?: boolean; } +function asRows(value: readonly T[] | T | null): T[] { + if (value === null) return []; + // `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( + 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 +166,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 +174,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 +192,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 +200,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 +209,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 +222,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 +237,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 +250,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 +260,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 +268,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 +278,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 +296,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/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/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"); } 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/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/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/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/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/eval/runUnderwritersEval.test.ts b/src/eval/runUnderwritersEval.test.ts new file mode 100644 index 00000000..38160fb5 --- /dev/null +++ b/src/eval/runUnderwritersEval.test.ts @@ -0,0 +1,54 @@ +/** + * @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/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/index.ts b/src/index.ts index 9fd99671..04c7a449 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,9 +96,10 @@ 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. +// `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 { 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.classification.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts new file mode 100644 index 00000000..fe650797 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.classification.test.ts @@ -0,0 +1,74 @@ +/** + * @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 { + s1ModelsWithWalk, + 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, + }, + models: s1ModelsWithWalk(), + }); + + 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.compensation.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.compensation.test.ts index ca2586dc..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 @@ -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,35 @@ describe("processFormS1 executive compensation", () => { "resolved" ); }); + + 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"); + + 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"); + 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 new file mode 100644 index 00000000..bc805ab9 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.management.test.ts @@ -0,0 +1,180 @@ +/** + * @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 { 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"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const HTML_PARSEABLE = [ + "

MANAGEMENT

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

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, + 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("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({ + 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(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).not.toBe(DETERMINISTIC_MODEL_ID); + }); + + 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; + + // S-1 names both officers: two 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"]); + + // 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: "s1a.htm", + form: "S-1/A", + formS1: { + header: NULL_HEADER, + html: HTML_PROSE_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).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 98c482ce..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 @@ -12,8 +12,18 @@ 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 { 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"; -import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; +import { DETERMINISTIC_MODEL_ID } from "./s1/parseOfferingTables"; +import { + fakeS1Model, + registerFakeStructuredProvider, + s1ModelsWithWalk, +} from "./s1/testing/fakeStructuredProvider"; const OFFERING_HTML = [ "

THE OFFERING

We are offering 5,000,000 shares.

", @@ -370,4 +380,517 @@ describe("processFormS1 offering terms", () => { const offering = dl.find((d) => d.section_name === "offering-terms"); expect(offering?.detail).toMatch(/NO issuer ticker rows/); }); + + 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. + // 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
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.

", + ].join(""); + 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({ + 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, + }, + models: s1ModelsWithWalk(), + }); + + const unit = await new SpacUnitTermsRepo().get("S-1", "0000000000-26-000010"); + 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?.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("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

", + "", + "", + "", + "", + "", + "
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.20,000,000
Total20,000,000
", + "

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

", + ].join(""); + // 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({ + 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).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 () => { + // 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

", + "", + "", + "", + "
Selling StockholderNumber of Shares
Acme Holdings LLC1,000,000
", + "

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

", + ].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", + }, + { + 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; + + 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(), + }); + + 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" && d.accession_number === "0000000000-26-000015" + ) + ).toHaveLength(1); + }); + + 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, + }, + models: s1ModelsWithWalk(), + }); + + 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("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

", + "", + "", + "", + "
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, + }, + models: s1ModelsWithWalk(), + }); + + 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.ownership.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts new file mode 100644 index 00000000..97ac896c --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ownership.test.ts @@ -0,0 +1,234 @@ +/** + * @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 { 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 = [ + "

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(""); + +// 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, + 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", + }, + ], +}; + +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", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + // 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({ + 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(), + }); + + 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], + ["person", 4312500], + ]); + const companies = await new CompanyObservationRepo().listAll(); + expect(companies.some((c) => /Halyard Sponsor/i.test(c.name ?? ""))).toBe(true); + 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("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 + // 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.profile.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts new file mode 100644 index 00000000..4073a1a8 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.profile.test.ts @@ -0,0 +1,84 @@ +/** + * @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("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: [] }, + ]); + 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(), + }); + + 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(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 new file mode 100644 index 00000000..b90fb7f8 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.related-party.test.ts @@ -0,0 +1,112 @@ +/** + * @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 { RelatedPartyTransactionRepo } from "../../../storage/related-party/RelatedPartyTransactionRepo"; +import { processFormS1 } from "./Form_S_1.storage"; +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 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: [ + { + 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("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({ + 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(), + }); + + 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); + 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.sponsors.test.ts b/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts new file mode 100644 index 00000000..d2d9cea6 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.storage.sponsors.test.ts @@ -0,0 +1,117 @@ +/** + * @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 { 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

", + `

${SPONSOR_SENTENCE}

`, + "

LEGAL MATTERS

x

", +].join(""); + +const HEADER_6770 = { + sic: 6770, + sicDescription: "BLANK CHECKS", + cik: null, + companyName: null, + 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", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + // 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: [], + focus_location: [], + description: null, + team: null, + url_spac: null, + confidence: 0.9, + source_span: SPONSOR_SPAN, + }, + { people: [] }, + { owners: [] }, + { parties: [] }, + { + sponsors: [{ legal_name: "Acme Sponsor LLC", confidence: 0.9, source_span: SPONSOR_SPAN }], + }, + ]); + 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(), + }); + + 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); + 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).not.toBe(DETERMINISTIC_MODEL_ID); + expect(provenance?.model_id).toBe(resolveModelId(fakeS1Model())); + }); +}); 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..9393d953 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,13 @@ import { } from "./s1/sectionExtractors"; import type { ExecutiveCompensationRow } from "./s1/executiveCompensationSchema"; import { hasSummaryCompensationTable } from "./s1/compensationHeuristic"; +import { parseSummaryCompensationTable } from "./s1/parseSummaryCompensationTable"; +import { ownershipCoverage, 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 { looksLikePartIIOnlyAmendment } from "./s1/partIIOnlyAmendment"; import { issuerHasCombinationListing } from "./s1/newcoListing"; import { MAX_RISK_FACTORS_CHARS } from "./s1/riskFactorChunks"; @@ -61,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"; @@ -174,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; } @@ -188,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); } @@ -610,18 +621,24 @@ 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: false }; + const classifierHolder: { upgraded: boolean; source: "ai" | "deterministic" } = { + upgraded: false, + source: "ai", + }; const classifierRunSection = makeRunSection({ deadLetters, extractor_id: EXTRACTOR_ID, @@ -638,12 +655,29 @@ 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", - extract: async (text) => { - const c = await extractSpacClassification(text, classifierModelResolved, args.context); - return c === null ? [] : [c]; - }, - persist: async () => { + clears: new Set(["s1_classification.is_spac", "s1_classification.entity_kind"]), + ...modelExtractChain( + classifierModels, + async (text, m) => { + const c = await extractSpacClassification(text, m, 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, + }, + } + ), + persist: async (_rows, meta) => { classifierHolder.upgraded = true; + if (meta.source === "deterministic") classifierHolder.source = "deterministic"; return 1; }, }); @@ -656,7 +690,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 { @@ -697,10 +731,27 @@ 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", - ...modelExtractChain(models, async (text, m) => { - const p = await extractSpacProfile(text, m, args.context); - return p === null ? [] : [p]; - }), + 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. + ...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"]), + }, + } + ), persist: async (rows) => { profileHolder.row = rows[0]; return 1; @@ -738,7 +789,34 @@ 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)), + // `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", + ]), + ...modelExtractChain(models, (text, m) => extractManagement(text, m, args.context), { + deterministic: { + extract: parseManagementRoster, + covers: new Set([ + "person_observation.titles", + "person_observation.birth_year", + "observation_provenance", + ]), + }, + }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); for (const r of rows) { @@ -802,7 +880,45 @@ 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)), + // 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.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", + ]), + // `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. + ...modelExtractChain(models, (text, m) => extractBeneficialOwnership(text, m, args.context), { + deterministic: { + 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. + }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); for (const r of rows) { @@ -871,7 +987,22 @@ 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)), + 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. + ...modelExtractChain(models, (text, m) => extractRelatedParty(text, m, args.context), { + deterministic: { + extract: parseRelatedPartyTables, + covers: new Set(["person_observation", "company_observation", "observation_provenance"]), + }, + }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); // Check every row against the storage schema's own declared bounds BEFORE @@ -1003,8 +1134,49 @@ 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) + // 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", + ]), + ...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", + ]), + }, + } ), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); @@ -1256,7 +1428,30 @@ 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)), + clears: new Set([ + "spac_sponsor_link", + "sponsor_family_membership", + "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. + ...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", + ]), + }, + }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); let wrote = 0; 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..e7c3f22f --- /dev/null +++ b/src/sec/forms/registration-statements/s1/deterministicPass.test.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import type { DeterministicPass } from "./deterministicPass"; +import { assertsCompletePopulation, 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("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 COLUMN declaration. Whether it + // found every sponsor ROW is `assertsCompletePopulation`'s question. + 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); + }); +}); + +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 new file mode 100644 index 00000000..e0a9849c --- /dev/null +++ b/src/sec/forms/registration-statements/s1/deterministicPass.ts @@ -0,0 +1,155 @@ +/** + * @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. + * + * **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 + * 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. */ + readonly extract: (text: string) => readonly TRow[]; + /** + * 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 — 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. 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; +} + +const COVERS_NOTHING: ReadonlySet = new Set(); + +/** + * 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 + * fail-safe answer costs one model call rather than a silently truncated table. + */ +export function preempts( + pass: DeterministicPass, + clears: ReadonlySet | undefined, + text: string +): boolean { + if (clears === undefined) return false; + const covers = resolveCovers(pass.covers, text); + for (const destination of clears) { + if (!covers.has(destination)) return false; + } + 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 { + 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 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 + * 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 7e9c19b5..26155c4a 100644 --- a/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts +++ b/src/sec/forms/registration-statements/s1/executiveCompensationSchema.ts @@ -68,4 +68,10 @@ export interface ExecutiveCompensationRow { footnote: string | null; confidence: number; source_span: string; + /** + * 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/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 8cf8dddb..c05e7ee8 100644 --- a/src/sec/forms/registration-statements/s1/offeringSections.ts +++ b/src/sec/forms/registration-statements/s1/offeringSections.ts @@ -41,6 +41,13 @@ import { boundSourceSpan, classifySpan } from "./verifySourceSpan"; import { verifyNumericObjectSpan } from "./verifyNumericObjectSpan"; import { anchorFieldSpan } from "./anchorFieldSpan"; import { FieldProvenanceRepo } from "../../../../storage/provenance/FieldProvenanceRepo"; +import { + parseSpacOfferingTerms, + parseSpacPromoteTerms, + promoteCoverage, +} from "./parseOfferingTables"; +import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; +import { parseSpacUseOfProceeds, useOfProceedsIsComplete } from "./parseSpacUseOfProceeds"; /** * Concatenate the sections production hands the offering-terms parser, so eval @@ -241,6 +248,10 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise({ sectionName: "offering-terms", text: offeringText, @@ -263,13 +274,38 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { - const terms = await extractOfferingTerms(text, m, context); - return terms === null ? [] : [terms]; - }), + clears: new Set([ + termsTable, + `field_provenance:${termsTable}`, + "issuer_ticker", + "field_provenance:issuer_ticker", + ]), + // SPAC-only: the walker reads a unit/price table a non-SPAC equity + // prospectus does not have. As declared it never stands in for the model — + // it reads the offering table, not the listing sentence naming the issuer's + // symbols, so the ticker series cleared above would be left empty. Teaching + // it to read that sentence is what would let `issuer_ticker` join `covers`. + ...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, + } + ), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); const terms = rows[0]; + const model_id = persistModelId(models, meta.modelIndex); const now = new Date().toISOString(); if (isSpac) { await spacUnitTermsRepo.save({ @@ -408,13 +444,44 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise { - const promote = await extractSponsorPromote(text, m, context); - return promote === null ? [] : [promote]; - }), + // Column-granular because the parse is column-granular: `parseSpacPromoteTerms` + // returns a row on EITHER a founder-share or a trust-per-share anchor, so a + // table stating one of them yields a row whose other columns are null. Named + // as a table, that row would overwrite the five figures the model reads out + // of the surrounding prose with nulls, on this filing and on every replay. + 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", + ]), + ...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, + }, + } + ), persist: async (rows, meta) => { - const model_id = persistModelId(models, meta.modelIndex); const promote = rows[0]; + const model_id = persistModelId(models, meta.modelIndex); await spacPromoteTermsRepo.save({ extractor_id, accession_number, @@ -463,9 +530,10 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise({ sectionName: "underwriters", - text: byName.get(S1_SECTIONS.UNDERWRITING), + text: underwritingText, emptyDetail: "no underwriters returned", lowConfidenceDetail: "all rows below confidence floor", invalidWriteDetail: "no underwriter rows had a usable legal name", @@ -476,7 +544,37 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise extractUnderwriters(text, m, context)), + // `underwriter_family_membership` is derived from the legal name, so the + // parse supplies it wherever it supplies the observation — it stays a bare + // table in both sets. `underwriter_link` does not: the syndicate table + // states an allocation and nothing else, while the ROLE ("sole book-running + // manager", "co-manager") is prose beside the table and the over-allotment + // column is often a separate table entirely. Named as a table, an S-1 + // re-run would empty the links and rewrite a filed bookrunner as NULL. + clears: new Set([ + "underwriter_link.role_detail", + "underwriter_link.shares_allocated", + "underwriter_link.over_allotment_shares", + "underwriter_family_membership", + "company_observation", + "observation_provenance", + ]), + // SPAC-only: the parser reads the syndicate table a unit IPO prints. As + // declared it never stands in for the model — teaching it to read the role + // sentence is what would let `role_detail` join `covers`. + ...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, + }), persist: async (rows, meta) => { const model_id = persistModelId(models, meta.modelIndex); let wrote = 0; @@ -559,19 +657,45 @@ export async function runOfferingSections(args: OfferingSectionsArgs): Promise({ sectionName: "use-of-proceeds", - text: byName.get(S1_SECTIONS.USE_OF_PROCEEDS), + text: useOfProceedsText, 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)), + // Bare on both sides, including the `note` the parse hardcodes null. The + // prompt directs every qualifier a line item carries into `purpose` ("the + // row label copied WHOLE, including any parenthetical the cell carries"), + // and the parse copies that same cell verbatim — so `note` holds nothing + // the row does not still say, which is not true of any other null in this + // change. + 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. + ...modelExtractChain(models, (text, m) => extractUseOfProceeds(text, m, context), { + deterministic: isSpac + ? { + 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. 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, + }), persist: async (rows) => { const now = new Date().toISOString(); let lineIndex = 0; diff --git a/src/sec/forms/registration-statements/s1/offeringTermsSchema.ts b/src/sec/forms/registration-statements/s1/offeringTermsSchema.ts index 869f92de..01a452ed 100644 --- a/src/sec/forms/registration-statements/s1/offeringTermsSchema.ts +++ b/src/sec/forms/registration-statements/s1/offeringTermsSchema.ts @@ -84,6 +84,10 @@ export interface OfferingTermsRow { confidence: number; source_span: string; tickers: ReadonlyArray; - /** 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.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts new file mode 100644 index 00000000..01f01737 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.corpus.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseBeneficialOwnership } from "./parseBeneficialOwnership"; + +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) + ); +} + +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); + }); + + 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([]); + } + }); + + // 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/parseBeneficialOwnership.test.ts b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts new file mode 100644 index 00000000..ae7efa67 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.test.ts @@ -0,0 +1,124 @@ +/** + * @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], + ]); + }); + 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 new file mode 100644 index 00000000..0f835beb --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseBeneficialOwnership.ts @@ -0,0 +1,253 @@ +/** + * @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"; +import { + cleanCell, + collapseRow, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; + +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); +} + +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)) { + 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 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; + } + 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 === "-"; +} 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..6ac1a046 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseManagementRoster.corpus.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseManagementRoster } from "./parseManagementRoster"; + +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) + ); +} + +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); + }); + + 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..d48e90da --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseManagementRoster.ts @@ -0,0 +1,187 @@ +/** + * @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"; +import { + cleanCell, + collapseRow, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; + +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(); +} 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..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,17 +4,15 @@ * 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, parseSpacPromoteTerms } from "./parseOfferingTables"; - -const MOCK_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../../html/mock_data/s1"); +import { + parseSpacOfferingTerms, + parseSpacPromoteTerms, + promoteCoverage, +} from "./parseOfferingTables"; const OFFERING_FIELDS = [ "price_per_unit", @@ -73,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); @@ -116,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); @@ -129,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); @@ -139,4 +126,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/parseOfferingTables.test.ts b/src/sec/forms/registration-statements/s1/parseOfferingTables.test.ts index 0ca908f5..f34f9f2b 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,29 @@ 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("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 | @@ -369,4 +428,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..89deff1c 100644 --- a/src/sec/forms/registration-statements/s1/parseOfferingTables.ts +++ b/src/sec/forms/registration-statements/s1/parseOfferingTables.ts @@ -5,11 +5,13 @@ */ 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"; +import { isSeparatorRow, splitPipeRow } from "./gfmTables"; -export const DETERMINISTIC_MODEL_ID = "deterministic"; +export { DETERMINISTIC_MODEL_ID }; const PRICE_MIN = 8; const PRICE_MAX = 12; @@ -17,7 +19,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 }, @@ -46,8 +48,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; @@ -79,8 +88,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; @@ -107,9 +119,39 @@ export function parseSpacPromoteTerms(text: string): SponsorPromoteRow | null { }; } -export function looksLikeUnitIpo(text: string): boolean { +/** + * 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); - return fields.price_per_unit !== null || fields.units_offered !== null; + 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 { + return isUnitIpoWalk(walkFields(text)); } function emptyWalk(): WalkedFields { @@ -139,6 +181,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) { @@ -154,41 +197,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 +245,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); @@ -279,6 +324,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; } @@ -299,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; @@ -344,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 @@ -408,6 +437,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); } @@ -420,11 +456,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/i.test(row.value) + isOutstandingBeforeLabel(row.label) && + /\bshares\b/i.test(row.value) && + !isNamedFounderValue(row.value) ); } @@ -487,13 +536,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 +545,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/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..b3ba4a57 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.corpus.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseRelatedPartyTables } from "./parseRelatedPartyTables"; + +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); +} + +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); + }); + + 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..f4bd6875 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseRelatedPartyTables.ts @@ -0,0 +1,131 @@ +/** + * @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"; +import { + cleanCell, + collapseRow, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; + +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(); +} 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..3ee1f2b9 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.corpus.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacClassification } from "./parseSpacClassification"; + +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); + }); + + 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(); + } + }); + + // 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/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..5f08ee5a --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacClassification.ts @@ -0,0 +1,41 @@ +/** + * @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; + } +} + +/** 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 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; + // `exec` returned it out of `text`, so it is verbatim by construction. + const source_span = vehicle[0]!; + 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..5a949160 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacProfile.corpus.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacProfile } from "./parseSpacProfile"; + +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; +} + +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); + }); + + 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..895ee5d3 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacProfile.ts @@ -0,0 +1,275 @@ +/** + * @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; + } +} + +/** 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 parseSpacProfile(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..980dea7f --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.corpus.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacSponsors } from "./parseSpacSponsors"; + +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") + ); +} + +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); + }); + + 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([]); + } + }); + + // 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/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..05ae54c7 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacSponsors.ts @@ -0,0 +1,86 @@ +/** + * @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 []; + } +} + +/** + * 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 parseSpacSponsors(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/parseSpacUnderwriters.corpus.test.ts b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts new file mode 100644 index 00000000..bcec38f2 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.corpus.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacUnderwriters } from "./parseSpacUnderwriters"; + +function nameKey(name: string): string { + return name + .normalize("NFKC") + .replace(/\s+/g, " ") + .replace(/[.,;:]+$/, "") + .trim() + .toLowerCase(); +} + +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); + }); + + 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..955e2442 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUnderwriters.ts @@ -0,0 +1,226 @@ +/** + * @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"; +import { cleanCell, isSeparatorRow, splitGfmTables, splitPipeRow } from "./gfmTables"; + +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. + * 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; + 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; +} 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..89f25861 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.corpus.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSpacUseOfProceeds, useOfProceedsIsComplete } from "./parseSpacUseOfProceeds"; + +function purposeKey(s: string): string { + return s.replace(/\s+/g, "").toLowerCase(); +} + +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); + }); + + 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 !== "") + ); + const extras = parsed + .map((row) => row.purpose ?? "") + .filter((p) => p !== "" && !allowed.has(purposeKey(p))); + 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 new file mode 100644 index 00000000..8782f6ed --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.test.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { + parseSpacUseOfProceeds, + resetUseOfProceedsParseCacheForTesting, + useOfProceedsIsComplete, +} 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", + "Reimbursed expenses", + "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([]); + }); + + it("skips source-of-funds rows", () => { + const text = [ + "| 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(purposes(text)).toEqual([ + "Underwriting discounts and commissions", + "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); + }); +}); + +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 new file mode 100644 index 00000000..4acc9681 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSpacUseOfProceeds.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +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; +/** + * 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).rows; + } catch { + return []; + } +} + +/** + * 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 }; + +/** + * 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; + return parsed; +} + +/** + * 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 { + try { + return collectLines(text).rows.some((r) => SPAC_USE.test(r.purpose ?? "")); + } catch { + return false; + } +} + +function collectLines(text: string): UseOfProceedsParse { + const out: UseOfProceedsLineRow[] = []; + let unrepresented = 0; + for (const table of splitGfmTables(text)) { + 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 ( + inSourceBlock || + isSkipPurpose(purpose) || + DATE_PURPOSE.test(purpose) || + isHeaderRow(cells) + ) { + 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, + amount, + percent, + note: null, + confidence: 1, + source_span: purposeRaw, + source: "deterministic", + }); + } + unrepresented += declined.filter((i) => i > firstItem && i < lastItem).length; + } + 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 { + 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; +} 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..832ddb2e --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.corpus.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { getGoldenLabels } from "../../../../eval/goldenS1Labels"; +import { loadS1Corpus, S1_CORPUS_TIMEOUT_MS, type S1CorpusFiling } from "./testing/s1Corpus"; +import { S1_SECTIONS } from "./DocumentSegmenter"; +import { parseSummaryCompensationTable } from "./parseSummaryCompensationTable"; + +function nameKey(s: string): string { + return s.replace(/\s+/g, "").toLowerCase(); +} + +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); + }); + + 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..b19e72c4 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/parseSummaryCompensationTable.ts @@ -0,0 +1,378 @@ +/** + * @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"; +import { + cleanCell, + isSeparatorRow, + mergeHeaderRows, + splitGfmTables, + splitPipeRow, +} from "./gfmTables"; + +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 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()); +} diff --git a/src/sec/forms/registration-statements/s1/s1Model.test.ts b/src/sec/forms/registration-statements/s1/s1Model.test.ts index 8872f5fd..e8348da2 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.test.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.test.ts @@ -4,60 +4,91 @@ * 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"; - -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; -}); +import { describe, expect, it } from "vitest"; +import type { ModelConfig } from "workglow"; +import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; +import { deterministicModelConfig, modelExtractChain, resolveModelId } from "./s1Model"; -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]); +function ai(id: string): ModelConfig { + return { + model_id: id, + provider: "fake", + capabilities: [], + provider_config: {}, + metadata: {}, + } as ModelConfig; +} + +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"); +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"]), + }, + } + ); + 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"]); }); - 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"); + + 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([]); }); - // 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("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: pass, + }); + 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" }]); }); -}); -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("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("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"]), + }, + } + ); + 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..616beb90 100644 --- a/src/sec/forms/registration-statements/s1/s1Model.ts +++ b/src/sec/forms/registration-statements/s1/s1Model.ts @@ -6,7 +6,9 @@ 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 type { RunSectionArgs } from "./sectionRunner"; /** The model ids used for S-1 extraction; overridable via SEC_S1_MODEL (CSV). */ @@ -80,26 +82,64 @@ 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; + } +): Pick< + RunSectionArgs, + "extract" | "emptyExtracts" | "modelIds" | "fallbackOnEmpty" | "deterministic" +> { 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); + // 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 []; + return [...pass.extract(text)]; + }; return { - extract: (text) => extract(text, primary), - emptyExtracts: models.slice(1).map((m) => (text: string) => extract(text, m)), - modelIds: models.map((m) => resolveModelId(m)).filter((id): id is string => id !== null), + extract: slot(primary), + emptyExtracts: models.slice(1).map((m) => slot(m)), + // 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) ?? ""), + // 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 new file mode 100644 index 00000000..f1034a70 --- /dev/null +++ b/src/sec/forms/registration-statements/s1/sectionRunner.deterministic.test.ts @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "vitest"; +import { DETERMINISTIC_MODEL_ID } from "../../../../config/Constants"; +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 modelIds?: readonly string[]; + readonly walkLast?: boolean; + readonly omitWalk?: 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 covers = overrides.covers ?? new Set(["person_observation"]); + // 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++; + 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", + text: TEXT, + emptyDetail: "empty", + lowConfidenceDetail: "low", + ...(overrides.verify === false ? {} : { verifyRow: (text, r) => text.includes(r.span) }), + clears: overrides.clears, + modelIds, + ...(overrides.omitWalk + ? {} + : { + deterministic: { + extract: () => detRows, + covers, + ...(overrides.complete !== undefined ? { complete: 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; + }, + }); + 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"]); + // 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 () => { + const h = harness({ + clears: new Set(["person_observation"]), + covers: new Set(["person_observation", "observation_provenance"]), + complete: () => true, + }); + 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"]); + }); + + // `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"]), + covers: new Set(["person_observation"]), + complete: () => true, + 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"); + }); + + // 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.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 () => { + 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); + }); + + 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 7a8d9c3e..6dfc2343 100644 --- a/src/sec/forms/registration-statements/s1/sectionRunner.ts +++ b/src/sec/forms/registration-statements/s1/sectionRunner.ts @@ -5,9 +5,12 @@ */ 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"; +import type { DeterministicPass } from "./deterministicPass"; +import { assertsCompletePopulation, preempts } from "./deterministicPass"; import { MixedRiskCaptionShapeError, NonceMismatchError, @@ -120,6 +123,20 @@ 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. + * + * 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; /** * Tried in order when {@link extract} (and any earlier fallback) returns `[]` * **or throws** a provider/extraction error. Abort, an already-aborted @@ -139,6 +156,16 @@ export interface RunSectionArgs { readonly fallbackOnEmpty?: boolean; /** Ids tried for this section; named in the MODEL_EMPTY detail when length > 1. */ readonly modelIds?: readonly string[]; + /** + * 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 deterministic?: DeterministicPass>; readonly persist: (rows: TRow[], meta: SectionPersistMeta) => Promise; } @@ -152,6 +179,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 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"; } export type RunSection = ( @@ -233,72 +267,20 @@ 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; - } - } - if (lastError !== undefined) throw lastError; - return lastRaw; - }; - 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 { - throw e; - } - } - } 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; - } + 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; @@ -313,12 +295,95 @@ export function makeRunSection(opts: { } 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) { + }; + const clearSlot = (): void => { + raw = []; + confident = []; + rows = []; + droppedUnverified = 0; + droppedTooLong = 0; + }; + let source: "deterministic" | "model" = "model"; + let walkComplete = false; + let lastError: unknown; + for (let i = 0; i < slots.length; i++) { + 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; + } catch (e) { + if (isImmediateExtractFailure(e)) throw e; + lastError = e; + clearSlot(); + continue; + } + // 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; + lastError = undefined; + break; + } + clearSlot(); + continue; + } + mixedShapeAttempts = 0; + let slotFailed = false; + for (let attempt = 1; attempt <= VERIFICATION_ATTEMPTS; attempt++) { + try { + 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; + } + if (isImmediateExtractFailure(e)) throw e; + lastError = e; + slotFailed = true; + clearSlot(); + break; + } + 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 = @@ -349,8 +414,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" ? walkComplete : rows.length === raw.length, modelIndex, + source, }); if (sargs.invalidWriteDetail !== undefined && wrote === 0) { await record("MODEL_INVALID_OUTPUT", sargs.invalidWriteDetail); diff --git a/src/sec/forms/registration-statements/s1/sectionSchemas.ts b/src/sec/forms/registration-statements/s1/sectionSchemas.ts index b605718a..897a41cf 100644 --- a/src/sec/forms/registration-statements/s1/sectionSchemas.ts +++ b/src/sec/forms/registration-statements/s1/sectionSchemas.ts @@ -132,6 +132,12 @@ export interface ManagementPersonRow { bio: string | null; confidence: number; source_span: string; + /** + * 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 { name: string; @@ -146,6 +152,12 @@ export interface BeneficialOwnerRow { footnote: string | null; confidence: number; source_span: string; + /** + * 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 { name: string; @@ -159,4 +171,10 @@ export interface RelatedPartyRow { period: string | null; footnote: string | null; }>; + /** + * 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/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!; } /** diff --git a/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts b/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts index fcee58fd..2fe23fbb 100644 --- a/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacClassifierSchema.ts @@ -41,4 +41,10 @@ export interface SpacClassificationRow { entity_kind: SpacEntityKind; confidence: number; source_span: string; + /** + * 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 0db0051f..453a8197 100644 --- a/src/sec/forms/registration-statements/s1/spacProfileSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacProfileSchema.ts @@ -128,4 +128,10 @@ export interface SpacProfileRow { url_spac: string | null; confidence: number; source_span: string; + /** + * 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 3a907ab2..45fb0f06 100644 --- a/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts +++ b/src/sec/forms/registration-statements/s1/spacSponsorSchema.ts @@ -32,4 +32,10 @@ export interface SpacSponsorRow { legal_name: string; confidence: number; source_span: string; + /** + * 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/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 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/src/sec/forms/registration-statements/s1/underwriterSchema.ts b/src/sec/forms/registration-statements/s1/underwriterSchema.ts index d648a1ea..e1464ae9 100644 --- a/src/sec/forms/registration-statements/s1/underwriterSchema.ts +++ b/src/sec/forms/registration-statements/s1/underwriterSchema.ts @@ -41,4 +41,10 @@ export interface UnderwriterRowOut { over_allotment_shares: number | null; confidence: number; source_span: string; + /** + * 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 72fd1121..b97e831a 100644 --- a/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts +++ b/src/sec/forms/registration-statements/s1/useOfProceedsSchema.ts @@ -38,4 +38,10 @@ export interface UseOfProceedsLineRow { note: string | null; confidence: number; source_span: string; + /** + * 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/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/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/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/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" }), }); 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/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/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/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/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; + } +} 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/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/EvalOfferingTablesTask.ts b/src/task/eval/EvalOfferingTablesTask.ts new file mode 100644 index 00000000..c1076cd6 --- /dev/null +++ b/src/task/eval/EvalOfferingTablesTask.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 { 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/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; + } +} 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; + } +} 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; + } +} diff --git a/src/task/forms/ComputeFormsWorklistTask.ts b/src/task/forms/ComputeFormsWorklistTask.ts index 11718cf7..651456bb 100644 --- a/src/task/forms/ComputeFormsWorklistTask.ts +++ b/src/task/forms/ComputeFormsWorklistTask.ts @@ -42,6 +42,13 @@ export type ComputeFormsWorklistTaskInput = { readonly shardCount?: number; /** When non-empty, only filings whose CIK is in this list are emitted. */ readonly ciks?: number[]; + /** + * 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 @@ -56,15 +63,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 +113,16 @@ function accessionShard(accession: string, shardCount: number): number { return (h >>> 0) % shardCount; } +/** 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[]; @@ -135,6 +162,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())), + filedOnOrAfter: Type.Optional(Type.String()), batchSize: Type.Optional(Type.Integer({ minimum: 1 })), }); } @@ -201,6 +229,9 @@ 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 filedOnOrAfter = input.filedOnOrAfter; const dryRun = isDryRun(); const batchSize = input.batchSize ?? WORKLIST_BATCH_SIZE; @@ -264,10 +295,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 (skipFiledBefore(f.filing_date, filedOnOrAfter)) continue; if (keys.has(filingRunKey(f))) continue; total++; } @@ -279,8 +311,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 }; } @@ -319,7 +353,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 +381,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 (skipFiledBefore(f.filing_date, filedOnOrAfter)) continue; if (this.successfulKeys.has(filingRunKey(f))) continue; accessionNumber.push(f.accession_number); cik.push(f.cik); @@ -389,47 +425,128 @@ 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/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/task/forms/formsSweep.test.ts b/src/task/forms/formsSweep.test.ts index 1a406b26..c6795706 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,8 @@ interface SeedFiling { accession_number: string; form: string; primary_doc: string; + items?: string | null; + filing_date?: string; } async function seed(f: SeedFiling): Promise { @@ -43,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, @@ -51,7 +54,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 +165,99 @@ 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 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, @@ -263,6 +359,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..130ab347 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[], + filedOnOrAfter?: string ): ComputeFormsWorklistTask { return new ComputeFormsWorklistTask({ - defaults: { form, shardIndex: shard?.index, shardCount: shard?.count, ciks }, + defaults: { + form, + shardIndex: shard?.index, + shardCount: shard?.count, + ciks, + filedOnOrAfter, + }, }); } diff --git a/src/task/index/CatchUpDailyIndexTask.test.ts b/src/task/index/CatchUpDailyIndexTask.test.ts index 749e4cff..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", @@ -60,6 +67,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 +171,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; @@ -230,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 b11c9b3a..77ce785a 100644 --- a/src/task/index/CatchUpDailyIndexTask.ts +++ b/src/task/index/CatchUpDailyIndexTask.ts @@ -18,6 +18,7 @@ import { import { CIK_LAST_UPDATE_REPOSITORY_TOKEN } from "../../storage/processing/CikLastUpdateSchema"; import { TypeSecDate } from "../../util/parseDate"; import { getHttpErrorStatus } from "../fetch/SecFetchJob"; +import { dailyIndexWasPublished, isWeekendDate } from "./dailyIndexPublication"; import { dailyIndexCacheRelPath, DEFAULT_DAILY_INDEX_LOOKBACK, @@ -27,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; @@ -138,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 (getHttpErrorStatus(err) === 404) { + if (await isUnpublishedDailyIndex(err, date, context)) { skipped404++; lastSuccess = date; await cursorRepo.put({ id: DAILY_INDEX_CURSOR_ID, last_success: date }); @@ -162,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 (getHttpErrorStatus(err) !== 404) { + 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/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 diff --git a/src/task/spac/ProcessSpacTimelineTask.test.ts b/src/task/spac/ProcessSpacTimelineTask.test.ts index 25cd3bc3..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; @@ -585,6 +585,56 @@ 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("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 }); + 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 +647,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..ab424ec7 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>; @@ -132,6 +136,13 @@ export type ProcessSpacTimelineTaskOutput = Static { 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 +193,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 +229,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 +305,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 +340,53 @@ 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)), - }); + // `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( + new ProcessAccessionDocFormTask({ + title: `${form} ${filing.accession_number}`, + }) + ); + 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); + } + } + } } } @@ -382,6 +427,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, 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; +} 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,