@klhapp/skillmux 1.0.1 → 1.2.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 CHANGED
@@ -2,6 +2,8 @@ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import { z } from "zod";
5
+ import { decideResolveOutcome } from "./decision";
6
+ import type { AuditRow, RankedCandidate } from "./types";
5
7
 
6
8
  export { generateDataset, type GenerateDatasetOptions } from "./dataset-generator";
7
9
 
@@ -13,11 +15,21 @@ export { generateDataset, type GenerateDatasetOptions } from "./dataset-generato
13
15
  export type DecisionSplit = "tune" | "test";
14
16
  export type DecisionOutcome = "matched" | "ambiguous" | "no_match";
15
17
 
18
+ export interface DecisionCaseProvenance {
19
+ version: 1;
20
+ source: "authored" | "audit_import";
21
+ review_status: "human_labelled" | "unreviewed";
22
+ query_storage: "raw" | "redacted";
23
+ audit_id?: number;
24
+ labelled_at?: string;
25
+ }
26
+
16
27
  export interface DecisionCase {
17
28
  query: string;
18
29
  split: DecisionSplit;
19
30
  expected_outcome: DecisionOutcome;
20
31
  relevant_skill_ids: string[];
32
+ provenance?: DecisionCaseProvenance;
21
33
  }
22
34
 
23
35
  // ---------------------------------------------------------------------------
@@ -29,6 +41,14 @@ const rawCaseSchema = z.object({
29
41
  split: z.enum(["tune", "test"]),
30
42
  expected_outcome: z.enum(["matched", "ambiguous", "no_match"]),
31
43
  relevant_skill_ids: z.array(z.string()),
44
+ provenance: z.object({
45
+ version: z.literal(1),
46
+ source: z.enum(["authored", "audit_import"]),
47
+ review_status: z.enum(["human_labelled", "unreviewed"]),
48
+ query_storage: z.enum(["raw", "redacted"]),
49
+ audit_id: z.number().int().positive().optional(),
50
+ labelled_at: z.string().datetime().optional(),
51
+ }).strict().optional(),
32
52
  }).strict();
33
53
 
34
54
  type RawCase = z.infer<typeof rawCaseSchema>;
@@ -43,6 +63,25 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
43
63
  }
44
64
 
45
65
  const { expected_outcome, relevant_skill_ids } = raw;
66
+ const provenance: DecisionCaseProvenance = raw.provenance ?? {
67
+ version: 1,
68
+ source: "authored",
69
+ review_status: "human_labelled",
70
+ query_storage: "raw",
71
+ };
72
+
73
+ if (provenance.source === "audit_import") {
74
+ if (provenance.audit_id === undefined) {
75
+ throw new Error(
76
+ `Validation error at case ${idx}: imported field "provenance.audit_id" is required`,
77
+ );
78
+ }
79
+ if (provenance.review_status !== "human_labelled" || !provenance.labelled_at) {
80
+ throw new Error(
81
+ `Validation error at case ${idx}: imported audit case is unreviewed; human label and "provenance.labelled_at" are required for certification`,
82
+ );
83
+ }
84
+ }
46
85
 
47
86
  if (expected_outcome === "matched") {
48
87
  if (relevant_skill_ids.length !== 1) {
@@ -65,7 +104,85 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
65
104
  }
66
105
  }
67
106
 
68
- return raw as DecisionCase;
107
+ return { ...raw, provenance };
108
+ }
109
+
110
+ export interface AuditFeedbackLabel {
111
+ split: DecisionSplit;
112
+ expected_outcome: DecisionOutcome;
113
+ relevant_skill_ids: string[];
114
+ labelled_at: string;
115
+ }
116
+
117
+ export type AuditQueryPrivacy =
118
+ | { include_raw_query: true }
119
+ | { include_raw_query: false; redacted_query: string };
120
+
121
+ /** Import an audit outcome only after a separate human label is supplied. */
122
+ export function importLabelledAuditCase(
123
+ audit: AuditRow,
124
+ label: AuditFeedbackLabel,
125
+ privacy: AuditQueryPrivacy,
126
+ ): DecisionCase {
127
+ const query = privacy.include_raw_query ? audit.query : privacy.redacted_query.trim();
128
+ if (!query) {
129
+ throw new Error("A non-empty redacted_query is required when raw audit queries are excluded");
130
+ }
131
+ const parsed = rawCaseSchema.parse({
132
+ query,
133
+ split: label.split,
134
+ expected_outcome: label.expected_outcome,
135
+ relevant_skill_ids: label.relevant_skill_ids,
136
+ provenance: {
137
+ version: 1,
138
+ source: "audit_import",
139
+ review_status: "human_labelled",
140
+ query_storage: privacy.include_raw_query ? "raw" : "redacted",
141
+ audit_id: audit.id,
142
+ labelled_at: label.labelled_at,
143
+ },
144
+ });
145
+ return validateCase(parsed, audit.id);
146
+ }
147
+
148
+ export interface DatasetProvenanceSummary {
149
+ version: 1;
150
+ human_labelled_case_count: number;
151
+ imported_labelled_case_count: number;
152
+ imported_unreviewed_case_count: number;
153
+ raw_query_case_count: number;
154
+ redacted_query_case_count: number;
155
+ }
156
+
157
+ export function summarizeDatasetProvenance(
158
+ cases: DecisionCase[],
159
+ ): DatasetProvenanceSummary {
160
+ const provenance = (item: DecisionCase): DecisionCaseProvenance =>
161
+ item.provenance ?? {
162
+ version: 1,
163
+ source: "authored",
164
+ review_status: "human_labelled",
165
+ query_storage: "raw",
166
+ };
167
+ return {
168
+ version: 1,
169
+ human_labelled_case_count:
170
+ cases.filter((item) => provenance(item).review_status === "human_labelled").length,
171
+ imported_labelled_case_count:
172
+ cases.filter((item) =>
173
+ provenance(item).source === "audit_import" &&
174
+ provenance(item).review_status === "human_labelled"
175
+ ).length,
176
+ imported_unreviewed_case_count:
177
+ cases.filter((item) =>
178
+ provenance(item).source === "audit_import" &&
179
+ provenance(item).review_status === "unreviewed"
180
+ ).length,
181
+ raw_query_case_count:
182
+ cases.filter((item) => provenance(item).query_storage === "raw").length,
183
+ redacted_query_case_count:
184
+ cases.filter((item) => provenance(item).query_storage === "redacted").length,
185
+ };
69
186
  }
70
187
 
71
188
  // ---------------------------------------------------------------------------
@@ -111,8 +228,12 @@ function validateDatasetCompleteness(cases: DecisionCase[]): void {
111
228
  * no_match → 0)
112
229
  * - Dataset completeness (both splits, all outcome types in each split)
113
230
  */
114
- export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
231
+ export function loadDecisionCases(
232
+ raw: unknown[],
233
+ validSkillIds?: Iterable<string>,
234
+ ): DecisionCase[] {
115
235
  const parsed: DecisionCase[] = [];
236
+ const validIds = validSkillIds ? new Set(validSkillIds) : undefined;
116
237
 
117
238
  for (let i = 0; i < raw.length; i++) {
118
239
  const item = raw[i];
@@ -126,7 +247,17 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
126
247
  );
127
248
  }
128
249
 
129
- parsed.push(validateCase(result.data, i));
250
+ const parsedCase = validateCase(result.data, i);
251
+ if (validIds) {
252
+ for (const skillId of parsedCase.relevant_skill_ids) {
253
+ if (!validIds.has(skillId)) {
254
+ throw new Error(
255
+ `Validation error at case ${i}: field "relevant_skill_ids" references unknown vault skill "${skillId}"`,
256
+ );
257
+ }
258
+ }
259
+ }
260
+ parsed.push(parsedCase);
130
261
  }
131
262
 
132
263
  validateDatasetCompleteness(parsed);
@@ -137,9 +268,12 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
137
268
  * Read a JSON file from disk and validate it as a decision-policy dataset.
138
269
  * Throws if the file cannot be read or the contents fail validation.
139
270
  */
140
- export function loadDecisionCasesFromFile(path: string): DecisionCase[] {
271
+ export function loadDecisionCasesFromFile(
272
+ path: string,
273
+ validSkillIds?: Iterable<string>,
274
+ ): DecisionCase[] {
141
275
  const raw = JSON.parse(readFileSync(path, "utf8")) as unknown[];
142
- return loadDecisionCases(raw);
276
+ return loadDecisionCases(raw, validSkillIds);
143
277
  }
144
278
 
145
279
  // ---------------------------------------------------------------------------
@@ -169,9 +303,12 @@ export interface SelectedThresholds {
169
303
 
170
304
  export interface CalibrationMetrics {
171
305
  auto_match_precision: number;
306
+ auto_match_precision_lower_bound: number;
172
307
  auto_match_coverage: number;
173
- shortlist_recall_at_5: number;
174
- false_no_match_rate: number;
308
+ auto_match_count: number;
309
+ correct_auto_match_count: number;
310
+ retrieval_recall_at_k: number;
311
+ delivered_shortlist_recall_at_k: number;
175
312
  }
176
313
 
177
314
  export interface ConfusionMatrix {
@@ -185,9 +322,16 @@ export interface CalibrationTestMetrics extends CalibrationMetrics {
185
322
  }
186
323
 
187
324
  export type CalibrationStatus = "completed" | "failed_gates";
325
+ export type CalibrationFailureReason =
326
+ | "recall_precondition_failed"
327
+ | "precision_floor_unreachable"
328
+ | "no_coverage"
329
+ | "insufficient_sample"
330
+ | "test_certification_failed";
188
331
 
189
332
  export interface CalibrationResult {
190
333
  status: CalibrationStatus;
334
+ failed_reason?: CalibrationFailureReason;
191
335
  observations: QueryObservation[];
192
336
  selected_thresholds?: SelectedThresholds;
193
337
  tune_metrics?: CalibrationMetrics;
@@ -196,87 +340,123 @@ export interface CalibrationResult {
196
340
 
197
341
  export interface RunCalibrationOptions {
198
342
  cases: DecisionCase[];
199
- getCandidates: (query: string) => Promise<CandidateDoc[]>;
343
+ getCandidates?: (query: string) => Promise<CandidateDoc[]>;
344
+ /** Production hook: candidates already scored by the shared retrieval pipeline. */
345
+ getRankedCandidates?: (
346
+ query: string,
347
+ ) => Promise<Array<{ skill_id: string; score: number }>>;
200
348
  reranker:
201
349
  | ((query: string, docs: CandidateDoc[]) => Promise<number[]>)
202
350
  | undefined;
203
351
  /** Default: 0.99 */
204
352
  minAutoMatchPrecision?: number;
205
353
  /** Default: 0.95 */
206
- minShortlistRecallAt5?: number;
354
+ minRetrievalRecallAtK?: number;
355
+ /** Default: minRetrievalRecallAtK */
356
+ minDeliveredShortlistRecallAtK?: number;
357
+ /** Default: 30 */
358
+ minAutoMatchCount?: number;
359
+ candidateLimit: number;
207
360
  }
208
361
 
209
362
  // ---------------------------------------------------------------------------
210
363
  // Decision simulation using cached observations
211
364
  // ---------------------------------------------------------------------------
212
365
 
213
- type SimulatedDecision = "matched" | "ambiguous" | "no_match";
214
-
215
- function simulateDecision(
366
+ function decideObservation(
216
367
  obs: QueryObservation,
217
368
  thresholds: SelectedThresholds,
218
369
  candidateLimit: number,
219
- ): SimulatedDecision {
220
- const { match_score, match_margin, candidate_floor } = thresholds;
221
- const eligible = obs.ranked.filter((c) => c.score >= candidate_floor);
222
- if (eligible.length === 0) return "no_match";
223
-
224
- const top = eligible[0]!;
225
- const second = obs.ranked[1];
226
- const margin = second ? top.score - second.score : top.score;
227
-
228
- if (top.score >= match_score && margin >= match_margin) return "matched";
229
- if (eligible.slice(0, candidateLimit).length > 0) return "ambiguous";
230
- return "no_match";
370
+ ) {
371
+ const candidates: RankedCandidate[] = obs.ranked.map((candidate) => ({
372
+ ...candidate,
373
+ title: candidate.skill_id,
374
+ description: "",
375
+ }));
376
+ return decideResolveOutcome({
377
+ reranked: true,
378
+ candidates,
379
+ thresholds: { ...thresholds, candidate_limit: candidateLimit },
380
+ });
231
381
  }
232
382
 
233
383
  function computeMetrics(
234
384
  observations: QueryObservation[],
235
385
  thresholds: SelectedThresholds,
236
- candidateLimit = 5,
386
+ candidateLimit: number,
237
387
  ): CalibrationMetrics {
238
388
  let autoMatchCount = 0;
239
389
  let correctAutoMatch = 0;
240
- let shortlistHit = 0;
241
- let falseNoMatch = 0;
390
+ let retrievalHit = 0;
391
+ let deliveredHit = 0;
242
392
  const matchableCases = observations.filter((o) => o.expected_outcome !== "no_match");
393
+ const expectedMatches = observations.filter((o) => o.expected_outcome === "matched");
243
394
 
244
395
  for (const obs of observations) {
245
- const decision = simulateDecision(obs, thresholds, candidateLimit);
246
- if (decision === "matched") {
396
+ const decision = decideObservation(obs, thresholds, candidateLimit);
397
+ if (decision.outcome === "matched") {
247
398
  autoMatchCount++;
248
- // Correct if the top candidate is in relevant_skill_ids
249
399
  const top = obs.ranked[0];
250
- if (top && obs.relevant_skill_ids.includes(top.skill_id)) correctAutoMatch++;
400
+ if (
401
+ obs.expected_outcome === "matched" &&
402
+ top?.skill_id === obs.relevant_skill_ids[0]
403
+ ) {
404
+ correctAutoMatch++;
405
+ }
251
406
  }
252
407
  if (obs.expected_outcome !== "no_match") {
253
- // Shortlist recall: at least one relevant skill in top 5
254
- const top5 = obs.ranked.slice(0, 5).map((c) => c.skill_id);
255
- if (obs.relevant_skill_ids.some((id) => top5.includes(id))) shortlistHit++;
256
- }
257
- if (obs.expected_outcome !== "no_match" && decision === "no_match") {
258
- falseNoMatch++;
408
+ const topK = obs.ranked.slice(0, candidateLimit).map((c) => c.skill_id);
409
+ if (obs.relevant_skill_ids.some((id) => topK.includes(id))) retrievalHit++;
410
+ const deliveredIds = decision.outcome === "matched"
411
+ ? [decision.skill_id]
412
+ : decision.outcome === "ambiguous"
413
+ ? decision.candidates.map((candidate) => candidate.skill_id)
414
+ : [];
415
+ if (obs.relevant_skill_ids.some((id) => deliveredIds.includes(id))) deliveredHit++;
259
416
  }
260
417
  }
261
418
 
262
- const auto_match_precision = autoMatchCount === 0 ? 1.0 : correctAutoMatch / autoMatchCount;
263
- const auto_match_coverage = matchableCases.length === 0
419
+ const auto_match_precision = autoMatchCount === 0 ? 0 : correctAutoMatch / autoMatchCount;
420
+ const auto_match_precision_lower_bound = wilsonLowerBound(correctAutoMatch, autoMatchCount);
421
+ const auto_match_coverage = expectedMatches.length === 0
264
422
  ? 0
265
- : autoMatchCount / matchableCases.length;
266
- const shortlist_recall_at_5 = matchableCases.length === 0
423
+ : correctAutoMatch / expectedMatches.length;
424
+ const retrieval_recall_at_k = matchableCases.length === 0
267
425
  ? 1.0
268
- : shortlistHit / matchableCases.length;
269
- const false_no_match_rate = matchableCases.length === 0
270
- ? 0
271
- : falseNoMatch / matchableCases.length;
426
+ : retrievalHit / matchableCases.length;
427
+ const delivered_shortlist_recall_at_k = matchableCases.length === 0
428
+ ? 1.0
429
+ : deliveredHit / matchableCases.length;
430
+
431
+ return {
432
+ auto_match_precision,
433
+ auto_match_precision_lower_bound,
434
+ auto_match_coverage,
435
+ auto_match_count: autoMatchCount,
436
+ correct_auto_match_count: correctAutoMatch,
437
+ retrieval_recall_at_k,
438
+ delivered_shortlist_recall_at_k,
439
+ };
440
+ }
272
441
 
273
- return { auto_match_precision, auto_match_coverage, shortlist_recall_at_5, false_no_match_rate };
442
+ /** 95% Wilson score lower confidence bound for a binomial proportion. */
443
+ export function wilsonLowerBound(successes: number, total: number): number {
444
+ if (total === 0) return 0;
445
+ const z = 1.959963984540054;
446
+ const proportion = successes / total;
447
+ const zSquared = z * z;
448
+ const denominator = 1 + zSquared / total;
449
+ const centre = proportion + zSquared / (2 * total);
450
+ const adjustment = z * Math.sqrt(
451
+ (proportion * (1 - proportion) + zSquared / (4 * total)) / total,
452
+ );
453
+ return Math.max(0, (centre - adjustment) / denominator);
274
454
  }
275
455
 
276
456
  function computeTestMetrics(
277
457
  observations: QueryObservation[],
278
458
  thresholds: SelectedThresholds,
279
- candidateLimit = 5,
459
+ candidateLimit: number,
280
460
  ): CalibrationTestMetrics {
281
461
  const base = computeMetrics(observations, thresholds, candidateLimit);
282
462
 
@@ -285,8 +465,8 @@ function computeTestMetrics(
285
465
  const matrix: ConfusionMatrix = { matched: emptyRow(), ambiguous: emptyRow(), no_match: emptyRow() };
286
466
 
287
467
  for (const obs of observations) {
288
- const predicted = simulateDecision(obs, thresholds, candidateLimit);
289
- matrix[obs.expected_outcome][predicted]++;
468
+ const predicted = decideObservation(obs, thresholds, candidateLimit);
469
+ matrix[obs.expected_outcome][predicted.outcome]++;
290
470
  }
291
471
 
292
472
  return { ...base, confusion_matrix: matrix };
@@ -300,13 +480,26 @@ function uniqueSorted(values: number[]): number[] {
300
480
  return [...new Set(values)].sort((a, b) => a - b);
301
481
  }
302
482
 
483
+ /** Smallest representable number greater than value. */
484
+ function nextUp(value: number): number {
485
+ if (!Number.isFinite(value)) return value;
486
+ if (Object.is(value, -0)) value = 0;
487
+ const buffer = new ArrayBuffer(8);
488
+ const float = new Float64Array(buffer);
489
+ const bits = new BigUint64Array(buffer);
490
+ float[0] = value;
491
+ bits[0] = bits[0]! + (value >= 0 ? 1n : -1n);
492
+ return float[0]!;
493
+ }
494
+
303
495
  function deriveThresholdCandidates(observations: QueryObservation[]): {
304
496
  scoreBreakpoints: number[];
305
497
  marginBreakpoints: number[];
306
498
  floorBreakpoints: number[];
307
499
  } {
308
- const scores: number[] = [];
500
+ const scores: number[] = [0];
309
501
  const margins: number[] = [];
502
+ const floors: number[] = [0];
310
503
 
311
504
  for (const obs of observations) {
312
505
  if (obs.ranked.length === 0) continue;
@@ -314,22 +507,15 @@ function deriveThresholdCandidates(observations: QueryObservation[]): {
314
507
  scores.push(top.score);
315
508
  const second = obs.ranked[1];
316
509
  margins.push(second ? top.score - second.score : top.score);
510
+ // candidate_floor is inclusive, so the policy changes only immediately
511
+ // above an observed score. Use that transition to make every breakpoint
512
+ // capable of trimming at least one non-top candidate.
513
+ for (const candidate of obs.ranked.slice(1)) floors.push(nextUp(candidate.score));
317
514
  }
318
515
 
319
- // Breakpoints: observed values + a small epsilon step below each
320
- const epsilon = 0.001;
321
- const scoreBreakpoints = uniqueSorted([
322
- ...scores.map((s) => Math.max(0, s - epsilon)),
323
- ...scores,
324
- ]);
325
- const marginBreakpoints = uniqueSorted([
326
- ...margins.map((m) => Math.max(0, m - epsilon)),
327
- ...margins,
328
- ]);
329
- const floorBreakpoints = uniqueSorted([
330
- ...scores.map((s) => Math.max(0, s - epsilon)),
331
- ...scores,
332
- ]);
516
+ const scoreBreakpoints = uniqueSorted(scores);
517
+ const marginBreakpoints = uniqueSorted([0, ...margins]);
518
+ const floorBreakpoints = uniqueSorted(floors);
333
519
 
334
520
  return { scoreBreakpoints, marginBreakpoints, floorBreakpoints };
335
521
  }
@@ -340,61 +526,214 @@ function deriveThresholdCandidates(observations: QueryObservation[]): {
340
526
 
341
527
  /**
342
528
  * Find the threshold triple that:
343
- * 1. Satisfies minAutoMatchPrecision AND minShortlistRecallAt5 gates
529
+ * 1. Satisfies Wilson precision, sample-count, coverage, and delivered-recall gates
344
530
  * 2. Among those: maximizes auto_match_coverage
345
- * 3. Ties broken by: higher auto_match_precision, then higher shortlist_recall_at_5,
346
- * then lower auto_match_coverage (as coverage the tiebreak)
531
+ * 3. Ties break on confidence, delivered recall, maximal safe shortlist trimming,
532
+ * then deterministic lower match thresholds
347
533
  */
534
+ interface CalibrationGates {
535
+ minAutoMatchPrecision: number;
536
+ minRetrievalRecallAtK: number;
537
+ minDeliveredShortlistRecallAtK: number;
538
+ minAutoMatchCount: number;
539
+ }
540
+
541
+ function metricsPass(metrics: CalibrationMetrics, gates: CalibrationGates): boolean {
542
+ return (
543
+ metrics.auto_match_precision_lower_bound >= gates.minAutoMatchPrecision &&
544
+ metrics.auto_match_count >= gates.minAutoMatchCount &&
545
+ metrics.auto_match_coverage > 0 &&
546
+ metrics.delivered_shortlist_recall_at_k >= gates.minDeliveredShortlistRecallAtK
547
+ );
548
+ }
549
+
550
+ function betterPolicy(
551
+ candidate: { thresholds: SelectedThresholds; metrics: CalibrationMetrics },
552
+ best: { thresholds: SelectedThresholds; metrics: CalibrationMetrics } | undefined,
553
+ ): boolean {
554
+ if (!best) return true;
555
+ const a = candidate.metrics;
556
+ const b = best.metrics;
557
+ if (a.auto_match_coverage !== b.auto_match_coverage) {
558
+ return a.auto_match_coverage > b.auto_match_coverage;
559
+ }
560
+ if (a.auto_match_precision_lower_bound !== b.auto_match_precision_lower_bound) {
561
+ return a.auto_match_precision_lower_bound > b.auto_match_precision_lower_bound;
562
+ }
563
+ if (a.delivered_shortlist_recall_at_k !== b.delivered_shortlist_recall_at_k) {
564
+ return a.delivered_shortlist_recall_at_k > b.delivered_shortlist_recall_at_k;
565
+ }
566
+ if (candidate.thresholds.candidate_floor !== best.thresholds.candidate_floor) {
567
+ return candidate.thresholds.candidate_floor > best.thresholds.candidate_floor;
568
+ }
569
+ if (candidate.thresholds.match_score !== best.thresholds.match_score) {
570
+ return candidate.thresholds.match_score < best.thresholds.match_score;
571
+ }
572
+ if (candidate.thresholds.match_margin !== best.thresholds.match_margin) {
573
+ return candidate.thresholds.match_margin < best.thresholds.match_margin;
574
+ }
575
+ return false;
576
+ }
577
+
578
+ function sampledFloorIndexes(length: number, maxSamples = 32): number[] {
579
+ if (length <= maxSamples) return Array.from({ length }, (_, index) => index);
580
+ return uniqueSorted(
581
+ Array.from({ length: maxSamples }, (_, index) =>
582
+ Math.round(index * (length - 1) / (maxSamples - 1))),
583
+ );
584
+ }
585
+
348
586
  function selectThresholds(
349
587
  tuneObservations: QueryObservation[],
350
- gates: { minAutoMatchPrecision: number; minShortlistRecallAt5: number },
588
+ gates: CalibrationGates,
351
589
  candidateLimit: number,
352
- ): SelectedThresholds | undefined {
590
+ ): { selected?: SelectedThresholds; reason?: CalibrationFailureReason } {
353
591
  const { scoreBreakpoints, marginBreakpoints, floorBreakpoints } =
354
592
  deriveThresholdCandidates(tuneObservations);
355
593
 
356
594
  let best:
357
595
  | { thresholds: SelectedThresholds; metrics: CalibrationMetrics }
358
596
  | undefined;
597
+ let sawCoverage = false;
598
+ let sawSample = false;
599
+ let sawPrecision = false;
600
+ const scoreIndexes = new Map(scoreBreakpoints.map((value, index) => [value, index]));
601
+ const marginIndexes = new Map(marginBreakpoints.map((value, index) => [value, index]));
602
+ const width = marginBreakpoints.length;
603
+ const expectedMatchCount = tuneObservations.filter(
604
+ (observation) => observation.expected_outcome === "matched",
605
+ ).length;
606
+ const matchable = tuneObservations.filter(
607
+ (observation) => observation.expected_outcome !== "no_match",
608
+ );
609
+ const retrievalHits = matchable.filter((observation) => {
610
+ const ids = observation.ranked.slice(0, candidateLimit).map((candidate) => candidate.skill_id);
611
+ return observation.relevant_skill_ids.some((id) => ids.includes(id));
612
+ }).length;
613
+ const retrievalRecall = matchable.length === 0 ? 1 : retrievalHits / matchable.length;
614
+
615
+ const evaluateFloor = (floorIndex: number) => {
616
+ const floor = floorBreakpoints[floorIndex]!;
617
+ const cells = scoreBreakpoints.length * width;
618
+ const autoMatches = new Uint32Array(cells);
619
+ const correctMatches = new Uint32Array(cells);
620
+ const deliveredDelta = new Int32Array(cells);
621
+ let ambiguousDeliveredHits = 0;
622
+
623
+ for (const observation of tuneObservations) {
624
+ const top = observation.ranked[0];
625
+ if (!top || top.score < floor) continue;
626
+ const second = observation.ranked[1];
627
+ const margin = second ? top.score - second.score : top.score;
628
+ const cell = scoreIndexes.get(top.score)! * width + marginIndexes.get(margin)!;
629
+ autoMatches[cell] = autoMatches[cell]! + 1;
630
+ const correct = (
631
+ observation.expected_outcome === "matched" &&
632
+ top.skill_id === observation.relevant_skill_ids[0]
633
+ );
634
+ if (correct) correctMatches[cell] = correctMatches[cell]! + 1;
635
+
636
+ if (observation.expected_outcome !== "no_match") {
637
+ const ambiguousIds = observation.ranked
638
+ .filter((candidate) => candidate.score >= floor)
639
+ .slice(0, candidateLimit)
640
+ .map((candidate) => candidate.skill_id);
641
+ const ambiguousHit = observation.relevant_skill_ids.some(
642
+ (id) => ambiguousIds.includes(id),
643
+ );
644
+ const matchedHit = observation.relevant_skill_ids.includes(top.skill_id);
645
+ if (ambiguousHit) ambiguousDeliveredHits++;
646
+ deliveredDelta[cell] =
647
+ deliveredDelta[cell]! + Number(matchedHit) - Number(ambiguousHit);
648
+ }
649
+ }
359
650
 
360
- for (const floor of floorBreakpoints) {
361
- for (const score of scoreBreakpoints) {
362
- if (score < floor) continue;
363
- for (const margin of marginBreakpoints) {
364
- const candidate: SelectedThresholds = { match_score: score, match_margin: margin, candidate_floor: floor };
365
- const m = computeMetrics(tuneObservations, candidate, candidateLimit);
366
-
367
- if (
368
- m.auto_match_precision < gates.minAutoMatchPrecision ||
369
- m.shortlist_recall_at_5 < gates.minShortlistRecallAt5
370
- ) {
371
- continue;
651
+ // A suffix sum turns the score/margin sweep into O(1) metric lookups:
652
+ // a case auto-matches at every threshold pair at or below its point.
653
+ for (let scoreIndex = scoreBreakpoints.length - 1; scoreIndex >= 0; scoreIndex--) {
654
+ for (let marginIndex = width - 1; marginIndex >= 0; marginIndex--) {
655
+ const cell = scoreIndex * width + marginIndex;
656
+ if (scoreIndex + 1 < scoreBreakpoints.length) {
657
+ const below = (scoreIndex + 1) * width + marginIndex;
658
+ autoMatches[cell] = autoMatches[cell]! + autoMatches[below]!;
659
+ correctMatches[cell] = correctMatches[cell]! + correctMatches[below]!;
660
+ deliveredDelta[cell] = deliveredDelta[cell]! + deliveredDelta[below]!;
372
661
  }
373
-
374
- if (!best) {
375
- best = { thresholds: candidate, metrics: m };
376
- continue;
662
+ if (marginIndex + 1 < width) {
663
+ const right = cell + 1;
664
+ autoMatches[cell] = autoMatches[cell]! + autoMatches[right]!;
665
+ correctMatches[cell] = correctMatches[cell]! + correctMatches[right]!;
666
+ deliveredDelta[cell] = deliveredDelta[cell]! + deliveredDelta[right]!;
377
667
  }
378
-
379
- // Prefer higher coverage, then higher precision, then higher recall, then lower coverage (impossible but symmetry)
380
- const bm = best.metrics;
381
- if (m.auto_match_coverage > bm.auto_match_coverage) {
382
- best = { thresholds: candidate, metrics: m };
383
- } else if (m.auto_match_coverage === bm.auto_match_coverage) {
384
- if (m.auto_match_precision > bm.auto_match_precision) {
385
- best = { thresholds: candidate, metrics: m };
386
- } else if (
387
- m.auto_match_precision === bm.auto_match_precision &&
388
- m.shortlist_recall_at_5 > bm.shortlist_recall_at_5
389
- ) {
390
- best = { thresholds: candidate, metrics: m };
391
- }
668
+ if (scoreIndex + 1 < scoreBreakpoints.length && marginIndex + 1 < width) {
669
+ const diagonal = (scoreIndex + 1) * width + marginIndex + 1;
670
+ autoMatches[cell] = autoMatches[cell]! - autoMatches[diagonal]!;
671
+ correctMatches[cell] = correctMatches[cell]! - correctMatches[diagonal]!;
672
+ deliveredDelta[cell] = deliveredDelta[cell]! - deliveredDelta[diagonal]!;
392
673
  }
393
674
  }
394
675
  }
676
+
677
+ for (let scoreIndex = 0; scoreIndex < scoreBreakpoints.length; scoreIndex++) {
678
+ const score = scoreBreakpoints[scoreIndex]!;
679
+ if (score < floor) continue;
680
+ for (let marginIndex = 0; marginIndex < marginBreakpoints.length; marginIndex++) {
681
+ const margin = marginBreakpoints[marginIndex]!;
682
+ const cell = scoreIndex * width + marginIndex;
683
+ const autoMatchCount = autoMatches[cell]!;
684
+ const correctAutoMatchCount = correctMatches[cell]!;
685
+ const deliveredHits = ambiguousDeliveredHits + deliveredDelta[cell]!;
686
+ const thresholds = {
687
+ match_score: score,
688
+ match_margin: margin,
689
+ candidate_floor: floor,
690
+ };
691
+ const metrics: CalibrationMetrics = {
692
+ auto_match_precision:
693
+ autoMatchCount === 0 ? 0 : correctAutoMatchCount / autoMatchCount,
694
+ auto_match_precision_lower_bound:
695
+ wilsonLowerBound(correctAutoMatchCount, autoMatchCount),
696
+ auto_match_coverage:
697
+ expectedMatchCount === 0 ? 0 : correctAutoMatchCount / expectedMatchCount,
698
+ auto_match_count: autoMatchCount,
699
+ correct_auto_match_count: correctAutoMatchCount,
700
+ retrieval_recall_at_k: retrievalRecall,
701
+ delivered_shortlist_recall_at_k:
702
+ matchable.length === 0 ? 1 : deliveredHits / matchable.length,
703
+ };
704
+ sawCoverage ||= metrics.auto_match_coverage > 0;
705
+ sawSample ||= metrics.auto_match_count >= gates.minAutoMatchCount;
706
+ sawPrecision ||= (
707
+ metrics.auto_match_precision_lower_bound >= gates.minAutoMatchPrecision
708
+ );
709
+ if (!metricsPass(metrics, gates)) continue;
710
+ const candidate = { thresholds, metrics };
711
+ if (betterPolicy(candidate, best)) best = candidate;
712
+ }
713
+ }
714
+ };
715
+
716
+ // Coarse-to-fine floor search. Floor candidates come from non-top scores, so
717
+ // this tunes shortlist trimming instead of merely suppressing top matches.
718
+ const coarse = sampledFloorIndexes(floorBreakpoints.length);
719
+ for (const index of coarse) evaluateFloor(index);
720
+ if (best && coarse.length < floorBreakpoints.length) {
721
+ const bestIndex = floorBreakpoints.indexOf(best.thresholds.candidate_floor);
722
+ const radius = Math.ceil(floorBreakpoints.length / coarse.length);
723
+ for (
724
+ let index = Math.max(0, bestIndex - radius);
725
+ index <= Math.min(floorBreakpoints.length - 1, bestIndex + radius);
726
+ index++
727
+ ) {
728
+ if (!coarse.includes(index)) evaluateFloor(index);
729
+ }
395
730
  }
396
731
 
397
- return best?.thresholds;
732
+ if (best) return { selected: best.thresholds };
733
+ if (!sawCoverage) return { reason: "no_coverage" };
734
+ if (!sawSample) return { reason: "insufficient_sample" };
735
+ if (!sawPrecision) return { reason: "precision_floor_unreachable" };
736
+ return { reason: "precision_floor_unreachable" };
398
737
  }
399
738
 
400
739
  // ---------------------------------------------------------------------------
@@ -412,12 +751,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
412
751
  const {
413
752
  cases,
414
753
  getCandidates,
754
+ getRankedCandidates,
415
755
  reranker,
416
756
  minAutoMatchPrecision = 0.99,
417
- minShortlistRecallAt5 = 0.95,
757
+ minRetrievalRecallAtK = 0.95,
758
+ minDeliveredShortlistRecallAtK = minRetrievalRecallAtK,
759
+ minAutoMatchCount = 30,
760
+ candidateLimit,
418
761
  } = opts;
419
762
 
420
- if (!reranker) {
763
+ if (!getRankedCandidates && (!getCandidates || !reranker)) {
421
764
  throw new Error(
422
765
  "A configured reranker is required to run calibration. " +
423
766
  "Configure inference.reranker in your TOML config.",
@@ -427,11 +770,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
427
770
  // --- Step 1: Cache observations (reranker called exactly once per query) ---
428
771
  const observations: QueryObservation[] = [];
429
772
  for (const c of cases) {
430
- const docs = await getCandidates(c.query);
431
- const scores = await reranker(c.query, docs);
432
- const ranked = docs
433
- .map((d, i) => ({ skill_id: d.skill_id, score: scores[i] ?? 0 }))
434
- .sort((a, b) => b.score - a.score);
773
+ let ranked: Array<{ skill_id: string; score: number }>;
774
+ if (getRankedCandidates) {
775
+ ranked = await getRankedCandidates(c.query);
776
+ } else {
777
+ const docs = await getCandidates!(c.query);
778
+ const scores = await reranker!(c.query, docs);
779
+ ranked = docs
780
+ .map((d, i) => ({ skill_id: d.skill_id, score: scores[i] ?? 0 }))
781
+ .sort((a, b) => b.score - a.score);
782
+ }
435
783
  observations.push({
436
784
  query: c.query,
437
785
  split: c.split,
@@ -443,22 +791,61 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
443
791
 
444
792
  // --- Step 2: Select thresholds from tune split only ---
445
793
  const tuneObs = observations.filter((o) => o.split === "tune");
446
- const selected = selectThresholds(
794
+ const testObs = observations.filter((o) => o.split === "test");
795
+ const retrievalThresholds = {
796
+ match_score: 0,
797
+ match_margin: 0,
798
+ candidate_floor: 0,
799
+ };
800
+ const tuneRetrieval = computeMetrics(tuneObs, retrievalThresholds, candidateLimit);
801
+ const testRetrieval = computeMetrics(testObs, retrievalThresholds, candidateLimit);
802
+ if (
803
+ tuneRetrieval.retrieval_recall_at_k < minRetrievalRecallAtK ||
804
+ testRetrieval.retrieval_recall_at_k < minRetrievalRecallAtK
805
+ ) {
806
+ return {
807
+ status: "failed_gates",
808
+ failed_reason: "recall_precondition_failed",
809
+ observations,
810
+ };
811
+ }
812
+
813
+ const gates = {
814
+ minAutoMatchPrecision,
815
+ minRetrievalRecallAtK,
816
+ minDeliveredShortlistRecallAtK,
817
+ minAutoMatchCount,
818
+ };
819
+ const selection = selectThresholds(
447
820
  tuneObs,
448
- { minAutoMatchPrecision, minShortlistRecallAt5 },
449
- 5,
821
+ gates,
822
+ candidateLimit,
450
823
  );
451
824
 
452
- if (!selected) {
453
- return { status: "failed_gates", observations };
825
+ if (!selection.selected) {
826
+ return {
827
+ status: "failed_gates",
828
+ failed_reason: selection.reason,
829
+ observations,
830
+ };
454
831
  }
832
+ const selected = selection.selected;
455
833
 
456
834
  // --- Step 3: Report tune metrics ---
457
- const tune_metrics = computeMetrics(tuneObs, selected);
835
+ const tune_metrics = computeMetrics(tuneObs, selected, candidateLimit);
458
836
 
459
837
  // --- Step 4: Evaluate untouched test split ---
460
- const testObs = observations.filter((o) => o.split === "test");
461
- const test_metrics = computeTestMetrics(testObs, selected);
838
+ const test_metrics = computeTestMetrics(testObs, selected, candidateLimit);
839
+ if (!metricsPass(test_metrics, gates)) {
840
+ return {
841
+ status: "failed_gates",
842
+ failed_reason: "test_certification_failed",
843
+ observations,
844
+ selected_thresholds: selected,
845
+ tune_metrics,
846
+ test_metrics,
847
+ };
848
+ }
462
849
 
463
850
  return { status: "completed", observations, selected_thresholds: selected, tune_metrics, test_metrics };
464
851
  }
@@ -479,8 +866,14 @@ export interface CalibrationRunRecord {
479
866
  embedding_fingerprint: string;
480
867
  corpus_fingerprint: string;
481
868
  dataset_hash: string;
869
+ dataset_provenance?: DatasetProvenanceSummary;
870
+ candidate_limit: number;
871
+ attempt_count?: number;
482
872
  min_auto_match_precision: number;
873
+ min_auto_match_count?: number;
874
+ min_delivered_shortlist_recall_at_k?: number;
483
875
  min_shortlist_recall_at_5: number;
876
+ failed_reason?: CalibrationFailureReason;
484
877
  selected_thresholds?: SelectedThresholds;
485
878
  tune_metrics?: CalibrationMetrics;
486
879
  test_metrics?: CalibrationTestMetrics;
@@ -496,8 +889,15 @@ export interface CalibrationRunSummary {
496
889
  embedding_fingerprint: string;
497
890
  corpus_fingerprint: string;
498
891
  dataset_hash: string;
892
+ human_labelled_case_count: number;
893
+ imported_labelled_case_count: number;
894
+ candidate_limit: number;
895
+ attempt_count: number;
499
896
  min_auto_match_precision: number;
897
+ min_auto_match_count: number;
898
+ min_delivered_shortlist_recall_at_k: number;
500
899
  min_shortlist_recall_at_5: number;
900
+ failed_reason?: CalibrationFailureReason;
501
901
  }
502
902
 
503
903
  /**
@@ -525,18 +925,50 @@ export function openCalibrateDb(stateDir: string): Database {
525
925
  test_metrics TEXT,
526
926
  observations TEXT NOT NULL
527
927
  )`);
928
+ const columns = db.query("PRAGMA table_info(calibration_runs)").all() as Array<{ name: string }>;
929
+ if (!columns.some((column) => column.name === "candidate_limit")) {
930
+ db.run("ALTER TABLE calibration_runs ADD COLUMN candidate_limit INTEGER NOT NULL DEFAULT 5");
931
+ }
932
+ if (!columns.some((column) => column.name === "attempt_count")) {
933
+ db.run("ALTER TABLE calibration_runs ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 1");
934
+ }
935
+ if (!columns.some((column) => column.name === "min_auto_match_count")) {
936
+ db.run("ALTER TABLE calibration_runs ADD COLUMN min_auto_match_count INTEGER NOT NULL DEFAULT 1");
937
+ }
938
+ if (!columns.some((column) => column.name === "min_delivered_shortlist_recall_at_k")) {
939
+ db.run("ALTER TABLE calibration_runs ADD COLUMN min_delivered_shortlist_recall_at_k REAL NOT NULL DEFAULT 0.95");
940
+ }
941
+ if (!columns.some((column) => column.name === "failed_reason")) {
942
+ db.run("ALTER TABLE calibration_runs ADD COLUMN failed_reason TEXT");
943
+ }
944
+ if (!columns.some((column) => column.name === "dataset_provenance")) {
945
+ db.run("ALTER TABLE calibration_runs ADD COLUMN dataset_provenance TEXT NOT NULL DEFAULT '{}'");
946
+ }
947
+ if (!columns.some((column) => column.name === "human_labelled_case_count")) {
948
+ db.run("ALTER TABLE calibration_runs ADD COLUMN human_labelled_case_count INTEGER NOT NULL DEFAULT 0");
949
+ }
950
+ if (!columns.some((column) => column.name === "imported_labelled_case_count")) {
951
+ db.run("ALTER TABLE calibration_runs ADD COLUMN imported_labelled_case_count INTEGER NOT NULL DEFAULT 0");
952
+ }
528
953
  return db;
529
954
  }
530
955
 
531
956
  /** Persist a calibration run (all fields) to the evidence store. */
532
957
  export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
958
+ const attemptCount = (
959
+ db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
960
+ .get(run.dataset_hash) as { count: number }
961
+ ).count + 1;
533
962
  db.run(
534
963
  `INSERT INTO calibration_runs (
535
964
  run_id, created_at, status,
536
965
  reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
537
- min_auto_match_precision, min_shortlist_recall_at_5,
538
- selected_thresholds, tune_metrics, test_metrics, observations
539
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
966
+ candidate_limit,
967
+ attempt_count, min_auto_match_precision, min_auto_match_count,
968
+ min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
969
+ selected_thresholds, tune_metrics, test_metrics, observations,
970
+ dataset_provenance, human_labelled_case_count, imported_labelled_case_count
971
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
540
972
  [
541
973
  run.run_id,
542
974
  run.created_at,
@@ -545,12 +977,20 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
545
977
  run.embedding_fingerprint,
546
978
  run.corpus_fingerprint,
547
979
  run.dataset_hash,
980
+ run.candidate_limit,
981
+ attemptCount,
548
982
  run.min_auto_match_precision,
983
+ run.min_auto_match_count ?? 1,
984
+ run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
549
985
  run.min_shortlist_recall_at_5,
986
+ run.failed_reason ?? null,
550
987
  run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
551
988
  run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
552
989
  run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
553
990
  JSON.stringify(run.observations),
991
+ JSON.stringify(run.dataset_provenance ?? {}),
992
+ run.dataset_provenance?.human_labelled_case_count ?? 0,
993
+ run.dataset_provenance?.imported_labelled_case_count ?? 0,
554
994
  ],
555
995
  );
556
996
  }
@@ -563,12 +1003,43 @@ interface RawCalibrationRow {
563
1003
  embedding_fingerprint: string;
564
1004
  corpus_fingerprint: string;
565
1005
  dataset_hash: string;
1006
+ candidate_limit: number;
1007
+ attempt_count: number;
566
1008
  min_auto_match_precision: number;
1009
+ min_auto_match_count: number;
1010
+ min_delivered_shortlist_recall_at_k: number;
567
1011
  min_shortlist_recall_at_5: number;
1012
+ failed_reason: string | null;
568
1013
  selected_thresholds: string | null;
569
1014
  tune_metrics: string | null;
570
1015
  test_metrics: string | null;
571
1016
  observations: string;
1017
+ dataset_provenance: string;
1018
+ human_labelled_case_count: number;
1019
+ imported_labelled_case_count: number;
1020
+ }
1021
+
1022
+ function parseMetrics(json: string): CalibrationMetrics {
1023
+ const parsed = JSON.parse(json) as Partial<CalibrationMetrics> & {
1024
+ shortlist_recall_at_5?: number;
1025
+ false_no_match_rate?: number;
1026
+ };
1027
+ if (parsed.retrieval_recall_at_k === undefined) {
1028
+ parsed.retrieval_recall_at_k = parsed.shortlist_recall_at_5 ?? 0;
1029
+ }
1030
+ delete parsed.shortlist_recall_at_5;
1031
+ delete parsed.false_no_match_rate;
1032
+ return {
1033
+ auto_match_precision: parsed.auto_match_precision ?? 0,
1034
+ auto_match_precision_lower_bound:
1035
+ parsed.auto_match_precision_lower_bound ?? parsed.auto_match_precision ?? 0,
1036
+ auto_match_coverage: parsed.auto_match_coverage ?? 0,
1037
+ auto_match_count: parsed.auto_match_count ?? 0,
1038
+ correct_auto_match_count: parsed.correct_auto_match_count ?? 0,
1039
+ retrieval_recall_at_k: parsed.retrieval_recall_at_k,
1040
+ delivered_shortlist_recall_at_k:
1041
+ parsed.delivered_shortlist_recall_at_k ?? parsed.retrieval_recall_at_k,
1042
+ };
572
1043
  }
573
1044
 
574
1045
  function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
@@ -580,16 +1051,28 @@ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
580
1051
  embedding_fingerprint: row.embedding_fingerprint,
581
1052
  corpus_fingerprint: row.corpus_fingerprint,
582
1053
  dataset_hash: row.dataset_hash,
1054
+ dataset_provenance:
1055
+ Object.keys(JSON.parse(row.dataset_provenance) as object).length > 0
1056
+ ? JSON.parse(row.dataset_provenance) as DatasetProvenanceSummary
1057
+ : undefined,
1058
+ candidate_limit: row.candidate_limit,
1059
+ attempt_count: row.attempt_count,
583
1060
  min_auto_match_precision: row.min_auto_match_precision,
1061
+ min_auto_match_count: row.min_auto_match_count,
1062
+ min_delivered_shortlist_recall_at_k: row.min_delivered_shortlist_recall_at_k,
584
1063
  min_shortlist_recall_at_5: row.min_shortlist_recall_at_5,
1064
+ failed_reason: row.failed_reason as CalibrationFailureReason | null ?? undefined,
585
1065
  selected_thresholds: row.selected_thresholds != null
586
1066
  ? (JSON.parse(row.selected_thresholds) as SelectedThresholds)
587
1067
  : undefined,
588
1068
  tune_metrics: row.tune_metrics != null
589
- ? (JSON.parse(row.tune_metrics) as CalibrationMetrics)
1069
+ ? parseMetrics(row.tune_metrics)
590
1070
  : undefined,
591
1071
  test_metrics: row.test_metrics != null
592
- ? (JSON.parse(row.test_metrics) as CalibrationTestMetrics)
1072
+ ? {
1073
+ ...parseMetrics(row.test_metrics),
1074
+ confusion_matrix: (JSON.parse(row.test_metrics) as CalibrationTestMetrics).confusion_matrix,
1075
+ }
593
1076
  : undefined,
594
1077
  observations: JSON.parse(row.observations) as QueryObservation[],
595
1078
  };
@@ -609,7 +1092,10 @@ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
609
1092
  .query(
610
1093
  `SELECT run_id, created_at, status,
611
1094
  reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
612
- min_auto_match_precision, min_shortlist_recall_at_5
1095
+ candidate_limit,
1096
+ attempt_count, min_auto_match_precision, min_auto_match_count,
1097
+ min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1098
+ human_labelled_case_count, imported_labelled_case_count
613
1099
  FROM calibration_runs ORDER BY created_at DESC`,
614
1100
  )
615
1101
  .all() as CalibrationRunSummary[];
@@ -685,10 +1171,25 @@ export async function applyCalibrationRun(
685
1171
  "failed_gates",
686
1172
  );
687
1173
  }
1174
+ if (
1175
+ !run.test_metrics ||
1176
+ !metricsPass(run.test_metrics, {
1177
+ minAutoMatchPrecision: run.min_auto_match_precision,
1178
+ minRetrievalRecallAtK: run.min_shortlist_recall_at_5,
1179
+ minDeliveredShortlistRecallAtK:
1180
+ run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
1181
+ minAutoMatchCount: run.min_auto_match_count ?? 1,
1182
+ })
1183
+ ) {
1184
+ throw new ApplyCalibrationError(
1185
+ `Calibration run "${runId}" is not certified on its frozen test split`,
1186
+ "test_certification_failed",
1187
+ );
1188
+ }
688
1189
 
689
1190
  // --- Gate 3: fingerprint staleness ---
690
1191
  if (
691
- opts.currentRerankerFingerprint !== undefined &&
1192
+ "currentRerankerFingerprint" in opts &&
692
1193
  opts.currentRerankerFingerprint !== run.reranker_fingerprint
693
1194
  ) {
694
1195
  throw new ApplyCalibrationError(
@@ -740,6 +1241,3 @@ export async function applyCalibrationRun(
740
1241
  runId,
741
1242
  });
742
1243
  }
743
-
744
-
745
-