@klhapp/skillmux 1.5.2 → 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 CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.0](https://github.com/klhq/skillmux/compare/v1.5.2...v1.6.0) (2026-08-18)
9
+
10
+
11
+ ### Added
12
+
13
+ * **calibrate:** add tune safety buffers ([#120](https://github.com/klhq/skillmux/issues/120)) ([66d501c](https://github.com/klhq/skillmux/commit/66d501c4ac16956e8e887622e5178caef743bec7))
14
+
8
15
  ## [1.5.2](https://github.com/klhq/skillmux/compare/v1.5.1...v1.5.2) (2026-08-17)
9
16
 
10
17
 
@@ -89,6 +89,42 @@ The operator owns the labels: supply or review the cases, start the run,
89
89
  inspect its evidence, and explicitly apply an acceptable result. A successful
90
90
  run never changes live thresholds by itself.
91
91
 
92
+ ## Certification gates and preflight feasibility
93
+
94
+ Calibration certifies threshold policies against statistical confidence gates before allowing them to be applied:
95
+
96
+ | Flag | Default | Description |
97
+ |---|---|---|
98
+ | `--min-auto-match-precision` | `0.75` | Minimum 95% Wilson score lower confidence bound on auto-match precision |
99
+ | `--min-auto-match-count` | `15` | Minimum number of auto-matches required in evaluation |
100
+ | `--min-retrieval-recall-at-k` | `0.95` | Minimum top-k retrieval recall on matchable queries |
101
+ | `--min-delivered-shortlist-recall-at-k` | `0.95` | Minimum delivered shortlist recall on matchable queries |
102
+ | `--tune-auto-match-precision-buffer` | `0.03` | Tune-only selection buffer for Wilson auto-match precision lower bound |
103
+ | `--tune-auto-match-count-buffer` | `3` | Tune-only selection buffer for minimum auto-match count |
104
+ | `--tune-delivered-shortlist-recall-buffer` | `0.02` | Tune-only selection buffer for delivered shortlist recall |
105
+
106
+ ### Tune selection buffers vs production gates
107
+
108
+ Tune selection buffers ensure that threshold optimization selects policies with sufficient headroom beyond production gates. A candidate policy during tune search must satisfy the production gates plus their respective tune selection buffers. Test-split certification evaluates selected policies against the original production gates without selection buffers.
109
+
110
+ ### Wilson lower confidence bound and evidence size
111
+
112
+ `min-auto-match-precision` is evaluated not as raw sample accuracy, but as a **95% Wilson score lower confidence bound** ($z \approx 1.960$). This accounts for statistical uncertainty in small datasets.
113
+
114
+ Because the Wilson lower bound penalizes small sample sizes:
115
+ - A gate of **0.75** lower bound requires at least **15** flawless (15/15) auto-matches ($\text{Wilson}(15, 15) \approx 0.7961$).
116
+ - A 20-case tune matched split can achieve at most $\text{Wilson}(20, 20) \approx 0.8389$.
117
+ - A gate of **0.99** lower bound is statistically impossible on small datasets; it requires at least **381** flawless auto-matches ($\text{Wilson}(381, 381) \approx 0.9900$).
118
+
119
+ ### Preflight feasibility check
120
+
121
+ To avoid running expensive remote embeddings and rerankings on gates that can never pass, Skillmux executes a **preflight feasibility calculation** immediately after loading the dataset and before creating a running calibration record:
122
+
123
+ $$\text{effective\_trials} = \max(N_{\text{tune\_matched}}, \text{minAutoMatchCount})$$
124
+ $$\text{max\_attainable\_precision} = \text{WilsonLowerBound}(N_{\text{tune\_matched}}, \text{effective\_trials})$$
125
+
126
+ If $\text{max\_attainable\_precision} < \text{minAutoMatchPrecision}$, calibration fails immediately with an actionable error indicating the requested precision, requested count, available tune matched cases, and maximum attainable lower bound.
127
+
92
128
  ## Reading a run
93
129
 
94
130
  A `run_id` identifies one immutable calibration attempt and its evidence.
@@ -156,7 +192,7 @@ Provenance: the small synthetic corpus and labelled decision cases in
156
192
  [`tests/router-core.spec.test.ts`](../tests/router-core.spec.test.ts), with the
157
193
  wire contract captured by
158
194
  [`tests/fixtures/reranker/jina-v1-request.json`](../tests/fixtures/reranker/jina-v1-request.json).
159
- That fixture is below the default 30-auto-match certification minimum, so the
195
+ That fixture is below the default 15-auto-match certification minimum, so the
160
196
  values are a smoke-test/reference profile, not a completed calibration run.
161
197
  Run the lifecycle above against the deployment's real corpus before enabling
162
198
  automatic matches in production.
package/docs/cli.md CHANGED
@@ -298,9 +298,21 @@ certification gates, run evidence, reference values, and the complete operator
298
298
  lifecycle.
299
299
 
300
300
  ```sh
301
- # Run calibration on a dataset with the default four workers
301
+ # Run calibration on a dataset with default certification gates (min precision 0.75, min count 15)
302
302
  skillmux calibrate run --dataset ./eval/queries.json
303
303
 
304
+ # Specify explicit certification gates and tune selection buffers
305
+ # Note: --min-auto-match-precision is interpreted as a 95% Wilson lower confidence bound.
306
+ # Calibration preflight validates that the requested gate is mathematically attainable on the tune split.
307
+ skillmux calibrate run --dataset ./eval/queries.json \
308
+ --min-auto-match-precision 0.75 \
309
+ --min-auto-match-count 15 \
310
+ --min-retrieval-recall-at-k 0.95 \
311
+ --min-delivered-shortlist-recall-at-k 0.95 \
312
+ --tune-auto-match-precision-buffer 0.03 \
313
+ --tune-auto-match-count-buffer 3 \
314
+ --tune-delivered-shortlist-recall-buffer 0.02
315
+
304
316
  # Set the bounded worker count
305
317
  skillmux calibrate run --dataset ./eval/queries.json --concurrency 6
306
318
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
package/src/adapters.ts CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import {
4
4
  applyCalibrationRun,
5
+ assertCalibrationFeasibility,
5
6
  computeCorpusFingerprint,
6
7
  createInitialCalibrationRun,
7
8
  finalizeCalibrationRun,
@@ -36,6 +37,7 @@ import {
36
37
  } from "./config-service";
37
38
  import type { ResolvedTarget } from "./context";
38
39
  import {
40
+ classifyInferenceError,
39
41
  configure,
40
42
  retrieveAndRerankSnapshot,
41
43
  syncVaultIfNeeded,
@@ -43,6 +45,25 @@ import {
43
45
  } from "./router-core";
44
46
  import type { Clients, Config } from "./types";
45
47
 
48
+ /**
49
+ * Maximum retry attempts for transient reranker availability errors during calibration.
50
+ *
51
+ * Single-capacity remote reranker endpoints can suffer isolated blips (e.g. transient
52
+ * connection reset or momentary 503) during long multi-case calibration runs.
53
+ * A small bounded budget of 2 retries (3 attempts total) allows calibration to ride out
54
+ * transient blips without stalling on hard failures.
55
+ */
56
+ export const CALIBRATION_RERANK_MAX_RETRIES = 2;
57
+
58
+ /**
59
+ * Base backoff delay (ms) between reranker retries during calibration.
60
+ *
61
+ * A conservative nonzero delay gives recovering remote endpoints time to settle.
62
+ * Because reranker admission is strictly FIFO serialized, waiting workers remain
63
+ * queued rather than creating synchronized retry storms.
64
+ */
65
+ export const CALIBRATION_RERANK_RETRY_BACKOFF_MS = 250;
66
+
46
67
  export interface Capabilities {
47
68
  config_read: boolean;
48
69
  config_write: boolean;
@@ -109,6 +130,9 @@ export interface TargetAdapter {
109
130
  minRetrievalRecallAtK?: number;
110
131
  minDeliveredShortlistRecallAtK?: number;
111
132
  minAutoMatchCount?: number;
133
+ tuneAutoMatchPrecisionBuffer?: number;
134
+ tuneAutoMatchCountBuffer?: number;
135
+ tuneDeliveredShortlistRecallBuffer?: number;
112
136
  concurrency?: number;
113
137
  resumeRunId?: string;
114
138
  onProgress?: (completed: number, total: number) => void;
@@ -205,6 +229,9 @@ export class LocalAdapter implements TargetAdapter {
205
229
  minRetrievalRecallAtK?: number;
206
230
  minDeliveredShortlistRecallAtK?: number;
207
231
  minAutoMatchCount?: number;
232
+ tuneAutoMatchPrecisionBuffer?: number;
233
+ tuneAutoMatchCountBuffer?: number;
234
+ tuneDeliveredShortlistRecallBuffer?: number;
208
235
  concurrency?: number;
209
236
  resumeRunId?: string;
210
237
  onProgress?: (completed: number, total: number) => void;
@@ -215,7 +242,47 @@ export class LocalAdapter implements TargetAdapter {
215
242
  const wallStart = collectTiming ? performance.now() : 0;
216
243
 
217
244
  const config = await loadConfig(this.configPath);
218
- const clients = this.clients ?? createClients(config);
245
+ const baseClients = this.clients ?? createClients(config);
246
+
247
+ // Bounded admission at the calibration reranker boundary: serialize rerank calls
248
+ // so concurrent case retrieval (embedding, lexical, vector) does not overwhelm
249
+ // a single-capacity remote reranker endpoint.
250
+ //
251
+ // If an admitted request encounters a transient reranker_unavailable error,
252
+ // retry with conservative backoff up to CALIBRATION_RERANK_MAX_RETRIES.
253
+ // Non-availability errors (protocol, timeouts) and permanent failures fail closed.
254
+ let rerankQueue = Promise.resolve() as Promise<any>;
255
+ const serializedRerank: typeof baseClients.rerank = baseClients.rerank
256
+ ? (query, docs) => {
257
+ const run = async () => {
258
+ let attempt = 0;
259
+ while (true) {
260
+ try {
261
+ return await baseClients.rerank!(query, docs);
262
+ } catch (err) {
263
+ attempt++;
264
+ const degradationReason = classifyInferenceError("reranker", err);
265
+ if (
266
+ degradationReason === "reranker_unavailable" &&
267
+ attempt <= CALIBRATION_RERANK_MAX_RETRIES
268
+ ) {
269
+ await Bun.sleep(CALIBRATION_RERANK_RETRY_BACKOFF_MS * attempt);
270
+ continue;
271
+ }
272
+ throw err;
273
+ }
274
+ }
275
+ };
276
+ const next = rerankQueue.then(run, run);
277
+ rerankQueue = next.catch(() => {});
278
+ return next;
279
+ }
280
+ : undefined;
281
+
282
+ const clients: Clients = {
283
+ ...baseClients,
284
+ rerank: serializedRerank,
285
+ };
219
286
  configure({ config, clients });
220
287
 
221
288
  // Measure vault synchronization
@@ -246,6 +313,7 @@ export class LocalAdapter implements TargetAdapter {
246
313
  datasetFile,
247
314
  indexedSkills.map((skill) => skill.skill_id),
248
315
  );
316
+
249
317
  const fingerprint = rerankerFingerprint(config);
250
318
  if (!fingerprint) {
251
319
  throw new Error("A configured remote reranker is required to record calibration.");
@@ -258,15 +326,27 @@ export class LocalAdapter implements TargetAdapter {
258
326
  k_vector: config.recall.k_vector,
259
327
  k_rerank: config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector),
260
328
  };
261
- const minAutoMatchPrecision = opts?.minAutoMatchPrecision ?? 0.99;
262
- const minAutoMatchCount = opts?.minAutoMatchCount ?? 30;
329
+ const minAutoMatchPrecision = opts?.minAutoMatchPrecision ?? 0.75;
330
+ const minAutoMatchCount = opts?.minAutoMatchCount ?? 15;
263
331
  const minDeliveredShortlistRecallAtK =
264
332
  opts?.minDeliveredShortlistRecallAtK ??
265
333
  opts?.minRetrievalRecallAtK ??
266
334
  0.95;
267
335
  const minShortlistRecallAt5 = opts?.minRetrievalRecallAtK ?? 0.95;
336
+ const tuneAutoMatchPrecisionBuffer = opts?.tuneAutoMatchPrecisionBuffer ?? 0.03;
337
+ const tuneAutoMatchCountBuffer = opts?.tuneAutoMatchCountBuffer ?? 3;
338
+ const tuneDeliveredShortlistRecallBuffer = opts?.tuneDeliveredShortlistRecallBuffer ?? 0.02;
268
339
  const concurrency = opts?.concurrency ?? 4;
269
340
 
341
+ assertCalibrationFeasibility(cases, {
342
+ minAutoMatchPrecision,
343
+ minAutoMatchCount,
344
+ minDeliveredShortlistRecallAtK,
345
+ tuneAutoMatchPrecisionBuffer,
346
+ tuneAutoMatchCountBuffer,
347
+ tuneDeliveredShortlistRecallBuffer,
348
+ });
349
+
270
350
  const db = openCalibrateDb(expandHome(config.state_dir));
271
351
  let runId: string;
272
352
  let initialObservations: Map<number, QueryObservation> | undefined = undefined;
@@ -326,6 +406,30 @@ export class LocalAdapter implements TargetAdapter {
326
406
  if (existingRun.min_shortlist_recall_at_5 !== minShortlistRecallAt5) {
327
407
  throw new Error("Certification gate mismatch: min_retrieval_recall_at_k differs from original run");
328
408
  }
409
+ if (
410
+ existingRun.tune_auto_match_precision_buffer !== undefined &&
411
+ existingRun.tune_auto_match_precision_buffer !== tuneAutoMatchPrecisionBuffer
412
+ ) {
413
+ throw new Error(
414
+ "Certification gate mismatch: tune_auto_match_precision_buffer differs from original run",
415
+ );
416
+ }
417
+ if (
418
+ existingRun.tune_auto_match_count_buffer !== undefined &&
419
+ existingRun.tune_auto_match_count_buffer !== tuneAutoMatchCountBuffer
420
+ ) {
421
+ throw new Error(
422
+ "Certification gate mismatch: tune_auto_match_count_buffer differs from original run",
423
+ );
424
+ }
425
+ if (
426
+ existingRun.tune_delivered_shortlist_recall_buffer !== undefined &&
427
+ existingRun.tune_delivered_shortlist_recall_buffer !== tuneDeliveredShortlistRecallBuffer
428
+ ) {
429
+ throw new Error(
430
+ "Certification gate mismatch: tune_delivered_shortlist_recall_buffer differs from original run",
431
+ );
432
+ }
329
433
 
330
434
  initialObservations = getCalibrationObservations(db, runId);
331
435
  if (initialObservations.size === 0 && existingRun.observations && existingRun.observations.length > 0) {
@@ -348,6 +452,9 @@ export class LocalAdapter implements TargetAdapter {
348
452
  min_auto_match_count: minAutoMatchCount,
349
453
  min_delivered_shortlist_recall_at_k: minDeliveredShortlistRecallAtK,
350
454
  min_shortlist_recall_at_5: minShortlistRecallAt5,
455
+ tune_auto_match_precision_buffer: tuneAutoMatchPrecisionBuffer,
456
+ tune_auto_match_count_buffer: tuneAutoMatchCountBuffer,
457
+ tune_delivered_shortlist_recall_buffer: tuneDeliveredShortlistRecallBuffer,
351
458
  });
352
459
  }
353
460
 
@@ -395,6 +502,9 @@ export class LocalAdapter implements TargetAdapter {
395
502
  minRetrievalRecallAtK: minShortlistRecallAt5,
396
503
  minDeliveredShortlistRecallAtK,
397
504
  minAutoMatchCount,
505
+ tuneAutoMatchPrecisionBuffer,
506
+ tuneAutoMatchCountBuffer,
507
+ tuneDeliveredShortlistRecallBuffer,
398
508
  concurrency,
399
509
  initialObservations,
400
510
  onProgress,
@@ -425,6 +535,7 @@ export class LocalAdapter implements TargetAdapter {
425
535
  selected_thresholds: result.selected_thresholds,
426
536
  tune_metrics: result.tune_metrics,
427
537
  test_metrics: result.test_metrics,
538
+ tune_gate_slack: result.tune_gate_slack,
428
539
  observations: result.observations,
429
540
  });
430
541
 
@@ -670,6 +781,9 @@ export class RemoteAdapter implements TargetAdapter {
670
781
  minRetrievalRecallAtK?: number;
671
782
  minDeliveredShortlistRecallAtK?: number;
672
783
  minAutoMatchCount?: number;
784
+ tuneAutoMatchPrecisionBuffer?: number;
785
+ tuneAutoMatchCountBuffer?: number;
786
+ tuneDeliveredShortlistRecallBuffer?: number;
673
787
  concurrency?: number;
674
788
  resumeRunId?: string;
675
789
  onProgress?: (completed: number, total: number) => void;
package/src/calibrate.ts CHANGED
@@ -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,6 +364,12 @@ 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;
361
374
  /** Default: 4 */
362
375
  concurrency?: number;
@@ -465,6 +478,97 @@ export function wilsonLowerBound(successes: number, total: number): number {
465
478
  return Math.max(0, (centre - adjustment) / denominator);
466
479
  }
467
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
+
468
572
  function computeTestMetrics(
469
573
  observations: QueryObservation[],
470
574
  thresholds: SelectedThresholds,
@@ -799,10 +903,13 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
799
903
  getCandidates,
800
904
  getRankedCandidates,
801
905
  reranker,
802
- minAutoMatchPrecision = 0.99,
906
+ minAutoMatchPrecision = 0.75,
803
907
  minRetrievalRecallAtK = 0.95,
804
908
  minDeliveredShortlistRecallAtK = minRetrievalRecallAtK,
805
- minAutoMatchCount = 30,
909
+ minAutoMatchCount = 15,
910
+ tuneAutoMatchPrecisionBuffer = 0.03,
911
+ tuneAutoMatchCountBuffer = 3,
912
+ tuneDeliveredShortlistRecallBuffer = 0.02,
806
913
  candidateLimit,
807
914
  concurrency = 4,
808
915
  initialObservations,
@@ -904,9 +1011,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
904
1011
  minDeliveredShortlistRecallAtK,
905
1012
  minAutoMatchCount,
906
1013
  };
1014
+ const tuneGates = {
1015
+ minAutoMatchPrecision: minAutoMatchPrecision + tuneAutoMatchPrecisionBuffer,
1016
+ minRetrievalRecallAtK,
1017
+ minDeliveredShortlistRecallAtK:
1018
+ minDeliveredShortlistRecallAtK + tuneDeliveredShortlistRecallBuffer,
1019
+ minAutoMatchCount: minAutoMatchCount + tuneAutoMatchCountBuffer,
1020
+ };
907
1021
  const selection = selectThresholds(
908
1022
  tuneObs,
909
- gates,
1023
+ tuneGates,
910
1024
  candidateLimit,
911
1025
  );
912
1026
 
@@ -919,8 +1033,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
919
1033
  }
920
1034
  const selected = selection.selected;
921
1035
 
922
- // --- Step 3: Report tune metrics ---
1036
+ // --- Step 3: Report tune metrics & explicit tune gate slack ---
923
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
+ };
924
1046
 
925
1047
  // --- Step 4: Evaluate untouched test split ---
926
1048
  const test_metrics = computeTestMetrics(testObs, selected, candidateLimit);
@@ -932,10 +1054,18 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
932
1054
  selected_thresholds: selected,
933
1055
  tune_metrics,
934
1056
  test_metrics,
1057
+ tune_gate_slack,
935
1058
  };
936
1059
  }
937
1060
 
938
- 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
+ };
939
1069
  }
940
1070
 
941
1071
  // ---------------------------------------------------------------------------
@@ -966,6 +1096,10 @@ export interface CalibrationRunRecord {
966
1096
  min_auto_match_count?: number;
967
1097
  min_delivered_shortlist_recall_at_k?: number;
968
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;
969
1103
  failed_reason?: CalibrationFailureReason;
970
1104
  selected_thresholds?: SelectedThresholds;
971
1105
  tune_metrics?: CalibrationMetrics;
@@ -990,6 +1124,9 @@ export interface CalibrationRunSummary {
990
1124
  min_auto_match_count: number;
991
1125
  min_delivered_shortlist_recall_at_k: number;
992
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;
993
1130
  failed_reason?: CalibrationFailureReason;
994
1131
  }
995
1132
 
@@ -1053,7 +1190,11 @@ export function openCalibrateDb(stateDir: string): Database {
1053
1190
  dataset_provenance TEXT NOT NULL DEFAULT '{}',
1054
1191
  human_labelled_case_count INTEGER NOT NULL DEFAULT 0,
1055
1192
  imported_labelled_case_count INTEGER NOT NULL DEFAULT 0,
1056
- recall_settings TEXT NOT NULL DEFAULT '{}'
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
1057
1198
  )`);
1058
1199
  db.run(
1059
1200
  `INSERT INTO calibration_runs_new (${selectCols}) SELECT ${selectCols} FROM calibration_runs`,
@@ -1106,6 +1247,18 @@ export function openCalibrateDb(stateDir: string): Database {
1106
1247
  if (!columns.some((column) => column.name === "recall_settings")) {
1107
1248
  db.run("ALTER TABLE calibration_runs ADD COLUMN recall_settings TEXT NOT NULL DEFAULT '{}'");
1108
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
+ }
1109
1262
 
1110
1263
  db.run(`CREATE TABLE IF NOT EXISTS calibration_observations (
1111
1264
  run_id TEXT NOT NULL,
@@ -1135,6 +1288,9 @@ export interface CreateInitialCalibrationRunOptions {
1135
1288
  min_auto_match_count?: number;
1136
1289
  min_delivered_shortlist_recall_at_k?: number;
1137
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;
1138
1294
  dataset_provenance?: DatasetProvenanceSummary;
1139
1295
  recall_settings?: {
1140
1296
  k_lexical: number;
@@ -1161,8 +1317,10 @@ export function createInitialCalibrationRun(
1161
1317
  min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1162
1318
  selected_thresholds, tune_metrics, test_metrics, observations,
1163
1319
  dataset_provenance, human_labelled_case_count, imported_labelled_case_count,
1164
- recall_settings
1165
- ) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1166
1324
  [
1167
1325
  run.run_id,
1168
1326
  run.created_at,
@@ -1186,6 +1344,10 @@ export function createInitialCalibrationRun(
1186
1344
  run.dataset_provenance?.human_labelled_case_count ?? 0,
1187
1345
  run.dataset_provenance?.imported_labelled_case_count ?? 0,
1188
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,
1189
1351
  ],
1190
1352
  );
1191
1353
  }
@@ -1251,6 +1413,7 @@ export interface FinalizeCalibrationRunOptions {
1251
1413
  selected_thresholds?: SelectedThresholds;
1252
1414
  tune_metrics?: CalibrationMetrics;
1253
1415
  test_metrics?: CalibrationTestMetrics;
1416
+ tune_gate_slack?: TuneGateSlack;
1254
1417
  observations: QueryObservation[];
1255
1418
  }
1256
1419
 
@@ -1266,7 +1429,8 @@ export function finalizeCalibrationRun(
1266
1429
  selected_thresholds = ?,
1267
1430
  tune_metrics = ?,
1268
1431
  test_metrics = ?,
1269
- observations = ?
1432
+ observations = ?,
1433
+ tune_gate_slack = ?
1270
1434
  WHERE run_id = ?`,
1271
1435
  [
1272
1436
  run.status,
@@ -1275,6 +1439,7 @@ export function finalizeCalibrationRun(
1275
1439
  run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
1276
1440
  run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
1277
1441
  JSON.stringify(run.observations),
1442
+ run.tune_gate_slack != null ? JSON.stringify(run.tune_gate_slack) : null,
1278
1443
  run.run_id,
1279
1444
  ],
1280
1445
  );
@@ -1296,8 +1461,10 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
1296
1461
  min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1297
1462
  selected_thresholds, tune_metrics, test_metrics, observations,
1298
1463
  dataset_provenance, human_labelled_case_count, imported_labelled_case_count,
1299
- recall_settings
1300
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1301
1468
  [
1302
1469
  run.run_id,
1303
1470
  run.created_at,
@@ -1321,6 +1488,10 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
1321
1488
  run.dataset_provenance?.human_labelled_case_count ?? 0,
1322
1489
  run.dataset_provenance?.imported_labelled_case_count ?? 0,
1323
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,
1324
1495
  ],
1325
1496
  );
1326
1497
  if (run.observations && run.observations.length > 0) {
@@ -1355,6 +1526,10 @@ interface RawCalibrationRow {
1355
1526
  human_labelled_case_count: number;
1356
1527
  imported_labelled_case_count: number;
1357
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;
1358
1533
  }
1359
1534
 
1360
1535
  function parseMetrics(json: string): CalibrationMetrics {
@@ -1403,6 +1578,10 @@ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
1403
1578
  min_auto_match_count: row.min_auto_match_count,
1404
1579
  min_delivered_shortlist_recall_at_k: row.min_delivered_shortlist_recall_at_k,
1405
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,
1406
1585
  failed_reason: row.failed_reason as CalibrationFailureReason | null ?? undefined,
1407
1586
  selected_thresholds: row.selected_thresholds != null
1408
1587
  ? (JSON.parse(row.selected_thresholds) as SelectedThresholds)
@@ -1445,7 +1624,9 @@ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
1445
1624
  reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
1446
1625
  candidate_limit,
1447
1626
  attempt_count, min_auto_match_precision, min_auto_match_count,
1448
- 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,
1449
1630
  human_labelled_case_count, imported_labelled_case_count
1450
1631
  FROM calibration_runs ORDER BY created_at DESC`,
1451
1632
  )
package/src/cli.ts CHANGED
@@ -475,6 +475,9 @@ async function handleCalibrateCommand(
475
475
  let minRetrievalRecallAtK: number | undefined;
476
476
  let minDeliveredShortlistRecallAtK: number | undefined;
477
477
  let minAutoMatchCount: number | undefined;
478
+ let tuneAutoMatchPrecisionBuffer: number | undefined;
479
+ let tuneAutoMatchCountBuffer: number | undefined;
480
+ let tuneDeliveredShortlistRecallBuffer: number | undefined;
478
481
  let concurrency: number | undefined;
479
482
  let resumeRunId: string | undefined;
480
483
  let timing = false;
@@ -500,6 +503,15 @@ async function handleCalibrateCommand(
500
503
  if (!Number.isInteger(minAutoMatchCount) || minAutoMatchCount < 1) {
501
504
  throw new Error("--min-auto-match-count must be a positive integer");
502
505
  }
506
+ } else if (option === "--tune-auto-match-precision-buffer") {
507
+ tuneAutoMatchPrecisionBuffer = readNumber(option, args[++i]);
508
+ } else if (option === "--tune-auto-match-count-buffer") {
509
+ tuneAutoMatchCountBuffer = readNumber(option, args[++i]);
510
+ if (!Number.isInteger(tuneAutoMatchCountBuffer) || tuneAutoMatchCountBuffer < 0) {
511
+ throw new Error("--tune-auto-match-count-buffer must be a non-negative integer");
512
+ }
513
+ } else if (option === "--tune-delivered-shortlist-recall-buffer") {
514
+ tuneDeliveredShortlistRecallBuffer = readNumber(option, args[++i]);
503
515
  } else if (option === "--concurrency") {
504
516
  const raw = args[++i];
505
517
  if (raw === undefined) throw new Error("--concurrency requires a value");
@@ -513,6 +525,8 @@ async function handleCalibrateCommand(
513
525
  if (!resumeRunId) throw new Error("--resume requires a run_id value");
514
526
  } else if (option === "--timing") {
515
527
  timing = true;
528
+ } else if (option === "--json") {
529
+ // Global flag accepted in the documented subcommand position
516
530
  } else {
517
531
  throw new Error(`unknown calibrate run option: ${option}`);
518
532
  }
@@ -521,6 +535,8 @@ async function handleCalibrateCommand(
521
535
  ["--min-auto-match-precision", minAutoMatchPrecision],
522
536
  ["--min-retrieval-recall-at-k", minRetrievalRecallAtK],
523
537
  ["--min-delivered-shortlist-recall-at-k", minDeliveredShortlistRecallAtK],
538
+ ["--tune-auto-match-precision-buffer", tuneAutoMatchPrecisionBuffer],
539
+ ["--tune-delivered-shortlist-recall-buffer", tuneDeliveredShortlistRecallBuffer],
524
540
  ] as const) {
525
541
  if (value !== undefined && (value < 0 || value > 1)) {
526
542
  throw new Error(`${flag} must be between 0 and 1`);
@@ -532,6 +548,9 @@ async function handleCalibrateCommand(
532
548
  minRetrievalRecallAtK,
533
549
  minDeliveredShortlistRecallAtK,
534
550
  minAutoMatchCount,
551
+ tuneAutoMatchPrecisionBuffer,
552
+ tuneAutoMatchCountBuffer,
553
+ tuneDeliveredShortlistRecallBuffer,
535
554
  concurrency,
536
555
  resumeRunId,
537
556
  timing,
@@ -703,6 +722,12 @@ Setup:
703
722
 
704
723
  Calibration:
705
724
  skillmux calibrate run [--dataset <path>] [--concurrency <n>] [--resume <run_id>]
725
+ [--min-auto-match-precision <0..1>] [--min-auto-match-count <n>]
726
+ [--min-retrieval-recall-at-k <0..1>]
727
+ [--min-delivered-shortlist-recall-at-k <0..1>]
728
+ [--tune-auto-match-precision-buffer <0..1>]
729
+ [--tune-auto-match-count-buffer <n>]
730
+ [--tune-delivered-shortlist-recall-buffer <0..1>]
706
731
  [--timing] [--json]
707
732
  skillmux calibrate <list|show|apply|generate-dataset>
708
733