-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheval.ts
More file actions
1559 lines (1505 loc) · 64.7 KB
/
Copy patheval.ts
File metadata and controls
1559 lines (1505 loc) · 64.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import type { Command } from "commander";
import { runCommand } from "../runCommand";
import { runWorkflowCli } from "../runWorkflow";
import { csvOptionValue, optionValue } from "../optionValue";
import { parseIntOption } from "../GlobalOptions";
import { KNOWN_MODEL_ID_SHAPES, modelApiKeyEnvVar } from "../../config/registerModels";
import { availableFixtureCiks, loadRealS1Sections } from "../../eval/realSections";
import { EVAL_EXTRACTORS, preparedSectionText } from "../../eval/fixtures";
import { defaultGoldenSweepExtractors } from "../../eval/defaultSweepExtractors";
import {
printEvalPrompts,
printPromptsNeedsSectionText,
type PrintPromptItem,
type PrintPromptsMode,
} from "../../eval/printEvalPrompts";
import type { EvalRawDump } from "../../eval/captureEvalRaw";
import { shouldDumpEvalRaw, writeEvalRawDump } from "../../eval/dumpEvalRaw";
import {
availableFixtures,
extractorsWithFixtures,
resolveEvalFixtures,
type EvalReport,
type ModelSummary,
type StabilitySummary,
} from "../../eval/runExtractionEval";
import type { ExtractionDiff } from "../../eval/scoreExtraction";
import { EvalExtractTask } from "../../task/eval/EvalExtractTask";
import { EvalS1Task, GOLDEN_REFERENCE, type OracleReport } from "../../task/eval/EvalS1Task";
import {
evalS1ConcurrencyProduct,
formatEvalS1Concurrency,
resolveEvalS1Concurrency,
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
* tier of two other cloud providers we hold keys for. Cross-provider is the
* point — extraction quality and reproducibility have both turned out to be
* provider-specific (OpenAI's reasoning family rejects `temperature` outright;
* only Gemini offers a sampling `seed`), so ranking within one vendor answers
* the wrong question. The sweep needs `DEEPSEEK_API_KEY` and `GEMINI_API_KEY`
* as well as `ANTHROPIC_API_KEY`; a missing key is recorded as a failed run per
* fixture rather than aborting the sweep.
*
* A local HFT model is deliberately NOT in the default sweep: it costs minutes
* per section and is not a production candidate. Pass one explicitly
* (`--models "$SEC_HFT_MODEL"`) to rank it — and note the local providers are
* the only ones that can pin a seed, so they are where genuine reproducibility
* is achievable.
*/
const DEFAULT_MODELS = [
"claude-haiku-4-5",
"claude-sonnet-5",
"deepseek-v4-flash",
"gemini-3.6-flash",
];
/**
* The default set restricted to providers this shell actually holds a key for.
*
* The defaults span three providers, so a bare `sec eval extract` on a machine
* with only `ANTHROPIC_API_KEY` set would spend the sweep producing failed runs
* for half the table and report them beside the real results as if the models
* had been ranked and lost. Dropping them (with a warning that names the ids and
* the variables that would bring them back) keeps the documented default list
* while making the bare command work wherever it is run.
*
* Explicit `--models` is never filtered: naming an id is a request to run it,
* and a failed run is the honest answer to "why doesn't this work?".
*/
function availableDefaultModels(): string[] {
const missing = new Map<string, string[]>();
const available = DEFAULT_MODELS.filter((id) => {
const envVar = modelApiKeyEnvVar(id);
if (envVar === undefined || (process.env[envVar] ?? "").trim() !== "") return true;
missing.set(envVar, [...(missing.get(envVar) ?? []), id]);
return false;
});
if (missing.size > 0) {
const detail = [...missing]
.map(([envVar, ids]) => `${ids.join(", ")} (needs ${envVar})`)
.join("; ");
console.warn(`Skipping default model(s) with no API key configured: ${detail}`);
}
// Every provider unconfigured is a configuration problem, not a reason to run
// an empty sweep and report a vacuous pass: hand back the full list so the
// failures name themselves.
return available.length > 0 ? available : [...DEFAULT_MODELS];
}
/**
* Default candidate for `sec eval s1`: score the cheap cloud model against the
* reference, the comparison that decides production extraction. Same reasoning
* as {@link DEFAULT_MODELS} — a local model is opt-in.
*/
const ORACLE_DEFAULT_CANDIDATE = "claude-haiku-4-5";
/**
* Default reference for `sec eval s1`: the committed human-verified labels.
*
* A model reference is only as good as its own reads — every candidate's score
* is capped by the reference's mistakes, and two runs of it disagree with each
* other anyway, so the yardstick moves between evaluations. Golden labels are
* fixed, free, and instant. They are the right default wherever they exist.
*
* The cost is coverage: labels exist for `management` and
* `beneficial-ownership` only, so a golden run scores those and reports every
* other section as skipped rather than silently passing. Pass
* `--reference <model-id>` to fall back to an oracle for the unlabelled
* extractors — accepting that its verdict is an opinion, not truth.
*/
const ORACLE_DEFAULT_REFERENCE = GOLDEN_REFERENCE;
function parseModels(ids: readonly string[] | undefined): string[] {
return [...new Set(ids ?? availableDefaultModels())];
}
/**
* 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
* the shapes rather than pretending to enumerate.
*/
function modelIdsHint(defaults: readonly string[]): string {
return (
`comma-separated model ids (default: ${defaults.join(", ")}); ` +
`any id routes by shape: ${KNOWN_MODEL_ID_SHAPES}`
);
}
/** `--format` is a closed two-value set on every eval subcommand. */
function requireFormat(value: string | boolean): string {
return optionValue("--format", value, () => "one of: table, json") ?? "table";
}
const PRINT_PROMPTS_MODES: readonly PrintPromptsMode[] = [
"instructions",
"template",
"document",
"schema",
"full",
];
function requirePrintPromptsMode(
value: string | boolean | undefined
): PrintPromptsMode | undefined {
const raw = optionValue(
"--print-prompts",
value,
() => `one of: ${PRINT_PROMPTS_MODES.join(", ")}`
);
if (raw === undefined) return undefined;
if (!(PRINT_PROMPTS_MODES as readonly string[]).includes(raw)) {
throw new Error(`--print-prompts needs a value — one of: ${PRINT_PROMPTS_MODES.join(", ")}`);
}
return raw as PrintPromptsMode;
}
function pct(x: number): string {
return `${(x * 100).toFixed(0)}%`;
}
function usd(x: number | null): string {
return x === null ? " ? " : `$${x.toFixed(5)}`;
}
function pad(s: string, w: number): string {
return s.length >= w ? s : s + " ".repeat(w - s.length);
}
/** Collapse whitespace and cap length so long bios / source spans stay one-line. */
function truncate(s: string, max = 60): string {
const flat = s.replace(/\s+/g, " ").trim();
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
}
function printSpacClassificationReport(report: SpacClassificationReport): void {
const { counts } = report;
console.log(
`spac-classification parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printSpacProfileReport(report: SpacProfileReport): void {
const { counts } = report;
console.log(
`spac-profile parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printSpacSponsorsReport(report: SpacSponsorsReport): void {
const { counts } = report;
console.log(
`spac-sponsors parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printRelatedPartyReport(report: RelatedPartyReport): void {
const { counts } = report;
console.log(
`related-party parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printManagementReport(report: ManagementReport): void {
const { counts } = report;
console.log(
`management parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printBeneficialOwnershipReport(report: BeneficialOwnershipReport): void {
const { counts } = report;
console.log(
`beneficial-ownership parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printExecutiveCompensationReport(report: ExecutiveCompensationReport): void {
const { counts } = report;
console.log(
`executive-compensation parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printUseOfProceedsReport(report: UseOfProceedsReport): void {
const { counts } = report;
console.log(
`use-of-proceeds parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printUnderwritersReport(report: UnderwritersReport): void {
const { counts } = report;
console.log(
`underwriters parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function printOfferingTablesReport(report: OfferingTablesReport): void {
const { counts } = report;
console.log(
`offering/promote parser vs stored rows ` +
`hit-agree=${counts["hit-agree"]} hit-disagree=${counts["hit-disagree"]} ` +
`miss=${counts.miss} empty=${counts.empty} skip=${counts.skip}`
);
const flagged = report.cases.filter((c) => c.bucket === "miss" || c.bucket === "hit-disagree");
if (flagged.length === 0) return;
console.log("\nmiss / hit-disagree:");
for (const c of flagged) {
const cik = c.cik === null ? "" : ` cik=${c.cik}`;
console.log(` ${c.bucket} ${c.kind} ${c.accession_number}${cik} ${c.cachePath ?? ""}`);
if (c.bucket === "hit-disagree") {
console.log(` parsed ${JSON.stringify(c.parsed)}`);
console.log(` stored ${JSON.stringify(c.stored)}`);
}
}
}
function hasDiff(d: ExtractionDiff): boolean {
return d.missing.length > 0 || d.extra.length > 0 || d.mismatches.length > 0;
}
/**
* Stderr dumps for `--dump-raw`: successful runs that disagree with truth.
* Independent of `--details` / `--no-details` — the flag asked for the payload.
*/
function dumpDisagreementRaws(
results: readonly {
readonly ok: boolean;
readonly model: string;
readonly fixture?: string;
readonly filing?: string;
readonly extractor: string;
readonly run?: number;
readonly raw: EvalRawDump | undefined;
readonly score: { readonly diff: ExtractionDiff } | null;
}[]
): void {
for (const r of results) {
if (!r.ok || r.score == null) continue;
if (!shouldDumpEvalRaw({ ok: true, raw: r.raw, diff: r.score.diff })) continue;
const section = r.fixture ?? `${r.filing} / ${r.extractor}`;
const runTag = r.run && r.run > 1 ? ` run=${r.run}` : "";
writeEvalRawDump(`${r.model} / ${section}${runTag}`, r.raw!);
}
}
/**
* Join a capped list of row keys, appending "(+N more)" when truncated. Each key
* is quoted: entity names routinely contain commas ("V-Cube, Inc."), so a bare
* ", " join renders two rows as `V-Cube, Inc., Naoaki Mashita` — unreadable, and
* indistinguishable from ONE name that merely contains a comma. That ambiguity
* matters here: whether a model emitted one combined name or two separate owners
* is exactly what these diffs exist to show. Same reasoning as `displayValue` in
* scoreExtraction.ts, which bracket-quotes arrays for this reason.
*/
function keyList(keys: readonly string[], cap = 8): string {
const shown = keys.slice(0, cap).map((k) => `"${truncate(k, 40)}"`);
const extra = keys.length - shown.length;
return extra > 0 ? `${shown.join(", ")} (+${extra} more)` : shown.join(", ");
}
/** @internal test seam for {@link keyList}. */
export const keyListForTesting = keyList;
interface DiffEntry {
readonly model: string;
readonly section: string;
readonly extractor: string;
readonly diff: ExtractionDiff;
}
/**
* Print the concrete disagreements behind the aggregate scores, grouped by model:
* expected/reference rows the candidate missed, rows it invented, and per-field
* value mismatches on the rows that aligned. This is the "why is the score not
* 100%" view the table alone can't give.
*/
function printDiffs(entries: readonly DiffEntry[], truthLabel: string): void {
const withDiff = entries.filter((e) => hasDiff(e.diff));
if (!withDiff.length) {
console.log(`\nno row/field disagreements — every scored run matched the ${truthLabel}.`);
return;
}
console.log(`\ndisagreements (${truthLabel} vs got):`);
const byModel = new Map<string, DiffEntry[]>();
for (const e of withDiff) {
(byModel.get(e.model) ?? byModel.set(e.model, []).get(e.model)!).push(e);
}
for (const [model, es] of byModel) {
console.log(`\n ${model}`);
for (const e of es) {
console.log(` ${e.section} / ${e.extractor}`);
if (e.diff.missing.length) {
console.log(` missing (${e.diff.missing.length}): ${keyList(e.diff.missing)}`);
}
if (e.diff.extra.length) {
console.log(` extra (${e.diff.extra.length}): ${keyList(e.diff.extra)}`);
}
const mm = e.diff.mismatches;
for (const m of mm.slice(0, 12)) {
console.log(
` ${truncate(m.key, 30)} · ${m.field}: "${truncate(m.expected)}" → "${truncate(m.got)}"`
);
}
if (mm.length > 12) console.log(` … +${mm.length - 12} more field mismatch(es)`);
}
}
}
const DEFAULT_SCORE_LEGEND =
"score = field-level F1 (names + titles): rewards found values and penalizes missed " +
"AND invented ones; found = expected people matched; prec = 1 − hallucinated rows.\n" +
"est.cost uses provider-stated spend when the API returned it (OpenRouter), " +
"else a char×rate-card estimate; local models are $0. " +
"Best-first: correctness, then cost, then latency.";
/**
* Reproducibility table, shown only when the sweep repeated fixtures.
*
* `same` is how many fixtures produced byte-identical output on every run;
* `same facts` relaxes that to ignore `source_span`/`confidence`. The gap
* between the two columns is the interesting part: on real filings the model
* has repeatedly found the SAME risks while cutting their captions at different
* points, which is a prompt problem, not a "the model is unreliable" problem.
* One number could not tell those apart.
*/
function printStability(report: EvalReport): void {
const stability = report.stability;
if (!stability || stability.length === 0) return;
const runs = stability[0].runs;
console.log(`\nreproducibility over ${runs} runs per fixture:`);
// Denominator is `measured`, not `fixtures`: a fixture that did not complete
// every repetition was never tested for reproducibility, and counting it
// against the stable total reads as a fixture that WAS tested and varied. The
// difference is named as skipped so the gap stays visible instead of being
// silently absorbed.
const skipped = (m: StabilitySummary): string =>
m.fixtures > m.measured ? ` (${m.fixtures - m.measured} skipped)` : "";
const cols: Array<[string, number, (s: StabilitySummary) => string]> = [
["model", 34, (m) => m.model],
["same", 12, (m) => `${m.stableExact}/${m.measured}${skipped(m)}`],
["same facts", 12, (m) => `${m.stableContent}/${m.measured}`],
];
console.log(cols.map(([h, w]) => pad(h, w)).join(" "));
console.log(cols.map(([, w]) => "-".repeat(w)).join(" "));
for (const m of [...stability].sort((a, b) => b.stableContent - a.stableContent)) {
console.log(cols.map(([, w, get]) => pad(get(m), w)).join(" "));
}
console.log(
"\n same = byte-identical rows incl. citations; same facts = ignoring source_span/confidence"
);
}
function printTable(
report: EvalReport,
details: boolean,
scoreLegend: string = DEFAULT_SCORE_LEGEND,
unscored = false
): void {
const cols: Array<[string, number, (m: ModelSummary) => string]> = [
["#", 2, () => ""],
["model", 34, (m) => m.model],
["provider", 20, (m) => m.provider],
["score", 7, (m) => pct(m.avgScore)],
["found", 7, (m) => pct(m.avgEntityRecall)],
["prec", 6, (m) => pct(m.avgPrecision)],
["latency", 10, (m) => `${m.avgLatencyMs.toFixed(0)}ms`],
["est.cost", 10, (m) => usd(m.totalUsd)],
["ok", 6, (m) => `${m.okRuns}/${m.runs}`],
];
console.log(cols.map(([h, w]) => pad(h, w)).join(" "));
console.log(cols.map(([, w]) => "-".repeat(w)).join(" "));
report.summaries.forEach((m, i) => {
const rank = pad(String(i + 1), 2);
const rest = cols
.slice(1)
.map(([, w, get]) => pad(get(m), w))
.join(" ");
console.log(`${rank} ${rest}`);
});
console.log(
unscored
? "\nscore/found/prec are NOT meaningful here: the real sections carry no golden labels " +
"(that is what `sec eval s1` uses a reference model for). Read the reproducibility " +
"table, latency and cost."
: `\n${scoreLegend}`
);
printStability(report);
const failed = report.results.filter((r) => !r.ok);
if (failed.length) {
console.log("\nfailures:");
for (const r of failed) {
const runTag = r.run > 1 ? ` run=${r.run}` : "";
console.log(` ${r.model} / ${r.fixture}${runTag}: ${r.error}`);
if (shouldDumpEvalRaw({ ok: false, raw: r.raw, diff: undefined }) && r.raw) {
writeEvalRawDump(`${r.model} / ${r.fixture}${runTag}`, r.raw);
}
}
}
if (details) {
printDiffs(
report.results
.filter((r) => r.ok)
.map((r) => ({
model: r.model,
section: r.fixture,
extractor: r.extractor,
diff: r.score.diff,
})),
"expected"
);
}
dumpDisagreementRaws(report.results);
}
/**
* Names the requested axes when the sweep could not reach them, so an operator
* who passed `--concurrency-section-model 4` and sees `x1` learns why (they
* named one model) rather than suspecting the flag was ignored.
*/
function concurrencyRequestNote(report: OracleReport): string {
const { concurrency: asked, effectiveConcurrency: got } = report;
if (
asked.s1 === got.s1 &&
asked.section === got.section &&
asked.sectionModel === got.sectionModel
)
return "";
return (
` — requested ${formatEvalS1Concurrency(asked)}, capped by the filings, the widest ` +
`filing's section count and the number of --models named`
);
}
function printOracleTable(report: OracleReport, details: boolean): void {
console.log(
`Reference (truth): ${report.reference} — over ${report.sections} real S-1 section(s)\n`
);
const cols: Array<[string, number, (m: OracleReport["summaries"][number]) => string]> = [
["role", 10, (m) => m.role],
["model", 34, (m) => m.model],
["agree", 7, (m) => (m.role === "reference" ? "—" : pct(m.avgAgreement))],
["recall", 7, (m) => (m.role === "reference" ? "—" : pct(m.avgEntityRecall))],
["prec", 6, (m) => (m.role === "reference" ? "—" : pct(m.avgPrecision))],
["rows", 6, (m) => String(m.totalRows)],
["dist", 6, (m) => String(m.totalDistinctRows)],
// Latency is measured under whatever parallelism the sweep ran at, so the
// header carries all three axes: an unlabelled `latency` invited comparison
// of a serial figure against a 20-wide one as though they measured the same
// thing, and wall-clock here includes time queued behind the sweep itself.
// The EFFECTIVE axes, not the requested ones — every axis is capped by the
// work available to it, so a default sweep with one `--models` id ran at
// 1x5x1 while the request read 1x5x4.
[
`lat@${formatEvalS1Concurrency(report.effectiveConcurrency)}`,
12,
(m) => `${m.avgLatencyMs.toFixed(0)}ms`,
],
["est.cost", 10, (m) => usd(m.totalUsd)],
["ok", 6, (m) => `${m.okRuns}/${m.runs}`],
];
console.log(cols.map(([h, w]) => pad(h, w)).join(" "));
console.log(cols.map(([, w]) => "-".repeat(w)).join(" "));
for (const m of report.summaries) {
console.log(cols.map(([, w, get]) => pad(get(m), w)).join(" "));
}
console.log(
"\nagree = field-value F1 vs the reference (names + titles): penalizes both missed and " +
"invented values; recall = reference entities the model also found;\nprec = model " +
"entities the reference also had (1 − hallucination), over DISTINCT rows; rows = raw " +
"rows emitted, dist = distinct after de-duping on the key field (gap = duplicate " +
"over-production).\nReference rows are the truth, so it has no agreement score.\n" +
`lat@${formatEvalS1Concurrency(report.effectiveConcurrency)} = mean wall-clock per ` +
`extraction measured with ${report.effectiveConcurrency.s1} filing(s) x at most ` +
`${report.effectiveConcurrency.section} section(s) x ` +
`${report.effectiveConcurrency.sectionModel} model(s) in flight ` +
`(up to ${evalS1ConcurrencyProduct(report.effectiveConcurrency)} concurrent ` +
`extractions)${concurrencyRequestNote(report)}.\n` +
"Wall-clock includes time queued behind the sweep's own other extractions — a local " +
"model's especially, since one worker serves them all — so set\n" +
"--concurrency-s1 1 --concurrency-section 1 --concurrency-section-model 1 for figures " +
"comparable across runs."
);
const failed = report.results.filter((r) => !r.ok);
if (failed.length) {
console.log("\nfailures:");
for (const r of failed) {
console.log(` ${r.model} / ${r.filing} / ${r.extractor}: ${r.error}`);
if (shouldDumpEvalRaw({ ok: false, raw: r.raw, diff: undefined }) && r.raw) {
writeEvalRawDump(`${r.model} / ${r.filing} / ${r.extractor}`, r.raw);
}
}
}
if (report.skipped.length) {
console.log("\nskipped (no such section / unparseable):");
for (const s of report.skipped) console.log(` ${s}`);
}
if (details) {
// Reference runs carry no score (they ARE the truth); only scored candidate
// runs have a diff to show against the reference.
printDiffs(
report.results
.filter((r) => r.score !== null)
.map((r) => ({
model: r.model,
section: r.filing,
extractor: r.extractor,
diff: r.score!.diff,
})),
"reference"
);
}
dumpDisagreementRaws(report.results);
}
/**
* Fixture names grouped under their extractor, one group per line.
*
* Flat, the list is ~20 hyphenated names in one paragraph; grouped, an operator
* who knows which extractor failed reads only that line.
*/
function formatFixtureList(
fixtures: ReadonlyArray<{ readonly name: string; readonly extractor: string }>
): string {
const byExtractor = new Map<string, string[]>();
for (const f of fixtures) {
const names = byExtractor.get(f.extractor) ?? [];
names.push(f.name);
byExtractor.set(f.extractor, names);
}
return [...byExtractor]
.map(([extractor, names]) => ` ${extractor}: ${names.join(", ")}`)
.join("\n");
}
export function addEvalCommands(program: Command): void {
const cmd = program
.command("eval")
.description("Compare extraction models on cost, speed, and correctness");
cmd
.command("extract")
.description("Run golden extraction fixtures across models and rank them")
// Every value option here takes an OPTIONAL argument so that omitting the
// value is ours to answer, not Commander's: `--extractor` alone exits with
// "argument missing" and no hint of what the arguments are, which is exactly
// the moment an operator needs the list. With `[name]` Commander hands the
// action `true` instead, and `optionValue` throws with the values.
.option("--models [csv]", `comma-separated model ids (default: ${DEFAULT_MODELS.join(", ")})`)
.option(
"--extractor [name]",
// Only offer what is actually scorable: an extractor registered in
// EVAL_EXTRACTORS with no committed fixture has nothing to run.
`limit to one extractor (${extractorsWithFixtures().join(", ")})`
)
.option(
"--fixture [csv]",
"limit to these fixtures by name, as printed in the failures list " +
"(e.g. s1-management-operating-company) — re-run just the one a model failed on"
)
.option("--format [fmt]", "table | json (default: table)")
.option(
"--print-prompts [mode]",
`dump extraction prompts and exit (${PRINT_PROMPTS_MODES.join(" | ")}) — no model calls`
)
.option("--no-details", "hide per-row/field disagreements after the table")
.option(
"--runs <n>",
"repeat each fixture N times per model and report reproducibility (default 1)",
(v) => Number(v)
)
.option(
"--real",
"sweep the REAL committed S-1 sections (12k-275k chars) instead of the curated miniatures; correctness is not scored (no golden labels at that size), reproducibility/latency/cost are"
)
.option(
"--dump-raw",
"print model JSON on stderr for hard failures and scoring disagreements (also included in --format json)"
)
.action(
async (opts: {
models?: string | boolean;
extractor?: string | boolean;
fixture?: string | boolean;
format: string | boolean;
details: boolean;
runs?: number;
real?: boolean;
printPrompts?: string | boolean;
dumpRaw?: boolean;
}) => {
await runCommand(async () => {
const extractor = optionValue(
"--extractor",
opts.extractor,
() => `one of: ${extractorsWithFixtures().join(", ")}`
);
if (extractor && !EVAL_EXTRACTORS[extractor]) {
throw new Error(
`unknown extractor "${extractor}"; known: ${Object.keys(EVAL_EXTRACTORS).join(", ")}`
);
}
const fixtures = csvOptionValue(
"--fixture",
opts.fixture,
() =>
`one or more fixture names:\n` +
formatFixtureList(availableFixtures({ extractor, real: opts.real }))
);
const printMode = requirePrintPromptsMode(opts.printPrompts);
if (printMode !== undefined) {
const items: PrintPromptItem[] = printPromptsNeedsSectionText(printMode)
? resolveEvalFixtures({
extractor,
fixtures,
real: opts.real === true,
}).map((f) => ({
extractor: f.extractor,
label: f.name,
sectionText: preparedSectionText(f.extractor, f.text),
}))
: (extractor ? [extractor] : Object.keys(EVAL_EXTRACTORS)).map((name) => ({
extractor: name,
label: name,
}));
printEvalPrompts({ mode: printMode, items });
return;
}
const format = requireFormat(opts.format);
const models = parseModels(
csvOptionValue("--models", opts.models, () => modelIdsHint(DEFAULT_MODELS))
);
// `--runs` keeps a required argument: a count has no list of legal
// values to print, so Commander's own message says everything ours would.
const runs = opts.runs;
if (runs !== undefined && (!Number.isFinite(runs) || runs < 1)) {
throw new Error(`--runs must be a positive integer; got "${runs}"`);
}
const input = {
models,
...(extractor ? { extractor } : {}),
...(fixtures ? { fixtures } : {}),
...(runs !== undefined ? { runs: Math.trunc(runs) } : {}),
...(opts.real ? { real: true } : {}),
...(opts.dumpRaw ? { dumpRaw: true } : {}),
};
// runWorkflowCli renders the task-graph progress UI on a TTY (clearing
// it before we print), and runs plainly when piped.
const report = await runWorkflowCli<EvalReport>([
new EvalExtractTask({ defaults: input }),
]);
if (format === "json") {
console.log(JSON.stringify(report, null, 2));
return;
}
printTable(report, opts.details, DEFAULT_SCORE_LEGEND, opts.real === true);
});
}
);
cmd
.command("s1")
.description("Compare candidate models against a reference on REAL committed S-1 sections")
// `[id]`/`[csv]` rather than `<…>`: see the note on `eval extract`.
// No Commander default on the value options below: with a default set,
// Commander resolves a value-less `--reference` to that default instead of
// handing us `true`, which would silently sweep the default where the old
// required form at least errored. The default is applied in the action.
.option(
"--reference [id]",
`reference (oracle) model id, or 'golden' for committed human-verified labels (default: ${ORACLE_DEFAULT_REFERENCE})`
)
.option(
"--models [csv]",
`model ids to score against the reference (default: ${ORACLE_DEFAULT_CANDIDATE})`
)
.option(
"--extractors [csv]",
`sections to pull (${Object.keys(EVAL_EXTRACTORS).join(", ")}); default: every extractor with golden labels that is not excluded from default sweeps (${defaultGoldenSweepExtractors().join(", ")}), or management against a model reference`
)
.option(
"--dir <path>",
"directory of real S-1 HTML to segment (default: committed mock_data; " +
"point at mock_data/s1/.cache after `sec fetch s1-fixtures`)"
)
.option(
"--cik [csv]",
"limit the sweep to these filer CIKs (leading zeros optional) — isolates one " +
"filing when checking a newly added fixture or label"
)
.option("--format [fmt]", "table | json (default: table)")
.option(
"--print-prompts [mode]",
`dump extraction prompts and exit (${PRINT_PROMPTS_MODES.join(" | ")}) — no model calls`
)
.option("--no-details", "hide per-row/field disagreements after the table")
.option(
"--dump-raw",
"print model JSON on stderr for hard failures and scoring disagreements (also included in --format json)"
)
.option(
"--effort [level]",
"override every extractor's baked-in thinking effort for this sweep " +
"(none|low|medium|high|extra|ultra)"
)
// Three axes rather than one number: they multiply, so a single flag can
// only bound the product by guessing how the operator wants it spent.
.option(
"--concurrency-s1 <n>",
`filings extracted at once (default ${EVAL_S1_CONCURRENCY_DEFAULTS.s1}); ` +
`s1 x section x section-model = concurrent extractions`,
parseIntOption
)
.option(
"--concurrency-section <n>",
`sections of one filing extracted at once (default ${EVAL_S1_CONCURRENCY_DEFAULTS.section}); ` +
`s1 x section x section-model = concurrent extractions`,
parseIntOption
)
.option(
"--concurrency-section-model <n>",
`candidate models scoring one section at once (default ${EVAL_S1_CONCURRENCY_DEFAULTS.sectionModel}); ` +
`s1 x section x section-model = concurrent extractions`,
parseIntOption
)
.action(
async (opts: {
reference: string | boolean;
models?: string | boolean;
extractors?: string | boolean;
dir?: string;
cik?: string | boolean;
format: string | boolean;
details: boolean;
printPrompts?: string | boolean;
dumpRaw?: boolean;
effort?: string | boolean;
concurrencyS1?: number;
concurrencySection?: number;
concurrencySectionModel?: number;
}) => {
await runCommand(async () => {
// Validated here rather than only inside the task so a bad value costs
// nothing — the sweep loads the corpus and registers models first.
const concurrencyS1 =
opts.concurrencyS1 === undefined
? undefined
: resolveEvalS1Concurrency("s1", opts.concurrencyS1);
const concurrencySection =
opts.concurrencySection === undefined
? undefined
: resolveEvalS1Concurrency("section", opts.concurrencySection);
const concurrencySectionModel =
opts.concurrencySectionModel === undefined
? undefined
: resolveEvalS1Concurrency("sectionModel", opts.concurrencySectionModel);
const requestedExtractors = csvOptionValue(
"--extractors",
opts.extractors,
// Every EVAL_EXTRACTORS key is legal here (unlike `eval extract`,
// which needs a committed fixture); which ones have golden labels
// is in --help, and repeating it doubles the line.
() => `one or more of: ${Object.keys(EVAL_EXTRACTORS).join(", ")}`
);
// The CIK list is the corpus `--dir` selects, so the hint reads the
// same directory the sweep will.
const ciks = csvOptionValue(
"--cik",
opts.cik,
() => `one or more filer CIKs: ${availableFixtureCiks(opts.dir).join(", ")}`
);
const printMode = requirePrintPromptsMode(opts.printPrompts);
if (printMode !== undefined) {
const extractors = requestedExtractors ?? defaultGoldenSweepExtractors();
for (const name of extractors) {
if (!EVAL_EXTRACTORS[name]) {
throw new Error(
`unknown extractor "${name}"; known: ${Object.keys(EVAL_EXTRACTORS).join(", ")}`
);
}
}
if (printPromptsNeedsSectionText(printMode)) {
const { sections, skipped } = loadRealS1Sections(extractors, opts.dir, ciks);
if (sections.length === 0) {
throw new Error(
`nothing to print — no S-1 sections matched` +
(skipped.length ? ` (${skipped.join("; ")})` : "")
);
}
printEvalPrompts({
mode: printMode,
items: sections.map((s) => ({
extractor: s.extractor,
label: `${s.filing} [${s.extractor}]`,
sectionText: preparedSectionText(s.extractor, s.text),
})),
});
} else {
printEvalPrompts({
mode: printMode,
items: extractors.map((extractor) => ({ extractor, label: extractor })),
});
}
return;
}
const format = requireFormat(opts.format);
const reference =
optionValue(
"--reference",