@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 +10 -0
- package/docs/calibration.md +54 -3
- package/docs/cli.md +10 -1
- package/package.json +1 -1
- package/src/adapters.ts +296 -49
- package/src/calibrate.ts +337 -14
- package/src/cli.ts +50 -0
- package/src/router-core.ts +87 -13
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
|
|
package/docs/calibration.md
CHANGED
|
@@ -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
|
|
34
|
-
|
|
35
|
-
|
|
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
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 {
|
|
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 {
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
773
|
-
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/router-core.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
554
|
-
|
|
555
|
-
|
|
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",
|
|
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 } : {}),
|