Skip to content

Commit 41d78d6

Browse files
committed
docs(spac): update CLAUDE.md with SPAC classification details
- Added clarification on the impact of `s1_classification.is_spac = false` on candidate identification and confidence levels. - Explained the behavior of the `sec sync spacs` process regarding CIKs with existing SPAC rows. - Enhanced documentation to improve understanding of the classification logic and its implications for operating companies.
1 parent 974e372 commit 41d78d6

8 files changed

Lines changed: 353 additions & 14 deletions

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2196,6 +2196,14 @@ Graded into `confidence`:
21962196
reverse-merge, then S-1 for the operating company's resale), OR 6770 with no
21972197
registration on file at all.
21982198

2199+
A latest `s1_classification.is_spac = false` (by filing date) also caps identify
2200+
at `low`, and `sec sync spacs` process skips that CIK even if an older candidate
2201+
row is still medium. That is how an operating company that only _looks_ like a
2202+
blank check (Associates First Capital, Sprint Capital) leaves the worklist
2203+
without a fourth confidence rung. A CIK that already has a `spac` row is never
2204+
dropped this way — a post-de-SPAC operating S-1 must not eject the vehicle.
2205+
`null` (no registration parsed yet) leaves the ladder in place.
2206+
21992207
Why the screen is worth having at all: `entities.sic` is the _current_ code, and
22002208
it drifts off 6770 at the de-SPAC — sometimes before the rename (Melar
22012209
Acquisition Corp. I reads 7389 while its own S-1 header says 6770). The header

src/cli/sync/spacSyncCiks.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { beforeEach, describe, expect, it } from "vitest";
88
import { globalServiceRegistry } from "workglow";
99
import { resetDependencyInjectionsForTesting } from "../../config/TestingDI";
1010
import { setupAllDatabases } from "../../config/setupAllDatabases";
11+
import { S1_CLASSIFICATION_REPOSITORY_TOKEN } from "../../storage/classification/S1ClassificationSchema";
12+
import { FILING_REPOSITORY_TOKEN } from "../../storage/filing/FilingSchema";
1113
import {
1214
SPAC_CANDIDATE_REPOSITORY_TOKEN,
1315
type SpacCandidate,
@@ -72,6 +74,42 @@ function minimalSpac(cik: number): Spac {
7274
};
7375
}
7476

77+
async function putClassification(args: {
78+
readonly cik: number;
79+
readonly accession_number: string;
80+
readonly filing_date: string;
81+
readonly is_spac: boolean;
82+
readonly created_at?: string;
83+
}): Promise<void> {
84+
await globalServiceRegistry.get(FILING_REPOSITORY_TOKEN).put({
85+
cik: args.cik,
86+
accession_number: args.accession_number,
87+
filing_date: args.filing_date,
88+
report_date: null,
89+
acceptance_date: `${args.filing_date}T12:00:00.000Z`,
90+
form: "S-1",
91+
file_number: null,
92+
film_number: null,
93+
primary_doc: "doc.htm",
94+
primary_doc_description: null,
95+
size: null,
96+
is_xbrl: null,
97+
is_inline_xbrl: null,
98+
items: null,
99+
act: null,
100+
});
101+
await globalServiceRegistry.get(S1_CLASSIFICATION_REPOSITORY_TOKEN).put({
102+
extractor_id: "S-1",
103+
accession_number: args.accession_number,
104+
cik: args.cik,
105+
sic: args.is_spac ? 6770 : 6141,
106+
sic_description: null,
107+
is_spac: args.is_spac,
108+
classifier_source: "sgml-header",
109+
created_at: args.created_at ?? "2026-08-17T00:00:00.000Z",
110+
});
111+
}
112+
75113
function candidateRow(cik: number, confidence: SpacCandidate["confidence"]): SpacCandidate {
76114
return {
77115
cik,
@@ -132,6 +170,74 @@ describe("listSpacProcessCiks", () => {
132170
await expect(listKnownSpacCiks()).resolves.toEqual([1]);
133171
await expect(listSpacProcessCiks()).resolves.toEqual([1, 2]);
134172
});
173+
174+
it("drops a medium candidate whose latest registration classified is_spac=false", async () => {
175+
const candidateRepo = globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN);
176+
await candidateRepo.put(candidateRow(7974, "medium"));
177+
await putClassification({
178+
cik: 7974,
179+
accession_number: "0000950134-96-000313",
180+
filing_date: "1996-02-09",
181+
is_spac: false,
182+
});
183+
184+
await expect(listSpacProcessCiks()).resolves.toEqual([]);
185+
});
186+
187+
it("keeps a known SPAC whose later operating registration classified is_spac=false", async () => {
188+
const spacRepo = globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN);
189+
const candidateRepo = globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN);
190+
await spacRepo.put(minimalSpac(1848507));
191+
await candidateRepo.put(candidateRow(1848507, "medium"));
192+
await putClassification({
193+
cik: 1848507,
194+
accession_number: "0001848507-26-000001",
195+
filing_date: "2026-01-15",
196+
is_spac: false,
197+
});
198+
199+
await expect(listSpacProcessCiks()).resolves.toEqual([1848507]);
200+
});
201+
202+
it("keeps a candidate when a later registration classified is_spac=true", async () => {
203+
const candidateRepo = globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN);
204+
await candidateRepo.put(candidateRow(9001, "medium"));
205+
await putClassification({
206+
cik: 9001,
207+
accession_number: "0000000000-21-000001",
208+
filing_date: "2021-01-01",
209+
is_spac: false,
210+
});
211+
await putClassification({
212+
cik: 9001,
213+
accession_number: "0000000000-21-000002",
214+
filing_date: "2021-06-01",
215+
is_spac: true,
216+
});
217+
218+
await expect(listSpacProcessCiks()).resolves.toEqual([9001]);
219+
});
220+
221+
it("uses the later filing date, not process order, as latest", async () => {
222+
const candidateRepo = globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN);
223+
await candidateRepo.put(candidateRow(9002, "high"));
224+
await putClassification({
225+
cik: 9002,
226+
accession_number: "0000000000-21-000010",
227+
filing_date: "2021-01-01",
228+
is_spac: true,
229+
created_at: "2026-08-20T00:00:00.000Z",
230+
});
231+
await putClassification({
232+
cik: 9002,
233+
accession_number: "0000000000-21-000011",
234+
filing_date: "2021-06-01",
235+
is_spac: false,
236+
created_at: "2026-08-01T00:00:00.000Z",
237+
});
238+
239+
await expect(listSpacProcessCiks()).resolves.toEqual([]);
240+
});
135241
});
136242

137243
async function recordRun(args: {

src/cli/sync/spacSyncCiks.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@
55
*/
66

77
import { globalServiceRegistry } from "workglow";
8+
import { S1_CLASSIFICATION_REPOSITORY_TOKEN } from "../../storage/classification/S1ClassificationSchema";
9+
import { FILING_REPOSITORY_TOKEN } from "../../storage/filing/FilingSchema";
810
import {
911
SPAC_CANDIDATE_REPOSITORY_TOKEN,
1012
type SpacCandidateConfidence,
1113
} from "../../storage/spac/SpacCandidateSchema";
1214
import { SPAC_REPOSITORY_TOKEN } from "../../storage/spac/SpacSchema";
15+
import {
16+
latestClassifiedAsSpac,
17+
type ClassificationVerdict,
18+
} from "../../task/spac/classifySpacCandidate";
1319
import { EXTRACTOR_RUN_REPOSITORY_TOKEN } from "../../storage/versioning/ExtractorRunSchema";
1420
import { SYNC_FORM_DOMAINS } from "./syncFormDomains";
1521

@@ -124,9 +130,10 @@ export async function filterSpacCiksByHistory(
124130
return ciks.filter((cik) => touched.has(cik));
125131
}
126132

127-
/** Known spac rows ∪ spac_candidate rows with confidence high|medium. */
133+
/** Known spac rows ∪ high|medium candidates, minus classified-not-SPAC CIKs. */
128134
export async function listSpacProcessCiks(): Promise<number[]> {
129135
const known = await listKnownSpacCiks();
136+
const knownSet = new Set(known);
130137
const ciks = new Set<number>(known);
131138
const candidateRepo = globalServiceRegistry.get(SPAC_CANDIDATE_REPOSITORY_TOKEN);
132139

@@ -139,13 +146,66 @@ export async function listSpacProcessCiks(): Promise<number[]> {
139146
[];
140147
}
141148

149+
const unknown = candidates.filter((row) => !knownSet.has(row.cik)).map((row) => row.cik);
150+
const rejected = await listCiksLatestClassificationNotSpac(unknown);
151+
142152
for (const row of candidates) {
153+
if (rejected.has(row.cik)) continue;
143154
ciks.add(row.cik);
144155
}
145156

146157
return [...ciks].sort((a, b) => a - b);
147158
}
148159

160+
/**
161+
* CIKs among `ciks` whose latest parsed registration classified `is_spac=false`.
162+
* A CIK with no classification is not rejected — the forms pipeline has not
163+
* answered yet.
164+
*/
165+
async function listCiksLatestClassificationNotSpac(ciks: readonly number[]): Promise<Set<number>> {
166+
const rejected = new Set<number>();
167+
if (ciks.length === 0) return rejected;
168+
169+
const classRepo = globalServiceRegistry.get(S1_CLASSIFICATION_REPOSITORY_TOKEN);
170+
const filingRepo = globalServiceRegistry.get(FILING_REPOSITORY_TOKEN);
171+
const byCik = new Map<number, ClassificationVerdict[]>();
172+
const accessions = new Set<string>();
173+
const sorted = [...new Set(ciks)].sort((a, b) => a - b);
174+
for (let i = 0; i < sorted.length; i += TOUCHED_CIK_CHUNK) {
175+
const chunk = sorted.slice(i, i + TOUCHED_CIK_CHUNK);
176+
const rows = (await classRepo.query({ cik: { value: chunk, operator: "in" } })) ?? [];
177+
for (const row of rows) {
178+
if (row.cik == null) continue;
179+
accessions.add(row.accession_number);
180+
const list = byCik.get(row.cik) ?? [];
181+
list.push({
182+
accession_number: row.accession_number,
183+
is_spac: row.is_spac,
184+
created_at: row.created_at,
185+
});
186+
byCik.set(row.cik, list);
187+
}
188+
}
189+
190+
const filingDateByAccession = new Map<string, string>();
191+
const accessionList = [...accessions];
192+
for (let i = 0; i < accessionList.length; i += TOUCHED_CIK_CHUNK) {
193+
const chunk = accessionList.slice(i, i + TOUCHED_CIK_CHUNK);
194+
const filings =
195+
(await filingRepo.query({ accession_number: { value: chunk, operator: "in" } })) ?? [];
196+
for (const filing of filings) {
197+
filingDateByAccession.set(filing.accession_number, filing.filing_date);
198+
}
199+
}
200+
201+
for (const [cik, rows] of byCik) {
202+
if (latestClassifiedAsSpac(rows, filingDateByAccession) === false) {
203+
rejected.add(cik);
204+
}
205+
}
206+
return rejected;
207+
}
208+
149209
/** CIKs that already have a `spac` row — the 8-K / proxy / 25-15 handlers' gate. */
150210
export async function listKnownSpacCiks(): Promise<number[]> {
151211
const spacRepo = globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN);

src/config/storageRegistry.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -934,11 +934,14 @@ export const SEC_STORAGE_REGISTRY: readonly StorageDefinition[] = [
934934
table: "s1_classification",
935935
schema: S1ClassificationSchema,
936936
primaryKeyNames: S1ClassificationPrimaryKeyNames,
937-
// The SPAC candidate scan groups this table by `cik` and reads only `sic`.
938-
// The PK is `(extractor_id, accession_number)`, so nothing else covers it —
937+
// The SPAC candidate scan groups this table by `cik` and reads `sic`,
938+
// `is_spac`, `created_at` and `accession_number`. The PK is
939+
// `(extractor_id, accession_number)`, so nothing else covers that set —
939940
// and `setupDatabase()` re-emits `CREATE INDEX IF NOT EXISTS` on every run,
940-
// so an existing database picks this up without a migration.
941-
indexes: [["cik", "sic"]],
941+
// so an existing database picks this up without a migration. The previous
942+
// `(cik, sic)` index is left standing on databases that already have it;
943+
// it is a prefix of this one and is unused by the scan once this exists.
944+
indexes: [["cik", "sic", "is_spac", "created_at", "accession_number"]],
942945
}),
943946
defineStorage({
944947
token: CANONICAL_PERSON_REPOSITORY_TOKEN,

src/task/spac/classifySpacCandidate.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const facts = (over: Partial<SpacCandidateFacts>): SpacCandidateFacts => ({
1818
name: null,
1919
current_sic: null,
2020
filed_sic_6770: null,
21+
classified_as_spac: null,
2122
first_reg_form: null,
2223
first_reg_date: null,
2324
renamed_from: null,
@@ -417,3 +418,69 @@ describe("the as-filed header SIC signal", () => {
417418
expect(row?.signal_filed_sic_6770).toBeNull();
418419
});
419420
});
421+
422+
describe("a parsed registration that is not a SPAC", () => {
423+
it("demotes a weak-named lender to low once its S-1 classified is_spac=false", () => {
424+
// ASSOCIATES FIRST CAPITAL CORP (CIK 7974): "%capital corp%" plus an S-1
425+
// grades medium, but every registration the forms pipeline has read is an
426+
// operating company. Process ignores `low`, so this is how it drops off
427+
// the worklist without a new confidence enum.
428+
const row = classifySpacCandidate(
429+
facts({
430+
cik: 7974,
431+
name: "ASSOCIATES FIRST CAPITAL CORP",
432+
current_sic: 6141,
433+
first_reg_form: "S-1",
434+
first_reg_date: "1996-02-09",
435+
classified_as_spac: false,
436+
}),
437+
AT
438+
);
439+
expect(row).toMatchObject({ confidence: "low", signal_name_match: false });
440+
});
441+
442+
it("does not demote when the classification has not been asked yet", () => {
443+
const row = classifySpacCandidate(
444+
facts({
445+
cik: 7974,
446+
name: "ASSOCIATES FIRST CAPITAL CORP",
447+
current_sic: 6141,
448+
first_reg_form: "S-1",
449+
first_reg_date: "1996-02-09",
450+
classified_as_spac: null,
451+
}),
452+
AT
453+
);
454+
expect(row?.confidence).toBe("medium");
455+
});
456+
457+
it("does not demote a high SPAC whose latest registration classified is_spac=true", () => {
458+
const row = classifySpacCandidate(
459+
facts({
460+
cik: 2082251,
461+
name: "Yuanxiang Acquisition Corp.",
462+
current_sic: 6770,
463+
first_reg_form: "F-1",
464+
first_reg_date: "2025-09-17",
465+
classified_as_spac: true,
466+
}),
467+
AT
468+
);
469+
expect(row?.confidence).toBe("high");
470+
});
471+
472+
it("caps even a 6770-coded name at low when the latest registration said it is not a SPAC", () => {
473+
const row = classifySpacCandidate(
474+
facts({
475+
cik: 42,
476+
name: "Someone Acquisition Corp",
477+
current_sic: 6770,
478+
first_reg_form: "S-1",
479+
first_reg_date: "2021-01-01",
480+
classified_as_spac: false,
481+
}),
482+
AT
483+
);
484+
expect(row?.confidence).toBe("low");
485+
});
486+
});

0 commit comments

Comments
 (0)