Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6f63ff3
feat(eval): add offering tables evaluation command and report
sroussey Aug 18, 2026
4177f65
feat(eval): introduce underwriters evaluation command and reporting
sroussey Aug 18, 2026
c5e7d08
feat(eval): add use-of-proceeds and executive-compensation evaluation…
sroussey Aug 18, 2026
22d0af4
feat(eval): add beneficial ownership evaluation command and reporting
sroussey Aug 18, 2026
432b7f0
feat(eval): add management, related-party, spac sponsors, spac profil…
sroussey Aug 19, 2026
661cbec
fix(s1): do not treat mixed outstanding-before shares as the founder …
sroussey Aug 19, 2026
87f32fb
Merge branch 'main' into code-extractors
sroussey Aug 19, 2026
f2c46b5
nit: format
sroussey Aug 19, 2026
2b0ce88
feat: add registerSafeFetch function to exports and update tests
sroussey Aug 20, 2026
950c987
fix: a deterministic pass may not preempt what it cannot supply
claude Aug 20, 2026
cb2f4a3
Merge pull request #311 from workglow-dev/claude/branch-security-revi…
sroussey Aug 20, 2026
886f932
Merge branch 'main' into code-extractors
sroussey Aug 20, 2026
821cbf5
Merge branch 'main' into code-extractors
sroussey Aug 20, 2026
47b7faf
chore: restore prettier formatting in src/index.ts
claude Aug 20, 2026
6083717
fix(s1): a deterministic pass may not preempt what it cannot supply
claude Aug 20, 2026
23c05fc
Merge pull request #313 from workglow-dev/claude/sharp-lamport-ata6g5…
sroussey Aug 20, 2026
96996b5
Merge pull request #315 from workglow-dev/claude/sharp-lamport-ata6g5…
sroussey Aug 20, 2026
e65d0ed
Merge branch 'main' into code-extractors
sroussey Aug 20, 2026
7d2b927
Anchor the use-of-proceeds skip rules to the start of the label
sroussey Aug 21, 2026
fa62f14
Require a row-completeness claim before a parse may preempt the model
sroussey Aug 21, 2026
bbb7837
Re-express the two storage tests against the row-completeness contract
claude Aug 21, 2026
f5133c7
refactor(spac): enhance SPAC process handling with new sweeps and imp…
sroussey Aug 21, 2026
2b4be37
Merge pull request #318 from workglow-dev/claude/zen-albattani-alr17v…
sroussey Aug 21, 2026
901de6b
feat(spac): enhance SPAC processing with new command options and impr…
sroussey Aug 21, 2026
d076e94
feat(sec): register deterministic as a reserved model id
sroussey Aug 21, 2026
9c09306
feat(sec): dispatch deterministic slots inside modelExtractChain
sroussey Aug 21, 2026
0fcec88
feat(sec): one-shot the deterministic walk at its list index
sroussey Aug 21, 2026
1427eb8
feat(sec): take the section walk from the model list
sroussey Aug 21, 2026
a3eeec5
test(sec): opt storage tests into the deterministic walk
sroussey Aug 21, 2026
d8b6594
feat(sec): eval --models deterministic runs the sync walk
sroussey Aug 21, 2026
bd065ed
docs(sec): deterministic is an opt-in model-list slot
sroussey Aug 21, 2026
2baf223
docs(sec): align the model id-shape table for prettier
sroussey Aug 21, 2026
ebb932d
refactor(sec): streamline parser evaluation and reporting interfaces
sroussey Aug 21, 2026
792bd7c
refactor: remove eightKItems from forms sweep logic
sroussey Aug 21, 2026
beb768c
refactor(sec): remove redundant clears from form processing logic
sroussey Aug 21, 2026
66a60f8
chore: update bun type deps
sroussey Aug 22, 2026
09acc15
feat(sec): implement global setup for S-1 corpus tests
sroussey Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
630 changes: 630 additions & 0 deletions src/cli/groups/eval.ts

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions src/cli/groups/evalOptionValues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
40 changes: 40 additions & 0 deletions src/eval/runBeneficialOwnershipEval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* 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");
});
});
245 changes: 245 additions & 0 deletions src/eval/runBeneficialOwnershipEval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* 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<BeneficialOwnershipBucket, number>;
}

export async function runBeneficialOwnershipEval(
options: BeneficialOwnershipEvalOptions = {}
): Promise<BeneficialOwnershipReport> {
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<BeneficialOwnershipBucket, number> = {
"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<Map<string, BeneficialOwnershipLineScore[]>> {
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<string, BeneficialOwnershipLineScore[]>();
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<BeneficialOwnershipCase> {
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 (/<SUBMISSION>|<SEC-HEADER>/i.test(raw)) {
return extractPrimaryDocFromSubmission(raw, primary) ?? "";
}
return raw;
}

async function loadFiling(
cik: number | null,
accession_number: string
): Promise<Filing | undefined> {
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];
}
37 changes: 37 additions & 0 deletions src/eval/runExecutiveCompensationEval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* 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");
});
});
Loading
Loading