@klhapp/skillmux 1.0.1 → 1.1.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 { RankedCandidate } from "./types";
5
7
 
6
8
  export { generateDataset, type GenerateDatasetOptions } from "./dataset-generator";
7
9
 
@@ -169,9 +171,12 @@ export interface SelectedThresholds {
169
171
 
170
172
  export interface CalibrationMetrics {
171
173
  auto_match_precision: number;
174
+ auto_match_precision_lower_bound: number;
172
175
  auto_match_coverage: number;
173
- shortlist_recall_at_5: number;
174
- false_no_match_rate: number;
176
+ auto_match_count: number;
177
+ correct_auto_match_count: number;
178
+ retrieval_recall_at_k: number;
179
+ delivered_shortlist_recall_at_k: number;
175
180
  }
176
181
 
177
182
  export interface ConfusionMatrix {
@@ -185,9 +190,16 @@ export interface CalibrationTestMetrics extends CalibrationMetrics {
185
190
  }
186
191
 
187
192
  export type CalibrationStatus = "completed" | "failed_gates";
193
+ export type CalibrationFailureReason =
194
+ | "recall_precondition_failed"
195
+ | "precision_floor_unreachable"
196
+ | "no_coverage"
197
+ | "insufficient_sample"
198
+ | "test_certification_failed";
188
199
 
189
200
  export interface CalibrationResult {
190
201
  status: CalibrationStatus;
202
+ failed_reason?: CalibrationFailureReason;
191
203
  observations: QueryObservation[];
192
204
  selected_thresholds?: SelectedThresholds;
193
205
  tune_metrics?: CalibrationMetrics;
@@ -196,87 +208,123 @@ export interface CalibrationResult {
196
208
 
197
209
  export interface RunCalibrationOptions {
198
210
  cases: DecisionCase[];
199
- getCandidates: (query: string) => Promise<CandidateDoc[]>;
211
+ getCandidates?: (query: string) => Promise<CandidateDoc[]>;
212
+ /** Production hook: candidates already scored by the shared retrieval pipeline. */
213
+ getRankedCandidates?: (
214
+ query: string,
215
+ ) => Promise<Array<{ skill_id: string; score: number }>>;
200
216
  reranker:
201
217
  | ((query: string, docs: CandidateDoc[]) => Promise<number[]>)
202
218
  | undefined;
203
219
  /** Default: 0.99 */
204
220
  minAutoMatchPrecision?: number;
205
221
  /** Default: 0.95 */
206
- minShortlistRecallAt5?: number;
222
+ minRetrievalRecallAtK?: number;
223
+ /** Default: minRetrievalRecallAtK */
224
+ minDeliveredShortlistRecallAtK?: number;
225
+ /** Default: 30 */
226
+ minAutoMatchCount?: number;
227
+ candidateLimit: number;
207
228
  }
208
229
 
209
230
  // ---------------------------------------------------------------------------
210
231
  // Decision simulation using cached observations
211
232
  // ---------------------------------------------------------------------------
212
233
 
213
- type SimulatedDecision = "matched" | "ambiguous" | "no_match";
214
-
215
- function simulateDecision(
234
+ function decideObservation(
216
235
  obs: QueryObservation,
217
236
  thresholds: SelectedThresholds,
218
237
  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";
238
+ ) {
239
+ const candidates: RankedCandidate[] = obs.ranked.map((candidate) => ({
240
+ ...candidate,
241
+ title: candidate.skill_id,
242
+ description: "",
243
+ }));
244
+ return decideResolveOutcome({
245
+ reranked: true,
246
+ candidates,
247
+ thresholds: { ...thresholds, candidate_limit: candidateLimit },
248
+ });
231
249
  }
232
250
 
233
251
  function computeMetrics(
234
252
  observations: QueryObservation[],
235
253
  thresholds: SelectedThresholds,
236
- candidateLimit = 5,
254
+ candidateLimit: number,
237
255
  ): CalibrationMetrics {
238
256
  let autoMatchCount = 0;
239
257
  let correctAutoMatch = 0;
240
- let shortlistHit = 0;
241
- let falseNoMatch = 0;
258
+ let retrievalHit = 0;
259
+ let deliveredHit = 0;
242
260
  const matchableCases = observations.filter((o) => o.expected_outcome !== "no_match");
261
+ const expectedMatches = observations.filter((o) => o.expected_outcome === "matched");
243
262
 
244
263
  for (const obs of observations) {
245
- const decision = simulateDecision(obs, thresholds, candidateLimit);
246
- if (decision === "matched") {
264
+ const decision = decideObservation(obs, thresholds, candidateLimit);
265
+ if (decision.outcome === "matched") {
247
266
  autoMatchCount++;
248
- // Correct if the top candidate is in relevant_skill_ids
249
267
  const top = obs.ranked[0];
250
- if (top && obs.relevant_skill_ids.includes(top.skill_id)) correctAutoMatch++;
268
+ if (
269
+ obs.expected_outcome === "matched" &&
270
+ top?.skill_id === obs.relevant_skill_ids[0]
271
+ ) {
272
+ correctAutoMatch++;
273
+ }
251
274
  }
252
275
  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++;
276
+ const topK = obs.ranked.slice(0, candidateLimit).map((c) => c.skill_id);
277
+ if (obs.relevant_skill_ids.some((id) => topK.includes(id))) retrievalHit++;
278
+ const deliveredIds = decision.outcome === "matched"
279
+ ? [decision.skill_id]
280
+ : decision.outcome === "ambiguous"
281
+ ? decision.candidates.map((candidate) => candidate.skill_id)
282
+ : [];
283
+ if (obs.relevant_skill_ids.some((id) => deliveredIds.includes(id))) deliveredHit++;
259
284
  }
260
285
  }
261
286
 
262
- const auto_match_precision = autoMatchCount === 0 ? 1.0 : correctAutoMatch / autoMatchCount;
263
- const auto_match_coverage = matchableCases.length === 0
287
+ const auto_match_precision = autoMatchCount === 0 ? 0 : correctAutoMatch / autoMatchCount;
288
+ const auto_match_precision_lower_bound = wilsonLowerBound(correctAutoMatch, autoMatchCount);
289
+ const auto_match_coverage = expectedMatches.length === 0
264
290
  ? 0
265
- : autoMatchCount / matchableCases.length;
266
- const shortlist_recall_at_5 = matchableCases.length === 0
291
+ : correctAutoMatch / expectedMatches.length;
292
+ const retrieval_recall_at_k = matchableCases.length === 0
267
293
  ? 1.0
268
- : shortlistHit / matchableCases.length;
269
- const false_no_match_rate = matchableCases.length === 0
270
- ? 0
271
- : falseNoMatch / matchableCases.length;
294
+ : retrievalHit / matchableCases.length;
295
+ const delivered_shortlist_recall_at_k = matchableCases.length === 0
296
+ ? 1.0
297
+ : deliveredHit / matchableCases.length;
298
+
299
+ return {
300
+ auto_match_precision,
301
+ auto_match_precision_lower_bound,
302
+ auto_match_coverage,
303
+ auto_match_count: autoMatchCount,
304
+ correct_auto_match_count: correctAutoMatch,
305
+ retrieval_recall_at_k,
306
+ delivered_shortlist_recall_at_k,
307
+ };
308
+ }
272
309
 
273
- return { auto_match_precision, auto_match_coverage, shortlist_recall_at_5, false_no_match_rate };
310
+ /** 95% Wilson score lower confidence bound for a binomial proportion. */
311
+ export function wilsonLowerBound(successes: number, total: number): number {
312
+ if (total === 0) return 0;
313
+ const z = 1.959963984540054;
314
+ const proportion = successes / total;
315
+ const zSquared = z * z;
316
+ const denominator = 1 + zSquared / total;
317
+ const centre = proportion + zSquared / (2 * total);
318
+ const adjustment = z * Math.sqrt(
319
+ (proportion * (1 - proportion) + zSquared / (4 * total)) / total,
320
+ );
321
+ return Math.max(0, (centre - adjustment) / denominator);
274
322
  }
275
323
 
276
324
  function computeTestMetrics(
277
325
  observations: QueryObservation[],
278
326
  thresholds: SelectedThresholds,
279
- candidateLimit = 5,
327
+ candidateLimit: number,
280
328
  ): CalibrationTestMetrics {
281
329
  const base = computeMetrics(observations, thresholds, candidateLimit);
282
330
 
@@ -285,8 +333,8 @@ function computeTestMetrics(
285
333
  const matrix: ConfusionMatrix = { matched: emptyRow(), ambiguous: emptyRow(), no_match: emptyRow() };
286
334
 
287
335
  for (const obs of observations) {
288
- const predicted = simulateDecision(obs, thresholds, candidateLimit);
289
- matrix[obs.expected_outcome][predicted]++;
336
+ const predicted = decideObservation(obs, thresholds, candidateLimit);
337
+ matrix[obs.expected_outcome][predicted.outcome]++;
290
338
  }
291
339
 
292
340
  return { ...base, confusion_matrix: matrix };
@@ -300,13 +348,26 @@ function uniqueSorted(values: number[]): number[] {
300
348
  return [...new Set(values)].sort((a, b) => a - b);
301
349
  }
302
350
 
351
+ /** Smallest representable number greater than value. */
352
+ function nextUp(value: number): number {
353
+ if (!Number.isFinite(value)) return value;
354
+ if (Object.is(value, -0)) value = 0;
355
+ const buffer = new ArrayBuffer(8);
356
+ const float = new Float64Array(buffer);
357
+ const bits = new BigUint64Array(buffer);
358
+ float[0] = value;
359
+ bits[0] = bits[0]! + (value >= 0 ? 1n : -1n);
360
+ return float[0]!;
361
+ }
362
+
303
363
  function deriveThresholdCandidates(observations: QueryObservation[]): {
304
364
  scoreBreakpoints: number[];
305
365
  marginBreakpoints: number[];
306
366
  floorBreakpoints: number[];
307
367
  } {
308
- const scores: number[] = [];
368
+ const scores: number[] = [0];
309
369
  const margins: number[] = [];
370
+ const floors: number[] = [0];
310
371
 
311
372
  for (const obs of observations) {
312
373
  if (obs.ranked.length === 0) continue;
@@ -314,22 +375,15 @@ function deriveThresholdCandidates(observations: QueryObservation[]): {
314
375
  scores.push(top.score);
315
376
  const second = obs.ranked[1];
316
377
  margins.push(second ? top.score - second.score : top.score);
378
+ // candidate_floor is inclusive, so the policy changes only immediately
379
+ // above an observed score. Use that transition to make every breakpoint
380
+ // capable of trimming at least one non-top candidate.
381
+ for (const candidate of obs.ranked.slice(1)) floors.push(nextUp(candidate.score));
317
382
  }
318
383
 
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
- ]);
384
+ const scoreBreakpoints = uniqueSorted(scores);
385
+ const marginBreakpoints = uniqueSorted([0, ...margins]);
386
+ const floorBreakpoints = uniqueSorted(floors);
333
387
 
334
388
  return { scoreBreakpoints, marginBreakpoints, floorBreakpoints };
335
389
  }
@@ -340,61 +394,214 @@ function deriveThresholdCandidates(observations: QueryObservation[]): {
340
394
 
341
395
  /**
342
396
  * Find the threshold triple that:
343
- * 1. Satisfies minAutoMatchPrecision AND minShortlistRecallAt5 gates
397
+ * 1. Satisfies Wilson precision, sample-count, coverage, and delivered-recall gates
344
398
  * 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)
399
+ * 3. Ties break on confidence, delivered recall, maximal safe shortlist trimming,
400
+ * then deterministic lower match thresholds
347
401
  */
402
+ interface CalibrationGates {
403
+ minAutoMatchPrecision: number;
404
+ minRetrievalRecallAtK: number;
405
+ minDeliveredShortlistRecallAtK: number;
406
+ minAutoMatchCount: number;
407
+ }
408
+
409
+ function metricsPass(metrics: CalibrationMetrics, gates: CalibrationGates): boolean {
410
+ return (
411
+ metrics.auto_match_precision_lower_bound >= gates.minAutoMatchPrecision &&
412
+ metrics.auto_match_count >= gates.minAutoMatchCount &&
413
+ metrics.auto_match_coverage > 0 &&
414
+ metrics.delivered_shortlist_recall_at_k >= gates.minDeliveredShortlistRecallAtK
415
+ );
416
+ }
417
+
418
+ function betterPolicy(
419
+ candidate: { thresholds: SelectedThresholds; metrics: CalibrationMetrics },
420
+ best: { thresholds: SelectedThresholds; metrics: CalibrationMetrics } | undefined,
421
+ ): boolean {
422
+ if (!best) return true;
423
+ const a = candidate.metrics;
424
+ const b = best.metrics;
425
+ if (a.auto_match_coverage !== b.auto_match_coverage) {
426
+ return a.auto_match_coverage > b.auto_match_coverage;
427
+ }
428
+ if (a.auto_match_precision_lower_bound !== b.auto_match_precision_lower_bound) {
429
+ return a.auto_match_precision_lower_bound > b.auto_match_precision_lower_bound;
430
+ }
431
+ if (a.delivered_shortlist_recall_at_k !== b.delivered_shortlist_recall_at_k) {
432
+ return a.delivered_shortlist_recall_at_k > b.delivered_shortlist_recall_at_k;
433
+ }
434
+ if (candidate.thresholds.candidate_floor !== best.thresholds.candidate_floor) {
435
+ return candidate.thresholds.candidate_floor > best.thresholds.candidate_floor;
436
+ }
437
+ if (candidate.thresholds.match_score !== best.thresholds.match_score) {
438
+ return candidate.thresholds.match_score < best.thresholds.match_score;
439
+ }
440
+ if (candidate.thresholds.match_margin !== best.thresholds.match_margin) {
441
+ return candidate.thresholds.match_margin < best.thresholds.match_margin;
442
+ }
443
+ return false;
444
+ }
445
+
446
+ function sampledFloorIndexes(length: number, maxSamples = 32): number[] {
447
+ if (length <= maxSamples) return Array.from({ length }, (_, index) => index);
448
+ return uniqueSorted(
449
+ Array.from({ length: maxSamples }, (_, index) =>
450
+ Math.round(index * (length - 1) / (maxSamples - 1))),
451
+ );
452
+ }
453
+
348
454
  function selectThresholds(
349
455
  tuneObservations: QueryObservation[],
350
- gates: { minAutoMatchPrecision: number; minShortlistRecallAt5: number },
456
+ gates: CalibrationGates,
351
457
  candidateLimit: number,
352
- ): SelectedThresholds | undefined {
458
+ ): { selected?: SelectedThresholds; reason?: CalibrationFailureReason } {
353
459
  const { scoreBreakpoints, marginBreakpoints, floorBreakpoints } =
354
460
  deriveThresholdCandidates(tuneObservations);
355
461
 
356
462
  let best:
357
463
  | { thresholds: SelectedThresholds; metrics: CalibrationMetrics }
358
464
  | undefined;
465
+ let sawCoverage = false;
466
+ let sawSample = false;
467
+ let sawPrecision = false;
468
+ const scoreIndexes = new Map(scoreBreakpoints.map((value, index) => [value, index]));
469
+ const marginIndexes = new Map(marginBreakpoints.map((value, index) => [value, index]));
470
+ const width = marginBreakpoints.length;
471
+ const expectedMatchCount = tuneObservations.filter(
472
+ (observation) => observation.expected_outcome === "matched",
473
+ ).length;
474
+ const matchable = tuneObservations.filter(
475
+ (observation) => observation.expected_outcome !== "no_match",
476
+ );
477
+ const retrievalHits = matchable.filter((observation) => {
478
+ const ids = observation.ranked.slice(0, candidateLimit).map((candidate) => candidate.skill_id);
479
+ return observation.relevant_skill_ids.some((id) => ids.includes(id));
480
+ }).length;
481
+ const retrievalRecall = matchable.length === 0 ? 1 : retrievalHits / matchable.length;
482
+
483
+ const evaluateFloor = (floorIndex: number) => {
484
+ const floor = floorBreakpoints[floorIndex]!;
485
+ const cells = scoreBreakpoints.length * width;
486
+ const autoMatches = new Uint32Array(cells);
487
+ const correctMatches = new Uint32Array(cells);
488
+ const deliveredDelta = new Int32Array(cells);
489
+ let ambiguousDeliveredHits = 0;
490
+
491
+ for (const observation of tuneObservations) {
492
+ const top = observation.ranked[0];
493
+ if (!top || top.score < floor) continue;
494
+ const second = observation.ranked[1];
495
+ const margin = second ? top.score - second.score : top.score;
496
+ const cell = scoreIndexes.get(top.score)! * width + marginIndexes.get(margin)!;
497
+ autoMatches[cell] = autoMatches[cell]! + 1;
498
+ const correct = (
499
+ observation.expected_outcome === "matched" &&
500
+ top.skill_id === observation.relevant_skill_ids[0]
501
+ );
502
+ if (correct) correctMatches[cell] = correctMatches[cell]! + 1;
503
+
504
+ if (observation.expected_outcome !== "no_match") {
505
+ const ambiguousIds = observation.ranked
506
+ .filter((candidate) => candidate.score >= floor)
507
+ .slice(0, candidateLimit)
508
+ .map((candidate) => candidate.skill_id);
509
+ const ambiguousHit = observation.relevant_skill_ids.some(
510
+ (id) => ambiguousIds.includes(id),
511
+ );
512
+ const matchedHit = observation.relevant_skill_ids.includes(top.skill_id);
513
+ if (ambiguousHit) ambiguousDeliveredHits++;
514
+ deliveredDelta[cell] =
515
+ deliveredDelta[cell]! + Number(matchedHit) - Number(ambiguousHit);
516
+ }
517
+ }
359
518
 
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;
519
+ // A suffix sum turns the score/margin sweep into O(1) metric lookups:
520
+ // a case auto-matches at every threshold pair at or below its point.
521
+ for (let scoreIndex = scoreBreakpoints.length - 1; scoreIndex >= 0; scoreIndex--) {
522
+ for (let marginIndex = width - 1; marginIndex >= 0; marginIndex--) {
523
+ const cell = scoreIndex * width + marginIndex;
524
+ if (scoreIndex + 1 < scoreBreakpoints.length) {
525
+ const below = (scoreIndex + 1) * width + marginIndex;
526
+ autoMatches[cell] = autoMatches[cell]! + autoMatches[below]!;
527
+ correctMatches[cell] = correctMatches[cell]! + correctMatches[below]!;
528
+ deliveredDelta[cell] = deliveredDelta[cell]! + deliveredDelta[below]!;
372
529
  }
373
-
374
- if (!best) {
375
- best = { thresholds: candidate, metrics: m };
376
- continue;
530
+ if (marginIndex + 1 < width) {
531
+ const right = cell + 1;
532
+ autoMatches[cell] = autoMatches[cell]! + autoMatches[right]!;
533
+ correctMatches[cell] = correctMatches[cell]! + correctMatches[right]!;
534
+ deliveredDelta[cell] = deliveredDelta[cell]! + deliveredDelta[right]!;
377
535
  }
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
- }
536
+ if (scoreIndex + 1 < scoreBreakpoints.length && marginIndex + 1 < width) {
537
+ const diagonal = (scoreIndex + 1) * width + marginIndex + 1;
538
+ autoMatches[cell] = autoMatches[cell]! - autoMatches[diagonal]!;
539
+ correctMatches[cell] = correctMatches[cell]! - correctMatches[diagonal]!;
540
+ deliveredDelta[cell] = deliveredDelta[cell]! - deliveredDelta[diagonal]!;
392
541
  }
393
542
  }
394
543
  }
544
+
545
+ for (let scoreIndex = 0; scoreIndex < scoreBreakpoints.length; scoreIndex++) {
546
+ const score = scoreBreakpoints[scoreIndex]!;
547
+ if (score < floor) continue;
548
+ for (let marginIndex = 0; marginIndex < marginBreakpoints.length; marginIndex++) {
549
+ const margin = marginBreakpoints[marginIndex]!;
550
+ const cell = scoreIndex * width + marginIndex;
551
+ const autoMatchCount = autoMatches[cell]!;
552
+ const correctAutoMatchCount = correctMatches[cell]!;
553
+ const deliveredHits = ambiguousDeliveredHits + deliveredDelta[cell]!;
554
+ const thresholds = {
555
+ match_score: score,
556
+ match_margin: margin,
557
+ candidate_floor: floor,
558
+ };
559
+ const metrics: CalibrationMetrics = {
560
+ auto_match_precision:
561
+ autoMatchCount === 0 ? 0 : correctAutoMatchCount / autoMatchCount,
562
+ auto_match_precision_lower_bound:
563
+ wilsonLowerBound(correctAutoMatchCount, autoMatchCount),
564
+ auto_match_coverage:
565
+ expectedMatchCount === 0 ? 0 : correctAutoMatchCount / expectedMatchCount,
566
+ auto_match_count: autoMatchCount,
567
+ correct_auto_match_count: correctAutoMatchCount,
568
+ retrieval_recall_at_k: retrievalRecall,
569
+ delivered_shortlist_recall_at_k:
570
+ matchable.length === 0 ? 1 : deliveredHits / matchable.length,
571
+ };
572
+ sawCoverage ||= metrics.auto_match_coverage > 0;
573
+ sawSample ||= metrics.auto_match_count >= gates.minAutoMatchCount;
574
+ sawPrecision ||= (
575
+ metrics.auto_match_precision_lower_bound >= gates.minAutoMatchPrecision
576
+ );
577
+ if (!metricsPass(metrics, gates)) continue;
578
+ const candidate = { thresholds, metrics };
579
+ if (betterPolicy(candidate, best)) best = candidate;
580
+ }
581
+ }
582
+ };
583
+
584
+ // Coarse-to-fine floor search. Floor candidates come from non-top scores, so
585
+ // this tunes shortlist trimming instead of merely suppressing top matches.
586
+ const coarse = sampledFloorIndexes(floorBreakpoints.length);
587
+ for (const index of coarse) evaluateFloor(index);
588
+ if (best && coarse.length < floorBreakpoints.length) {
589
+ const bestIndex = floorBreakpoints.indexOf(best.thresholds.candidate_floor);
590
+ const radius = Math.ceil(floorBreakpoints.length / coarse.length);
591
+ for (
592
+ let index = Math.max(0, bestIndex - radius);
593
+ index <= Math.min(floorBreakpoints.length - 1, bestIndex + radius);
594
+ index++
595
+ ) {
596
+ if (!coarse.includes(index)) evaluateFloor(index);
597
+ }
395
598
  }
396
599
 
397
- return best?.thresholds;
600
+ if (best) return { selected: best.thresholds };
601
+ if (!sawCoverage) return { reason: "no_coverage" };
602
+ if (!sawSample) return { reason: "insufficient_sample" };
603
+ if (!sawPrecision) return { reason: "precision_floor_unreachable" };
604
+ return { reason: "precision_floor_unreachable" };
398
605
  }
399
606
 
400
607
  // ---------------------------------------------------------------------------
@@ -412,12 +619,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
412
619
  const {
413
620
  cases,
414
621
  getCandidates,
622
+ getRankedCandidates,
415
623
  reranker,
416
624
  minAutoMatchPrecision = 0.99,
417
- minShortlistRecallAt5 = 0.95,
625
+ minRetrievalRecallAtK = 0.95,
626
+ minDeliveredShortlistRecallAtK = minRetrievalRecallAtK,
627
+ minAutoMatchCount = 30,
628
+ candidateLimit,
418
629
  } = opts;
419
630
 
420
- if (!reranker) {
631
+ if (!getRankedCandidates && (!getCandidates || !reranker)) {
421
632
  throw new Error(
422
633
  "A configured reranker is required to run calibration. " +
423
634
  "Configure inference.reranker in your TOML config.",
@@ -427,11 +638,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
427
638
  // --- Step 1: Cache observations (reranker called exactly once per query) ---
428
639
  const observations: QueryObservation[] = [];
429
640
  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);
641
+ let ranked: Array<{ skill_id: string; score: number }>;
642
+ if (getRankedCandidates) {
643
+ ranked = await getRankedCandidates(c.query);
644
+ } else {
645
+ const docs = await getCandidates!(c.query);
646
+ const scores = await reranker!(c.query, docs);
647
+ ranked = docs
648
+ .map((d, i) => ({ skill_id: d.skill_id, score: scores[i] ?? 0 }))
649
+ .sort((a, b) => b.score - a.score);
650
+ }
435
651
  observations.push({
436
652
  query: c.query,
437
653
  split: c.split,
@@ -443,22 +659,61 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
443
659
 
444
660
  // --- Step 2: Select thresholds from tune split only ---
445
661
  const tuneObs = observations.filter((o) => o.split === "tune");
446
- const selected = selectThresholds(
662
+ const testObs = observations.filter((o) => o.split === "test");
663
+ const retrievalThresholds = {
664
+ match_score: 0,
665
+ match_margin: 0,
666
+ candidate_floor: 0,
667
+ };
668
+ const tuneRetrieval = computeMetrics(tuneObs, retrievalThresholds, candidateLimit);
669
+ const testRetrieval = computeMetrics(testObs, retrievalThresholds, candidateLimit);
670
+ if (
671
+ tuneRetrieval.retrieval_recall_at_k < minRetrievalRecallAtK ||
672
+ testRetrieval.retrieval_recall_at_k < minRetrievalRecallAtK
673
+ ) {
674
+ return {
675
+ status: "failed_gates",
676
+ failed_reason: "recall_precondition_failed",
677
+ observations,
678
+ };
679
+ }
680
+
681
+ const gates = {
682
+ minAutoMatchPrecision,
683
+ minRetrievalRecallAtK,
684
+ minDeliveredShortlistRecallAtK,
685
+ minAutoMatchCount,
686
+ };
687
+ const selection = selectThresholds(
447
688
  tuneObs,
448
- { minAutoMatchPrecision, minShortlistRecallAt5 },
449
- 5,
689
+ gates,
690
+ candidateLimit,
450
691
  );
451
692
 
452
- if (!selected) {
453
- return { status: "failed_gates", observations };
693
+ if (!selection.selected) {
694
+ return {
695
+ status: "failed_gates",
696
+ failed_reason: selection.reason,
697
+ observations,
698
+ };
454
699
  }
700
+ const selected = selection.selected;
455
701
 
456
702
  // --- Step 3: Report tune metrics ---
457
- const tune_metrics = computeMetrics(tuneObs, selected);
703
+ const tune_metrics = computeMetrics(tuneObs, selected, candidateLimit);
458
704
 
459
705
  // --- Step 4: Evaluate untouched test split ---
460
- const testObs = observations.filter((o) => o.split === "test");
461
- const test_metrics = computeTestMetrics(testObs, selected);
706
+ const test_metrics = computeTestMetrics(testObs, selected, candidateLimit);
707
+ if (!metricsPass(test_metrics, gates)) {
708
+ return {
709
+ status: "failed_gates",
710
+ failed_reason: "test_certification_failed",
711
+ observations,
712
+ selected_thresholds: selected,
713
+ tune_metrics,
714
+ test_metrics,
715
+ };
716
+ }
462
717
 
463
718
  return { status: "completed", observations, selected_thresholds: selected, tune_metrics, test_metrics };
464
719
  }
@@ -479,8 +734,13 @@ export interface CalibrationRunRecord {
479
734
  embedding_fingerprint: string;
480
735
  corpus_fingerprint: string;
481
736
  dataset_hash: string;
737
+ candidate_limit: number;
738
+ attempt_count?: number;
482
739
  min_auto_match_precision: number;
740
+ min_auto_match_count?: number;
741
+ min_delivered_shortlist_recall_at_k?: number;
483
742
  min_shortlist_recall_at_5: number;
743
+ failed_reason?: CalibrationFailureReason;
484
744
  selected_thresholds?: SelectedThresholds;
485
745
  tune_metrics?: CalibrationMetrics;
486
746
  test_metrics?: CalibrationTestMetrics;
@@ -496,8 +756,13 @@ export interface CalibrationRunSummary {
496
756
  embedding_fingerprint: string;
497
757
  corpus_fingerprint: string;
498
758
  dataset_hash: string;
759
+ candidate_limit: number;
760
+ attempt_count: number;
499
761
  min_auto_match_precision: number;
762
+ min_auto_match_count: number;
763
+ min_delivered_shortlist_recall_at_k: number;
500
764
  min_shortlist_recall_at_5: number;
765
+ failed_reason?: CalibrationFailureReason;
501
766
  }
502
767
 
503
768
  /**
@@ -525,18 +790,40 @@ export function openCalibrateDb(stateDir: string): Database {
525
790
  test_metrics TEXT,
526
791
  observations TEXT NOT NULL
527
792
  )`);
793
+ const columns = db.query("PRAGMA table_info(calibration_runs)").all() as Array<{ name: string }>;
794
+ if (!columns.some((column) => column.name === "candidate_limit")) {
795
+ db.run("ALTER TABLE calibration_runs ADD COLUMN candidate_limit INTEGER NOT NULL DEFAULT 5");
796
+ }
797
+ if (!columns.some((column) => column.name === "attempt_count")) {
798
+ db.run("ALTER TABLE calibration_runs ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 1");
799
+ }
800
+ if (!columns.some((column) => column.name === "min_auto_match_count")) {
801
+ db.run("ALTER TABLE calibration_runs ADD COLUMN min_auto_match_count INTEGER NOT NULL DEFAULT 1");
802
+ }
803
+ if (!columns.some((column) => column.name === "min_delivered_shortlist_recall_at_k")) {
804
+ db.run("ALTER TABLE calibration_runs ADD COLUMN min_delivered_shortlist_recall_at_k REAL NOT NULL DEFAULT 0.95");
805
+ }
806
+ if (!columns.some((column) => column.name === "failed_reason")) {
807
+ db.run("ALTER TABLE calibration_runs ADD COLUMN failed_reason TEXT");
808
+ }
528
809
  return db;
529
810
  }
530
811
 
531
812
  /** Persist a calibration run (all fields) to the evidence store. */
532
813
  export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
814
+ const attemptCount = (
815
+ db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
816
+ .get(run.dataset_hash) as { count: number }
817
+ ).count + 1;
533
818
  db.run(
534
819
  `INSERT INTO calibration_runs (
535
820
  run_id, created_at, status,
536
821
  reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
537
- min_auto_match_precision, min_shortlist_recall_at_5,
822
+ candidate_limit,
823
+ attempt_count, min_auto_match_precision, min_auto_match_count,
824
+ min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
538
825
  selected_thresholds, tune_metrics, test_metrics, observations
539
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
826
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
540
827
  [
541
828
  run.run_id,
542
829
  run.created_at,
@@ -545,8 +832,13 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
545
832
  run.embedding_fingerprint,
546
833
  run.corpus_fingerprint,
547
834
  run.dataset_hash,
835
+ run.candidate_limit,
836
+ attemptCount,
548
837
  run.min_auto_match_precision,
838
+ run.min_auto_match_count ?? 1,
839
+ run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
549
840
  run.min_shortlist_recall_at_5,
841
+ run.failed_reason ?? null,
550
842
  run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
551
843
  run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
552
844
  run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
@@ -563,14 +855,42 @@ interface RawCalibrationRow {
563
855
  embedding_fingerprint: string;
564
856
  corpus_fingerprint: string;
565
857
  dataset_hash: string;
858
+ candidate_limit: number;
859
+ attempt_count: number;
566
860
  min_auto_match_precision: number;
861
+ min_auto_match_count: number;
862
+ min_delivered_shortlist_recall_at_k: number;
567
863
  min_shortlist_recall_at_5: number;
864
+ failed_reason: string | null;
568
865
  selected_thresholds: string | null;
569
866
  tune_metrics: string | null;
570
867
  test_metrics: string | null;
571
868
  observations: string;
572
869
  }
573
870
 
871
+ function parseMetrics(json: string): CalibrationMetrics {
872
+ const parsed = JSON.parse(json) as Partial<CalibrationMetrics> & {
873
+ shortlist_recall_at_5?: number;
874
+ false_no_match_rate?: number;
875
+ };
876
+ if (parsed.retrieval_recall_at_k === undefined) {
877
+ parsed.retrieval_recall_at_k = parsed.shortlist_recall_at_5 ?? 0;
878
+ }
879
+ delete parsed.shortlist_recall_at_5;
880
+ delete parsed.false_no_match_rate;
881
+ return {
882
+ auto_match_precision: parsed.auto_match_precision ?? 0,
883
+ auto_match_precision_lower_bound:
884
+ parsed.auto_match_precision_lower_bound ?? parsed.auto_match_precision ?? 0,
885
+ auto_match_coverage: parsed.auto_match_coverage ?? 0,
886
+ auto_match_count: parsed.auto_match_count ?? 0,
887
+ correct_auto_match_count: parsed.correct_auto_match_count ?? 0,
888
+ retrieval_recall_at_k: parsed.retrieval_recall_at_k,
889
+ delivered_shortlist_recall_at_k:
890
+ parsed.delivered_shortlist_recall_at_k ?? parsed.retrieval_recall_at_k,
891
+ };
892
+ }
893
+
574
894
  function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
575
895
  return {
576
896
  run_id: row.run_id,
@@ -580,16 +900,24 @@ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
580
900
  embedding_fingerprint: row.embedding_fingerprint,
581
901
  corpus_fingerprint: row.corpus_fingerprint,
582
902
  dataset_hash: row.dataset_hash,
903
+ candidate_limit: row.candidate_limit,
904
+ attempt_count: row.attempt_count,
583
905
  min_auto_match_precision: row.min_auto_match_precision,
906
+ min_auto_match_count: row.min_auto_match_count,
907
+ min_delivered_shortlist_recall_at_k: row.min_delivered_shortlist_recall_at_k,
584
908
  min_shortlist_recall_at_5: row.min_shortlist_recall_at_5,
909
+ failed_reason: row.failed_reason as CalibrationFailureReason | null ?? undefined,
585
910
  selected_thresholds: row.selected_thresholds != null
586
911
  ? (JSON.parse(row.selected_thresholds) as SelectedThresholds)
587
912
  : undefined,
588
913
  tune_metrics: row.tune_metrics != null
589
- ? (JSON.parse(row.tune_metrics) as CalibrationMetrics)
914
+ ? parseMetrics(row.tune_metrics)
590
915
  : undefined,
591
916
  test_metrics: row.test_metrics != null
592
- ? (JSON.parse(row.test_metrics) as CalibrationTestMetrics)
917
+ ? {
918
+ ...parseMetrics(row.test_metrics),
919
+ confusion_matrix: (JSON.parse(row.test_metrics) as CalibrationTestMetrics).confusion_matrix,
920
+ }
593
921
  : undefined,
594
922
  observations: JSON.parse(row.observations) as QueryObservation[],
595
923
  };
@@ -609,7 +937,9 @@ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
609
937
  .query(
610
938
  `SELECT run_id, created_at, status,
611
939
  reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
612
- min_auto_match_precision, min_shortlist_recall_at_5
940
+ candidate_limit,
941
+ attempt_count, min_auto_match_precision, min_auto_match_count,
942
+ min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason
613
943
  FROM calibration_runs ORDER BY created_at DESC`,
614
944
  )
615
945
  .all() as CalibrationRunSummary[];
@@ -685,10 +1015,25 @@ export async function applyCalibrationRun(
685
1015
  "failed_gates",
686
1016
  );
687
1017
  }
1018
+ if (
1019
+ !run.test_metrics ||
1020
+ !metricsPass(run.test_metrics, {
1021
+ minAutoMatchPrecision: run.min_auto_match_precision,
1022
+ minRetrievalRecallAtK: run.min_shortlist_recall_at_5,
1023
+ minDeliveredShortlistRecallAtK:
1024
+ run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
1025
+ minAutoMatchCount: run.min_auto_match_count ?? 1,
1026
+ })
1027
+ ) {
1028
+ throw new ApplyCalibrationError(
1029
+ `Calibration run "${runId}" is not certified on its frozen test split`,
1030
+ "test_certification_failed",
1031
+ );
1032
+ }
688
1033
 
689
1034
  // --- Gate 3: fingerprint staleness ---
690
1035
  if (
691
- opts.currentRerankerFingerprint !== undefined &&
1036
+ "currentRerankerFingerprint" in opts &&
692
1037
  opts.currentRerankerFingerprint !== run.reranker_fingerprint
693
1038
  ) {
694
1039
  throw new ApplyCalibrationError(
@@ -740,6 +1085,3 @@ export async function applyCalibrationRun(
740
1085
  runId,
741
1086
  });
742
1087
  }
743
-
744
-
745
-