@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/CHANGELOG.md +43 -0
- package/README.md +77 -28
- package/config.remote.example.toml +3 -3
- package/docs/configuration.md +39 -4
- package/docs/schema.json +13 -5
- package/package.json +1 -1
- package/src/adapters.ts +98 -18
- package/src/calibrate.ts +461 -119
- package/src/cli.ts +44 -2
- package/src/clients.ts +264 -48
- package/src/config-service.ts +25 -8
- package/src/config-watcher.ts +7 -0
- package/src/config.ts +106 -25
- package/src/decision.ts +4 -1
- package/src/doctor.ts +16 -5
- package/src/eval.ts +2 -1
- package/src/router-core.ts +73 -42
- package/src/server.ts +8 -5
- package/src/types.ts +3 -3
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
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
function simulateDecision(
|
|
234
|
+
function decideObservation(
|
|
216
235
|
obs: QueryObservation,
|
|
217
236
|
thresholds: SelectedThresholds,
|
|
218
237
|
candidateLimit: number,
|
|
219
|
-
)
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
254
|
+
candidateLimit: number,
|
|
237
255
|
): CalibrationMetrics {
|
|
238
256
|
let autoMatchCount = 0;
|
|
239
257
|
let correctAutoMatch = 0;
|
|
240
|
-
let
|
|
241
|
-
let
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 ?
|
|
263
|
-
const
|
|
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
|
-
:
|
|
266
|
-
const
|
|
291
|
+
: correctAutoMatch / expectedMatches.length;
|
|
292
|
+
const retrieval_recall_at_k = matchableCases.length === 0
|
|
267
293
|
? 1.0
|
|
268
|
-
:
|
|
269
|
-
const
|
|
270
|
-
? 0
|
|
271
|
-
:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
320
|
-
const
|
|
321
|
-
const
|
|
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
|
|
397
|
+
* 1. Satisfies Wilson precision, sample-count, coverage, and delivered-recall gates
|
|
344
398
|
* 2. Among those: maximizes auto_match_coverage
|
|
345
|
-
* 3. Ties
|
|
346
|
-
* then lower
|
|
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:
|
|
456
|
+
gates: CalibrationGates,
|
|
351
457
|
candidateLimit: number,
|
|
352
|
-
): SelectedThresholds
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
for (
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
|
|
449
|
-
|
|
689
|
+
gates,
|
|
690
|
+
candidateLimit,
|
|
450
691
|
);
|
|
451
692
|
|
|
452
|
-
if (!selected) {
|
|
453
|
-
return {
|
|
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
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
? (
|
|
914
|
+
? parseMetrics(row.tune_metrics)
|
|
590
915
|
: undefined,
|
|
591
916
|
test_metrics: row.test_metrics != null
|
|
592
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|