@klhapp/skillmux 1.5.1 → 1.5.2

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,16 @@ 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.5.2](https://github.com/klhq/skillmux/compare/v1.5.1...v1.5.2) (2026-08-17)
9
+
10
+
11
+ ### Changed
12
+
13
+ * **calibration:** parallelize resumable evaluations ([#115](https://github.com/klhq/skillmux/issues/115)) ([49b3fba](https://github.com/klhq/skillmux/commit/49b3fba43961aa7a6648eda89c5c08d994c7433a))
14
+ * **calibration:** report aggregate stage timings ([#119](https://github.com/klhq/skillmux/issues/119)) ([086128f](https://github.com/klhq/skillmux/commit/086128fdba8ded82ccf6a51d2e0117a18c2eea17))
15
+ * **calibration:** reuse synchronized retrieval snapshot ([#117](https://github.com/klhq/skillmux/issues/117)) ([1fcf295](https://github.com/klhq/skillmux/commit/1fcf2959123b4f9ca6841a2978a3fa5552c23122))
16
+ * **retrieval:** expose opt-in stage timings ([#118](https://github.com/klhq/skillmux/issues/118)) ([f459c90](https://github.com/klhq/skillmux/commit/f459c90f440834cea2ee008300bad3e15771949e))
17
+
8
18
  ## [1.5.1](https://github.com/klhq/skillmux/compare/v1.5.0...v1.5.1) (2026-08-17)
9
19
 
10
20
 
@@ -30,9 +30,60 @@ skillmux calibrate apply RUN_ID
30
30
  ```
31
31
 
32
32
  Skillmux retrieves candidates and reranks exactly once for each evaluation
33
- query. It caches those observations, searches thresholds on the `tune` split,
34
- then certifies the selected policy on the frozen `test` split. Calibration
35
- starts only when an operator invokes `calibrate run`.
33
+ query. It runs four queries at a time by default. Set a different positive
34
+ worker limit with `--concurrency N`. The CLI writes completed-case progress to
35
+ stderr without exposing query text.
36
+
37
+ ### Timing report
38
+
39
+ Add `--timing` to any `calibrate run` invocation to write an aggregate
40
+ performance report to **stderr** after the run finishes (with a completed or
41
+ failed-gates result; a thrown error produces no report). Stdout remains valid
42
+ JSON under `--json --timing`.
43
+
44
+ ```sh
45
+ skillmux calibrate run --dataset ./eval/queries.json --timing
46
+ ```
47
+
48
+ The report uses stable snake_case field names in milliseconds:
49
+
50
+ | Field | Description |
51
+ |---|---|
52
+ | `cases_total` | Total dataset cases |
53
+ | `cases_executed` | Cases retrieved in this invocation |
54
+ | `cases_reused` | Cases loaded from a prior interrupted run (resume) |
55
+ | `wall_ms` | Wall-clock duration of the full calibrateRun operation |
56
+ | `vault_sync_ms` | One-time vault synchronization before retrieval |
57
+ | `cumulative_embedding_ms` | Total worker time in embedding across all queries |
58
+ | `cumulative_lexical_ms` | Total worker time in lexical search across all queries |
59
+ | `cumulative_vector_ms` | Total worker time in vector search across all queries |
60
+ | `cumulative_reranker_ms` | Total worker time in reranking across all queries |
61
+ | `cumulative_checkpoint_ms` | Total worker time writing observation checkpoints |
62
+ | `policy_evaluation_ms` | Threshold selection and test-split certification |
63
+
64
+ **Cumulative vs wall time.** The cumulative fields (`cumulative_embedding_ms`,
65
+ `cumulative_lexical_ms`, `cumulative_vector_ms`, `cumulative_reranker_ms`,
66
+ `cumulative_checkpoint_ms`) are total _worker time_ summed across all concurrent
67
+ query retrievals. Because multiple queries run at the same time, the sum of these
68
+ fields typically exceeds `wall_ms`. They measure how much time each stage
69
+ consumed across all workers, not how much wall-clock time each stage accounted
70
+ for. `cases_executed + cases_reused = cases_total`.
71
+
72
+ Timing collection is fully disabled when `--timing` is absent; it does not affect
73
+ calibration results, resume behavior, checkpoint durability, or JSON schemas.
74
+ Skillmux checkpoints each observation in the calibration evidence database.
75
+ If inference fails or you interrupt the process, find the `running` run with
76
+ `calibrate list` and resume it with the same dataset and certification flags:
77
+
78
+ ```sh
79
+ skillmux calibrate run --dataset ./eval/queries.json --resume RUN_ID
80
+ ```
81
+
82
+ Resume rejects changes to the dataset, corpus, inference models, recall
83
+ settings, candidate limit, or certification gates. After all observations
84
+ exist, Skillmux searches thresholds on the `tune` split and certifies the
85
+ selected policy on the frozen `test` split. Calibration starts only when an
86
+ operator invokes `calibrate run`.
36
87
 
37
88
  The operator owns the labels: supply or review the cases, start the run,
38
89
  inspect its evidence, and explicitly apply an acceptable result. A successful
package/docs/cli.md CHANGED
@@ -298,9 +298,18 @@ certification gates, run evidence, reference values, and the complete operator
298
298
  lifecycle.
299
299
 
300
300
  ```sh
301
- # Run calibration on a dataset
301
+ # Run calibration on a dataset with the default four workers
302
302
  skillmux calibrate run --dataset ./eval/queries.json
303
303
 
304
+ # Set the bounded worker count
305
+ skillmux calibrate run --dataset ./eval/queries.json --concurrency 6
306
+
307
+ # Print aggregate performance timing to stderr
308
+ skillmux calibrate run --dataset ./eval/queries.json --timing
309
+
310
+ # Resume a running or interrupted attempt with the same inputs and gates
311
+ skillmux calibrate run --dataset ./eval/queries.json --resume <run_id>
312
+
304
313
  # List stored calibration runs in the evidence store
305
314
  skillmux calibrate list
306
315
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
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
@@ -1,6 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
- import { applyCalibrationRun, computeCorpusFingerprint, getCalibrationRun, insertCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, summarizeDatasetProvenance, type CalibrationResult } from "./calibrate";
3
+ import {
4
+ applyCalibrationRun,
5
+ computeCorpusFingerprint,
6
+ createInitialCalibrationRun,
7
+ finalizeCalibrationRun,
8
+ getCalibrationObservations,
9
+ getCalibrationRun,
10
+ insertCalibrationRun,
11
+ listCalibrationRuns,
12
+ loadDecisionCasesFromFile,
13
+ openCalibrateDb,
14
+ runCalibration,
15
+ saveCalibrationObservation,
16
+ summarizeDatasetProvenance,
17
+ type CalibrationResult,
18
+ type QueryObservation,
19
+ } from "./calibrate";
4
20
  import { createClients } from "./clients";
5
21
  import { embeddingFingerprint, expandHome, loadConfig, rerankerFingerprint, resolveConfigPath } from "./config";
6
22
  import { openIndex } from "./db";
@@ -19,8 +35,13 @@ import {
19
35
  type SetConfigResult,
20
36
  } from "./config-service";
21
37
  import type { ResolvedTarget } from "./context";
22
- import { configure, retrieveAndRerank } from "./router-core";
23
- import type { Config } from "./types";
38
+ import {
39
+ configure,
40
+ retrieveAndRerankSnapshot,
41
+ syncVaultIfNeeded,
42
+ type StageTiming,
43
+ } from "./router-core";
44
+ import type { Clients, Config } from "./types";
24
45
 
25
46
  export interface Capabilities {
26
47
  config_read: boolean;
@@ -34,6 +55,44 @@ export interface Capabilities {
34
55
  export interface TargetAdapterOptions {
35
56
  configPath?: string;
36
57
  allowInsecure?: boolean;
58
+ clients?: Clients;
59
+ }
60
+
61
+ /**
62
+ * Aggregate timing data collected during a single calibrateRun invocation.
63
+ *
64
+ * All durations are in milliseconds and are non-negative.
65
+ *
66
+ * Cumulative fields (cumulative_embedding_ms, cumulative_lexical_ms,
67
+ * cumulative_vector_ms, cumulative_reranker_ms, cumulative_checkpoint_ms)
68
+ * represent total worker time summed across all concurrent query retrievals
69
+ * or checkpoint writes. Because queries run concurrently, the sum of these
70
+ * cumulative fields may exceed wall_ms — they measure how much worker time
71
+ * each stage consumed, not how much wall-clock time it contributed.
72
+ */
73
+ export interface CalibrationTimingSummary {
74
+ /** Total number of dataset cases. */
75
+ cases_total: number;
76
+ /** Cases actually retrieved in this invocation (not reused from a prior run). */
77
+ cases_executed: number;
78
+ /** Cases loaded from a prior interrupted run (resume observations). */
79
+ cases_reused: number;
80
+ /** Wall-clock duration of the full calibrateRun operation (ms). */
81
+ wall_ms: number;
82
+ /** Duration of the one-time vault synchronization before retrieval (ms). */
83
+ vault_sync_ms: number;
84
+ /** Cumulative worker time spent in embedding across all queries (ms). */
85
+ cumulative_embedding_ms: number;
86
+ /** Cumulative worker time spent in lexical search across all queries (ms). */
87
+ cumulative_lexical_ms: number;
88
+ /** Cumulative worker time spent in vector search across all queries (ms). */
89
+ cumulative_vector_ms: number;
90
+ /** Cumulative worker time spent in reranking across all queries (ms). */
91
+ cumulative_reranker_ms: number;
92
+ /** Cumulative worker time spent writing observation checkpoints (ms). */
93
+ cumulative_checkpoint_ms: number;
94
+ /** Duration of threshold selection and test-split certification (ms). */
95
+ policy_evaluation_ms: number;
37
96
  }
38
97
 
39
98
  export interface TargetAdapter {
@@ -50,6 +109,13 @@ export interface TargetAdapter {
50
109
  minRetrievalRecallAtK?: number;
51
110
  minDeliveredShortlistRecallAtK?: number;
52
111
  minAutoMatchCount?: number;
112
+ concurrency?: number;
113
+ resumeRunId?: string;
114
+ onProgress?: (completed: number, total: number) => void;
115
+ /** Opt-in timing collection. When true, onTimingSummary is called after a non-throwing result. */
116
+ timing?: boolean;
117
+ /** Called after calibration completes or fails-gates (not called on throws). */
118
+ onTimingSummary?: (summary: CalibrationTimingSummary) => void;
53
119
  }): Promise<{ run_id?: string; result?: CalibrationResult }>;
54
120
  calibrateList(): Promise<any[]>;
55
121
  calibrateShow(runId: string): Promise<any>;
@@ -68,9 +134,11 @@ export function isLoopbackHost(hostname: string): boolean {
68
134
 
69
135
  export class LocalAdapter implements TargetAdapter {
70
136
  private configPath: string;
137
+ private clients?: Clients;
71
138
 
72
139
  constructor(opts?: TargetAdapterOptions) {
73
140
  this.configPath = resolveConfigPath(opts?.configPath);
141
+ this.clients = opts?.clients;
74
142
  }
75
143
 
76
144
  async getCapabilities(): Promise<Capabilities> {
@@ -119,6 +187,7 @@ export class LocalAdapter implements TargetAdapter {
119
187
  if (caps.persistence === "externally_managed") {
120
188
  throw new CliError("Configuration is externally managed and cannot be modified", 4);
121
189
  }
190
+ validateDottedKey(key);
122
191
  return setDottedKey(key, rawValStr, {
123
192
  configPath: this.configPath,
124
193
  dryRun: opts?.dryRun,
@@ -136,10 +205,31 @@ export class LocalAdapter implements TargetAdapter {
136
205
  minRetrievalRecallAtK?: number;
137
206
  minDeliveredShortlistRecallAtK?: number;
138
207
  minAutoMatchCount?: number;
208
+ concurrency?: number;
209
+ resumeRunId?: string;
210
+ onProgress?: (completed: number, total: number) => void;
211
+ timing?: boolean;
212
+ onTimingSummary?: (summary: CalibrationTimingSummary) => void;
139
213
  }): Promise<{ run_id?: string; result?: CalibrationResult }> {
214
+ const collectTiming = opts?.timing === true;
215
+ const wallStart = collectTiming ? performance.now() : 0;
216
+
140
217
  const config = await loadConfig(this.configPath);
218
+ const clients = this.clients ?? createClients(config);
219
+ configure({ config, clients });
220
+
221
+ // Measure vault synchronization
222
+ let vault_sync_ms = 0;
223
+ if (collectTiming) {
224
+ const t0 = performance.now();
225
+ await syncVaultIfNeeded();
226
+ vault_sync_ms = Math.max(0, performance.now() - t0);
227
+ } else {
228
+ await syncVaultIfNeeded();
229
+ }
230
+
141
231
  const candidateLimit =
142
- config.output?.ambiguous_candidate_limit ?? config.thresholds.candidate_limit ?? 5;
232
+ config.output?.ambiguous_candidate_limit ?? config.thresholds?.candidate_limit ?? 5;
143
233
  const datasetFile = opts?.datasetPath ?? join(expandHome(config.state_dir), "queries.json");
144
234
  const indexDb = openIndex(expandHome(config.state_dir));
145
235
  let indexedSkills: Array<{ skill_id: string; content_sha256: string }>;
@@ -156,71 +246,222 @@ export class LocalAdapter implements TargetAdapter {
156
246
  datasetFile,
157
247
  indexedSkills.map((skill) => skill.skill_id),
158
248
  );
159
- const clients = createClients(config);
160
- configure({ config, clients });
161
- const result = await runCalibration({
162
- cases,
163
- getRankedCandidates: async (query: string) => {
164
- const result = await retrieveAndRerank({ query, forceLexical: false });
165
- if (result.retrieval !== "reranked") {
166
- throw new Error(
167
- "Calibration requires successful hybrid retrieval and reranking for every query.",
168
- );
169
- }
170
- return result.candidates.map((candidate) => ({
171
- skill_id: candidate.skill_id,
172
- score: candidate.score ?? 0,
173
- }));
174
- },
175
- reranker: clients.rerank,
176
- candidateLimit,
177
- minAutoMatchPrecision: opts?.minAutoMatchPrecision,
178
- minRetrievalRecallAtK: opts?.minRetrievalRecallAtK,
179
- minDeliveredShortlistRecallAtK: opts?.minDeliveredShortlistRecallAtK,
180
- minAutoMatchCount: opts?.minAutoMatchCount,
181
- });
182
249
  const fingerprint = rerankerFingerprint(config);
183
250
  if (!fingerprint) {
184
251
  throw new Error("A configured remote reranker is required to record calibration.");
185
252
  }
186
253
  const datasetText = await Bun.file(datasetFile).text();
187
- const runId = `run_${crypto.randomUUID()}`;
254
+ const datasetHash = createHash("sha256").update(datasetText).digest("hex");
255
+ const embedFp = embeddingFingerprint(config);
256
+ const recallSettings = {
257
+ k_lexical: config.recall.k_lexical,
258
+ k_vector: config.recall.k_vector,
259
+ k_rerank: config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector),
260
+ };
261
+ const minAutoMatchPrecision = opts?.minAutoMatchPrecision ?? 0.99;
262
+ const minAutoMatchCount = opts?.minAutoMatchCount ?? 30;
263
+ const minDeliveredShortlistRecallAtK =
264
+ opts?.minDeliveredShortlistRecallAtK ??
265
+ opts?.minRetrievalRecallAtK ??
266
+ 0.95;
267
+ const minShortlistRecallAt5 = opts?.minRetrievalRecallAtK ?? 0.95;
268
+ const concurrency = opts?.concurrency ?? 4;
269
+
188
270
  const db = openCalibrateDb(expandHome(config.state_dir));
271
+ let runId: string;
272
+ let initialObservations: Map<number, QueryObservation> | undefined = undefined;
273
+
274
+ // Cumulative timing accumulators (only used when collectTiming=true)
275
+ let cumulative_embedding_ms = 0;
276
+ let cumulative_lexical_ms = 0;
277
+ let cumulative_vector_ms = 0;
278
+ let cumulative_reranker_ms = 0;
279
+ let cumulative_checkpoint_ms = 0;
280
+
189
281
  try {
190
- insertCalibrationRun(db, {
282
+ if (opts?.resumeRunId) {
283
+ runId = opts.resumeRunId;
284
+ const existingRun = getCalibrationRun(db, runId);
285
+ if (!existingRun) {
286
+ throw new Error(`Calibration run "${runId}" not found`);
287
+ }
288
+ if (existingRun.status !== "running") {
289
+ throw new Error(`Cannot resume completed calibration run "${runId}"`);
290
+ }
291
+ if (existingRun.dataset_hash !== datasetHash) {
292
+ throw new Error("Dataset hash mismatch: cannot resume run with a different dataset");
293
+ }
294
+ if (existingRun.corpus_fingerprint !== corpusFingerprint) {
295
+ throw new Error("Corpus fingerprint mismatch: cannot resume run with modified vault skills");
296
+ }
297
+ if (existingRun.embedding_fingerprint !== embedFp) {
298
+ throw new Error("Embedding fingerprint mismatch: cannot resume run with different embedding configuration");
299
+ }
300
+ if (existingRun.reranker_fingerprint !== fingerprint) {
301
+ throw new Error("Reranker fingerprint mismatch: cannot resume run with different reranker configuration");
302
+ }
303
+ if (existingRun.candidate_limit !== candidateLimit) {
304
+ throw new Error("Candidate limit mismatch: cannot resume run with different candidate limit");
305
+ }
306
+ if (
307
+ existingRun.recall_settings &&
308
+ JSON.stringify(existingRun.recall_settings) !== JSON.stringify(recallSettings)
309
+ ) {
310
+ throw new Error("Recall settings mismatch: cannot resume run with different recall parameters");
311
+ }
312
+ if (existingRun.min_auto_match_precision !== minAutoMatchPrecision) {
313
+ throw new Error("Certification gate mismatch: min_auto_match_precision differs from original run");
314
+ }
315
+ if ((existingRun.min_auto_match_count ?? 1) !== minAutoMatchCount) {
316
+ throw new Error("Certification gate mismatch: min_auto_match_count differs from original run");
317
+ }
318
+ if (
319
+ (existingRun.min_delivered_shortlist_recall_at_k ?? existingRun.min_shortlist_recall_at_5) !==
320
+ minDeliveredShortlistRecallAtK
321
+ ) {
322
+ throw new Error(
323
+ "Certification gate mismatch: min_delivered_shortlist_recall_at_k differs from original run",
324
+ );
325
+ }
326
+ if (existingRun.min_shortlist_recall_at_5 !== minShortlistRecallAt5) {
327
+ throw new Error("Certification gate mismatch: min_retrieval_recall_at_k differs from original run");
328
+ }
329
+
330
+ initialObservations = getCalibrationObservations(db, runId);
331
+ if (initialObservations.size === 0 && existingRun.observations && existingRun.observations.length > 0) {
332
+ existingRun.observations.forEach((obs, idx) => initialObservations!.set(idx, obs));
333
+ }
334
+ } else {
335
+ runId = `run_${crypto.randomUUID()}`;
336
+ createInitialCalibrationRun(db, {
337
+ run_id: runId,
338
+ created_at: new Date().toISOString(),
339
+ status: "running",
340
+ reranker_fingerprint: fingerprint,
341
+ embedding_fingerprint: embedFp,
342
+ corpus_fingerprint: corpusFingerprint,
343
+ dataset_hash: datasetHash,
344
+ dataset_provenance: summarizeDatasetProvenance(cases),
345
+ recall_settings: recallSettings,
346
+ candidate_limit: candidateLimit,
347
+ min_auto_match_precision: minAutoMatchPrecision,
348
+ min_auto_match_count: minAutoMatchCount,
349
+ min_delivered_shortlist_recall_at_k: minDeliveredShortlistRecallAtK,
350
+ min_shortlist_recall_at_5: minShortlistRecallAt5,
351
+ });
352
+ }
353
+
354
+ const casesReused = initialObservations?.size ?? 0;
355
+
356
+ const onProgress = opts?.onProgress ?? ((completed, total) => {
357
+ process.stderr.write(`Calibration observations: ${completed}/${total}\n`);
358
+ });
359
+
360
+ const retrievalTiming = collectTiming
361
+ ? {
362
+ onTiming: (timing: StageTiming) => {
363
+ cumulative_embedding_ms += timing.embedding_ms;
364
+ cumulative_lexical_ms += timing.lexical_ms;
365
+ cumulative_vector_ms += timing.vector_ms;
366
+ cumulative_reranker_ms += timing.reranker_ms;
367
+ },
368
+ }
369
+ : undefined;
370
+
371
+ const getRankedCandidates = async (query: string) => {
372
+ const res = await retrieveAndRerankSnapshot(
373
+ { query, forceLexical: false },
374
+ retrievalTiming,
375
+ );
376
+ if (res.retrieval !== "reranked") {
377
+ throw new Error(
378
+ "Calibration requires successful hybrid retrieval and reranking for every query.",
379
+ );
380
+ }
381
+ return res.candidates.map((candidate) => ({
382
+ skill_id: candidate.skill_id,
383
+ score: candidate.score ?? 0,
384
+ }));
385
+ };
386
+
387
+ let policyEvaluationStart = 0;
388
+
389
+ const result = await runCalibration({
390
+ cases,
391
+ getRankedCandidates,
392
+ reranker: clients.rerank,
393
+ candidateLimit,
394
+ minAutoMatchPrecision,
395
+ minRetrievalRecallAtK: minShortlistRecallAt5,
396
+ minDeliveredShortlistRecallAtK,
397
+ minAutoMatchCount,
398
+ concurrency,
399
+ initialObservations,
400
+ onProgress,
401
+ onObservation: collectTiming
402
+ ? (obs, caseIdx) => {
403
+ const t0 = performance.now();
404
+ saveCalibrationObservation(db, runId, caseIdx, obs);
405
+ const t1 = performance.now();
406
+ cumulative_checkpoint_ms += Math.max(0, t1 - t0);
407
+ }
408
+ : (obs, caseIdx) => {
409
+ saveCalibrationObservation(db, runId, caseIdx, obs);
410
+ },
411
+ onObservationsReady: collectTiming
412
+ ? () => {
413
+ policyEvaluationStart = performance.now();
414
+ }
415
+ : undefined,
416
+ });
417
+
418
+ // policy_evaluation_ms: threshold selection + certification duration
419
+ const policyEvalEnd = collectTiming ? performance.now() : 0;
420
+
421
+ finalizeCalibrationRun(db, {
191
422
  run_id: runId,
192
- created_at: new Date().toISOString(),
193
423
  status: result.status,
194
- reranker_fingerprint: fingerprint,
195
- embedding_fingerprint: embeddingFingerprint(config),
196
- corpus_fingerprint: corpusFingerprint,
197
- dataset_hash: createHash("sha256").update(datasetText).digest("hex"),
198
- dataset_provenance: summarizeDatasetProvenance(cases),
199
- recall_settings: {
200
- k_lexical: config.recall.k_lexical,
201
- k_vector: config.recall.k_vector,
202
- k_rerank: config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector),
203
- },
204
- candidate_limit: candidateLimit,
205
- min_auto_match_precision: opts?.minAutoMatchPrecision ?? 0.99,
206
- min_auto_match_count: opts?.minAutoMatchCount ?? 30,
207
- min_delivered_shortlist_recall_at_k:
208
- opts?.minDeliveredShortlistRecallAtK ??
209
- opts?.minRetrievalRecallAtK ??
210
- 0.95,
211
- min_shortlist_recall_at_5: opts?.minRetrievalRecallAtK ?? 0.95,
212
424
  failed_reason: result.failed_reason,
213
425
  selected_thresholds: result.selected_thresholds,
214
426
  tune_metrics: result.tune_metrics,
215
427
  test_metrics: result.test_metrics,
216
428
  observations: result.observations,
217
429
  });
430
+
431
+ // Emit timing summary after a non-throwing result (completed or failed-gates).
432
+ // Never called when calibrateRun throws.
433
+ if (collectTiming && opts?.onTimingSummary) {
434
+ const wall_ms = Math.max(0, performance.now() - wallStart);
435
+ // policy_evaluation_ms covers threshold selection + test-split certification,
436
+ // the internal work runCalibration does after all observations are collected.
437
+ const policy_evaluation_ms = Math.max(0, policyEvalEnd - policyEvaluationStart);
438
+
439
+ const cases_reused = casesReused;
440
+ const cases_total = cases.length;
441
+ const cases_executed = cases_total - cases_reused;
442
+
443
+ opts.onTimingSummary({
444
+ cases_total,
445
+ cases_executed,
446
+ cases_reused,
447
+ wall_ms,
448
+ vault_sync_ms,
449
+ cumulative_embedding_ms,
450
+ cumulative_lexical_ms,
451
+ cumulative_vector_ms,
452
+ cumulative_reranker_ms,
453
+ cumulative_checkpoint_ms,
454
+ policy_evaluation_ms,
455
+ });
456
+ }
457
+
458
+ return { run_id: runId, result };
218
459
  } finally {
219
460
  db.close();
220
461
  }
221
- return { run_id: runId, result };
222
462
  }
223
463
 
464
+
224
465
  async calibrateList(): Promise<any[]> {
225
466
  const config = await loadConfig(this.configPath);
226
467
  const db = openCalibrateDb(expandHome(config.state_dir));
@@ -429,11 +670,17 @@ export class RemoteAdapter implements TargetAdapter {
429
670
  minRetrievalRecallAtK?: number;
430
671
  minDeliveredShortlistRecallAtK?: number;
431
672
  minAutoMatchCount?: number;
673
+ concurrency?: number;
674
+ resumeRunId?: string;
675
+ onProgress?: (completed: number, total: number) => void;
676
+ timing?: boolean;
677
+ onTimingSummary?: (summary: CalibrationTimingSummary) => void;
432
678
  }): Promise<{ run_id?: string; result?: CalibrationResult }> {
433
679
  void opts;
434
680
  throw this.remoteCalibrationNotImplemented();
435
681
  }
436
682
 
683
+
437
684
  async calibrateList(): Promise<any[]> {
438
685
  throw this.remoteCalibrationNotImplemented();
439
686
  }
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"
@@ -358,6 +358,17 @@ export interface RunCalibrationOptions {
358
358
  /** Default: 30 */
359
359
  minAutoMatchCount?: number;
360
360
  candidateLimit: number;
361
+ /** Default: 4 */
362
+ concurrency?: number;
363
+ initialObservations?: Map<number, QueryObservation> | QueryObservation[];
364
+ onProgress?: (completed: number, total: number) => void;
365
+ onObservation?: (
366
+ observation: QueryObservation,
367
+ caseIndex: number,
368
+ completedCount: number,
369
+ totalCount: number,
370
+ ) => void | Promise<void>;
371
+ onObservationsReady?: () => void;
361
372
  }
362
373
 
363
374
  // ---------------------------------------------------------------------------
@@ -741,6 +752,40 @@ function selectThresholds(
741
752
  // Public API — runCalibration (AC2, AC3, AC4)
742
753
  // ---------------------------------------------------------------------------
743
754
 
755
+ async function processWithConcurrency<T>(
756
+ items: readonly T[],
757
+ concurrency: number,
758
+ processItem: (item: T) => Promise<void>,
759
+ ): Promise<void> {
760
+ let nextItemPosition = 0;
761
+ let firstError: unknown;
762
+
763
+ const claimNextPosition = (): number | undefined => {
764
+ if (firstError !== undefined || nextItemPosition >= items.length) return undefined;
765
+ return nextItemPosition++;
766
+ };
767
+
768
+ const workerCount = Math.min(concurrency, items.length);
769
+ const workers = Array.from({ length: workerCount }, async () => {
770
+ for (
771
+ let itemPosition = claimNextPosition();
772
+ itemPosition !== undefined;
773
+ itemPosition = claimNextPosition()
774
+ ) {
775
+ try {
776
+ await processItem(items[itemPosition]!);
777
+ } catch (error) {
778
+ firstError ??= error;
779
+ }
780
+ }
781
+ });
782
+
783
+ // Drain work already in flight before propagating an error. Callers may close
784
+ // resources after rejection, so no worker may still be checkpointing then.
785
+ await Promise.all(workers);
786
+ if (firstError !== undefined) throw firstError;
787
+ }
788
+
744
789
  /**
745
790
  * Run an in-memory calibration:
746
791
  * 1. Require a configured reranker (AC2)
@@ -759,8 +804,16 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
759
804
  minDeliveredShortlistRecallAtK = minRetrievalRecallAtK,
760
805
  minAutoMatchCount = 30,
761
806
  candidateLimit,
807
+ concurrency = 4,
808
+ initialObservations,
809
+ onProgress,
810
+ onObservation,
762
811
  } = opts;
763
812
 
813
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
814
+ throw new Error("concurrency must be a positive integer");
815
+ }
816
+
764
817
  if (!getRankedCandidates && (!getCandidates || !reranker)) {
765
818
  throw new Error(
766
819
  "A configured reranker is required to run calibration. " +
@@ -769,8 +822,31 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
769
822
  }
770
823
 
771
824
  // --- Step 1: Cache observations (reranker called exactly once per query) ---
772
- const observations: QueryObservation[] = [];
773
- for (const c of cases) {
825
+ const obsMap = new Map<number, QueryObservation>();
826
+ if (initialObservations) {
827
+ if (Array.isArray(initialObservations)) {
828
+ initialObservations.forEach((obs, idx) => {
829
+ if (obs) obsMap.set(idx, obs);
830
+ });
831
+ } else {
832
+ for (const [idx, obs] of initialObservations.entries()) {
833
+ obsMap.set(idx, obs);
834
+ }
835
+ }
836
+ }
837
+
838
+ let completedCount = obsMap.size;
839
+ if (onProgress && completedCount > 0) {
840
+ onProgress(completedCount, cases.length);
841
+ }
842
+
843
+ const pendingIndices: number[] = [];
844
+ for (let i = 0; i < cases.length; i++) {
845
+ if (!obsMap.has(i)) pendingIndices.push(i);
846
+ }
847
+
848
+ await processWithConcurrency(pendingIndices, concurrency, async (caseIndex) => {
849
+ const c = cases[caseIndex]!;
774
850
  let ranked: Array<{ skill_id: string; score: number }>;
775
851
  if (getRankedCandidates) {
776
852
  ranked = await getRankedCandidates(c.query);
@@ -781,14 +857,25 @@ export async function runCalibration(opts: RunCalibrationOptions): Promise<Calib
781
857
  .map((d, i) => ({ skill_id: d.skill_id, score: scores[i] ?? 0 }))
782
858
  .sort((a, b) => b.score - a.score);
783
859
  }
784
- observations.push({
860
+ const obs: QueryObservation = {
785
861
  query: c.query,
786
862
  split: c.split,
787
863
  expected_outcome: c.expected_outcome,
788
864
  relevant_skill_ids: c.relevant_skill_ids,
789
865
  ranked,
790
- });
791
- }
866
+ };
867
+ obsMap.set(caseIndex, obs);
868
+ if (onObservation) {
869
+ await onObservation(obs, caseIndex, completedCount + 1, cases.length);
870
+ }
871
+ const progressCount = ++completedCount;
872
+ if (onProgress) {
873
+ onProgress(progressCount, cases.length);
874
+ }
875
+ });
876
+
877
+ const observations: QueryObservation[] = cases.map((_, i) => obsMap.get(i)!);
878
+ opts.onObservationsReady?.();
792
879
 
793
880
  // --- Step 2: Select thresholds from tune split only ---
794
881
  const tuneObs = observations.filter((o) => o.split === "tune");
@@ -928,10 +1015,58 @@ export function openCalibrateDb(stateDir: string): Database {
928
1015
  const db = new Database(join(stateDir, "calibrate.sqlite3"), { create: true });
929
1016
  db.run("PRAGMA journal_mode = WAL");
930
1017
  db.run("PRAGMA busy_timeout = 2000");
1018
+
1019
+ const tableDef = db
1020
+ .query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'calibration_runs'")
1021
+ .get() as { sql: string } | null;
1022
+
1023
+ if (
1024
+ tableDef &&
1025
+ tableDef.sql &&
1026
+ tableDef.sql.includes("CHECK") &&
1027
+ !tableDef.sql.includes("'running'")
1028
+ ) {
1029
+ const cols = db.query("PRAGMA table_info(calibration_runs)").all() as Array<{ name: string }>;
1030
+ const colNames = cols.map((c) => c.name);
1031
+ const selectCols = colNames.join(", ");
1032
+
1033
+ db.transaction(() => {
1034
+ db.run(`CREATE TABLE calibration_runs_new (
1035
+ run_id TEXT PRIMARY KEY,
1036
+ created_at TEXT NOT NULL,
1037
+ status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed_gates')),
1038
+ reranker_fingerprint TEXT NOT NULL,
1039
+ embedding_fingerprint TEXT NOT NULL,
1040
+ corpus_fingerprint TEXT NOT NULL,
1041
+ dataset_hash TEXT NOT NULL,
1042
+ min_auto_match_precision REAL NOT NULL,
1043
+ min_shortlist_recall_at_5 REAL NOT NULL,
1044
+ selected_thresholds TEXT,
1045
+ tune_metrics TEXT,
1046
+ test_metrics TEXT,
1047
+ observations TEXT NOT NULL,
1048
+ candidate_limit INTEGER NOT NULL DEFAULT 5,
1049
+ attempt_count INTEGER NOT NULL DEFAULT 1,
1050
+ min_auto_match_count INTEGER NOT NULL DEFAULT 1,
1051
+ min_delivered_shortlist_recall_at_k REAL NOT NULL DEFAULT 0.95,
1052
+ failed_reason TEXT,
1053
+ dataset_provenance TEXT NOT NULL DEFAULT '{}',
1054
+ human_labelled_case_count INTEGER NOT NULL DEFAULT 0,
1055
+ imported_labelled_case_count INTEGER NOT NULL DEFAULT 0,
1056
+ recall_settings TEXT NOT NULL DEFAULT '{}'
1057
+ )`);
1058
+ db.run(
1059
+ `INSERT INTO calibration_runs_new (${selectCols}) SELECT ${selectCols} FROM calibration_runs`,
1060
+ );
1061
+ db.run("DROP TABLE calibration_runs");
1062
+ db.run("ALTER TABLE calibration_runs_new RENAME TO calibration_runs");
1063
+ })();
1064
+ }
1065
+
931
1066
  db.run(`CREATE TABLE IF NOT EXISTS calibration_runs (
932
1067
  run_id TEXT PRIMARY KEY,
933
1068
  created_at TEXT NOT NULL,
934
- status TEXT NOT NULL CHECK (status IN ('completed', 'failed_gates')),
1069
+ status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed_gates')),
935
1070
  reranker_fingerprint TEXT NOT NULL,
936
1071
  embedding_fingerprint TEXT NOT NULL,
937
1072
  corpus_fingerprint TEXT NOT NULL,
@@ -971,11 +1106,48 @@ export function openCalibrateDb(stateDir: string): Database {
971
1106
  if (!columns.some((column) => column.name === "recall_settings")) {
972
1107
  db.run("ALTER TABLE calibration_runs ADD COLUMN recall_settings TEXT NOT NULL DEFAULT '{}'");
973
1108
  }
1109
+
1110
+ db.run(`CREATE TABLE IF NOT EXISTS calibration_observations (
1111
+ run_id TEXT NOT NULL,
1112
+ case_index INTEGER NOT NULL,
1113
+ query TEXT NOT NULL,
1114
+ split TEXT NOT NULL,
1115
+ expected_outcome TEXT NOT NULL,
1116
+ relevant_skill_ids TEXT NOT NULL,
1117
+ ranked TEXT NOT NULL,
1118
+ PRIMARY KEY (run_id, case_index),
1119
+ FOREIGN KEY (run_id) REFERENCES calibration_runs(run_id) ON DELETE CASCADE
1120
+ )`);
1121
+
974
1122
  return db;
975
1123
  }
976
1124
 
977
- /** Persist a calibration run (all fields) to the evidence store. */
978
- export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
1125
+ export interface CreateInitialCalibrationRunOptions {
1126
+ run_id: string;
1127
+ created_at: string;
1128
+ status: "running";
1129
+ reranker_fingerprint: string;
1130
+ embedding_fingerprint: string;
1131
+ corpus_fingerprint: string;
1132
+ dataset_hash: string;
1133
+ candidate_limit: number;
1134
+ min_auto_match_precision: number;
1135
+ min_auto_match_count?: number;
1136
+ min_delivered_shortlist_recall_at_k?: number;
1137
+ min_shortlist_recall_at_5: number;
1138
+ dataset_provenance?: DatasetProvenanceSummary;
1139
+ recall_settings?: {
1140
+ k_lexical: number;
1141
+ k_vector: number;
1142
+ k_rerank: number;
1143
+ };
1144
+ }
1145
+
1146
+ /** Create an initial calibration run record with 'running' status before inference starts. */
1147
+ export function createInitialCalibrationRun(
1148
+ db: Database,
1149
+ run: CreateInitialCalibrationRunOptions,
1150
+ ): void {
979
1151
  const attemptCount = (
980
1152
  db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
981
1153
  .get(run.dataset_hash) as { count: number }
@@ -1005,19 +1177,161 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
1005
1177
  run.min_auto_match_count ?? 1,
1006
1178
  run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
1007
1179
  run.min_shortlist_recall_at_5,
1180
+ null,
1181
+ null,
1182
+ null,
1183
+ null,
1184
+ "[]",
1185
+ JSON.stringify(run.dataset_provenance ?? {}),
1186
+ run.dataset_provenance?.human_labelled_case_count ?? 0,
1187
+ run.dataset_provenance?.imported_labelled_case_count ?? 0,
1188
+ JSON.stringify(run.recall_settings ?? {}),
1189
+ ],
1190
+ );
1191
+ }
1192
+
1193
+ /** Save a single case observation incrementally. */
1194
+ export function saveCalibrationObservation(
1195
+ db: Database,
1196
+ runId: string,
1197
+ caseIndex: number,
1198
+ observation: QueryObservation,
1199
+ ): void {
1200
+ db.run(
1201
+ `INSERT OR REPLACE INTO calibration_observations (
1202
+ run_id, case_index, query, split, expected_outcome, relevant_skill_ids, ranked
1203
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
1204
+ [
1205
+ runId,
1206
+ caseIndex,
1207
+ observation.query,
1208
+ observation.split,
1209
+ observation.expected_outcome,
1210
+ JSON.stringify(observation.relevant_skill_ids),
1211
+ JSON.stringify(observation.ranked),
1212
+ ],
1213
+ );
1214
+ }
1215
+
1216
+ /** Retrieve all completed observations for a run_id, keyed by case index. */
1217
+ export function getCalibrationObservations(
1218
+ db: Database,
1219
+ runId: string,
1220
+ ): Map<number, QueryObservation> {
1221
+ const rows = db
1222
+ .query(
1223
+ `SELECT case_index, query, split, expected_outcome, relevant_skill_ids, ranked
1224
+ FROM calibration_observations WHERE run_id = ? ORDER BY case_index ASC`,
1225
+ )
1226
+ .all(runId) as Array<{
1227
+ case_index: number;
1228
+ query: string;
1229
+ split: DecisionSplit;
1230
+ expected_outcome: DecisionOutcome;
1231
+ relevant_skill_ids: string;
1232
+ ranked: string;
1233
+ }>;
1234
+ const map = new Map<number, QueryObservation>();
1235
+ for (const r of rows) {
1236
+ map.set(r.case_index, {
1237
+ query: r.query,
1238
+ split: r.split,
1239
+ expected_outcome: r.expected_outcome,
1240
+ relevant_skill_ids: JSON.parse(r.relevant_skill_ids) as string[],
1241
+ ranked: JSON.parse(r.ranked) as Array<{ skill_id: string; score: number }>,
1242
+ });
1243
+ }
1244
+ return map;
1245
+ }
1246
+
1247
+ export interface FinalizeCalibrationRunOptions {
1248
+ run_id: string;
1249
+ status: CalibrationStatus;
1250
+ failed_reason?: CalibrationFailureReason;
1251
+ selected_thresholds?: SelectedThresholds;
1252
+ tune_metrics?: CalibrationMetrics;
1253
+ test_metrics?: CalibrationTestMetrics;
1254
+ observations: QueryObservation[];
1255
+ }
1256
+
1257
+ /** Finalize a calibration run record with its completion/failure status, metrics, and thresholds. */
1258
+ export function finalizeCalibrationRun(
1259
+ db: Database,
1260
+ run: FinalizeCalibrationRunOptions,
1261
+ ): void {
1262
+ db.run(
1263
+ `UPDATE calibration_runs SET
1264
+ status = ?,
1265
+ failed_reason = ?,
1266
+ selected_thresholds = ?,
1267
+ tune_metrics = ?,
1268
+ test_metrics = ?,
1269
+ observations = ?
1270
+ WHERE run_id = ?`,
1271
+ [
1272
+ run.status,
1008
1273
  run.failed_reason ?? null,
1009
1274
  run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
1010
1275
  run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
1011
1276
  run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
1012
1277
  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 ?? {}),
1278
+ run.run_id,
1017
1279
  ],
1018
1280
  );
1019
1281
  }
1020
1282
 
1283
+ /** Persist a calibration run (all fields) to the evidence store. */
1284
+ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
1285
+ const attemptCount = (
1286
+ db.query("SELECT COUNT(*) AS count FROM calibration_runs WHERE dataset_hash = ?")
1287
+ .get(run.dataset_hash) as { count: number }
1288
+ ).count + 1;
1289
+ db.transaction(() => {
1290
+ db.run(
1291
+ `INSERT OR REPLACE INTO calibration_runs (
1292
+ run_id, created_at, status,
1293
+ reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
1294
+ candidate_limit,
1295
+ attempt_count, min_auto_match_precision, min_auto_match_count,
1296
+ min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1297
+ selected_thresholds, tune_metrics, test_metrics, observations,
1298
+ dataset_provenance, human_labelled_case_count, imported_labelled_case_count,
1299
+ recall_settings
1300
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1301
+ [
1302
+ run.run_id,
1303
+ run.created_at,
1304
+ run.status,
1305
+ run.reranker_fingerprint,
1306
+ run.embedding_fingerprint,
1307
+ run.corpus_fingerprint,
1308
+ run.dataset_hash,
1309
+ run.candidate_limit,
1310
+ attemptCount,
1311
+ run.min_auto_match_precision,
1312
+ run.min_auto_match_count ?? 1,
1313
+ run.min_delivered_shortlist_recall_at_k ?? run.min_shortlist_recall_at_5,
1314
+ run.min_shortlist_recall_at_5,
1315
+ run.failed_reason ?? null,
1316
+ run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
1317
+ run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
1318
+ run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
1319
+ JSON.stringify(run.observations),
1320
+ JSON.stringify(run.dataset_provenance ?? {}),
1321
+ run.dataset_provenance?.human_labelled_case_count ?? 0,
1322
+ run.dataset_provenance?.imported_labelled_case_count ?? 0,
1323
+ JSON.stringify(run.recall_settings ?? {}),
1324
+ ],
1325
+ );
1326
+ if (run.observations && run.observations.length > 0) {
1327
+ for (let i = 0; i < run.observations.length; i++) {
1328
+ const obs = run.observations[i]!;
1329
+ saveCalibrationObservation(db, run.run_id, i, obs);
1330
+ }
1331
+ }
1332
+ })();
1333
+ }
1334
+
1021
1335
  interface RawCalibrationRow {
1022
1336
  run_id: string;
1023
1337
  created_at: string;
@@ -1111,7 +1425,16 @@ export function getCalibrationRun(db: Database, runId: string): CalibrationRunRe
1111
1425
  const row = db
1112
1426
  .query("SELECT * FROM calibration_runs WHERE run_id = ?")
1113
1427
  .get(runId) as RawCalibrationRow | null;
1114
- return row ? rowToRecord(row) : null;
1428
+ if (!row) return null;
1429
+ const record = rowToRecord(row);
1430
+ if (record.observations.length === 0) {
1431
+ const obsMap = getCalibrationObservations(db, runId);
1432
+ if (obsMap.size > 0) {
1433
+ const sortedIndices = Array.from(obsMap.keys()).sort((a, b) => a - b);
1434
+ record.observations = sortedIndices.map((idx) => obsMap.get(idx)!);
1435
+ }
1436
+ }
1437
+ return record;
1115
1438
  }
1116
1439
 
1117
1440
  /** List all runs ordered by created_at descending (excludes observations blob). */
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 concurrency: number | undefined;
479
+ let resumeRunId: string | undefined;
480
+ let timing = false;
478
481
  const readNumber = (flag: string, raw: string | undefined): number => {
479
482
  if (raw === undefined) throw new Error(`${flag} requires a value`);
480
483
  const value = Number(raw);
@@ -497,6 +500,19 @@ async function handleCalibrateCommand(
497
500
  if (!Number.isInteger(minAutoMatchCount) || minAutoMatchCount < 1) {
498
501
  throw new Error("--min-auto-match-count must be a positive integer");
499
502
  }
503
+ } else if (option === "--concurrency") {
504
+ const raw = args[++i];
505
+ if (raw === undefined) throw new Error("--concurrency requires a value");
506
+ const val = Number(raw);
507
+ if (!Number.isInteger(val) || val < 1) {
508
+ throw new Error("--concurrency must be a positive integer");
509
+ }
510
+ concurrency = val;
511
+ } else if (option === "--resume") {
512
+ resumeRunId = args[++i];
513
+ if (!resumeRunId) throw new Error("--resume requires a run_id value");
514
+ } else if (option === "--timing") {
515
+ timing = true;
500
516
  } else {
501
517
  throw new Error(`unknown calibrate run option: ${option}`);
502
518
  }
@@ -516,6 +532,34 @@ async function handleCalibrateCommand(
516
532
  minRetrievalRecallAtK,
517
533
  minDeliveredShortlistRecallAtK,
518
534
  minAutoMatchCount,
535
+ concurrency,
536
+ resumeRunId,
537
+ timing,
538
+ onTimingSummary: timing
539
+ ? (summary) => {
540
+ // Write timing report to stderr only — stdout remains valid JSON under --json.
541
+ // Cumulative fields are total worker time across concurrent queries and may
542
+ // exceed wall_ms. They do not sum to wall time.
543
+ process.stderr.write(
544
+ [
545
+ "--- calibrate run timing ---",
546
+ `cases_total: ${summary.cases_total}`,
547
+ `cases_executed: ${summary.cases_executed} (retrieved in this invocation)`,
548
+ `cases_reused: ${summary.cases_reused} (loaded from prior interrupted run)`,
549
+ `wall_ms: ${summary.wall_ms.toFixed(1)}`,
550
+ `vault_sync_ms: ${summary.vault_sync_ms.toFixed(1)}`,
551
+ "cumulative worker time (concurrent totals; may exceed wall_ms):",
552
+ ` cumulative_embedding_ms: ${summary.cumulative_embedding_ms.toFixed(1)}`,
553
+ ` cumulative_lexical_ms: ${summary.cumulative_lexical_ms.toFixed(1)}`,
554
+ ` cumulative_vector_ms: ${summary.cumulative_vector_ms.toFixed(1)}`,
555
+ ` cumulative_reranker_ms: ${summary.cumulative_reranker_ms.toFixed(1)}`,
556
+ ` cumulative_checkpoint_ms: ${summary.cumulative_checkpoint_ms.toFixed(1)}`,
557
+ `policy_evaluation_ms: ${summary.policy_evaluation_ms.toFixed(1)}`,
558
+ "",
559
+ ].join("\n"),
560
+ );
561
+ }
562
+ : undefined,
519
563
  });
520
564
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
521
565
  renderCalibrationTarget(ctx.target);
@@ -525,6 +569,7 @@ async function handleCalibrateCommand(
525
569
  return;
526
570
  }
527
571
 
572
+
528
573
  if (sub === "list") {
529
574
  const res = await adapter.calibrateList();
530
575
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
@@ -656,6 +701,11 @@ Setup:
656
701
  skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
657
702
  skillmux skill which <skill_id>
658
703
 
704
+ Calibration:
705
+ skillmux calibrate run [--dataset <path>] [--concurrency <n>] [--resume <run_id>]
706
+ [--timing] [--json]
707
+ skillmux calibrate <list|show|apply|generate-dataset>
708
+
659
709
  Init clients:
660
710
  claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
661
711
  antigravity, goose, hermes, skillmux-mcp
@@ -530,18 +530,64 @@ export function classifyInferenceError(
530
530
  return stage === "embedding" ? "embedding_unavailable" : "reranker_unavailable";
531
531
  }
532
532
 
533
+ export interface StageTiming {
534
+ embedding_ms: number;
535
+ lexical_ms: number;
536
+ vector_ms: number;
537
+ reranker_ms: number;
538
+ }
539
+
540
+ export interface RetrieveSnapshotOptions {
541
+ onTiming?: (timing: StageTiming) => void;
542
+ }
543
+
533
544
  /**
534
545
  * Retrieve the full fused candidate set and rerank it once without applying
535
- * decision thresholds. Calibration uses this to avoid observing the policy it
536
- * is trying to replace.
546
+ * decision thresholds, synchronizing the vault before reading the index.
537
547
  */
538
548
  export async function retrieveAndRerank(
539
549
  input: ResolveSkillInput,
540
550
  ): Promise<RetrievalResult> {
541
- const { config, db } = await getEnv();
542
551
  await syncVaultIfNeeded();
552
+ return retrieveAndRerankSnapshot(input);
553
+ }
554
+
555
+ /**
556
+ * Retrieve candidates against an already-synchronized index snapshot without
557
+ * triggering syncVaultIfNeeded(). Calibration uses this to avoid observing the
558
+ * policy it is trying to replace while keeping one corpus for the whole run.
559
+ */
560
+ export async function retrieveAndRerankSnapshot(
561
+ input: ResolveSkillInput,
562
+ options?: RetrieveSnapshotOptions,
563
+ ): Promise<RetrievalResult> {
564
+ const { config, db } = await getEnv();
543
565
  const clients = getClients();
566
+ const measureTiming = options?.onTiming !== undefined;
567
+
568
+ let embedding_ms = 0;
569
+ let lexical_ms = 0;
570
+ let vector_ms = 0;
571
+ let reranker_ms = 0;
572
+
573
+ const tEmbed0 = measureTiming ? performance.now() : 0;
574
+ const embedPromise =
575
+ !input.forceLexical && clients.embed
576
+ ? clients.embed([input.query]).then(
577
+ (res) => {
578
+ if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
579
+ return res;
580
+ },
581
+ (err) => {
582
+ if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
583
+ return { error: err };
584
+ },
585
+ )
586
+ : null;
587
+
588
+ const tLex0 = measureTiming ? performance.now() : 0;
544
589
  const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
590
+ if (measureTiming) lexical_ms = Math.max(0, performance.now() - tLex0);
545
591
  const lexicalRanks = new Map(lexical.map((row, index) => [row.skill_id, index + 1]));
546
592
 
547
593
  let retrieval: RetrievalResult["retrieval"] = "lexical";
@@ -550,18 +596,12 @@ export async function retrieveAndRerank(
550
596
  let rows = lexical;
551
597
  let fusedRows: SkillRow[] | null = null;
552
598
 
553
- if (!input.forceLexical) {
554
- try {
555
- const queryVec = (await clients.embed([input.query]))[0];
556
- if (!queryVec) throw new Error("Embedding client returned no query vector.");
557
- const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
558
- rows = reciprocalRankFusion(lexical, nearest);
559
- fusedRows = rows;
560
- retrieval = "hybrid";
561
- } catch (embedError) {
599
+ if (embedPromise) {
600
+ const embedRes = await embedPromise;
601
+ if (embedRes && typeof embedRes === "object" && "error" in embedRes) {
562
602
  retrieval = "lexical";
563
603
  degraded_from = clients.rerank ? "reranked" : "hybrid";
564
- degradation_reason = classifyInferenceError("embedding", embedError);
604
+ degradation_reason = classifyInferenceError("embedding", embedRes.error);
565
605
  console.error(
566
606
  JSON.stringify({
567
607
  level: "warn",
@@ -570,6 +610,30 @@ export async function retrieveAndRerank(
570
610
  reason: degradation_reason,
571
611
  }),
572
612
  );
613
+ } else {
614
+ const tVec0 = measureTiming ? performance.now() : 0;
615
+ try {
616
+ const queryVec = (embedRes as Float32Array[])[0];
617
+ if (!queryVec) throw new Error("Embedding client returned no query vector.");
618
+ const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
619
+ if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
620
+ rows = reciprocalRankFusion(lexical, nearest);
621
+ fusedRows = rows;
622
+ retrieval = "hybrid";
623
+ } catch (embedError) {
624
+ if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
625
+ retrieval = "lexical";
626
+ degraded_from = clients.rerank ? "reranked" : "hybrid";
627
+ degradation_reason = classifyInferenceError("embedding", embedError);
628
+ console.error(
629
+ JSON.stringify({
630
+ level: "warn",
631
+ stage: "embedding",
632
+ degraded_from,
633
+ reason: degradation_reason,
634
+ }),
635
+ );
636
+ }
573
637
  }
574
638
  }
575
639
 
@@ -577,14 +641,17 @@ export async function retrieveAndRerank(
577
641
  if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
578
642
  const kRerank = config.recall.k_rerank ?? 10;
579
643
  const rerankCandidates = rows.slice(0, kRerank);
644
+ const tRerank0 = measureTiming ? performance.now() : 0;
580
645
  try {
581
646
  scores = await clients.rerank(
582
647
  input.query,
583
648
  rerankCandidates.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
584
649
  );
650
+ if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
585
651
  retrieval = "reranked";
586
652
  rows = rerankCandidates;
587
653
  } catch (rerankError) {
654
+ if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
588
655
  scores = null;
589
656
  degraded_from = "reranked";
590
657
  degradation_reason = classifyInferenceError("reranker", rerankError);
@@ -612,6 +679,13 @@ export async function retrieveAndRerank(
612
679
  : new Map<string, number>();
613
680
  const traceRows = fusedRows ?? rows;
614
681
 
682
+ options?.onTiming?.({
683
+ embedding_ms,
684
+ lexical_ms,
685
+ vector_ms,
686
+ reranker_ms,
687
+ });
688
+
615
689
  return {
616
690
  retrieval,
617
691
  ...(degraded_from ? { degraded_from, degradation_reason } : {}),