@klhapp/skillmux 1.6.0 → 1.7.1
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 +21 -0
- package/README.md +7 -7
- package/config.example.toml +5 -0
- package/config.remote.example.toml +4 -7
- package/docs/README.md +4 -4
- package/docs/assets/architecture.svg +1 -1
- package/docs/cli.md +4 -57
- package/docs/concepts.md +12 -14
- package/docs/configuration.md +10 -11
- package/docs/deployment.md +11 -8
- package/docs/getting-started.md +1 -1
- package/docs/mcp-routing.md +28 -23
- package/docs/ranked-shortlist-migration.md +160 -0
- package/docs/schema.json +37 -139
- package/docs/skill-management.md +7 -2
- package/docs/troubleshooting.md +12 -14
- package/package.json +1 -1
- package/src/adapters.ts +1 -536
- package/src/audit.ts +1 -3
- package/src/cli.ts +19 -254
- package/src/completions.ts +0 -4
- package/src/config-service.ts +19 -32
- package/src/config-watcher.ts +2 -6
- package/src/config.ts +61 -60
- package/src/db.ts +32 -18
- package/src/doctor.ts +1 -84
- package/src/eval.ts +143 -60
- package/src/init.ts +4 -5
- package/src/metrics.ts +1 -13
- package/src/router-core.ts +42 -149
- package/src/server.ts +13 -27
- package/src/stats.ts +126 -57
- package/src/types.ts +8 -39
- package/docs/calibration.md +0 -198
- package/src/calibrate.ts +0 -1775
- package/src/config-mutation.ts +0 -65
- package/src/dataset-generator.ts +0 -119
- package/src/decision.ts +0 -45
package/src/config.ts
CHANGED
|
@@ -43,14 +43,11 @@ const configSchema = z.object({
|
|
|
43
43
|
message: "recall.k_rerank cannot exceed k_lexical + k_vector",
|
|
44
44
|
}),
|
|
45
45
|
output: z.object({
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
match_margin: z.number().nonnegative().optional(),
|
|
52
|
-
candidate_floor: z.number().optional(),
|
|
53
|
-
}).strict().optional(),
|
|
46
|
+
top_k: z.number().int().positive().default(10),
|
|
47
|
+
max_top_k: z.number().int().positive().default(50),
|
|
48
|
+
}).strict().refine((o) => o.top_k <= o.max_top_k, {
|
|
49
|
+
message: "output.top_k cannot exceed output.max_top_k",
|
|
50
|
+
}).default({ top_k: 10, max_top_k: 50 }),
|
|
54
51
|
inference: z.discriminatedUnion("mode", [
|
|
55
52
|
z.object({
|
|
56
53
|
mode: z.literal("local"),
|
|
@@ -74,8 +71,6 @@ const configSchema = z.object({
|
|
|
74
71
|
model: z.string().min(1),
|
|
75
72
|
api_key_env: z.string().min(1).optional(),
|
|
76
73
|
}).strict().optional(),
|
|
77
|
-
thresholds: remoteThresholdsSchema.optional(),
|
|
78
|
-
calibration: z.object({ run_id: z.string().min(1) }).strict().optional(),
|
|
79
74
|
}).strict(),
|
|
80
75
|
]),
|
|
81
76
|
server: z.object({
|
|
@@ -93,7 +88,15 @@ const configSchema = z.object({
|
|
|
93
88
|
token_env: z.string().min(1),
|
|
94
89
|
}).strict().optional(),
|
|
95
90
|
}).strict().optional(),
|
|
96
|
-
}).strict()
|
|
91
|
+
}).strict().refine((cfg) => {
|
|
92
|
+
const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
|
|
93
|
+
if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}, {
|
|
98
|
+
message: "output.max_top_k cannot exceed recall.k_rerank when reranking is enabled",
|
|
99
|
+
});
|
|
97
100
|
|
|
98
101
|
// Fallback values only; a config.toml (SKILLMUX_CONFIG or default path)
|
|
99
102
|
// overrides them. The local bundle is the zero-config OSS path.
|
|
@@ -107,8 +110,7 @@ const DEFAULTS: Config = {
|
|
|
107
110
|
local_vault_paths: [],
|
|
108
111
|
state_dir: "~/.local/state/skillmux",
|
|
109
112
|
recall: { k_lexical: 20, k_vector: 20, k_rerank: 10 },
|
|
110
|
-
|
|
111
|
-
output: { ambiguous_candidate_limit: 5 },
|
|
113
|
+
output: { top_k: 10, max_top_k: 50 },
|
|
112
114
|
inference: {
|
|
113
115
|
mode: "local",
|
|
114
116
|
bundle: LOCAL_BUNDLE_ID,
|
|
@@ -232,6 +234,18 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
232
234
|
"The old client appended /v1/embeddings.",
|
|
233
235
|
);
|
|
234
236
|
}
|
|
237
|
+
const removedLegacyOutputEnv = [
|
|
238
|
+
"SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
|
|
239
|
+
"AMBIGUOUS_CANDIDATE_LIMIT",
|
|
240
|
+
"SKILLMUX_CANDIDATE_LIMIT",
|
|
241
|
+
"CANDIDATE_LIMIT",
|
|
242
|
+
].find((name) => process.env[name] !== undefined);
|
|
243
|
+
if (removedLegacyOutputEnv) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`${removedLegacyOutputEnv} is no longer supported. Use SKILLMUX_OUTPUT_TOP_K instead.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
235
249
|
const configPath = resolveConfigPath(path);
|
|
236
250
|
const file = Bun.file(expandHome(configPath));
|
|
237
251
|
|
|
@@ -248,6 +262,26 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
248
262
|
merged = baseConfig;
|
|
249
263
|
} else {
|
|
250
264
|
const parsed = Bun.TOML.parse(await file.text()) as Record<string, unknown>;
|
|
265
|
+
if ("thresholds" in parsed) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
"The [thresholds] table is obsolete. Threshold calibration was removed; use [output] with top_k.",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (isPlainObject(parsed.output) && "ambiguous_candidate_limit" in parsed.output) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
"output.ambiguous_candidate_limit is obsolete. Use output.top_k instead.",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (isPlainObject(parsed.inference) && "thresholds" in parsed.inference) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
"inference.thresholds is obsolete. Threshold calibration was removed.",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (isPlainObject(parsed.inference) && "calibration" in parsed.inference) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
"inference.calibration is obsolete and should be deleted. Threshold calibration was removed; use skillmux eval for ranking evaluation.",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
251
285
|
if ("embedding" in parsed || "rerank" in parsed || "remote_timeout_ms" in parsed) {
|
|
252
286
|
throw new Error(
|
|
253
287
|
"Legacy inference config is not supported. Move [embedding], [rerank], and remote_timeout_ms under [inference] using config.remote.example.toml.",
|
|
@@ -279,30 +313,9 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
279
313
|
throw new Error("Remote inference requires an inference.embedding section.");
|
|
280
314
|
}
|
|
281
315
|
}
|
|
282
|
-
if (parsed.thresholds && typeof parsed.thresholds === "object" && (parsed.thresholds as Record<string, unknown>).candidate_limit !== undefined) {
|
|
283
|
-
console.error("skillmux: thresholds.candidate_limit is deprecated, use output.ambiguous_candidate_limit instead");
|
|
284
|
-
if (!parsed.output || (parsed.output as Record<string, unknown>).ambiguous_candidate_limit === undefined) {
|
|
285
|
-
parsed.output = {
|
|
286
|
-
...(typeof parsed.output === "object" && parsed.output !== null ? parsed.output : {}),
|
|
287
|
-
ambiguous_candidate_limit: (parsed.thresholds as Record<string, unknown>).candidate_limit,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
316
|
|
|
292
317
|
if (parsed.inference && typeof parsed.inference === "object" && "mode" in parsed.inference) {
|
|
293
318
|
if (parsed.inference.mode === "remote") {
|
|
294
|
-
if ("thresholds" in parsed.inference) {
|
|
295
|
-
const rawRemoteThresholds = (parsed.inference as Record<string, unknown>).thresholds;
|
|
296
|
-
if (
|
|
297
|
-
typeof rawRemoteThresholds === "object" &&
|
|
298
|
-
rawRemoteThresholds !== null &&
|
|
299
|
-
!("match_score" in rawRemoteThresholds) &&
|
|
300
|
-
!("match_margin" in rawRemoteThresholds) &&
|
|
301
|
-
!("candidate_floor" in rawRemoteThresholds)
|
|
302
|
-
) {
|
|
303
|
-
throw new Error("Invalid inference.thresholds in config.toml: must specify at least one threshold.");
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
319
|
const withoutInference = { ...parsed };
|
|
307
320
|
delete withoutInference.inference;
|
|
308
321
|
merged = {
|
|
@@ -318,12 +331,7 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
318
331
|
}
|
|
319
332
|
|
|
320
333
|
if (!merged.output) {
|
|
321
|
-
merged.output = {
|
|
322
|
-
}
|
|
323
|
-
if (!merged.thresholds) {
|
|
324
|
-
merged.thresholds = { candidate_limit: merged.output.ambiguous_candidate_limit };
|
|
325
|
-
} else if (merged.thresholds.candidate_limit === undefined) {
|
|
326
|
-
merged.thresholds.candidate_limit = merged.output.ambiguous_candidate_limit;
|
|
334
|
+
merged.output = { top_k: 10, max_top_k: 50 };
|
|
327
335
|
}
|
|
328
336
|
|
|
329
337
|
// Warn about deprecated generic environment variables regardless of override policy
|
|
@@ -333,8 +341,8 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
333
341
|
RECALL_K_LEXICAL: "SKILLMUX_RECALL_K_LEXICAL",
|
|
334
342
|
RECALL_K_VECTOR: "SKILLMUX_RECALL_K_VECTOR",
|
|
335
343
|
RECALL_K_RERANK: "SKILLMUX_RECALL_K_RERANK",
|
|
336
|
-
|
|
337
|
-
|
|
344
|
+
OUTPUT_TOP_K: "SKILLMUX_OUTPUT_TOP_K",
|
|
345
|
+
OUTPUT_MAX_TOP_K: "SKILLMUX_OUTPUT_MAX_TOP_K",
|
|
338
346
|
EMBED_MODEL: "SKILLMUX_EMBED_MODEL",
|
|
339
347
|
EMBED_ENDPOINT: "SKILLMUX_EMBED_ENDPOINT",
|
|
340
348
|
EMBED_DIMENSION: "SKILLMUX_EMBED_DIMENSION",
|
|
@@ -398,14 +406,17 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
398
406
|
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_rerank: ${kRerankStr}`);
|
|
399
407
|
merged.recall.k_rerank = k;
|
|
400
408
|
}
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
+
const topKStr = getEnv("SKILLMUX_OUTPUT_TOP_K", "OUTPUT_TOP_K");
|
|
410
|
+
if (topKStr) {
|
|
411
|
+
const k = Number(topKStr);
|
|
412
|
+
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid output.top_k: ${topKStr}`);
|
|
413
|
+
merged.output.top_k = k;
|
|
414
|
+
}
|
|
415
|
+
const maxTopKStr = getEnv("SKILLMUX_OUTPUT_MAX_TOP_K", "OUTPUT_MAX_TOP_K");
|
|
416
|
+
if (maxTopKStr) {
|
|
417
|
+
const k = Number(maxTopKStr);
|
|
418
|
+
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid output.max_top_k: ${maxTopKStr}`);
|
|
419
|
+
merged.output.max_top_k = k;
|
|
409
420
|
}
|
|
410
421
|
}
|
|
411
422
|
|
|
@@ -441,16 +452,6 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
441
452
|
if (merged.inference.reranker && (!merged.inference.reranker.endpoint || !merged.inference.reranker.model)) {
|
|
442
453
|
throw new Error("Configured inference.reranker requires adapter, endpoint, and model.");
|
|
443
454
|
}
|
|
444
|
-
if (merged.inference.reranker && !merged.inference.thresholds) {
|
|
445
|
-
const warningKey = "inference.reranker.without-thresholds";
|
|
446
|
-
if (!warnedEnv.has(warningKey)) {
|
|
447
|
-
warnedEnv.add(warningKey);
|
|
448
|
-
console.error(
|
|
449
|
-
"skillmux: configured reranker has no calibrated inference.thresholds; " +
|
|
450
|
-
"routing will remain ambiguous until you run `skillmux calibrate run`.",
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
455
|
const embedEndpoint = getEnv("SKILLMUX_EMBED_ENDPOINT", "EMBED_ENDPOINT");
|
|
455
456
|
const embedModel = getEnv("SKILLMUX_EMBED_MODEL", "EMBED_MODEL");
|
|
456
457
|
const embedDimStr = getEnv("SKILLMUX_EMBED_DIMENSION", "EMBED_DIMENSION");
|
package/src/db.ts
CHANGED
|
@@ -43,24 +43,43 @@ export function openIndex(stateDir: string): Database {
|
|
|
43
43
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
44
|
ts TEXT NOT NULL,
|
|
45
45
|
query TEXT NOT NULL,
|
|
46
|
-
outcome TEXT NOT NULL CHECK (outcome IN ('matched', 'ambiguous', 'no_match')),
|
|
47
|
-
degraded INTEGER NOT NULL,
|
|
48
46
|
retrieval TEXT NOT NULL DEFAULT 'lexical',
|
|
49
47
|
degraded_from TEXT,
|
|
50
48
|
degradation_reason TEXT,
|
|
51
49
|
candidates TEXT NOT NULL,
|
|
52
|
-
selected_skill_id TEXT,
|
|
53
50
|
latency_ms INTEGER NOT NULL
|
|
54
51
|
)`);
|
|
55
52
|
const auditColumns = db.query("PRAGMA table_info(audit)").all() as { name: string }[];
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
53
|
+
const columnNames = new Set(auditColumns.map((column) => column.name));
|
|
54
|
+
const hasLegacyColumns =
|
|
55
|
+
columnNames.has("outcome") ||
|
|
56
|
+
columnNames.has("selected_skill_id") ||
|
|
57
|
+
columnNames.has("degraded");
|
|
58
|
+
const missingCanonicalColumns =
|
|
59
|
+
!columnNames.has("retrieval") ||
|
|
60
|
+
!columnNames.has("degraded_from") ||
|
|
61
|
+
!columnNames.has("degradation_reason");
|
|
62
|
+
|
|
63
|
+
if (hasLegacyColumns || missingCanonicalColumns) {
|
|
64
|
+
db.transaction(() => {
|
|
65
|
+
db.run(`CREATE TABLE audit_new (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
ts TEXT NOT NULL,
|
|
68
|
+
query TEXT NOT NULL,
|
|
69
|
+
retrieval TEXT NOT NULL DEFAULT 'lexical',
|
|
70
|
+
degraded_from TEXT,
|
|
71
|
+
degradation_reason TEXT,
|
|
72
|
+
candidates TEXT NOT NULL,
|
|
73
|
+
latency_ms INTEGER NOT NULL
|
|
74
|
+
)`);
|
|
75
|
+
const retrievalExpr = columnNames.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
|
|
76
|
+
const degradedFromExpr = columnNames.has("degraded_from") ? "degraded_from" : "NULL";
|
|
77
|
+
const degradationReasonExpr = columnNames.has("degradation_reason") ? "degradation_reason" : "NULL";
|
|
78
|
+
db.run(`INSERT INTO audit_new (id, ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
79
|
+
SELECT id, ts, query, ${retrievalExpr}, ${degradedFromExpr}, ${degradationReasonExpr}, candidates, latency_ms FROM audit`);
|
|
80
|
+
db.run("DROP TABLE audit");
|
|
81
|
+
db.run("ALTER TABLE audit_new RENAME TO audit");
|
|
82
|
+
})();
|
|
64
83
|
}
|
|
65
84
|
db.run(`CREATE TABLE IF NOT EXISTS index_meta (
|
|
66
85
|
key TEXT PRIMARY KEY,
|
|
@@ -263,29 +282,24 @@ export function vectorTopK(db: Database, query: Float32Array, k: number): SkillR
|
|
|
263
282
|
export interface AuditInsert {
|
|
264
283
|
ts: string;
|
|
265
284
|
query: string;
|
|
266
|
-
outcome: string;
|
|
267
285
|
retrieval: AuditRow["retrieval"];
|
|
268
286
|
degraded_from?: string | null;
|
|
269
287
|
degradation_reason?: string | null;
|
|
270
288
|
candidates: AuditCandidate[];
|
|
271
|
-
selected_skill_id: string | null;
|
|
272
289
|
latency_ms: number;
|
|
273
290
|
}
|
|
274
291
|
|
|
275
292
|
export function insertAudit(db: Database, row: AuditInsert): void {
|
|
276
293
|
db.run(
|
|
277
|
-
`INSERT INTO audit (ts, query,
|
|
278
|
-
VALUES (?, ?, ?, ?, ?, ?,
|
|
294
|
+
`INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
295
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
279
296
|
[
|
|
280
297
|
row.ts,
|
|
281
298
|
row.query,
|
|
282
|
-
row.outcome,
|
|
283
|
-
row.retrieval === "lexical" || row.degradation_reason ? 1 : 0,
|
|
284
299
|
row.retrieval,
|
|
285
300
|
row.degraded_from ?? null,
|
|
286
301
|
row.degradation_reason ?? null,
|
|
287
302
|
JSON.stringify(row.candidates),
|
|
288
|
-
row.selected_skill_id,
|
|
289
303
|
row.latency_ms,
|
|
290
304
|
],
|
|
291
305
|
);
|
package/src/doctor.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { computeCorpusFingerprint, getCalibrationRun, openCalibrateDb } from "./calibrate";
|
|
3
2
|
import { createClients, RemoteInferenceError } from "./clients";
|
|
4
|
-
import { embeddingDimension,
|
|
3
|
+
import { embeddingDimension, expandHome } from "./config";
|
|
5
4
|
import { describeDeployment, type DeploymentIdentity } from "./deployment";
|
|
6
|
-
import { openIndex } from "./db";
|
|
7
5
|
import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
|
|
8
6
|
import { readSkillmuxMarker } from "./sync";
|
|
9
7
|
import type { Config } from "./types";
|
|
@@ -32,83 +30,6 @@ export interface DoctorReport {
|
|
|
32
30
|
checks: DoctorCheck[];
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
/**
|
|
36
|
-
* Warn when live `inference.thresholds` didn't come from an applied
|
|
37
|
-
* `skillmux calibrate` run — e.g. hand-copied from an example config.
|
|
38
|
-
* Reranker score scales are not portable across models/adapters/corpora,
|
|
39
|
-
* so uncalibrated thresholds routinely make automatic matching unreachable
|
|
40
|
-
* without any visible error.
|
|
41
|
-
*/
|
|
42
|
-
function checkCalibration(config: Config): DoctorCheck {
|
|
43
|
-
const inference = config.inference;
|
|
44
|
-
if (inference.mode !== "remote") throw new Error("checkCalibration requires remote inference mode");
|
|
45
|
-
const runId = inference.calibration?.run_id;
|
|
46
|
-
if (!runId) {
|
|
47
|
-
return {
|
|
48
|
-
name: "calibration",
|
|
49
|
-
ok: false,
|
|
50
|
-
detail: "inference.thresholds are set but were never produced by `skillmux calibrate apply` — " +
|
|
51
|
-
"likely copied from an example config. Reranker scores are not portable across models, " +
|
|
52
|
-
"adapters, or corpora, so automatic matching may never trigger (or may trigger incorrectly). " +
|
|
53
|
-
"Run `skillmux calibrate`.",
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const calibrateDb = openCalibrateDb(expandHome(config.state_dir));
|
|
58
|
-
let run;
|
|
59
|
-
try {
|
|
60
|
-
run = getCalibrationRun(calibrateDb, runId);
|
|
61
|
-
} finally {
|
|
62
|
-
calibrateDb.close();
|
|
63
|
-
}
|
|
64
|
-
if (!run) {
|
|
65
|
-
return {
|
|
66
|
-
name: "calibration",
|
|
67
|
-
ok: false,
|
|
68
|
-
detail: `inference.calibration.run_id "${runId}" was not found in the local calibration ` +
|
|
69
|
-
"evidence store (state_dir may differ from where it was calibrated). Recalibrate.",
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
if (run.status !== "completed") {
|
|
73
|
-
return {
|
|
74
|
-
name: "calibration",
|
|
75
|
-
ok: false,
|
|
76
|
-
detail: `inference.calibration.run_id "${runId}" has status "${run.status}" and should never ` +
|
|
77
|
-
"have been applied. Recalibrate.",
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const indexDb = openIndex(expandHome(config.state_dir));
|
|
82
|
-
let currentCorpusFingerprint: string;
|
|
83
|
-
try {
|
|
84
|
-
currentCorpusFingerprint = computeCorpusFingerprint(indexDb);
|
|
85
|
-
} finally {
|
|
86
|
-
indexDb.close();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const stale = [
|
|
90
|
-
rerankerFingerprint(config) !== run.reranker_fingerprint ? "reranker" : null,
|
|
91
|
-
embeddingFingerprint(config) !== run.embedding_fingerprint ? "embedding" : null,
|
|
92
|
-
currentCorpusFingerprint !== run.corpus_fingerprint ? "vault contents" : null,
|
|
93
|
-
run.recall_settings && (
|
|
94
|
-
run.recall_settings.k_lexical !== config.recall.k_lexical ||
|
|
95
|
-
run.recall_settings.k_vector !== config.recall.k_vector ||
|
|
96
|
-
run.recall_settings.k_rerank !== (config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector))
|
|
97
|
-
) ? "recall settings" : null,
|
|
98
|
-
].filter((part): part is string => part !== null);
|
|
99
|
-
|
|
100
|
-
if (stale.length > 0) {
|
|
101
|
-
return {
|
|
102
|
-
name: "calibration",
|
|
103
|
-
ok: false,
|
|
104
|
-
detail: `applied calibration run "${runId}" is stale — ${stale.join(", ")} changed since it was ` +
|
|
105
|
-
"calibrated. Recalibrate.",
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return { name: "calibration", ok: true, detail: `thresholds from applied calibration run "${runId}"` };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
33
|
export { describeDeployment };
|
|
113
34
|
|
|
114
35
|
export async function diagnose(
|
|
@@ -246,10 +167,6 @@ export async function diagnose(
|
|
|
246
167
|
}
|
|
247
168
|
}
|
|
248
169
|
|
|
249
|
-
if (config.inference.mode === "remote" && config.inference.reranker && config.inference.thresholds) {
|
|
250
|
-
checks.push(checkCalibration(config));
|
|
251
|
-
}
|
|
252
|
-
|
|
253
170
|
const inferenceReady = checks.some((check) => check.name === "embedding" && check.ok);
|
|
254
171
|
const rerankerReady = checks.some((check) => check.name === "reranker" && check.ok);
|
|
255
172
|
const coreReady = checks.some((check) => check.name === "vault" && check.ok)
|
package/src/eval.ts
CHANGED
|
@@ -1,31 +1,68 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { z } from "zod";
|
|
4
3
|
import {
|
|
5
4
|
backfillEmbeddings,
|
|
6
|
-
decideRetrievalResult,
|
|
7
5
|
getRuntime,
|
|
8
6
|
retrieveAndRerank,
|
|
9
7
|
} from "./router-core";
|
|
10
8
|
|
|
11
9
|
export interface EvalCase {
|
|
12
10
|
query: string;
|
|
13
|
-
|
|
11
|
+
split?: string;
|
|
12
|
+
relevant_skill_ids: string[];
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
23
|
-
|
|
15
|
+
export function parseEvalCases(raw: unknown): EvalCase[] {
|
|
16
|
+
if (!Array.isArray(raw)) throw new Error("Eval cases file must contain a JSON array");
|
|
17
|
+
const result: EvalCase[] = [];
|
|
18
|
+
for (let i = 0; i < raw.length; i++) {
|
|
19
|
+
const item = raw[i];
|
|
20
|
+
if (typeof item !== "object" || item === null) {
|
|
21
|
+
throw new Error(`Eval case at index ${i} must be an object`);
|
|
22
|
+
}
|
|
23
|
+
if ("expected" in item) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Legacy 'expected' field at case ${i} is no longer supported in eval datasets; use 'relevant_skill_ids' instead.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if ("expected_outcome" in item) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Legacy 'expected_outcome' field at case ${i} is no longer supported in eval datasets; remove it and provide 'relevant_skill_ids' instead.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (typeof (item as any).query !== "string" || (item as any).query.trim().length === 0) {
|
|
34
|
+
throw new Error(`Eval case at index ${i} has invalid 'query': must be a non-empty string`);
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
!Array.isArray((item as any).relevant_skill_ids) ||
|
|
38
|
+
(item as any).relevant_skill_ids.some((id: unknown) => typeof id !== "string")
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(`Eval case at index ${i} has invalid 'relevant_skill_ids': must be an array of strings`);
|
|
41
|
+
}
|
|
42
|
+
const relevantSkillIds = (item as any).relevant_skill_ids as string[];
|
|
43
|
+
if (relevantSkillIds.some((id) => id.trim().length === 0)) {
|
|
44
|
+
throw new Error(`Eval case at index ${i} has invalid 'relevant_skill_ids': IDs must be non-empty strings`);
|
|
45
|
+
}
|
|
46
|
+
if (new Set(relevantSkillIds).size !== relevantSkillIds.length) {
|
|
47
|
+
throw new Error(`Eval case at index ${i} has invalid 'relevant_skill_ids': duplicate IDs are not allowed`);
|
|
48
|
+
}
|
|
49
|
+
if ("split" in item && (item as any).split !== undefined && typeof (item as any).split !== "string") {
|
|
50
|
+
throw new Error(`Eval case at index ${i} has invalid 'split': must be a string if present`);
|
|
51
|
+
}
|
|
52
|
+
result.push({
|
|
53
|
+
query: (item as any).query,
|
|
54
|
+
...((item as any).split !== undefined ? { split: (item as any).split } : {}),
|
|
55
|
+
relevant_skill_ids: relevantSkillIds,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
24
60
|
|
|
25
61
|
export interface EvalMetrics {
|
|
26
|
-
recall_at_3: number;
|
|
27
62
|
recall_at_5: number;
|
|
63
|
+
recall_at_10: number;
|
|
28
64
|
mrr: number;
|
|
65
|
+
ndcg_at_10: number;
|
|
29
66
|
}
|
|
30
67
|
|
|
31
68
|
export interface CandidateEvalDetail {
|
|
@@ -37,8 +74,7 @@ export interface CandidateEvalDetail {
|
|
|
37
74
|
|
|
38
75
|
export interface EvalCaseResult {
|
|
39
76
|
query: string;
|
|
40
|
-
|
|
41
|
-
outcome: "matched" | "ambiguous" | "no_match";
|
|
77
|
+
relevant_skill_ids: string[];
|
|
42
78
|
retrieval: string;
|
|
43
79
|
degraded_from?: string | null;
|
|
44
80
|
degradation_reason?: string | null;
|
|
@@ -53,48 +89,88 @@ export interface EvalCaseResult {
|
|
|
53
89
|
|
|
54
90
|
export interface EvalReport {
|
|
55
91
|
queries: number;
|
|
92
|
+
judged_queries: number;
|
|
93
|
+
unjudged_queries: number;
|
|
56
94
|
lexical: EvalMetrics;
|
|
57
95
|
hybrid: EvalMetrics;
|
|
58
96
|
cases?: EvalCaseResult[];
|
|
59
97
|
}
|
|
60
98
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
99
|
+
export function computeRankingMetrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
|
|
100
|
+
const judgedIndices: number[] = [];
|
|
101
|
+
for (let i = 0; i < cases.length; i++) {
|
|
102
|
+
if (cases[i]!.relevant_skill_ids && cases[i]!.relevant_skill_ids.length > 0) {
|
|
103
|
+
judgedIndices.push(i);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (judgedIndices.length === 0) {
|
|
108
|
+
return {
|
|
109
|
+
recall_at_5: 0,
|
|
110
|
+
recall_at_10: 0,
|
|
111
|
+
mrr: 0,
|
|
112
|
+
ndcg_at_10: 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let totalRecall5 = 0;
|
|
117
|
+
let totalRecall10 = 0;
|
|
118
|
+
let totalMRR = 0;
|
|
119
|
+
let totalNDCG10 = 0;
|
|
120
|
+
|
|
121
|
+
for (const idx of judgedIndices) {
|
|
122
|
+
const c = cases[idx]!;
|
|
123
|
+
const ranking = rankings[idx] ?? [];
|
|
124
|
+
const relevantSet = new Set(c.relevant_skill_ids);
|
|
125
|
+
const numRelevant = c.relevant_skill_ids.length;
|
|
126
|
+
|
|
127
|
+
// Recall@5: fraction of relevant skill IDs present in the first 5 results
|
|
128
|
+
const top5 = ranking.slice(0, 5);
|
|
129
|
+
const count5 = top5.filter((id) => relevantSet.has(id)).length;
|
|
130
|
+
totalRecall5 += count5 / numRelevant;
|
|
131
|
+
|
|
132
|
+
// Recall@10: fraction of relevant skill IDs present in the first 10 results
|
|
133
|
+
const top10 = ranking.slice(0, 10);
|
|
134
|
+
const count10 = top10.filter((id) => relevantSet.has(id)).length;
|
|
135
|
+
totalRecall10 += count10 / numRelevant;
|
|
136
|
+
|
|
137
|
+
// MRR: reciprocal rank of the first relevant result (1-based), 0 if missing
|
|
138
|
+
const firstRelevantRank = ranking.findIndex((id) => relevantSet.has(id));
|
|
139
|
+
if (firstRelevantRank >= 0) {
|
|
140
|
+
totalMRR += 1 / (firstRelevantRank + 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// nDCG@10: binary relevance, DCG gain 1/log2(rank+1), IDCG over min(numRelevant, 10)
|
|
144
|
+
let dcg10 = 0;
|
|
145
|
+
for (let r = 0; r < Math.min(10, ranking.length); r++) {
|
|
146
|
+
if (relevantSet.has(ranking[r]!)) {
|
|
147
|
+
dcg10 += 1 / Math.log2(r + 2);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let idcg10 = 0;
|
|
151
|
+
const idealCount = Math.min(numRelevant, 10);
|
|
152
|
+
for (let r = 0; r < idealCount; r++) {
|
|
153
|
+
idcg10 += 1 / Math.log2(r + 2);
|
|
154
|
+
}
|
|
155
|
+
if (idcg10 > 0) {
|
|
156
|
+
totalNDCG10 += dcg10 / idcg10;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const judgedCount = judgedIndices.length;
|
|
73
161
|
return {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
mrr:
|
|
162
|
+
recall_at_5: totalRecall5 / judgedCount,
|
|
163
|
+
recall_at_10: totalRecall10 / judgedCount,
|
|
164
|
+
mrr: totalMRR / judgedCount,
|
|
165
|
+
ndcg_at_10: totalNDCG10 / judgedCount,
|
|
77
166
|
};
|
|
78
167
|
}
|
|
79
168
|
|
|
80
169
|
export function loadEvalCases(path = join(import.meta.dir, "..", "eval", "queries.json")): EvalCase[] {
|
|
81
170
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
82
|
-
|
|
83
|
-
const parsed = z.array(rawEvalItemSchema).parse(raw);
|
|
84
|
-
const result: EvalCase[] = [];
|
|
85
|
-
for (const item of parsed) {
|
|
86
|
-
const expected = item.expected ?? item.relevant_skill_ids;
|
|
87
|
-
if (expected && expected.length > 0) {
|
|
88
|
-
result.push({ query: item.query, expected });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (parsed.length > 0 && result.length === 0) {
|
|
92
|
-
throw new Error("Eval cases file contained no cases with expected targets");
|
|
93
|
-
}
|
|
94
|
-
return result;
|
|
171
|
+
return parseEvalCases(raw);
|
|
95
172
|
}
|
|
96
173
|
|
|
97
|
-
|
|
98
174
|
export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
|
|
99
175
|
const { config } = await getRuntime();
|
|
100
176
|
await backfillEmbeddings();
|
|
@@ -107,27 +183,27 @@ export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
|
|
|
107
183
|
for (const evalCase of cases) {
|
|
108
184
|
const start = performance.now();
|
|
109
185
|
const retrievalResult = await retrieveAndRerank({ query: evalCase.query });
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
186
|
+
|
|
187
|
+
// lexical metrics use lexical rank
|
|
188
|
+
lexicalRankings.push(
|
|
189
|
+
retrievalResult.trace
|
|
190
|
+
.filter((candidate) => candidate.lexical_rank !== null)
|
|
191
|
+
.sort((a, b) => a.lexical_rank! - b.lexical_rank!)
|
|
192
|
+
.map((candidate) => candidate.skill_id),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// retrieveAndRerank returns candidates in the exact order delivered to the
|
|
196
|
+
// caller: reranked when available, otherwise fused or lexical fallback.
|
|
197
|
+
hybridRankings.push(
|
|
198
|
+
retrievalResult.candidates.map((candidate) => candidate.skill_id),
|
|
199
|
+
);
|
|
123
200
|
|
|
124
201
|
const latency_ms = Math.round(performance.now() - start);
|
|
125
202
|
const candidateDetails: CandidateEvalDetail[] = retrievalResult.trace;
|
|
126
203
|
|
|
127
204
|
caseResults.push({
|
|
128
205
|
query: evalCase.query,
|
|
129
|
-
|
|
130
|
-
outcome: decision.outcome,
|
|
206
|
+
relevant_skill_ids: evalCase.relevant_skill_ids,
|
|
131
207
|
retrieval: retrievalResult.retrieval,
|
|
132
208
|
degraded_from: retrievalResult.degraded_from ?? null,
|
|
133
209
|
degradation_reason: retrievalResult.degradation_reason ?? null,
|
|
@@ -141,10 +217,17 @@ export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
|
|
|
141
217
|
});
|
|
142
218
|
}
|
|
143
219
|
|
|
220
|
+
const judged_queries = cases.filter(
|
|
221
|
+
(c) => c.relevant_skill_ids && c.relevant_skill_ids.length > 0,
|
|
222
|
+
).length;
|
|
223
|
+
const unjudged_queries = cases.length - judged_queries;
|
|
224
|
+
|
|
144
225
|
return {
|
|
145
226
|
queries: cases.length,
|
|
146
|
-
|
|
147
|
-
|
|
227
|
+
judged_queries,
|
|
228
|
+
unjudged_queries,
|
|
229
|
+
lexical: computeRankingMetrics(lexicalRankings, cases),
|
|
230
|
+
hybrid: computeRankingMetrics(hybridRankings, cases),
|
|
148
231
|
cases: caseResults,
|
|
149
232
|
};
|
|
150
233
|
}
|