@klhapp/skillmux 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/calibrate.ts DELETED
@@ -1,1594 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdirSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { Database } from "bun:sqlite";
5
- import { z } from "zod";
6
- import { decideResolveOutcome } from "./decision";
7
- import type { AuditRow, RankedCandidate } from "./types";
8
-
9
- export { generateDataset, type GenerateDatasetOptions } from "./dataset-generator";
10
-
11
- // ---------------------------------------------------------------------------
12
-
13
- // Decision-policy dataset types (AC1)
14
- // ---------------------------------------------------------------------------
15
-
16
- export type DecisionSplit = "tune" | "test";
17
- export type DecisionOutcome = "matched" | "ambiguous" | "no_match";
18
-
19
- export interface DecisionCaseProvenance {
20
- version: 1;
21
- source: "authored" | "audit_import";
22
- review_status: "human_labelled" | "unreviewed";
23
- query_storage: "raw" | "redacted";
24
- audit_id?: number;
25
- labelled_at?: string;
26
- }
27
-
28
- export interface DecisionCase {
29
- query: string;
30
- split: DecisionSplit;
31
- expected_outcome: DecisionOutcome;
32
- relevant_skill_ids: string[];
33
- provenance?: DecisionCaseProvenance;
34
- }
35
-
36
- // ---------------------------------------------------------------------------
37
- // Raw Zod schema — field-level validation only (cross-field rules below)
38
- // ---------------------------------------------------------------------------
39
-
40
- const rawCaseSchema = z.object({
41
- query: z.string(),
42
- split: z.enum(["tune", "test"]),
43
- expected_outcome: z.enum(["matched", "ambiguous", "no_match"]),
44
- relevant_skill_ids: z.array(z.string()),
45
- provenance: z.object({
46
- version: z.literal(1),
47
- source: z.enum(["authored", "audit_import"]),
48
- review_status: z.enum(["human_labelled", "unreviewed"]),
49
- query_storage: z.enum(["raw", "redacted"]),
50
- audit_id: z.number().int().positive().optional(),
51
- labelled_at: z.string().datetime().optional(),
52
- }).strict().optional(),
53
- }).strict();
54
-
55
- type RawCase = z.infer<typeof rawCaseSchema>;
56
-
57
- // ---------------------------------------------------------------------------
58
- // Cross-field validation helpers
59
- // ---------------------------------------------------------------------------
60
-
61
- function validateCase(raw: RawCase, idx: number): DecisionCase {
62
- if (!raw.query) {
63
- throw new Error(`Validation error at case ${idx}: field "query" must be a non-empty string`);
64
- }
65
-
66
- const { expected_outcome, relevant_skill_ids } = raw;
67
- const provenance: DecisionCaseProvenance = raw.provenance ?? {
68
- version: 1,
69
- source: "authored",
70
- review_status: "human_labelled",
71
- query_storage: "raw",
72
- };
73
-
74
- if (provenance.source === "audit_import") {
75
- if (provenance.audit_id === undefined) {
76
- throw new Error(
77
- `Validation error at case ${idx}: imported field "provenance.audit_id" is required`,
78
- );
79
- }
80
- if (provenance.review_status !== "human_labelled" || !provenance.labelled_at) {
81
- throw new Error(
82
- `Validation error at case ${idx}: imported audit case is unreviewed; human label and "provenance.labelled_at" are required for certification`,
83
- );
84
- }
85
- }
86
-
87
- if (expected_outcome === "matched") {
88
- if (relevant_skill_ids.length !== 1) {
89
- throw new Error(
90
- `Validation error at case ${idx}: field "relevant_skill_ids" must contain exactly one entry for outcome "matched"`,
91
- );
92
- }
93
- } else if (expected_outcome === "ambiguous") {
94
- if (relevant_skill_ids.length < 1) {
95
- throw new Error(
96
- `Validation error at case ${idx}: field "relevant_skill_ids" must contain at least one entry for outcome "ambiguous"`,
97
- );
98
- }
99
- } else {
100
- // no_match
101
- if (relevant_skill_ids.length !== 0) {
102
- throw new Error(
103
- `Validation error at case ${idx}: field "relevant_skill_ids" must be empty for outcome "no_match"`,
104
- );
105
- }
106
- }
107
-
108
- return { ...raw, provenance };
109
- }
110
-
111
- export interface AuditFeedbackLabel {
112
- split: DecisionSplit;
113
- expected_outcome: DecisionOutcome;
114
- relevant_skill_ids: string[];
115
- labelled_at: string;
116
- }
117
-
118
- export type AuditQueryPrivacy =
119
- | { include_raw_query: true }
120
- | { include_raw_query: false; redacted_query: string };
121
-
122
- /** Import an audit outcome only after a separate human label is supplied. */
123
- export function importLabelledAuditCase(
124
- audit: AuditRow,
125
- label: AuditFeedbackLabel,
126
- privacy: AuditQueryPrivacy,
127
- ): DecisionCase {
128
- const query = privacy.include_raw_query ? audit.query : privacy.redacted_query.trim();
129
- if (!query) {
130
- throw new Error("A non-empty redacted_query is required when raw audit queries are excluded");
131
- }
132
- const parsed = rawCaseSchema.parse({
133
- query,
134
- split: label.split,
135
- expected_outcome: label.expected_outcome,
136
- relevant_skill_ids: label.relevant_skill_ids,
137
- provenance: {
138
- version: 1,
139
- source: "audit_import",
140
- review_status: "human_labelled",
141
- query_storage: privacy.include_raw_query ? "raw" : "redacted",
142
- audit_id: audit.id,
143
- labelled_at: label.labelled_at,
144
- },
145
- });
146
- return validateCase(parsed, audit.id);
147
- }
148
-
149
- export interface DatasetProvenanceSummary {
150
- version: 1;
151
- human_labelled_case_count: number;
152
- imported_labelled_case_count: number;
153
- imported_unreviewed_case_count: number;
154
- raw_query_case_count: number;
155
- redacted_query_case_count: number;
156
- }
157
-
158
- export function summarizeDatasetProvenance(
159
- cases: DecisionCase[],
160
- ): DatasetProvenanceSummary {
161
- const provenance = (item: DecisionCase): DecisionCaseProvenance =>
162
- item.provenance ?? {
163
- version: 1,
164
- source: "authored",
165
- review_status: "human_labelled",
166
- query_storage: "raw",
167
- };
168
- return {
169
- version: 1,
170
- human_labelled_case_count:
171
- cases.filter((item) => provenance(item).review_status === "human_labelled").length,
172
- imported_labelled_case_count:
173
- cases.filter((item) =>
174
- provenance(item).source === "audit_import" &&
175
- provenance(item).review_status === "human_labelled"
176
- ).length,
177
- imported_unreviewed_case_count:
178
- cases.filter((item) =>
179
- provenance(item).source === "audit_import" &&
180
- provenance(item).review_status === "unreviewed"
181
- ).length,
182
- raw_query_case_count:
183
- cases.filter((item) => provenance(item).query_storage === "raw").length,
184
- redacted_query_case_count:
185
- cases.filter((item) => provenance(item).query_storage === "redacted").length,
186
- };
187
- }
188
-
189
- // ---------------------------------------------------------------------------
190
- // Dataset-level completeness checks
191
- // ---------------------------------------------------------------------------
192
-
193
- type SplitOutcomeSet = Record<DecisionSplit, Set<DecisionOutcome>>;
194
-
195
- function validateDatasetCompleteness(cases: DecisionCase[]): void {
196
- const present: SplitOutcomeSet = { tune: new Set(), test: new Set() };
197
-
198
- for (const c of cases) {
199
- present[c.split].add(c.expected_outcome);
200
- }
201
-
202
- for (const split of ["tune", "test"] as DecisionSplit[]) {
203
- if (present[split].size === 0) {
204
- throw new Error(
205
- `Dataset must include cases for both "tune" and "test" splits — missing "${split}"`,
206
- );
207
- }
208
- for (const outcome of ["matched", "ambiguous", "no_match"] as DecisionOutcome[]) {
209
- if (!present[split].has(outcome)) {
210
- throw new Error(
211
- `Dataset must include "${outcome}" cases in the "${split}" split`,
212
- );
213
- }
214
- }
215
- }
216
- }
217
-
218
- // ---------------------------------------------------------------------------
219
- // Public API
220
- // ---------------------------------------------------------------------------
221
-
222
- /**
223
- * Parse and validate an array of raw objects as a decision-policy dataset.
224
- *
225
- * Throws a descriptive error (including case index and field name) on the
226
- * first validation failure. Validates:
227
- * - Required fields and their types/enums (Zod)
228
- * - Cross-field constraints (matched → exactly 1 skill, ambiguous → ≥1,
229
- * no_match → 0)
230
- * - Dataset completeness (both splits, all outcome types in each split)
231
- */
232
- export function loadDecisionCases(
233
- raw: unknown[],
234
- validSkillIds?: Iterable<string>,
235
- ): DecisionCase[] {
236
- const parsed: DecisionCase[] = [];
237
- const validIds = validSkillIds ? new Set(validSkillIds) : undefined;
238
-
239
- for (let i = 0; i < raw.length; i++) {
240
- const item = raw[i];
241
- const result = rawCaseSchema.safeParse(item);
242
-
243
- if (!result.success) {
244
- const firstIssue = result.error.issues[0]!;
245
- const fieldPath = firstIssue.path.join(".") || "unknown";
246
- throw new Error(
247
- `Validation error at case ${i}: field "${fieldPath}" — ${firstIssue.message}`,
248
- );
249
- }
250
-
251
- const parsedCase = validateCase(result.data, i);
252
- if (validIds) {
253
- for (const skillId of parsedCase.relevant_skill_ids) {
254
- if (!validIds.has(skillId)) {
255
- throw new Error(
256
- `Validation error at case ${i}: field "relevant_skill_ids" references unknown vault skill "${skillId}"`,
257
- );
258
- }
259
- }
260
- }
261
- parsed.push(parsedCase);
262
- }
263
-
264
- validateDatasetCompleteness(parsed);
265
- return parsed;
266
- }
267
-
268
- /**
269
- * Read a JSON file from disk and validate it as a decision-policy dataset.
270
- * Throws if the file cannot be read or the contents fail validation.
271
- */
272
- export function loadDecisionCasesFromFile(
273
- path: string,
274
- validSkillIds?: Iterable<string>,
275
- ): DecisionCase[] {
276
- const raw = JSON.parse(readFileSync(path, "utf8")) as unknown[];
277
- return loadDecisionCases(raw, validSkillIds);
278
- }
279
-
280
- // ---------------------------------------------------------------------------
281
- // Calibration run — types (AC2, AC3, AC4)
282
- // ---------------------------------------------------------------------------
283
-
284
- export interface CandidateDoc {
285
- skill_id: string;
286
- text: string;
287
- }
288
-
289
- /** A single cached observation for one query. */
290
- export interface QueryObservation {
291
- query: string;
292
- split: DecisionSplit;
293
- expected_outcome: DecisionOutcome;
294
- relevant_skill_ids: string[];
295
- /** Candidates in descending score order after reranking. */
296
- ranked: Array<{ skill_id: string; score: number }>;
297
- }
298
-
299
- export interface SelectedThresholds {
300
- match_score: number;
301
- match_margin: number;
302
- candidate_floor: number;
303
- }
304
-
305
- export interface CalibrationMetrics {
306
- auto_match_precision: number;
307
- auto_match_precision_lower_bound: number;
308
- auto_match_coverage: number;
309
- auto_match_count: number;
310
- correct_auto_match_count: number;
311
- retrieval_recall_at_k: number;
312
- delivered_shortlist_recall_at_k: number;
313
- }
314
-
315
- export interface ConfusionMatrix {
316
- matched: Record<DecisionOutcome, number>;
317
- ambiguous: Record<DecisionOutcome, number>;
318
- no_match: Record<DecisionOutcome, number>;
319
- }
320
-
321
- export interface CalibrationTestMetrics extends CalibrationMetrics {
322
- confusion_matrix: ConfusionMatrix;
323
- }
324
-
325
- export type CalibrationStatus = "running" | "completed" | "failed_gates";
326
- export type CalibrationFailureReason =
327
- | "recall_precondition_failed"
328
- | "precision_floor_unreachable"
329
- | "no_coverage"
330
- | "insufficient_sample"
331
- | "test_certification_failed";
332
-
333
- export interface CalibrationResult {
334
- status: CalibrationStatus;
335
- failed_reason?: CalibrationFailureReason;
336
- observations: QueryObservation[];
337
- selected_thresholds?: SelectedThresholds;
338
- tune_metrics?: CalibrationMetrics;
339
- test_metrics?: CalibrationTestMetrics;
340
- }
341
-
342
- export interface RunCalibrationOptions {
343
- cases: DecisionCase[];
344
- getCandidates?: (query: string) => Promise<CandidateDoc[]>;
345
- /** Production hook: candidates already scored by the shared retrieval pipeline. */
346
- getRankedCandidates?: (
347
- query: string,
348
- ) => Promise<Array<{ skill_id: string; score: number }>>;
349
- reranker:
350
- | ((query: string, docs: CandidateDoc[]) => Promise<number[]>)
351
- | undefined;
352
- /** Default: 0.99 */
353
- minAutoMatchPrecision?: number;
354
- /** Default: 0.95 */
355
- minRetrievalRecallAtK?: number;
356
- /** Default: minRetrievalRecallAtK */
357
- minDeliveredShortlistRecallAtK?: number;
358
- /** Default: 30 */
359
- minAutoMatchCount?: number;
360
- candidateLimit: number;
361
- /** Default: 4 */
362
- concurrency?: number;
363
- initialObservations?: Map<number, QueryObservation> | QueryObservation[];
364
- onProgress?: (completed: number, total: number) => void;
365
- onObservation?: (
366
- observation: QueryObservation,
367
- caseIndex: number,
368
- completedCount: number,
369
- totalCount: number,
370
- ) => void | Promise<void>;
371
- onObservationsReady?: () => void;
372
- }
373
-
374
- // ---------------------------------------------------------------------------
375
- // Decision simulation using cached observations
376
- // ---------------------------------------------------------------------------
377
-
378
- function decideObservation(
379
- obs: QueryObservation,
380
- thresholds: SelectedThresholds,
381
- candidateLimit: number,
382
- ) {
383
- const candidates: RankedCandidate[] = obs.ranked.map((candidate) => ({
384
- ...candidate,
385
- title: candidate.skill_id,
386
- description: "",
387
- }));
388
- return decideResolveOutcome({
389
- reranked: true,
390
- candidates,
391
- thresholds: { ...thresholds, candidate_limit: candidateLimit },
392
- });
393
- }
394
-
395
- function computeMetrics(
396
- observations: QueryObservation[],
397
- thresholds: SelectedThresholds,
398
- candidateLimit: number,
399
- ): CalibrationMetrics {
400
- let autoMatchCount = 0;
401
- let correctAutoMatch = 0;
402
- let retrievalHit = 0;
403
- let deliveredHit = 0;
404
- const matchableCases = observations.filter((o) => o.expected_outcome !== "no_match");
405
- const expectedMatches = observations.filter((o) => o.expected_outcome === "matched");
406
-
407
- for (const obs of observations) {
408
- const decision = decideObservation(obs, thresholds, candidateLimit);
409
- if (decision.outcome === "matched") {
410
- autoMatchCount++;
411
- const top = obs.ranked[0];
412
- if (
413
- obs.expected_outcome === "matched" &&
414
- top?.skill_id === obs.relevant_skill_ids[0]
415
- ) {
416
- correctAutoMatch++;
417
- }
418
- }
419
- if (obs.expected_outcome !== "no_match") {
420
- const topK = obs.ranked.slice(0, candidateLimit).map((c) => c.skill_id);
421
- if (obs.relevant_skill_ids.some((id) => topK.includes(id))) retrievalHit++;
422
- const deliveredIds = decision.outcome === "matched"
423
- ? [decision.skill_id]
424
- : decision.outcome === "ambiguous"
425
- ? decision.candidates.map((candidate) => candidate.skill_id)
426
- : [];
427
- if (obs.relevant_skill_ids.some((id) => deliveredIds.includes(id))) deliveredHit++;
428
- }
429
- }
430
-
431
- const auto_match_precision = autoMatchCount === 0 ? 0 : correctAutoMatch / autoMatchCount;
432
- const auto_match_precision_lower_bound = wilsonLowerBound(correctAutoMatch, autoMatchCount);
433
- const auto_match_coverage = expectedMatches.length === 0
434
- ? 0
435
- : correctAutoMatch / expectedMatches.length;
436
- const retrieval_recall_at_k = matchableCases.length === 0
437
- ? 1.0
438
- : retrievalHit / matchableCases.length;
439
- const delivered_shortlist_recall_at_k = matchableCases.length === 0
440
- ? 1.0
441
- : deliveredHit / matchableCases.length;
442
-
443
- return {
444
- auto_match_precision,
445
- auto_match_precision_lower_bound,
446
- auto_match_coverage,
447
- auto_match_count: autoMatchCount,
448
- correct_auto_match_count: correctAutoMatch,
449
- retrieval_recall_at_k,
450
- delivered_shortlist_recall_at_k,
451
- };
452
- }
453
-
454
- /** 95% Wilson score lower confidence bound for a binomial proportion. */
455
- export function wilsonLowerBound(successes: number, total: number): number {
456
- if (total === 0) return 0;
457
- const z = 1.959963984540054;
458
- const proportion = successes / total;
459
- const zSquared = z * z;
460
- const denominator = 1 + zSquared / total;
461
- const centre = proportion + zSquared / (2 * total);
462
- const adjustment = z * Math.sqrt(
463
- (proportion * (1 - proportion) + zSquared / (4 * total)) / total,
464
- );
465
- return Math.max(0, (centre - adjustment) / denominator);
466
- }
467
-
468
- function computeTestMetrics(
469
- observations: QueryObservation[],
470
- thresholds: SelectedThresholds,
471
- candidateLimit: number,
472
- ): CalibrationTestMetrics {
473
- const base = computeMetrics(observations, thresholds, candidateLimit);
474
-
475
- // Build confusion matrix: rows = expected, cols = predicted
476
- const emptyRow = (): Record<DecisionOutcome, number> => ({ matched: 0, ambiguous: 0, no_match: 0 });
477
- const matrix: ConfusionMatrix = { matched: emptyRow(), ambiguous: emptyRow(), no_match: emptyRow() };
478
-
479
- for (const obs of observations) {
480
- const predicted = decideObservation(obs, thresholds, candidateLimit);
481
- matrix[obs.expected_outcome][predicted.outcome]++;
482
- }
483
-
484
- return { ...base, confusion_matrix: matrix };
485
- }
486
-
487
- // ---------------------------------------------------------------------------
488
- // Threshold search space derivation (AC3)
489
- // ---------------------------------------------------------------------------
490
-
491
- function uniqueSorted(values: number[]): number[] {
492
- return [...new Set(values)].sort((a, b) => a - b);
493
- }
494
-
495
- /** Smallest representable number greater than value. */
496
- function nextUp(value: number): number {
497
- if (!Number.isFinite(value)) return value;
498
- if (Object.is(value, -0)) value = 0;
499
- const buffer = new ArrayBuffer(8);
500
- const float = new Float64Array(buffer);
501
- const bits = new BigUint64Array(buffer);
502
- float[0] = value;
503
- bits[0] = bits[0]! + (value >= 0 ? 1n : -1n);
504
- return float[0]!;
505
- }
506
-
507
- function deriveThresholdCandidates(observations: QueryObservation[]): {
508
- scoreBreakpoints: number[];
509
- marginBreakpoints: number[];
510
- floorBreakpoints: number[];
511
- } {
512
- const scores: number[] = [0];
513
- const margins: number[] = [];
514
- const floors: number[] = [0];
515
-
516
- for (const obs of observations) {
517
- if (obs.ranked.length === 0) continue;
518
- const top = obs.ranked[0]!;
519
- scores.push(top.score);
520
- const second = obs.ranked[1];
521
- margins.push(second ? top.score - second.score : top.score);
522
- // candidate_floor is inclusive, so the policy changes only immediately
523
- // above an observed score. Use that transition to make every breakpoint
524
- // capable of trimming at least one non-top candidate.
525
- for (const candidate of obs.ranked.slice(1)) floors.push(nextUp(candidate.score));
526
- }
527
-
528
- const scoreBreakpoints = uniqueSorted(scores);
529
- const marginBreakpoints = uniqueSorted([0, ...margins]);
530
- const floorBreakpoints = uniqueSorted(floors);
531
-
532
- return { scoreBreakpoints, marginBreakpoints, floorBreakpoints };
533
- }
534
-
535
- // ---------------------------------------------------------------------------
536
- // Deterministic optimizer (AC3)
537
- // ---------------------------------------------------------------------------
538
-
539
- /**
540
- * Find the threshold triple that:
541
- * 1. Satisfies Wilson precision, sample-count, coverage, and delivered-recall gates
542
- * 2. Among those: maximizes auto_match_coverage
543
- * 3. Ties break on confidence, delivered recall, maximal safe shortlist trimming,
544
- * then deterministic lower match thresholds
545
- */
546
- interface CalibrationGates {
547
- minAutoMatchPrecision: number;
548
- minRetrievalRecallAtK: number;
549
- minDeliveredShortlistRecallAtK: number;
550
- minAutoMatchCount: number;
551
- }
552
-
553
- function metricsPass(metrics: CalibrationMetrics, gates: CalibrationGates): boolean {
554
- return (
555
- metrics.auto_match_precision_lower_bound >= gates.minAutoMatchPrecision &&
556
- metrics.auto_match_count >= gates.minAutoMatchCount &&
557
- metrics.auto_match_coverage > 0 &&
558
- metrics.delivered_shortlist_recall_at_k >= gates.minDeliveredShortlistRecallAtK
559
- );
560
- }
561
-
562
- function betterPolicy(
563
- candidate: { thresholds: SelectedThresholds; metrics: CalibrationMetrics },
564
- best: { thresholds: SelectedThresholds; metrics: CalibrationMetrics } | undefined,
565
- ): boolean {
566
- if (!best) return true;
567
- const a = candidate.metrics;
568
- const b = best.metrics;
569
- if (a.auto_match_coverage !== b.auto_match_coverage) {
570
- return a.auto_match_coverage > b.auto_match_coverage;
571
- }
572
- if (a.auto_match_precision_lower_bound !== b.auto_match_precision_lower_bound) {
573
- return a.auto_match_precision_lower_bound > b.auto_match_precision_lower_bound;
574
- }
575
- if (a.delivered_shortlist_recall_at_k !== b.delivered_shortlist_recall_at_k) {
576
- return a.delivered_shortlist_recall_at_k > b.delivered_shortlist_recall_at_k;
577
- }
578
- if (candidate.thresholds.candidate_floor !== best.thresholds.candidate_floor) {
579
- return candidate.thresholds.candidate_floor > best.thresholds.candidate_floor;
580
- }
581
- if (candidate.thresholds.match_score !== best.thresholds.match_score) {
582
- return candidate.thresholds.match_score < best.thresholds.match_score;
583
- }
584
- if (candidate.thresholds.match_margin !== best.thresholds.match_margin) {
585
- return candidate.thresholds.match_margin < best.thresholds.match_margin;
586
- }
587
- return false;
588
- }
589
-
590
- function sampledFloorIndexes(length: number, maxSamples = 32): number[] {
591
- if (length <= maxSamples) return Array.from({ length }, (_, index) => index);
592
- return uniqueSorted(
593
- Array.from({ length: maxSamples }, (_, index) =>
594
- Math.round(index * (length - 1) / (maxSamples - 1))),
595
- );
596
- }
597
-
598
- function selectThresholds(
599
- tuneObservations: QueryObservation[],
600
- gates: CalibrationGates,
601
- candidateLimit: number,
602
- ): { selected?: SelectedThresholds; reason?: CalibrationFailureReason } {
603
- const { scoreBreakpoints, marginBreakpoints, floorBreakpoints } =
604
- deriveThresholdCandidates(tuneObservations);
605
-
606
- let best:
607
- | { thresholds: SelectedThresholds; metrics: CalibrationMetrics }
608
- | undefined;
609
- let sawCoverage = false;
610
- let sawSample = false;
611
- let sawPrecision = false;
612
- const scoreIndexes = new Map(scoreBreakpoints.map((value, index) => [value, index]));
613
- const marginIndexes = new Map(marginBreakpoints.map((value, index) => [value, index]));
614
- const width = marginBreakpoints.length;
615
- const expectedMatchCount = tuneObservations.filter(
616
- (observation) => observation.expected_outcome === "matched",
617
- ).length;
618
- const matchable = tuneObservations.filter(
619
- (observation) => observation.expected_outcome !== "no_match",
620
- );
621
- const retrievalHits = matchable.filter((observation) => {
622
- const ids = observation.ranked.slice(0, candidateLimit).map((candidate) => candidate.skill_id);
623
- return observation.relevant_skill_ids.some((id) => ids.includes(id));
624
- }).length;
625
- const retrievalRecall = matchable.length === 0 ? 1 : retrievalHits / matchable.length;
626
-
627
- const evaluateFloor = (floorIndex: number) => {
628
- const floor = floorBreakpoints[floorIndex]!;
629
- const cells = scoreBreakpoints.length * width;
630
- const autoMatches = new Uint32Array(cells);
631
- const correctMatches = new Uint32Array(cells);
632
- const deliveredDelta = new Int32Array(cells);
633
- let ambiguousDeliveredHits = 0;
634
-
635
- for (const observation of tuneObservations) {
636
- const top = observation.ranked[0];
637
- if (!top || top.score < floor) continue;
638
- const second = observation.ranked[1];
639
- const margin = second ? top.score - second.score : top.score;
640
- const cell = scoreIndexes.get(top.score)! * width + marginIndexes.get(margin)!;
641
- autoMatches[cell] = autoMatches[cell]! + 1;
642
- const correct = (
643
- observation.expected_outcome === "matched" &&
644
- top.skill_id === observation.relevant_skill_ids[0]
645
- );
646
- if (correct) correctMatches[cell] = correctMatches[cell]! + 1;
647
-
648
- if (observation.expected_outcome !== "no_match") {
649
- const ambiguousIds = observation.ranked
650
- .filter((candidate) => candidate.score >= floor)
651
- .slice(0, candidateLimit)
652
- .map((candidate) => candidate.skill_id);
653
- const ambiguousHit = observation.relevant_skill_ids.some(
654
- (id) => ambiguousIds.includes(id),
655
- );
656
- const matchedHit = observation.relevant_skill_ids.includes(top.skill_id);
657
- if (ambiguousHit) ambiguousDeliveredHits++;
658
- deliveredDelta[cell] =
659
- deliveredDelta[cell]! + Number(matchedHit) - Number(ambiguousHit);
660
- }
661
- }
662
-
663
- // A suffix sum turns the score/margin sweep into O(1) metric lookups:
664
- // a case auto-matches at every threshold pair at or below its point.
665
- for (let scoreIndex = scoreBreakpoints.length - 1; scoreIndex >= 0; scoreIndex--) {
666
- for (let marginIndex = width - 1; marginIndex >= 0; marginIndex--) {
667
- const cell = scoreIndex * width + marginIndex;
668
- if (scoreIndex + 1 < scoreBreakpoints.length) {
669
- const below = (scoreIndex + 1) * width + marginIndex;
670
- autoMatches[cell] = autoMatches[cell]! + autoMatches[below]!;
671
- correctMatches[cell] = correctMatches[cell]! + correctMatches[below]!;
672
- deliveredDelta[cell] = deliveredDelta[cell]! + deliveredDelta[below]!;
673
- }
674
- if (marginIndex + 1 < width) {
675
- const right = cell + 1;
676
- autoMatches[cell] = autoMatches[cell]! + autoMatches[right]!;
677
- correctMatches[cell] = correctMatches[cell]! + correctMatches[right]!;
678
- deliveredDelta[cell] = deliveredDelta[cell]! + deliveredDelta[right]!;
679
- }
680
- if (scoreIndex + 1 < scoreBreakpoints.length && marginIndex + 1 < width) {
681
- const diagonal = (scoreIndex + 1) * width + marginIndex + 1;
682
- autoMatches[cell] = autoMatches[cell]! - autoMatches[diagonal]!;
683
- correctMatches[cell] = correctMatches[cell]! - correctMatches[diagonal]!;
684
- deliveredDelta[cell] = deliveredDelta[cell]! - deliveredDelta[diagonal]!;
685
- }
686
- }
687
- }
688
-
689
- for (let scoreIndex = 0; scoreIndex < scoreBreakpoints.length; scoreIndex++) {
690
- const score = scoreBreakpoints[scoreIndex]!;
691
- if (score < floor) continue;
692
- for (let marginIndex = 0; marginIndex < marginBreakpoints.length; marginIndex++) {
693
- const margin = marginBreakpoints[marginIndex]!;
694
- const cell = scoreIndex * width + marginIndex;
695
- const autoMatchCount = autoMatches[cell]!;
696
- const correctAutoMatchCount = correctMatches[cell]!;
697
- const deliveredHits = ambiguousDeliveredHits + deliveredDelta[cell]!;
698
- const thresholds = {
699
- match_score: score,
700
- match_margin: margin,
701
- candidate_floor: floor,
702
- };
703
- const metrics: CalibrationMetrics = {
704
- auto_match_precision:
705
- autoMatchCount === 0 ? 0 : correctAutoMatchCount / autoMatchCount,
706
- auto_match_precision_lower_bound:
707
- wilsonLowerBound(correctAutoMatchCount, autoMatchCount),
708
- auto_match_coverage:
709
- expectedMatchCount === 0 ? 0 : correctAutoMatchCount / expectedMatchCount,
710
- auto_match_count: autoMatchCount,
711
- correct_auto_match_count: correctAutoMatchCount,
712
- retrieval_recall_at_k: retrievalRecall,
713
- delivered_shortlist_recall_at_k:
714
- matchable.length === 0 ? 1 : deliveredHits / matchable.length,
715
- };
716
- sawCoverage ||= metrics.auto_match_coverage > 0;
717
- sawSample ||= metrics.auto_match_count >= gates.minAutoMatchCount;
718
- sawPrecision ||= (
719
- metrics.auto_match_precision_lower_bound >= gates.minAutoMatchPrecision
720
- );
721
- if (!metricsPass(metrics, gates)) continue;
722
- const candidate = { thresholds, metrics };
723
- if (betterPolicy(candidate, best)) best = candidate;
724
- }
725
- }
726
- };
727
-
728
- // Coarse-to-fine floor search. Floor candidates come from non-top scores, so
729
- // this tunes shortlist trimming instead of merely suppressing top matches.
730
- const coarse = sampledFloorIndexes(floorBreakpoints.length);
731
- for (const index of coarse) evaluateFloor(index);
732
- if (best && coarse.length < floorBreakpoints.length) {
733
- const bestIndex = floorBreakpoints.indexOf(best.thresholds.candidate_floor);
734
- const radius = Math.ceil(floorBreakpoints.length / coarse.length);
735
- for (
736
- let index = Math.max(0, bestIndex - radius);
737
- index <= Math.min(floorBreakpoints.length - 1, bestIndex + radius);
738
- index++
739
- ) {
740
- if (!coarse.includes(index)) evaluateFloor(index);
741
- }
742
- }
743
-
744
- if (best) return { selected: best.thresholds };
745
- if (!sawCoverage) return { reason: "no_coverage" };
746
- if (!sawSample) return { reason: "insufficient_sample" };
747
- if (!sawPrecision) return { reason: "precision_floor_unreachable" };
748
- return { reason: "precision_floor_unreachable" };
749
- }
750
-
751
- // ---------------------------------------------------------------------------
752
- // Public API — runCalibration (AC2, AC3, AC4)
753
- // ---------------------------------------------------------------------------
754
-
755
- async function processWithConcurrency<T>(
756
- items: readonly T[],
757
- concurrency: number,
758
- processItem: (item: T) => Promise<void>,
759
- ): Promise<void> {
760
- let nextItemPosition = 0;
761
- let firstError: unknown;
762
-
763
- const claimNextPosition = (): number | undefined => {
764
- if (firstError !== undefined || nextItemPosition >= items.length) return undefined;
765
- return nextItemPosition++;
766
- };
767
-
768
- const workerCount = Math.min(concurrency, items.length);
769
- const workers = Array.from({ length: workerCount }, async () => {
770
- for (
771
- let itemPosition = claimNextPosition();
772
- itemPosition !== undefined;
773
- itemPosition = claimNextPosition()
774
- ) {
775
- try {
776
- await processItem(items[itemPosition]!);
777
- } catch (error) {
778
- firstError ??= error;
779
- }
780
- }
781
- });
782
-
783
- // Drain work already in flight before propagating an error. Callers may close
784
- // resources after rejection, so no worker may still be checkpointing then.
785
- await Promise.all(workers);
786
- if (firstError !== undefined) throw firstError;
787
- }
788
-
789
- /**
790
- * Run an in-memory calibration:
791
- * 1. Require a configured reranker (AC2)
792
- * 2. Collect and cache per-query observations via hybrid retrieval + reranking (AC2)
793
- * 3. Search cached observations for optimal thresholds (AC3)
794
- * 4. Evaluate selected thresholds on untouched test split (AC4)
795
- */
796
- export async function runCalibration(opts: RunCalibrationOptions): Promise<CalibrationResult> {
797
- const {
798
- cases,
799
- getCandidates,
800
- getRankedCandidates,
801
- reranker,
802
- minAutoMatchPrecision = 0.99,
803
- minRetrievalRecallAtK = 0.95,
804
- minDeliveredShortlistRecallAtK = minRetrievalRecallAtK,
805
- minAutoMatchCount = 30,
806
- candidateLimit,
807
- concurrency = 4,
808
- initialObservations,
809
- onProgress,
810
- onObservation,
811
- } = opts;
812
-
813
- if (!Number.isInteger(concurrency) || concurrency < 1) {
814
- throw new Error("concurrency must be a positive integer");
815
- }
816
-
817
- if (!getRankedCandidates && (!getCandidates || !reranker)) {
818
- throw new Error(
819
- "A configured reranker is required to run calibration. " +
820
- "Configure inference.reranker in your TOML config.",
821
- );
822
- }
823
-
824
- // --- Step 1: Cache observations (reranker called exactly once per query) ---
825
- const obsMap = new Map<number, QueryObservation>();
826
- if (initialObservations) {
827
- if (Array.isArray(initialObservations)) {
828
- initialObservations.forEach((obs, idx) => {
829
- if (obs) obsMap.set(idx, obs);
830
- });
831
- } else {
832
- for (const [idx, obs] of initialObservations.entries()) {
833
- obsMap.set(idx, obs);
834
- }
835
- }
836
- }
837
-
838
- let completedCount = obsMap.size;
839
- if (onProgress && completedCount > 0) {
840
- onProgress(completedCount, cases.length);
841
- }
842
-
843
- const pendingIndices: number[] = [];
844
- for (let i = 0; i < cases.length; i++) {
845
- if (!obsMap.has(i)) pendingIndices.push(i);
846
- }
847
-
848
- await processWithConcurrency(pendingIndices, concurrency, async (caseIndex) => {
849
- const c = cases[caseIndex]!;
850
- let ranked: Array<{ skill_id: string; score: number }>;
851
- if (getRankedCandidates) {
852
- ranked = await getRankedCandidates(c.query);
853
- } else {
854
- const docs = await getCandidates!(c.query);
855
- const scores = await reranker!(c.query, docs);
856
- ranked = docs
857
- .map((d, i) => ({ skill_id: d.skill_id, score: scores[i] ?? 0 }))
858
- .sort((a, b) => b.score - a.score);
859
- }
860
- const obs: QueryObservation = {
861
- query: c.query,
862
- split: c.split,
863
- expected_outcome: c.expected_outcome,
864
- relevant_skill_ids: c.relevant_skill_ids,
865
- ranked,
866
- };
867
- obsMap.set(caseIndex, obs);
868
- if (onObservation) {
869
- await onObservation(obs, caseIndex, completedCount + 1, cases.length);
870
- }
871
- const progressCount = ++completedCount;
872
- if (onProgress) {
873
- onProgress(progressCount, cases.length);
874
- }
875
- });
876
-
877
- const observations: QueryObservation[] = cases.map((_, i) => obsMap.get(i)!);
878
- opts.onObservationsReady?.();
879
-
880
- // --- Step 2: Select thresholds from tune split only ---
881
- const tuneObs = observations.filter((o) => o.split === "tune");
882
- const testObs = observations.filter((o) => o.split === "test");
883
- const retrievalThresholds = {
884
- match_score: 0,
885
- match_margin: 0,
886
- candidate_floor: 0,
887
- };
888
- const tuneRetrieval = computeMetrics(tuneObs, retrievalThresholds, candidateLimit);
889
- const testRetrieval = computeMetrics(testObs, retrievalThresholds, candidateLimit);
890
- if (
891
- tuneRetrieval.retrieval_recall_at_k < minRetrievalRecallAtK ||
892
- testRetrieval.retrieval_recall_at_k < minRetrievalRecallAtK
893
- ) {
894
- return {
895
- status: "failed_gates",
896
- failed_reason: "recall_precondition_failed",
897
- observations,
898
- };
899
- }
900
-
901
- const gates = {
902
- minAutoMatchPrecision,
903
- minRetrievalRecallAtK,
904
- minDeliveredShortlistRecallAtK,
905
- minAutoMatchCount,
906
- };
907
- const selection = selectThresholds(
908
- tuneObs,
909
- gates,
910
- candidateLimit,
911
- );
912
-
913
- if (!selection.selected) {
914
- return {
915
- status: "failed_gates",
916
- failed_reason: selection.reason,
917
- observations,
918
- };
919
- }
920
- const selected = selection.selected;
921
-
922
- // --- Step 3: Report tune metrics ---
923
- const tune_metrics = computeMetrics(tuneObs, selected, candidateLimit);
924
-
925
- // --- Step 4: Evaluate untouched test split ---
926
- const test_metrics = computeTestMetrics(testObs, selected, candidateLimit);
927
- if (!metricsPass(test_metrics, gates)) {
928
- return {
929
- status: "failed_gates",
930
- failed_reason: "test_certification_failed",
931
- observations,
932
- selected_thresholds: selected,
933
- tune_metrics,
934
- test_metrics,
935
- };
936
- }
937
-
938
- return { status: "completed", observations, selected_thresholds: selected, tune_metrics, test_metrics };
939
- }
940
-
941
- // ---------------------------------------------------------------------------
942
- // SQLite evidence store (AC5, AC6)
943
- // ---------------------------------------------------------------------------
944
-
945
- /**
946
- * All fields stored for a single calibration run.
947
- * SQLite is evidence and history only — never read on the resolve_skill path.
948
- */
949
- export interface CalibrationRunRecord {
950
- run_id: string;
951
- created_at: string;
952
- status: CalibrationStatus;
953
- reranker_fingerprint: string;
954
- embedding_fingerprint: string;
955
- corpus_fingerprint: string;
956
- dataset_hash: string;
957
- recall_settings?: {
958
- k_lexical: number;
959
- k_vector: number;
960
- k_rerank: number;
961
- };
962
- dataset_provenance?: DatasetProvenanceSummary;
963
- candidate_limit: number;
964
- attempt_count?: number;
965
- min_auto_match_precision: number;
966
- min_auto_match_count?: number;
967
- min_delivered_shortlist_recall_at_k?: number;
968
- min_shortlist_recall_at_5: number;
969
- failed_reason?: CalibrationFailureReason;
970
- selected_thresholds?: SelectedThresholds;
971
- tune_metrics?: CalibrationMetrics;
972
- test_metrics?: CalibrationTestMetrics;
973
- observations: QueryObservation[];
974
- }
975
-
976
- /** Summary row returned by listCalibrationRuns (no observations blob). */
977
- export interface CalibrationRunSummary {
978
- run_id: string;
979
- created_at: string;
980
- status: CalibrationStatus;
981
- reranker_fingerprint: string;
982
- embedding_fingerprint: string;
983
- corpus_fingerprint: string;
984
- dataset_hash: string;
985
- human_labelled_case_count: number;
986
- imported_labelled_case_count: number;
987
- candidate_limit: number;
988
- attempt_count: number;
989
- min_auto_match_precision: number;
990
- min_auto_match_count: number;
991
- min_delivered_shortlist_recall_at_k: number;
992
- min_shortlist_recall_at_5: number;
993
- failed_reason?: CalibrationFailureReason;
994
- }
995
-
996
- /**
997
- * Fingerprint the indexed vault content so calibration runs can detect
998
- * corpus drift. Must match how `insertCalibrationRun` computes it at
999
- * `calibrate run` time.
1000
- */
1001
- export function computeCorpusFingerprint(indexDb: Database): string {
1002
- const indexedSkills = indexDb
1003
- .query("SELECT skill_id, content_sha256 FROM skills ORDER BY skill_id")
1004
- .all() as Array<{ skill_id: string; content_sha256: string }>;
1005
- return "vault:" + createHash("sha256").update(JSON.stringify(indexedSkills)).digest("hex");
1006
- }
1007
-
1008
- /**
1009
- * Open (or create) the calibration evidence database in `stateDir`.
1010
- * Uses a separate `calibrate.sqlite3` file — never the index.sqlite3 used
1011
- * on the resolve_skill request path.
1012
- */
1013
- export function openCalibrateDb(stateDir: string): Database {
1014
- mkdirSync(stateDir, { recursive: true });
1015
- const db = new Database(join(stateDir, "calibrate.sqlite3"), { create: true });
1016
- db.run("PRAGMA journal_mode = WAL");
1017
- db.run("PRAGMA busy_timeout = 2000");
1018
-
1019
- const tableDef = db
1020
- .query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'calibration_runs'")
1021
- .get() as { sql: string } | null;
1022
-
1023
- if (
1024
- tableDef &&
1025
- tableDef.sql &&
1026
- tableDef.sql.includes("CHECK") &&
1027
- !tableDef.sql.includes("'running'")
1028
- ) {
1029
- const cols = db.query("PRAGMA table_info(calibration_runs)").all() as Array<{ name: string }>;
1030
- const colNames = cols.map((c) => c.name);
1031
- const selectCols = colNames.join(", ");
1032
-
1033
- db.transaction(() => {
1034
- db.run(`CREATE TABLE calibration_runs_new (
1035
- run_id TEXT PRIMARY KEY,
1036
- created_at TEXT NOT NULL,
1037
- status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed_gates')),
1038
- reranker_fingerprint TEXT NOT NULL,
1039
- embedding_fingerprint TEXT NOT NULL,
1040
- corpus_fingerprint TEXT NOT NULL,
1041
- dataset_hash TEXT NOT NULL,
1042
- min_auto_match_precision REAL NOT NULL,
1043
- min_shortlist_recall_at_5 REAL NOT NULL,
1044
- selected_thresholds TEXT,
1045
- tune_metrics TEXT,
1046
- test_metrics TEXT,
1047
- observations TEXT NOT NULL,
1048
- candidate_limit INTEGER NOT NULL DEFAULT 5,
1049
- attempt_count INTEGER NOT NULL DEFAULT 1,
1050
- min_auto_match_count INTEGER NOT NULL DEFAULT 1,
1051
- min_delivered_shortlist_recall_at_k REAL NOT NULL DEFAULT 0.95,
1052
- failed_reason TEXT,
1053
- dataset_provenance TEXT NOT NULL DEFAULT '{}',
1054
- human_labelled_case_count INTEGER NOT NULL DEFAULT 0,
1055
- imported_labelled_case_count INTEGER NOT NULL DEFAULT 0,
1056
- recall_settings TEXT NOT NULL DEFAULT '{}'
1057
- )`);
1058
- db.run(
1059
- `INSERT INTO calibration_runs_new (${selectCols}) SELECT ${selectCols} FROM calibration_runs`,
1060
- );
1061
- db.run("DROP TABLE calibration_runs");
1062
- db.run("ALTER TABLE calibration_runs_new RENAME TO calibration_runs");
1063
- })();
1064
- }
1065
-
1066
- db.run(`CREATE TABLE IF NOT EXISTS calibration_runs (
1067
- run_id TEXT PRIMARY KEY,
1068
- created_at TEXT NOT NULL,
1069
- status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed_gates')),
1070
- reranker_fingerprint TEXT NOT NULL,
1071
- embedding_fingerprint TEXT NOT NULL,
1072
- corpus_fingerprint TEXT NOT NULL,
1073
- dataset_hash TEXT NOT NULL,
1074
- min_auto_match_precision REAL NOT NULL,
1075
- min_shortlist_recall_at_5 REAL NOT NULL,
1076
- selected_thresholds TEXT,
1077
- tune_metrics TEXT,
1078
- test_metrics TEXT,
1079
- observations TEXT NOT NULL
1080
- )`);
1081
- const columns = db.query("PRAGMA table_info(calibration_runs)").all() as Array<{ name: string }>;
1082
- if (!columns.some((column) => column.name === "candidate_limit")) {
1083
- db.run("ALTER TABLE calibration_runs ADD COLUMN candidate_limit INTEGER NOT NULL DEFAULT 5");
1084
- }
1085
- if (!columns.some((column) => column.name === "attempt_count")) {
1086
- db.run("ALTER TABLE calibration_runs ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 1");
1087
- }
1088
- if (!columns.some((column) => column.name === "min_auto_match_count")) {
1089
- db.run("ALTER TABLE calibration_runs ADD COLUMN min_auto_match_count INTEGER NOT NULL DEFAULT 1");
1090
- }
1091
- if (!columns.some((column) => column.name === "min_delivered_shortlist_recall_at_k")) {
1092
- db.run("ALTER TABLE calibration_runs ADD COLUMN min_delivered_shortlist_recall_at_k REAL NOT NULL DEFAULT 0.95");
1093
- }
1094
- if (!columns.some((column) => column.name === "failed_reason")) {
1095
- db.run("ALTER TABLE calibration_runs ADD COLUMN failed_reason TEXT");
1096
- }
1097
- if (!columns.some((column) => column.name === "dataset_provenance")) {
1098
- db.run("ALTER TABLE calibration_runs ADD COLUMN dataset_provenance TEXT NOT NULL DEFAULT '{}'");
1099
- }
1100
- if (!columns.some((column) => column.name === "human_labelled_case_count")) {
1101
- db.run("ALTER TABLE calibration_runs ADD COLUMN human_labelled_case_count INTEGER NOT NULL DEFAULT 0");
1102
- }
1103
- if (!columns.some((column) => column.name === "imported_labelled_case_count")) {
1104
- db.run("ALTER TABLE calibration_runs ADD COLUMN imported_labelled_case_count INTEGER NOT NULL DEFAULT 0");
1105
- }
1106
- if (!columns.some((column) => column.name === "recall_settings")) {
1107
- db.run("ALTER TABLE calibration_runs ADD COLUMN recall_settings TEXT NOT NULL DEFAULT '{}'");
1108
- }
1109
-
1110
- db.run(`CREATE TABLE IF NOT EXISTS calibration_observations (
1111
- run_id TEXT NOT NULL,
1112
- case_index INTEGER NOT NULL,
1113
- query TEXT NOT NULL,
1114
- split TEXT NOT NULL,
1115
- expected_outcome TEXT NOT NULL,
1116
- relevant_skill_ids TEXT NOT NULL,
1117
- ranked TEXT NOT NULL,
1118
- PRIMARY KEY (run_id, case_index),
1119
- FOREIGN KEY (run_id) REFERENCES calibration_runs(run_id) ON DELETE CASCADE
1120
- )`);
1121
-
1122
- return db;
1123
- }
1124
-
1125
- export interface CreateInitialCalibrationRunOptions {
1126
- run_id: string;
1127
- created_at: string;
1128
- status: "running";
1129
- reranker_fingerprint: string;
1130
- embedding_fingerprint: string;
1131
- corpus_fingerprint: string;
1132
- dataset_hash: string;
1133
- candidate_limit: number;
1134
- min_auto_match_precision: number;
1135
- min_auto_match_count?: number;
1136
- min_delivered_shortlist_recall_at_k?: number;
1137
- min_shortlist_recall_at_5: number;
1138
- dataset_provenance?: DatasetProvenanceSummary;
1139
- recall_settings?: {
1140
- k_lexical: number;
1141
- k_vector: number;
1142
- k_rerank: number;
1143
- };
1144
- }
1145
-
1146
- /** Create an initial calibration run record with 'running' status before inference starts. */
1147
- export function createInitialCalibrationRun(
1148
- db: Database,
1149
- run: CreateInitialCalibrationRunOptions,
1150
- ): void {
1151
- const attemptCount = (
1152
- db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
1153
- .get(run.dataset_hash) as { count: number }
1154
- ).count + 1;
1155
- db.run(
1156
- `INSERT INTO calibration_runs (
1157
- run_id, created_at, status,
1158
- reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
1159
- candidate_limit,
1160
- attempt_count, min_auto_match_precision, min_auto_match_count,
1161
- min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1162
- selected_thresholds, tune_metrics, test_metrics, observations,
1163
- dataset_provenance, human_labelled_case_count, imported_labelled_case_count,
1164
- recall_settings
1165
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1166
- [
1167
- run.run_id,
1168
- run.created_at,
1169
- run.status,
1170
- run.reranker_fingerprint,
1171
- run.embedding_fingerprint,
1172
- run.corpus_fingerprint,
1173
- run.dataset_hash,
1174
- run.candidate_limit,
1175
- attemptCount,
1176
- run.min_auto_match_precision,
1177
- run.min_auto_match_count ?? 1,
1178
- run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
1179
- run.min_shortlist_recall_at_5,
1180
- null,
1181
- null,
1182
- null,
1183
- null,
1184
- "[]",
1185
- JSON.stringify(run.dataset_provenance ?? {}),
1186
- run.dataset_provenance?.human_labelled_case_count ?? 0,
1187
- run.dataset_provenance?.imported_labelled_case_count ?? 0,
1188
- JSON.stringify(run.recall_settings ?? {}),
1189
- ],
1190
- );
1191
- }
1192
-
1193
- /** Save a single case observation incrementally. */
1194
- export function saveCalibrationObservation(
1195
- db: Database,
1196
- runId: string,
1197
- caseIndex: number,
1198
- observation: QueryObservation,
1199
- ): void {
1200
- db.run(
1201
- `INSERT OR REPLACE INTO calibration_observations (
1202
- run_id, case_index, query, split, expected_outcome, relevant_skill_ids, ranked
1203
- ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
1204
- [
1205
- runId,
1206
- caseIndex,
1207
- observation.query,
1208
- observation.split,
1209
- observation.expected_outcome,
1210
- JSON.stringify(observation.relevant_skill_ids),
1211
- JSON.stringify(observation.ranked),
1212
- ],
1213
- );
1214
- }
1215
-
1216
- /** Retrieve all completed observations for a run_id, keyed by case index. */
1217
- export function getCalibrationObservations(
1218
- db: Database,
1219
- runId: string,
1220
- ): Map<number, QueryObservation> {
1221
- const rows = db
1222
- .query(
1223
- `SELECT case_index, query, split, expected_outcome, relevant_skill_ids, ranked
1224
- FROM calibration_observations WHERE run_id = ? ORDER BY case_index ASC`,
1225
- )
1226
- .all(runId) as Array<{
1227
- case_index: number;
1228
- query: string;
1229
- split: DecisionSplit;
1230
- expected_outcome: DecisionOutcome;
1231
- relevant_skill_ids: string;
1232
- ranked: string;
1233
- }>;
1234
- const map = new Map<number, QueryObservation>();
1235
- for (const r of rows) {
1236
- map.set(r.case_index, {
1237
- query: r.query,
1238
- split: r.split,
1239
- expected_outcome: r.expected_outcome,
1240
- relevant_skill_ids: JSON.parse(r.relevant_skill_ids) as string[],
1241
- ranked: JSON.parse(r.ranked) as Array<{ skill_id: string; score: number }>,
1242
- });
1243
- }
1244
- return map;
1245
- }
1246
-
1247
- export interface FinalizeCalibrationRunOptions {
1248
- run_id: string;
1249
- status: CalibrationStatus;
1250
- failed_reason?: CalibrationFailureReason;
1251
- selected_thresholds?: SelectedThresholds;
1252
- tune_metrics?: CalibrationMetrics;
1253
- test_metrics?: CalibrationTestMetrics;
1254
- observations: QueryObservation[];
1255
- }
1256
-
1257
- /** Finalize a calibration run record with its completion/failure status, metrics, and thresholds. */
1258
- export function finalizeCalibrationRun(
1259
- db: Database,
1260
- run: FinalizeCalibrationRunOptions,
1261
- ): void {
1262
- db.run(
1263
- `UPDATE calibration_runs SET
1264
- status = ?,
1265
- failed_reason = ?,
1266
- selected_thresholds = ?,
1267
- tune_metrics = ?,
1268
- test_metrics = ?,
1269
- observations = ?
1270
- WHERE run_id = ?`,
1271
- [
1272
- run.status,
1273
- run.failed_reason ?? null,
1274
- run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
1275
- run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
1276
- run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
1277
- JSON.stringify(run.observations),
1278
- run.run_id,
1279
- ],
1280
- );
1281
- }
1282
-
1283
- /** Persist a calibration run (all fields) to the evidence store. */
1284
- export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
1285
- const attemptCount = (
1286
- db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
1287
- .get(run.dataset_hash) as { count: number }
1288
- ).count + 1;
1289
- db.transaction(() => {
1290
- db.run(
1291
- `INSERT OR REPLACE INTO calibration_runs (
1292
- run_id, created_at, status,
1293
- reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
1294
- candidate_limit,
1295
- attempt_count, min_auto_match_precision, min_auto_match_count,
1296
- min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1297
- selected_thresholds, tune_metrics, test_metrics, observations,
1298
- dataset_provenance, human_labelled_case_count, imported_labelled_case_count,
1299
- recall_settings
1300
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1301
- [
1302
- run.run_id,
1303
- run.created_at,
1304
- run.status,
1305
- run.reranker_fingerprint,
1306
- run.embedding_fingerprint,
1307
- run.corpus_fingerprint,
1308
- run.dataset_hash,
1309
- run.candidate_limit,
1310
- attemptCount,
1311
- run.min_auto_match_precision,
1312
- run.min_auto_match_count ?? 1,
1313
- run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
1314
- run.min_shortlist_recall_at_5,
1315
- run.failed_reason ?? null,
1316
- run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
1317
- run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
1318
- run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
1319
- JSON.stringify(run.observations),
1320
- JSON.stringify(run.dataset_provenance ?? {}),
1321
- run.dataset_provenance?.human_labelled_case_count ?? 0,
1322
- run.dataset_provenance?.imported_labelled_case_count ?? 0,
1323
- JSON.stringify(run.recall_settings ?? {}),
1324
- ],
1325
- );
1326
- if (run.observations && run.observations.length > 0) {
1327
- for (let i = 0; i < run.observations.length; i++) {
1328
- const obs = run.observations[i]!;
1329
- saveCalibrationObservation(db, run.run_id, i, obs);
1330
- }
1331
- }
1332
- })();
1333
- }
1334
-
1335
- interface RawCalibrationRow {
1336
- run_id: string;
1337
- created_at: string;
1338
- status: string;
1339
- reranker_fingerprint: string;
1340
- embedding_fingerprint: string;
1341
- corpus_fingerprint: string;
1342
- dataset_hash: string;
1343
- candidate_limit: number;
1344
- attempt_count: number;
1345
- min_auto_match_precision: number;
1346
- min_auto_match_count: number;
1347
- min_delivered_shortlist_recall_at_k: number;
1348
- min_shortlist_recall_at_5: number;
1349
- failed_reason: string | null;
1350
- selected_thresholds: string | null;
1351
- tune_metrics: string | null;
1352
- test_metrics: string | null;
1353
- observations: string;
1354
- dataset_provenance: string;
1355
- human_labelled_case_count: number;
1356
- imported_labelled_case_count: number;
1357
- recall_settings: string;
1358
- }
1359
-
1360
- function parseMetrics(json: string): CalibrationMetrics {
1361
- const parsed = JSON.parse(json) as Partial<CalibrationMetrics> & {
1362
- shortlist_recall_at_5?: number;
1363
- false_no_match_rate?: number;
1364
- };
1365
- if (parsed.retrieval_recall_at_k === undefined) {
1366
- parsed.retrieval_recall_at_k = parsed.shortlist_recall_at_5 ?? 0;
1367
- }
1368
- delete parsed.shortlist_recall_at_5;
1369
- delete parsed.false_no_match_rate;
1370
- return {
1371
- auto_match_precision: parsed.auto_match_precision ?? 0,
1372
- auto_match_precision_lower_bound:
1373
- parsed.auto_match_precision_lower_bound ?? parsed.auto_match_precision ?? 0,
1374
- auto_match_coverage: parsed.auto_match_coverage ?? 0,
1375
- auto_match_count: parsed.auto_match_count ?? 0,
1376
- correct_auto_match_count: parsed.correct_auto_match_count ?? 0,
1377
- retrieval_recall_at_k: parsed.retrieval_recall_at_k,
1378
- delivered_shortlist_recall_at_k:
1379
- parsed.delivered_shortlist_recall_at_k ?? parsed.retrieval_recall_at_k,
1380
- };
1381
- }
1382
-
1383
- function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
1384
- return {
1385
- run_id: row.run_id,
1386
- created_at: row.created_at,
1387
- status: row.status as CalibrationStatus,
1388
- reranker_fingerprint: row.reranker_fingerprint,
1389
- embedding_fingerprint: row.embedding_fingerprint,
1390
- corpus_fingerprint: row.corpus_fingerprint,
1391
- dataset_hash: row.dataset_hash,
1392
- recall_settings:
1393
- Object.keys(JSON.parse(row.recall_settings) as object).length > 0
1394
- ? JSON.parse(row.recall_settings) as CalibrationRunRecord["recall_settings"]
1395
- : undefined,
1396
- dataset_provenance:
1397
- Object.keys(JSON.parse(row.dataset_provenance) as object).length > 0
1398
- ? JSON.parse(row.dataset_provenance) as DatasetProvenanceSummary
1399
- : undefined,
1400
- candidate_limit: row.candidate_limit,
1401
- attempt_count: row.attempt_count,
1402
- min_auto_match_precision: row.min_auto_match_precision,
1403
- min_auto_match_count: row.min_auto_match_count,
1404
- min_delivered_shortlist_recall_at_k: row.min_delivered_shortlist_recall_at_k,
1405
- min_shortlist_recall_at_5: row.min_shortlist_recall_at_5,
1406
- failed_reason: row.failed_reason as CalibrationFailureReason | null ?? undefined,
1407
- selected_thresholds: row.selected_thresholds != null
1408
- ? (JSON.parse(row.selected_thresholds) as SelectedThresholds)
1409
- : undefined,
1410
- tune_metrics: row.tune_metrics != null
1411
- ? parseMetrics(row.tune_metrics)
1412
- : undefined,
1413
- test_metrics: row.test_metrics != null
1414
- ? {
1415
- ...parseMetrics(row.test_metrics),
1416
- confusion_matrix: (JSON.parse(row.test_metrics) as CalibrationTestMetrics).confusion_matrix,
1417
- }
1418
- : undefined,
1419
- observations: JSON.parse(row.observations) as QueryObservation[],
1420
- };
1421
- }
1422
-
1423
- /** Retrieve a full run record by run_id. Returns null if not found. */
1424
- export function getCalibrationRun(db: Database, runId: string): CalibrationRunRecord | null {
1425
- const row = db
1426
- .query("SELECT * FROM calibration_runs WHERE run_id = ?")
1427
- .get(runId) as RawCalibrationRow | null;
1428
- if (!row) return null;
1429
- const record = rowToRecord(row);
1430
- if (record.observations.length === 0) {
1431
- const obsMap = getCalibrationObservations(db, runId);
1432
- if (obsMap.size > 0) {
1433
- const sortedIndices = Array.from(obsMap.keys()).sort((a, b) => a - b);
1434
- record.observations = sortedIndices.map((idx) => obsMap.get(idx)!);
1435
- }
1436
- }
1437
- return record;
1438
- }
1439
-
1440
- /** List all runs ordered by created_at descending (excludes observations blob). */
1441
- export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
1442
- return db
1443
- .query(
1444
- `SELECT run_id, created_at, status,
1445
- reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
1446
- candidate_limit,
1447
- attempt_count, min_auto_match_precision, min_auto_match_count,
1448
- min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1449
- human_labelled_case_count, imported_labelled_case_count
1450
- FROM calibration_runs ORDER BY created_at DESC`,
1451
- )
1452
- .all() as CalibrationRunSummary[];
1453
- }
1454
-
1455
- // ---------------------------------------------------------------------------
1456
- // calibrate apply — gated TOML write (AC6, AC7)
1457
- // ---------------------------------------------------------------------------
1458
-
1459
- /** Structured error for apply rejections — always includes a reason string. */
1460
- export class ApplyCalibrationError extends Error {
1461
- constructor(
1462
- message: string,
1463
- public readonly reason: string,
1464
- ) {
1465
- super(message);
1466
- this.name = "ApplyCalibrationError";
1467
- }
1468
- }
1469
-
1470
- export interface ApplyCalibrationOptions {
1471
- /** If provided, checked against run.reranker_fingerprint; mismatch rejects. */
1472
- currentRerankerFingerprint?: string;
1473
- /** If provided, checked against run.embedding_fingerprint; mismatch rejects. */
1474
- currentEmbeddingFingerprint?: string;
1475
- /** If provided, checked against run.corpus_fingerprint; mismatch rejects. */
1476
- currentCorpusFingerprint?: string;
1477
- /**
1478
- * Keys currently masked by environment variable overrides.
1479
- * If any of the three threshold keys appear here, the apply is rejected
1480
- * (the write would be invisible at runtime).
1481
- */
1482
- maskedEnvKeys?: string[];
1483
- }
1484
-
1485
- const THRESHOLD_KEYS = [
1486
- "inference.thresholds.match_score",
1487
- "inference.thresholds.match_margin",
1488
- "inference.thresholds.candidate_floor",
1489
- ] as const;
1490
-
1491
- /**
1492
- * Atomically write calibration thresholds and provenance to the TOML config.
1493
- *
1494
- * Rejects (ApplyCalibrationError) when:
1495
- * - run_id not found in the evidence store
1496
- * - run status is failed_gates
1497
- * - any fingerprint check fails (reranker, embedding, corpus)
1498
- * - any threshold key is masked by an environment variable override
1499
- *
1500
- * On success: reads the existing TOML, patches [inference.thresholds] and
1501
- * [inference.calibration] sections, and atomically writes via rename.
1502
- */
1503
- export async function applyCalibrationRun(
1504
- db: Database,
1505
- runId: string,
1506
- tomlPath: string,
1507
- opts: ApplyCalibrationOptions,
1508
- ): Promise<void> {
1509
- // --- Gate 1: run must exist ---
1510
- const run = getCalibrationRun(db, runId);
1511
- if (!run) {
1512
- throw new ApplyCalibrationError(
1513
- `Calibration run "${runId}" not found`,
1514
- "run_not_found",
1515
- );
1516
- }
1517
-
1518
- // --- Gate 2: run must have completed successfully ---
1519
- if (run.status !== "completed" || !run.selected_thresholds) {
1520
- throw new ApplyCalibrationError(
1521
- `Calibration run "${runId}" has status "${run.status}" and cannot be applied`,
1522
- "failed_gates",
1523
- );
1524
- }
1525
- if (
1526
- !run.test_metrics ||
1527
- !metricsPass(run.test_metrics, {
1528
- minAutoMatchPrecision: run.min_auto_match_precision,
1529
- minRetrievalRecallAtK: run.min_shortlist_recall_at_5,
1530
- minDeliveredShortlistRecallAtK:
1531
- run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
1532
- minAutoMatchCount: run.min_auto_match_count ?? 1,
1533
- })
1534
- ) {
1535
- throw new ApplyCalibrationError(
1536
- `Calibration run "${runId}" is not certified on its frozen test split`,
1537
- "test_certification_failed",
1538
- );
1539
- }
1540
-
1541
- // --- Gate 3: fingerprint staleness ---
1542
- if (
1543
- "currentRerankerFingerprint" in opts &&
1544
- opts.currentRerankerFingerprint !== run.reranker_fingerprint
1545
- ) {
1546
- throw new ApplyCalibrationError(
1547
- `Reranker fingerprint mismatch: run was calibrated with "${run.reranker_fingerprint}" ` +
1548
- `but current config has "${opts.currentRerankerFingerprint}"`,
1549
- "stale_reranker_fingerprint",
1550
- );
1551
- }
1552
- if (
1553
- opts.currentEmbeddingFingerprint !== undefined &&
1554
- opts.currentEmbeddingFingerprint !== run.embedding_fingerprint
1555
- ) {
1556
- throw new ApplyCalibrationError(
1557
- `Embedding fingerprint mismatch: run was calibrated with "${run.embedding_fingerprint}" ` +
1558
- `but current config has "${opts.currentEmbeddingFingerprint}"`,
1559
- "stale_embedding_fingerprint",
1560
- );
1561
- }
1562
- if (
1563
- opts.currentCorpusFingerprint !== undefined &&
1564
- opts.currentCorpusFingerprint !== run.corpus_fingerprint
1565
- ) {
1566
- throw new ApplyCalibrationError(
1567
- `Corpus fingerprint mismatch: run was calibrated against "${run.corpus_fingerprint}" ` +
1568
- `but current vault has "${opts.currentCorpusFingerprint}"`,
1569
- "stale_corpus_fingerprint",
1570
- );
1571
- }
1572
-
1573
- // --- Gate 4: env-masked key check (AC7) ---
1574
- const masked = opts.maskedEnvKeys ?? [];
1575
- const maskedThresholdKeys = THRESHOLD_KEYS.filter((k) => masked.includes(k));
1576
- if (maskedThresholdKeys.length > 0) {
1577
- throw new ApplyCalibrationError(
1578
- `Cannot apply: the following threshold keys are masked by environment variable overrides ` +
1579
- `and the TOML write would be invisible at runtime: ${maskedThresholdKeys.join(", ")}`,
1580
- "env_masked_keys",
1581
- );
1582
- }
1583
-
1584
- // --- Atomic TOML write ---
1585
- const { match_score, match_margin, candidate_floor } = run.selected_thresholds;
1586
-
1587
- const { patchTomlFile } = await import("./config-mutation");
1588
- await patchTomlFile(tomlPath, {
1589
- matchScore: match_score,
1590
- matchMargin: match_margin,
1591
- candidateFloor: candidate_floor,
1592
- runId,
1593
- });
1594
- }