@klhapp/skillmux 1.5.1 → 1.6.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 +17 -0
- package/docs/calibration.md +91 -4
- package/docs/cli.md +22 -1
- package/package.json +1 -1
- package/src/adapters.ts +410 -49
- package/src/calibrate.ts +526 -22
- package/src/cli.ts +75 -0
- package/src/router-core.ts +87 -13
package/src/calibrate.ts
CHANGED
|
@@ -322,7 +322,7 @@ export interface CalibrationTestMetrics extends CalibrationMetrics {
|
|
|
322
322
|
confusion_matrix: ConfusionMatrix;
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
export type CalibrationStatus = "completed" | "failed_gates";
|
|
325
|
+
export type CalibrationStatus = "running" | "completed" | "failed_gates";
|
|
326
326
|
export type CalibrationFailureReason =
|
|
327
327
|
| "recall_precondition_failed"
|
|
328
328
|
| "precision_floor_unreachable"
|
|
@@ -330,6 +330,12 @@ export type CalibrationFailureReason =
|
|
|
330
330
|
| "insufficient_sample"
|
|
331
331
|
| "test_certification_failed";
|
|
332
332
|
|
|
333
|
+
export interface TuneGateSlack {
|
|
334
|
+
auto_match_precision_lower_bound_slack: number;
|
|
335
|
+
auto_match_count_slack: number;
|
|
336
|
+
delivered_shortlist_recall_slack: number;
|
|
337
|
+
}
|
|
338
|
+
|
|
333
339
|
export interface CalibrationResult {
|
|
334
340
|
status: CalibrationStatus;
|
|
335
341
|
failed_reason?: CalibrationFailureReason;
|
|
@@ -337,6 +343,7 @@ export interface CalibrationResult {
|
|
|
337
343
|
selected_thresholds?: SelectedThresholds;
|
|
338
344
|
tune_metrics?: CalibrationMetrics;
|
|
339
345
|
test_metrics?: CalibrationTestMetrics;
|
|
346
|
+
tune_gate_slack?: TuneGateSlack;
|
|
340
347
|
}
|
|
341
348
|
|
|
342
349
|
export interface RunCalibrationOptions {
|
|
@@ -357,7 +364,24 @@ export interface RunCalibrationOptions {
|
|
|
357
364
|
minDeliveredShortlistRecallAtK?: number;
|
|
358
365
|
/** Default: 30 */
|
|
359
366
|
minAutoMatchCount?: number;
|
|
367
|
+
/** Tune-only selection buffer for Wilson auto-match precision lower bound. Default: 0.03 */
|
|
368
|
+
tuneAutoMatchPrecisionBuffer?: number;
|
|
369
|
+
/** Tune-only selection buffer for minimum auto-match count. Default: 3 */
|
|
370
|
+
tuneAutoMatchCountBuffer?: number;
|
|
371
|
+
/** Tune-only selection buffer for delivered shortlist recall. Default: 0.02 */
|
|
372
|
+
tuneDeliveredShortlistRecallBuffer?: number;
|
|
360
373
|
candidateLimit: number;
|
|
374
|
+
/** Default: 4 */
|
|
375
|
+
concurrency?: number;
|
|
376
|
+
initialObservations?: Map<number, QueryObservation> | QueryObservation[];
|
|
377
|
+
onProgress?: (completed: number, total: number) => void;
|
|
378
|
+
onObservation?: (
|
|
379
|
+
observation: QueryObservation,
|
|
380
|
+
caseIndex: number,
|
|
381
|
+
completedCount: number,
|
|
382
|
+
totalCount: number,
|
|
383
|
+
) => void | Promise<void>;
|
|
384
|
+
onObservationsReady?: () => void;
|
|
361
385
|
}
|
|
362
386
|
|
|
363
387
|
// ---------------------------------------------------------------------------
|
|
@@ -454,6 +478,97 @@ export function wilsonLowerBound(successes: number, total: number): number {
|
|
|
454
478
|
return Math.max(0, (centre - adjustment) / denominator);
|
|
455
479
|
}
|
|
456
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Computes the maximum attainable 95% Wilson lower bound on auto-match precision
|
|
483
|
+
* for the tune split subject to minAutoMatchCount and the number of matched tune cases.
|
|
484
|
+
* Under perfect ranking/classification (zero false positives), at most all true matched
|
|
485
|
+
* tune cases can be auto-matched.
|
|
486
|
+
*/
|
|
487
|
+
export function computeMaxAttainablePrecisionLowerBound(
|
|
488
|
+
cases: DecisionCase[],
|
|
489
|
+
minAutoMatchCount: number,
|
|
490
|
+
): { tuneMatchedCount: number; maxAttainablePrecision: number } {
|
|
491
|
+
const tuneMatchedCount = cases.filter(
|
|
492
|
+
(c) => c.split === "tune" && c.expected_outcome === "matched",
|
|
493
|
+
).length;
|
|
494
|
+
const effectiveTrials = Math.max(tuneMatchedCount, minAutoMatchCount);
|
|
495
|
+
const maxAttainablePrecision = wilsonLowerBound(tuneMatchedCount, effectiveTrials);
|
|
496
|
+
return { tuneMatchedCount, maxAttainablePrecision };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Asserts that the requested auto-match precision and count gates are mathematically
|
|
501
|
+
* attainable on the given dataset's tune split before inference begins.
|
|
502
|
+
*/
|
|
503
|
+
export function assertCalibrationFeasibility(
|
|
504
|
+
cases: DecisionCase[],
|
|
505
|
+
gates: {
|
|
506
|
+
minAutoMatchPrecision: number;
|
|
507
|
+
minAutoMatchCount: number;
|
|
508
|
+
minDeliveredShortlistRecallAtK?: number;
|
|
509
|
+
tuneAutoMatchPrecisionBuffer?: number;
|
|
510
|
+
tuneAutoMatchCountBuffer?: number;
|
|
511
|
+
tuneDeliveredShortlistRecallBuffer?: number;
|
|
512
|
+
},
|
|
513
|
+
): void {
|
|
514
|
+
const precisionBuffer = gates.tuneAutoMatchPrecisionBuffer ?? 0.03;
|
|
515
|
+
const countBuffer = gates.tuneAutoMatchCountBuffer ?? 3;
|
|
516
|
+
const recallBuffer = gates.tuneDeliveredShortlistRecallBuffer ?? 0.02;
|
|
517
|
+
|
|
518
|
+
if (precisionBuffer < 0 || !Number.isFinite(precisionBuffer)) {
|
|
519
|
+
throw new Error("tune_auto_match_precision_buffer must be a non-negative number");
|
|
520
|
+
}
|
|
521
|
+
if (countBuffer < 0 || !Number.isInteger(countBuffer)) {
|
|
522
|
+
throw new Error("tune_auto_match_count_buffer must be a non-negative integer");
|
|
523
|
+
}
|
|
524
|
+
if (recallBuffer < 0 || !Number.isFinite(recallBuffer)) {
|
|
525
|
+
throw new Error("tune_delivered_shortlist_recall_buffer must be a non-negative number");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const effectivePrecision = gates.minAutoMatchPrecision + precisionBuffer;
|
|
529
|
+
if (effectivePrecision > 1.0) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`Requested calibration gates with tune buffers are mathematically impossible: ` +
|
|
532
|
+
`effective min_auto_match_precision (${effectivePrecision.toFixed(2)}) exceeds 1.0 ` +
|
|
533
|
+
`(min_auto_match_precision=${gates.minAutoMatchPrecision}, tune_buffer=${precisionBuffer}). ` +
|
|
534
|
+
`Lower --min-auto-match-precision or --tune-auto-match-precision-buffer.`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const baseDeliveredRecall = gates.minDeliveredShortlistRecallAtK ?? 0.95;
|
|
539
|
+
const effectiveDeliveredRecall = baseDeliveredRecall + recallBuffer;
|
|
540
|
+
if (effectiveDeliveredRecall > 1.0) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`Requested calibration gates with tune buffers are mathematically impossible: ` +
|
|
543
|
+
`effective min_delivered_shortlist_recall_at_k (${effectiveDeliveredRecall.toFixed(2)}) exceeds 1.0 ` +
|
|
544
|
+
`(min_delivered_shortlist_recall_at_k=${baseDeliveredRecall}, tune_buffer=${recallBuffer}). ` +
|
|
545
|
+
`Lower --min-delivered-shortlist-recall-at-k or --tune-delivered-shortlist-recall-buffer.`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const effectiveCount = gates.minAutoMatchCount + countBuffer;
|
|
550
|
+
const { tuneMatchedCount, maxAttainablePrecision } =
|
|
551
|
+
computeMaxAttainablePrecisionLowerBound(cases, effectiveCount);
|
|
552
|
+
if (effectiveCount > tuneMatchedCount) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
`Requested calibration gates are mathematically unattainable on this dataset: ` +
|
|
555
|
+
`effective min_auto_match_count (${effectiveCount}) exceeds the number of matched cases in the ` +
|
|
556
|
+
`tune split (${tuneMatchedCount}) (min_auto_match_count=${gates.minAutoMatchCount}, tune_buffer=${countBuffer}). ` +
|
|
557
|
+
`Lower --min-auto-match-count or --tune-auto-match-count-buffer, or supply a dataset with more matched tune cases.`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (maxAttainablePrecision < effectivePrecision) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`Requested calibration gates are mathematically unattainable on this dataset: ` +
|
|
563
|
+
`min_auto_match_precision=${gates.minAutoMatchPrecision} requires more evidence than the ` +
|
|
564
|
+
`tune split provides (${tuneMatchedCount} matched cases, min_auto_match_count=${gates.minAutoMatchCount}, effective min_auto_match_count=${effectiveCount}). ` +
|
|
565
|
+
`The maximum attainable 95% Wilson lower bound under perfect classification is ` +
|
|
566
|
+
`${maxAttainablePrecision.toFixed(4)}. ` +
|
|
567
|
+
`Lower --min-auto-match-precision or supply a larger dataset.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
457
572
|
function computeTestMetrics(
|
|
458
573
|
observations: QueryObservation[],
|
|
459
574
|
thresholds: SelectedThresholds,
|
|
@@ -741,6 +856,40 @@ function selectThresholds(
|
|
|
741
856
|
// Public API — runCalibration (AC2, AC3, AC4)
|
|
742
857
|
// ---------------------------------------------------------------------------
|
|
743
858
|
|
|
859
|
+
async function processWithConcurrency<T>(
|
|
860
|
+
items: readonly T[],
|
|
861
|
+
concurrency: number,
|
|
862
|
+
processItem: (item: T) => Promise<void>,
|
|
863
|
+
): Promise<void> {
|
|
864
|
+
let nextItemPosition = 0;
|
|
865
|
+
let firstError: unknown;
|
|
866
|
+
|
|
867
|
+
const claimNextPosition = (): number | undefined => {
|
|
868
|
+
if (firstError !== undefined || nextItemPosition >= items.length) return undefined;
|
|
869
|
+
return nextItemPosition++;
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const workerCount = Math.min(concurrency, items.length);
|
|
873
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
874
|
+
for (
|
|
875
|
+
let itemPosition = claimNextPosition();
|
|
876
|
+
itemPosition !== undefined;
|
|
877
|
+
itemPosition = claimNextPosition()
|
|
878
|
+
) {
|
|
879
|
+
try {
|
|
880
|
+
await processItem(items[itemPosition]!);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
firstError ??= error;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// Drain work already in flight before propagating an error. Callers may close
|
|
888
|
+
// resources after rejection, so no worker may still be checkpointing then.
|
|
889
|
+
await Promise.all(workers);
|
|
890
|
+
if (firstError !== undefined) throw firstError;
|
|
891
|
+
}
|
|
892
|
+
|
|
744
893
|
/**
|
|
745
894
|
* Run an in-memory calibration:
|
|
746
895
|
* 1. Require a configured reranker (AC2)
|
|
@@ -754,13 +903,24 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
|
|
|
754
903
|
getCandidates,
|
|
755
904
|
getRankedCandidates,
|
|
756
905
|
reranker,
|
|
757
|
-
minAutoMatchPrecision = 0.
|
|
906
|
+
minAutoMatchPrecision = 0.75,
|
|
758
907
|
minRetrievalRecallAtK = 0.95,
|
|
759
908
|
minDeliveredShortlistRecallAtK = minRetrievalRecallAtK,
|
|
760
|
-
minAutoMatchCount =
|
|
909
|
+
minAutoMatchCount = 15,
|
|
910
|
+
tuneAutoMatchPrecisionBuffer = 0.03,
|
|
911
|
+
tuneAutoMatchCountBuffer = 3,
|
|
912
|
+
tuneDeliveredShortlistRecallBuffer = 0.02,
|
|
761
913
|
candidateLimit,
|
|
914
|
+
concurrency = 4,
|
|
915
|
+
initialObservations,
|
|
916
|
+
onProgress,
|
|
917
|
+
onObservation,
|
|
762
918
|
} = opts;
|
|
763
919
|
|
|
920
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
921
|
+
throw new Error("concurrency must be a positive integer");
|
|
922
|
+
}
|
|
923
|
+
|
|
764
924
|
if (!getRankedCandidates && (!getCandidates || !reranker)) {
|
|
765
925
|
throw new Error(
|
|
766
926
|
"A configured reranker is required to run calibration. " +
|
|
@@ -769,8 +929,31 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
|
|
|
769
929
|
}
|
|
770
930
|
|
|
771
931
|
// --- Step 1: Cache observations (reranker called exactly once per query) ---
|
|
772
|
-
const
|
|
773
|
-
|
|
932
|
+
const obsMap = new Map<number, QueryObservation>();
|
|
933
|
+
if (initialObservations) {
|
|
934
|
+
if (Array.isArray(initialObservations)) {
|
|
935
|
+
initialObservations.forEach((obs, idx) => {
|
|
936
|
+
if (obs) obsMap.set(idx, obs);
|
|
937
|
+
});
|
|
938
|
+
} else {
|
|
939
|
+
for (const [idx, obs] of initialObservations.entries()) {
|
|
940
|
+
obsMap.set(idx, obs);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
let completedCount = obsMap.size;
|
|
946
|
+
if (onProgress && completedCount > 0) {
|
|
947
|
+
onProgress(completedCount, cases.length);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const pendingIndices: number[] = [];
|
|
951
|
+
for (let i = 0; i < cases.length; i++) {
|
|
952
|
+
if (!obsMap.has(i)) pendingIndices.push(i);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
await processWithConcurrency(pendingIndices, concurrency, async (caseIndex) => {
|
|
956
|
+
const c = cases[caseIndex]!;
|
|
774
957
|
let ranked: Array<{ skill_id: string; score: number }>;
|
|
775
958
|
if (getRankedCandidates) {
|
|
776
959
|
ranked = await getRankedCandidates(c.query);
|
|
@@ -781,14 +964,25 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
|
|
|
781
964
|
.map((d, i) => ({ skill_id: d.skill_id, score: scores[i] ?? 0 }))
|
|
782
965
|
.sort((a, b) => b.score - a.score);
|
|
783
966
|
}
|
|
784
|
-
|
|
967
|
+
const obs: QueryObservation = {
|
|
785
968
|
query: c.query,
|
|
786
969
|
split: c.split,
|
|
787
970
|
expected_outcome: c.expected_outcome,
|
|
788
971
|
relevant_skill_ids: c.relevant_skill_ids,
|
|
789
972
|
ranked,
|
|
790
|
-
}
|
|
791
|
-
|
|
973
|
+
};
|
|
974
|
+
obsMap.set(caseIndex, obs);
|
|
975
|
+
if (onObservation) {
|
|
976
|
+
await onObservation(obs, caseIndex, completedCount + 1, cases.length);
|
|
977
|
+
}
|
|
978
|
+
const progressCount = ++completedCount;
|
|
979
|
+
if (onProgress) {
|
|
980
|
+
onProgress(progressCount, cases.length);
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
const observations: QueryObservation[] = cases.map((_, i) => obsMap.get(i)!);
|
|
985
|
+
opts.onObservationsReady?.();
|
|
792
986
|
|
|
793
987
|
// --- Step 2: Select thresholds from tune split only ---
|
|
794
988
|
const tuneObs = observations.filter((o) => o.split === "tune");
|
|
@@ -817,9 +1011,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
|
|
|
817
1011
|
minDeliveredShortlistRecallAtK,
|
|
818
1012
|
minAutoMatchCount,
|
|
819
1013
|
};
|
|
1014
|
+
const tuneGates = {
|
|
1015
|
+
minAutoMatchPrecision: minAutoMatchPrecision + tuneAutoMatchPrecisionBuffer,
|
|
1016
|
+
minRetrievalRecallAtK,
|
|
1017
|
+
minDeliveredShortlistRecallAtK:
|
|
1018
|
+
minDeliveredShortlistRecallAtK + tuneDeliveredShortlistRecallBuffer,
|
|
1019
|
+
minAutoMatchCount: minAutoMatchCount + tuneAutoMatchCountBuffer,
|
|
1020
|
+
};
|
|
820
1021
|
const selection = selectThresholds(
|
|
821
1022
|
tuneObs,
|
|
822
|
-
|
|
1023
|
+
tuneGates,
|
|
823
1024
|
candidateLimit,
|
|
824
1025
|
);
|
|
825
1026
|
|
|
@@ -832,8 +1033,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
|
|
|
832
1033
|
}
|
|
833
1034
|
const selected = selection.selected;
|
|
834
1035
|
|
|
835
|
-
// --- Step 3: Report tune metrics ---
|
|
1036
|
+
// --- Step 3: Report tune metrics & explicit tune gate slack ---
|
|
836
1037
|
const tune_metrics = computeMetrics(tuneObs, selected, candidateLimit);
|
|
1038
|
+
const tune_gate_slack: TuneGateSlack = {
|
|
1039
|
+
auto_match_precision_lower_bound_slack:
|
|
1040
|
+
tune_metrics.auto_match_precision_lower_bound - minAutoMatchPrecision,
|
|
1041
|
+
auto_match_count_slack:
|
|
1042
|
+
tune_metrics.auto_match_count - minAutoMatchCount,
|
|
1043
|
+
delivered_shortlist_recall_slack:
|
|
1044
|
+
tune_metrics.delivered_shortlist_recall_at_k - minDeliveredShortlistRecallAtK,
|
|
1045
|
+
};
|
|
837
1046
|
|
|
838
1047
|
// --- Step 4: Evaluate untouched test split ---
|
|
839
1048
|
const test_metrics = computeTestMetrics(testObs, selected, candidateLimit);
|
|
@@ -845,10 +1054,18 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
|
|
|
845
1054
|
selected_thresholds: selected,
|
|
846
1055
|
tune_metrics,
|
|
847
1056
|
test_metrics,
|
|
1057
|
+
tune_gate_slack,
|
|
848
1058
|
};
|
|
849
1059
|
}
|
|
850
1060
|
|
|
851
|
-
return {
|
|
1061
|
+
return {
|
|
1062
|
+
status: "completed",
|
|
1063
|
+
observations,
|
|
1064
|
+
selected_thresholds: selected,
|
|
1065
|
+
tune_metrics,
|
|
1066
|
+
test_metrics,
|
|
1067
|
+
tune_gate_slack,
|
|
1068
|
+
};
|
|
852
1069
|
}
|
|
853
1070
|
|
|
854
1071
|
// ---------------------------------------------------------------------------
|
|
@@ -879,6 +1096,10 @@ export interface CalibrationRunRecord {
|
|
|
879
1096
|
min_auto_match_count?: number;
|
|
880
1097
|
min_delivered_shortlist_recall_at_k?: number;
|
|
881
1098
|
min_shortlist_recall_at_5: number;
|
|
1099
|
+
tune_auto_match_precision_buffer?: number;
|
|
1100
|
+
tune_auto_match_count_buffer?: number;
|
|
1101
|
+
tune_delivered_shortlist_recall_buffer?: number;
|
|
1102
|
+
tune_gate_slack?: TuneGateSlack;
|
|
882
1103
|
failed_reason?: CalibrationFailureReason;
|
|
883
1104
|
selected_thresholds?: SelectedThresholds;
|
|
884
1105
|
tune_metrics?: CalibrationMetrics;
|
|
@@ -903,6 +1124,9 @@ export interface CalibrationRunSummary {
|
|
|
903
1124
|
min_auto_match_count: number;
|
|
904
1125
|
min_delivered_shortlist_recall_at_k: number;
|
|
905
1126
|
min_shortlist_recall_at_5: number;
|
|
1127
|
+
tune_auto_match_precision_buffer: number;
|
|
1128
|
+
tune_auto_match_count_buffer: number;
|
|
1129
|
+
tune_delivered_shortlist_recall_buffer: number;
|
|
906
1130
|
failed_reason?: CalibrationFailureReason;
|
|
907
1131
|
}
|
|
908
1132
|
|
|
@@ -928,10 +1152,62 @@ export function openCalibrateDb(stateDir: string): Database {
|
|
|
928
1152
|
const db = new Database(join(stateDir, "calibrate.sqlite3"), { create: true });
|
|
929
1153
|
db.run("PRAGMA journal_mode = WAL");
|
|
930
1154
|
db.run("PRAGMA busy_timeout = 2000");
|
|
1155
|
+
|
|
1156
|
+
const tableDef = db
|
|
1157
|
+
.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'calibration_runs'")
|
|
1158
|
+
.get() as { sql: string } | null;
|
|
1159
|
+
|
|
1160
|
+
if (
|
|
1161
|
+
tableDef &&
|
|
1162
|
+
tableDef.sql &&
|
|
1163
|
+
tableDef.sql.includes("CHECK") &&
|
|
1164
|
+
!tableDef.sql.includes("'running'")
|
|
1165
|
+
) {
|
|
1166
|
+
const cols = db.query("PRAGMA table_info(calibration_runs)").all() as Array<{ name: string }>;
|
|
1167
|
+
const colNames = cols.map((c) => c.name);
|
|
1168
|
+
const selectCols = colNames.join(", ");
|
|
1169
|
+
|
|
1170
|
+
db.transaction(() => {
|
|
1171
|
+
db.run(`CREATE TABLE calibration_runs_new (
|
|
1172
|
+
run_id TEXT PRIMARY KEY,
|
|
1173
|
+
created_at TEXT NOT NULL,
|
|
1174
|
+
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed_gates')),
|
|
1175
|
+
reranker_fingerprint TEXT NOT NULL,
|
|
1176
|
+
embedding_fingerprint TEXT NOT NULL,
|
|
1177
|
+
corpus_fingerprint TEXT NOT NULL,
|
|
1178
|
+
dataset_hash TEXT NOT NULL,
|
|
1179
|
+
min_auto_match_precision REAL NOT NULL,
|
|
1180
|
+
min_shortlist_recall_at_5 REAL NOT NULL,
|
|
1181
|
+
selected_thresholds TEXT,
|
|
1182
|
+
tune_metrics TEXT,
|
|
1183
|
+
test_metrics TEXT,
|
|
1184
|
+
observations TEXT NOT NULL,
|
|
1185
|
+
candidate_limit INTEGER NOT NULL DEFAULT 5,
|
|
1186
|
+
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
1187
|
+
min_auto_match_count INTEGER NOT NULL DEFAULT 1,
|
|
1188
|
+
min_delivered_shortlist_recall_at_k REAL NOT NULL DEFAULT 0.95,
|
|
1189
|
+
failed_reason TEXT,
|
|
1190
|
+
dataset_provenance TEXT NOT NULL DEFAULT '{}',
|
|
1191
|
+
human_labelled_case_count INTEGER NOT NULL DEFAULT 0,
|
|
1192
|
+
imported_labelled_case_count INTEGER NOT NULL DEFAULT 0,
|
|
1193
|
+
recall_settings TEXT NOT NULL DEFAULT '{}',
|
|
1194
|
+
tune_auto_match_precision_buffer REAL NOT NULL DEFAULT 0.03,
|
|
1195
|
+
tune_auto_match_count_buffer INTEGER NOT NULL DEFAULT 3,
|
|
1196
|
+
tune_delivered_shortlist_recall_buffer REAL NOT NULL DEFAULT 0.02,
|
|
1197
|
+
tune_gate_slack TEXT
|
|
1198
|
+
)`);
|
|
1199
|
+
db.run(
|
|
1200
|
+
`INSERT INTO calibration_runs_new (${selectCols}) SELECT ${selectCols} FROM calibration_runs`,
|
|
1201
|
+
);
|
|
1202
|
+
db.run("DROP TABLE calibration_runs");
|
|
1203
|
+
db.run("ALTER TABLE calibration_runs_new RENAME TO calibration_runs");
|
|
1204
|
+
})();
|
|
1205
|
+
}
|
|
1206
|
+
|
|
931
1207
|
db.run(`CREATE TABLE IF NOT EXISTS calibration_runs (
|
|
932
1208
|
run_id TEXT PRIMARY KEY,
|
|
933
1209
|
created_at TEXT NOT NULL,
|
|
934
|
-
status TEXT NOT NULL CHECK (status IN ('completed', 'failed_gates')),
|
|
1210
|
+
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed_gates')),
|
|
935
1211
|
reranker_fingerprint TEXT NOT NULL,
|
|
936
1212
|
embedding_fingerprint TEXT NOT NULL,
|
|
937
1213
|
corpus_fingerprint TEXT NOT NULL,
|
|
@@ -971,11 +1247,63 @@ export function openCalibrateDb(stateDir: string): Database {
|
|
|
971
1247
|
if (!columns.some((column) => column.name === "recall_settings")) {
|
|
972
1248
|
db.run("ALTER TABLE calibration_runs ADD COLUMN recall_settings TEXT NOT NULL DEFAULT '{}'");
|
|
973
1249
|
}
|
|
1250
|
+
if (!columns.some((column) => column.name === "tune_auto_match_precision_buffer")) {
|
|
1251
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN tune_auto_match_precision_buffer REAL NOT NULL DEFAULT 0.03");
|
|
1252
|
+
}
|
|
1253
|
+
if (!columns.some((column) => column.name === "tune_auto_match_count_buffer")) {
|
|
1254
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN tune_auto_match_count_buffer INTEGER NOT NULL DEFAULT 3");
|
|
1255
|
+
}
|
|
1256
|
+
if (!columns.some((column) => column.name === "tune_delivered_shortlist_recall_buffer")) {
|
|
1257
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN tune_delivered_shortlist_recall_buffer REAL NOT NULL DEFAULT 0.02");
|
|
1258
|
+
}
|
|
1259
|
+
if (!columns.some((column) => column.name === "tune_gate_slack")) {
|
|
1260
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN tune_gate_slack TEXT");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
db.run(`CREATE TABLE IF NOT EXISTS calibration_observations (
|
|
1264
|
+
run_id TEXT NOT NULL,
|
|
1265
|
+
case_index INTEGER NOT NULL,
|
|
1266
|
+
query TEXT NOT NULL,
|
|
1267
|
+
split TEXT NOT NULL,
|
|
1268
|
+
expected_outcome TEXT NOT NULL,
|
|
1269
|
+
relevant_skill_ids TEXT NOT NULL,
|
|
1270
|
+
ranked TEXT NOT NULL,
|
|
1271
|
+
PRIMARY KEY (run_id, case_index),
|
|
1272
|
+
FOREIGN KEY (run_id) REFERENCES calibration_runs(run_id) ON DELETE CASCADE
|
|
1273
|
+
)`);
|
|
1274
|
+
|
|
974
1275
|
return db;
|
|
975
1276
|
}
|
|
976
1277
|
|
|
977
|
-
|
|
978
|
-
|
|
1278
|
+
export interface CreateInitialCalibrationRunOptions {
|
|
1279
|
+
run_id: string;
|
|
1280
|
+
created_at: string;
|
|
1281
|
+
status: "running";
|
|
1282
|
+
reranker_fingerprint: string;
|
|
1283
|
+
embedding_fingerprint: string;
|
|
1284
|
+
corpus_fingerprint: string;
|
|
1285
|
+
dataset_hash: string;
|
|
1286
|
+
candidate_limit: number;
|
|
1287
|
+
min_auto_match_precision: number;
|
|
1288
|
+
min_auto_match_count?: number;
|
|
1289
|
+
min_delivered_shortlist_recall_at_k?: number;
|
|
1290
|
+
min_shortlist_recall_at_5: number;
|
|
1291
|
+
tune_auto_match_precision_buffer?: number;
|
|
1292
|
+
tune_auto_match_count_buffer?: number;
|
|
1293
|
+
tune_delivered_shortlist_recall_buffer?: number;
|
|
1294
|
+
dataset_provenance?: DatasetProvenanceSummary;
|
|
1295
|
+
recall_settings?: {
|
|
1296
|
+
k_lexical: number;
|
|
1297
|
+
k_vector: number;
|
|
1298
|
+
k_rerank: number;
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** Create an initial calibration run record with 'running' status before inference starts. */
|
|
1303
|
+
export function createInitialCalibrationRun(
|
|
1304
|
+
db: Database,
|
|
1305
|
+
run: CreateInitialCalibrationRunOptions,
|
|
1306
|
+
): void {
|
|
979
1307
|
const attemptCount = (
|
|
980
1308
|
db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
|
|
981
1309
|
.get(run.dataset_hash) as { count: number }
|
|
@@ -989,8 +1317,10 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
|
|
|
989
1317
|
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
|
|
990
1318
|
selected_thresholds, tune_metrics, test_metrics, observations,
|
|
991
1319
|
dataset_provenance, human_labelled_case_count, imported_labelled_case_count,
|
|
992
|
-
recall_settings
|
|
993
|
-
|
|
1320
|
+
recall_settings,
|
|
1321
|
+
tune_auto_match_precision_buffer, tune_auto_match_count_buffer,
|
|
1322
|
+
tune_delivered_shortlist_recall_buffer, tune_gate_slack
|
|
1323
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
994
1324
|
[
|
|
995
1325
|
run.run_id,
|
|
996
1326
|
run.created_at,
|
|
@@ -1005,19 +1335,174 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
|
|
|
1005
1335
|
run.min_auto_match_count ?? 1,
|
|
1006
1336
|
run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
|
|
1007
1337
|
run.min_shortlist_recall_at_5,
|
|
1338
|
+
null,
|
|
1339
|
+
null,
|
|
1340
|
+
null,
|
|
1341
|
+
null,
|
|
1342
|
+
"[]",
|
|
1343
|
+
JSON.stringify(run.dataset_provenance ?? {}),
|
|
1344
|
+
run.dataset_provenance?.human_labelled_case_count ?? 0,
|
|
1345
|
+
run.dataset_provenance?.imported_labelled_case_count ?? 0,
|
|
1346
|
+
JSON.stringify(run.recall_settings ?? {}),
|
|
1347
|
+
run.tune_auto_match_precision_buffer ?? 0.03,
|
|
1348
|
+
run.tune_auto_match_count_buffer ?? 3,
|
|
1349
|
+
run.tune_delivered_shortlist_recall_buffer ?? 0.02,
|
|
1350
|
+
null,
|
|
1351
|
+
],
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Save a single case observation incrementally. */
|
|
1356
|
+
export function saveCalibrationObservation(
|
|
1357
|
+
db: Database,
|
|
1358
|
+
runId: string,
|
|
1359
|
+
caseIndex: number,
|
|
1360
|
+
observation: QueryObservation,
|
|
1361
|
+
): void {
|
|
1362
|
+
db.run(
|
|
1363
|
+
`INSERT OR REPLACE INTO calibration_observations (
|
|
1364
|
+
run_id, case_index, query, split, expected_outcome, relevant_skill_ids, ranked
|
|
1365
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1366
|
+
[
|
|
1367
|
+
runId,
|
|
1368
|
+
caseIndex,
|
|
1369
|
+
observation.query,
|
|
1370
|
+
observation.split,
|
|
1371
|
+
observation.expected_outcome,
|
|
1372
|
+
JSON.stringify(observation.relevant_skill_ids),
|
|
1373
|
+
JSON.stringify(observation.ranked),
|
|
1374
|
+
],
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/** Retrieve all completed observations for a run_id, keyed by case index. */
|
|
1379
|
+
export function getCalibrationObservations(
|
|
1380
|
+
db: Database,
|
|
1381
|
+
runId: string,
|
|
1382
|
+
): Map<number, QueryObservation> {
|
|
1383
|
+
const rows = db
|
|
1384
|
+
.query(
|
|
1385
|
+
`SELECT case_index, query, split, expected_outcome, relevant_skill_ids, ranked
|
|
1386
|
+
FROM calibration_observations WHERE run_id = ? ORDER BY case_index ASC`,
|
|
1387
|
+
)
|
|
1388
|
+
.all(runId) as Array<{
|
|
1389
|
+
case_index: number;
|
|
1390
|
+
query: string;
|
|
1391
|
+
split: DecisionSplit;
|
|
1392
|
+
expected_outcome: DecisionOutcome;
|
|
1393
|
+
relevant_skill_ids: string;
|
|
1394
|
+
ranked: string;
|
|
1395
|
+
}>;
|
|
1396
|
+
const map = new Map<number, QueryObservation>();
|
|
1397
|
+
for (const r of rows) {
|
|
1398
|
+
map.set(r.case_index, {
|
|
1399
|
+
query: r.query,
|
|
1400
|
+
split: r.split,
|
|
1401
|
+
expected_outcome: r.expected_outcome,
|
|
1402
|
+
relevant_skill_ids: JSON.parse(r.relevant_skill_ids) as string[],
|
|
1403
|
+
ranked: JSON.parse(r.ranked) as Array<{ skill_id: string; score: number }>,
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
return map;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
export interface FinalizeCalibrationRunOptions {
|
|
1410
|
+
run_id: string;
|
|
1411
|
+
status: CalibrationStatus;
|
|
1412
|
+
failed_reason?: CalibrationFailureReason;
|
|
1413
|
+
selected_thresholds?: SelectedThresholds;
|
|
1414
|
+
tune_metrics?: CalibrationMetrics;
|
|
1415
|
+
test_metrics?: CalibrationTestMetrics;
|
|
1416
|
+
tune_gate_slack?: TuneGateSlack;
|
|
1417
|
+
observations: QueryObservation[];
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/** Finalize a calibration run record with its completion/failure status, metrics, and thresholds. */
|
|
1421
|
+
export function finalizeCalibrationRun(
|
|
1422
|
+
db: Database,
|
|
1423
|
+
run: FinalizeCalibrationRunOptions,
|
|
1424
|
+
): void {
|
|
1425
|
+
db.run(
|
|
1426
|
+
`UPDATE calibration_runs SET
|
|
1427
|
+
status = ?,
|
|
1428
|
+
failed_reason = ?,
|
|
1429
|
+
selected_thresholds = ?,
|
|
1430
|
+
tune_metrics = ?,
|
|
1431
|
+
test_metrics = ?,
|
|
1432
|
+
observations = ?,
|
|
1433
|
+
tune_gate_slack = ?
|
|
1434
|
+
WHERE run_id = ?`,
|
|
1435
|
+
[
|
|
1436
|
+
run.status,
|
|
1008
1437
|
run.failed_reason ?? null,
|
|
1009
1438
|
run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
|
|
1010
1439
|
run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
|
|
1011
1440
|
run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
|
|
1012
1441
|
JSON.stringify(run.observations),
|
|
1013
|
-
JSON.stringify(run.
|
|
1014
|
-
run.
|
|
1015
|
-
run.dataset_provenance?.imported_labelled_case_count ?? 0,
|
|
1016
|
-
JSON.stringify(run.recall_settings ?? {}),
|
|
1442
|
+
run.tune_gate_slack != null ? JSON.stringify(run.tune_gate_slack) : null,
|
|
1443
|
+
run.run_id,
|
|
1017
1444
|
],
|
|
1018
1445
|
);
|
|
1019
1446
|
}
|
|
1020
1447
|
|
|
1448
|
+
/** Persist a calibration run (all fields) to the evidence store. */
|
|
1449
|
+
export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
|
|
1450
|
+
const attemptCount = (
|
|
1451
|
+
db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
|
|
1452
|
+
.get(run.dataset_hash) as { count: number }
|
|
1453
|
+
).count + 1;
|
|
1454
|
+
db.transaction(() => {
|
|
1455
|
+
db.run(
|
|
1456
|
+
`INSERT OR REPLACE INTO calibration_runs (
|
|
1457
|
+
run_id, created_at, status,
|
|
1458
|
+
reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
|
|
1459
|
+
candidate_limit,
|
|
1460
|
+
attempt_count, min_auto_match_precision, min_auto_match_count,
|
|
1461
|
+
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
|
|
1462
|
+
selected_thresholds, tune_metrics, test_metrics, observations,
|
|
1463
|
+
dataset_provenance, human_labelled_case_count, imported_labelled_case_count,
|
|
1464
|
+
recall_settings,
|
|
1465
|
+
tune_auto_match_precision_buffer, tune_auto_match_count_buffer,
|
|
1466
|
+
tune_delivered_shortlist_recall_buffer, tune_gate_slack
|
|
1467
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1468
|
+
[
|
|
1469
|
+
run.run_id,
|
|
1470
|
+
run.created_at,
|
|
1471
|
+
run.status,
|
|
1472
|
+
run.reranker_fingerprint,
|
|
1473
|
+
run.embedding_fingerprint,
|
|
1474
|
+
run.corpus_fingerprint,
|
|
1475
|
+
run.dataset_hash,
|
|
1476
|
+
run.candidate_limit,
|
|
1477
|
+
attemptCount,
|
|
1478
|
+
run.min_auto_match_precision,
|
|
1479
|
+
run.min_auto_match_count ?? 1,
|
|
1480
|
+
run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
|
|
1481
|
+
run.min_shortlist_recall_at_5,
|
|
1482
|
+
run.failed_reason ?? null,
|
|
1483
|
+
run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
|
|
1484
|
+
run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
|
|
1485
|
+
run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
|
|
1486
|
+
JSON.stringify(run.observations),
|
|
1487
|
+
JSON.stringify(run.dataset_provenance ?? {}),
|
|
1488
|
+
run.dataset_provenance?.human_labelled_case_count ?? 0,
|
|
1489
|
+
run.dataset_provenance?.imported_labelled_case_count ?? 0,
|
|
1490
|
+
JSON.stringify(run.recall_settings ?? {}),
|
|
1491
|
+
run.tune_auto_match_precision_buffer ?? 0.03,
|
|
1492
|
+
run.tune_auto_match_count_buffer ?? 3,
|
|
1493
|
+
run.tune_delivered_shortlist_recall_buffer ?? 0.02,
|
|
1494
|
+
run.tune_gate_slack != null ? JSON.stringify(run.tune_gate_slack) : null,
|
|
1495
|
+
],
|
|
1496
|
+
);
|
|
1497
|
+
if (run.observations && run.observations.length > 0) {
|
|
1498
|
+
for (let i = 0; i < run.observations.length; i++) {
|
|
1499
|
+
const obs = run.observations[i]!;
|
|
1500
|
+
saveCalibrationObservation(db, run.run_id, i, obs);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
})();
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1021
1506
|
interface RawCalibrationRow {
|
|
1022
1507
|
run_id: string;
|
|
1023
1508
|
created_at: string;
|
|
@@ -1041,6 +1526,10 @@ interface RawCalibrationRow {
|
|
|
1041
1526
|
human_labelled_case_count: number;
|
|
1042
1527
|
imported_labelled_case_count: number;
|
|
1043
1528
|
recall_settings: string;
|
|
1529
|
+
tune_auto_match_precision_buffer: number | null;
|
|
1530
|
+
tune_auto_match_count_buffer: number | null;
|
|
1531
|
+
tune_delivered_shortlist_recall_buffer: number | null;
|
|
1532
|
+
tune_gate_slack: string | null;
|
|
1044
1533
|
}
|
|
1045
1534
|
|
|
1046
1535
|
function parseMetrics(json: string): CalibrationMetrics {
|
|
@@ -1089,6 +1578,10 @@ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
|
|
|
1089
1578
|
min_auto_match_count: row.min_auto_match_count,
|
|
1090
1579
|
min_delivered_shortlist_recall_at_k: row.min_delivered_shortlist_recall_at_k,
|
|
1091
1580
|
min_shortlist_recall_at_5: row.min_shortlist_recall_at_5,
|
|
1581
|
+
tune_auto_match_precision_buffer: row.tune_auto_match_precision_buffer ?? 0.03,
|
|
1582
|
+
tune_auto_match_count_buffer: row.tune_auto_match_count_buffer ?? 3,
|
|
1583
|
+
tune_delivered_shortlist_recall_buffer: row.tune_delivered_shortlist_recall_buffer ?? 0.02,
|
|
1584
|
+
tune_gate_slack: row.tune_gate_slack != null ? JSON.parse(row.tune_gate_slack) : undefined,
|
|
1092
1585
|
failed_reason: row.failed_reason as CalibrationFailureReason | null ?? undefined,
|
|
1093
1586
|
selected_thresholds: row.selected_thresholds != null
|
|
1094
1587
|
? (JSON.parse(row.selected_thresholds) as SelectedThresholds)
|
|
@@ -1111,7 +1604,16 @@ export function getCalibrationRun(db: Database, runId: string): CalibrationRunRe
|
|
|
1111
1604
|
const row = db
|
|
1112
1605
|
.query("SELECT * FROM calibration_runs WHERE run_id = ?")
|
|
1113
1606
|
.get(runId) as RawCalibrationRow | null;
|
|
1114
|
-
|
|
1607
|
+
if (!row) return null;
|
|
1608
|
+
const record = rowToRecord(row);
|
|
1609
|
+
if (record.observations.length === 0) {
|
|
1610
|
+
const obsMap = getCalibrationObservations(db, runId);
|
|
1611
|
+
if (obsMap.size > 0) {
|
|
1612
|
+
const sortedIndices = Array.from(obsMap.keys()).sort((a, b) => a - b);
|
|
1613
|
+
record.observations = sortedIndices.map((idx) => obsMap.get(idx)!);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
return record;
|
|
1115
1617
|
}
|
|
1116
1618
|
|
|
1117
1619
|
/** List all runs ordered by created_at descending (excludes observations blob). */
|
|
@@ -1122,7 +1624,9 @@ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
|
|
|
1122
1624
|
reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
|
|
1123
1625
|
candidate_limit,
|
|
1124
1626
|
attempt_count, min_auto_match_precision, min_auto_match_count,
|
|
1125
|
-
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5,
|
|
1627
|
+
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5,
|
|
1628
|
+
tune_auto_match_precision_buffer, tune_auto_match_count_buffer,
|
|
1629
|
+
tune_delivered_shortlist_recall_buffer, failed_reason,
|
|
1126
1630
|
human_labelled_case_count, imported_labelled_case_count
|
|
1127
1631
|
FROM calibration_runs ORDER BY created_at DESC`,
|
|
1128
1632
|
)
|