@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/CHANGELOG.md +53 -0
- package/README.md +83 -29
- package/config.remote.example.toml +6 -4
- package/docs/calibration.md +106 -0
- package/docs/configuration.md +51 -7
- package/docs/schema.json +13 -5
- package/package.json +2 -1
- package/src/adapters.ts +111 -38
- package/src/calibrate.ts +623 -125
- package/src/cli.ts +56 -6
- 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/dataset-generator.ts +75 -96
- 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 +9 -66
- 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 { 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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
function simulateDecision(
|
|
366
|
+
function decideObservation(
|
|
216
367
|
obs: QueryObservation,
|
|
217
368
|
thresholds: SelectedThresholds,
|
|
218
369
|
candidateLimit: number,
|
|
219
|
-
)
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
386
|
+
candidateLimit: number,
|
|
237
387
|
): CalibrationMetrics {
|
|
238
388
|
let autoMatchCount = 0;
|
|
239
389
|
let correctAutoMatch = 0;
|
|
240
|
-
let
|
|
241
|
-
let
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 ?
|
|
263
|
-
const
|
|
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
|
-
:
|
|
266
|
-
const
|
|
423
|
+
: correctAutoMatch / expectedMatches.length;
|
|
424
|
+
const retrieval_recall_at_k = matchableCases.length === 0
|
|
267
425
|
? 1.0
|
|
268
|
-
:
|
|
269
|
-
const
|
|
270
|
-
? 0
|
|
271
|
-
:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
]);
|
|
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
|
|
529
|
+
* 1. Satisfies Wilson precision, sample-count, coverage, and delivered-recall gates
|
|
344
530
|
* 2. Among those: maximizes auto_match_coverage
|
|
345
|
-
* 3. Ties
|
|
346
|
-
* then lower
|
|
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:
|
|
588
|
+
gates: CalibrationGates,
|
|
351
589
|
candidateLimit: number,
|
|
352
|
-
): SelectedThresholds
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
for (
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
|
|
449
|
-
|
|
821
|
+
gates,
|
|
822
|
+
candidateLimit,
|
|
450
823
|
);
|
|
451
824
|
|
|
452
|
-
if (!selected) {
|
|
453
|
-
return {
|
|
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
|
|
461
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
? (
|
|
1069
|
+
? parseMetrics(row.tune_metrics)
|
|
590
1070
|
: undefined,
|
|
591
1071
|
test_metrics: row.test_metrics != null
|
|
592
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|