@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/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.99,
906
+ minAutoMatchPrecision = 0.75,
758
907
  minRetrievalRecallAtK = 0.95,
759
908
  minDeliveredShortlistRecallAtK = minRetrievalRecallAtK,
760
- minAutoMatchCount = 30,
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 observations: QueryObservation[] = [];
773
- for (const c of cases) {
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
- observations.push({
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
- gates,
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 { status: "completed", observations, selected_thresholds: selected, tune_metrics, test_metrics };
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
- /** Persist a calibration run (all fields) to the evidence store. */
978
- export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
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
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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.dataset_provenance ?? {}),
1014
- run.dataset_provenance?.human_labelled_case_count ?? 0,
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
- return row ? rowToRecord(row) : null;
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, failed_reason,
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
  )