@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/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
- ambiguous_candidate_limit: z.number().int().positive().default(5),
47
- }).strict().optional(),
48
- thresholds: z.object({
49
- candidate_limit: z.number().int().positive().optional(),
50
- match_score: z.number().optional(),
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
- thresholds: { candidate_limit: 5 },
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 = { ambiguous_candidate_limit: merged.thresholds?.candidate_limit ?? 5 };
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
- AMBIGUOUS_CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
337
- CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
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 ambiguousLimitStr =
402
- getEnv("SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT", "AMBIGUOUS_CANDIDATE_LIMIT") ??
403
- getEnv("SKILLMUX_CANDIDATE_LIMIT", "CANDIDATE_LIMIT");
404
- if (ambiguousLimitStr) {
405
- const lim = Number(ambiguousLimitStr);
406
- if (!Number.isInteger(lim) || lim < 1) throw new Error(`Invalid output.ambiguous_candidate_limit: ${ambiguousLimitStr}`);
407
- merged.output.ambiguous_candidate_limit = lim;
408
- merged.thresholds.candidate_limit = lim;
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
- if (!auditColumns.some((column) => column.name === "retrieval")) {
57
- db.run("ALTER TABLE audit ADD COLUMN retrieval TEXT NOT NULL DEFAULT 'lexical'");
58
- }
59
- if (!auditColumns.some((column) => column.name === "degraded_from")) {
60
- db.run("ALTER TABLE audit ADD COLUMN degraded_from TEXT");
61
- }
62
- if (!auditColumns.some((column) => column.name === "degradation_reason")) {
63
- db.run("ALTER TABLE audit ADD COLUMN degradation_reason TEXT");
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, outcome, degraded, retrieval, degraded_from, degradation_reason, candidates, selected_skill_id, latency_ms)
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, embeddingFingerprint, expandHome, rerankerFingerprint } from "./config";
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
- expected: string[];
11
+ split?: string;
12
+ relevant_skill_ids: string[];
14
13
  }
15
14
 
16
- const rawEvalItemSchema = z.object({
17
- query: z.string().min(1),
18
- expected: z.array(z.string().min(1)).optional(),
19
- relevant_skill_ids: z.array(z.string()).optional(),
20
- split: z.string().optional(),
21
- expected_outcome: z.string().optional(),
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
- expected: string[];
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 metrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
62
- if (cases.length === 0) return { recall_at_3: 0, recall_at_5: 0, mrr: 0 };
63
- let recall3 = 0;
64
- let recall5 = 0;
65
- let reciprocalRanks = 0;
66
- rankings.forEach((ranking, index) => {
67
- const expected = new Set(cases[index]!.expected);
68
- if (ranking.slice(0, 3).some((id) => expected.has(id))) recall3++;
69
- if (ranking.slice(0, 5).some((id) => expected.has(id))) recall5++;
70
- const rank = ranking.findIndex((id) => expected.has(id));
71
- if (rank >= 0) reciprocalRanks += 1 / (rank + 1);
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
- recall_at_3: recall3 / cases.length,
75
- recall_at_5: recall5 / cases.length,
76
- mrr: reciprocalRanks / cases.length,
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
- if (!Array.isArray(raw)) throw new Error("Eval cases file must contain a JSON array");
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
- const decision = decideRetrievalResult(config, retrievalResult);
111
- const fusedRanking = retrievalResult.trace
112
- .filter((candidate) => candidate.fused_rank !== null)
113
- .sort((a, b) => a.fused_rank! - b.fused_rank!)
114
- .map((candidate) => candidate.skill_id);
115
-
116
- lexicalRankings.push(retrievalResult.trace
117
- .filter((candidate) => candidate.lexical_rank !== null)
118
- .sort((a, b) => a.lexical_rank! - b.lexical_rank!)
119
- .map((candidate) => candidate.skill_id));
120
- hybridRankings.push(fusedRanking.length > 0
121
- ? fusedRanking
122
- : retrievalResult.candidates.map((candidate) => candidate.skill_id));
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
- expected: evalCase.expected,
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
- lexical: metrics(lexicalRankings, cases),
147
- hybrid: metrics(hybridRankings, cases),
227
+ judged_queries,
228
+ unjudged_queries,
229
+ lexical: computeRankingMetrics(lexicalRankings, cases),
230
+ hybrid: computeRankingMetrics(hybridRankings, cases),
148
231
  cases: caseResults,
149
232
  };
150
233
  }