@klhapp/skillmux 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -26,16 +26,31 @@ const remoteThresholdsSchema = z.object({
26
26
  }).strict();
27
27
 
28
28
  const configSchema = z.object({
29
+ config: z.object({
30
+ environment_overrides: z.boolean().default(true),
31
+ }).strict().optional(),
29
32
  vault_path: z.string().min(1),
30
33
  local_vault_paths: z.array(z.string()),
31
34
  state_dir: z.string().min(1),
32
- recall: z.object({ k_lexical: z.number().int().positive(), k_vector: z.number().int().positive() }).strict(),
35
+ recall: z.object({
36
+ k_lexical: z.number().int().positive(),
37
+ k_vector: z.number().int().positive(),
38
+ k_rerank: z.number().int().positive().optional(),
39
+ }).strict().transform((r) => ({
40
+ ...r,
41
+ k_rerank: r.k_rerank ?? Math.min(10, r.k_lexical + r.k_vector),
42
+ })).refine((r) => r.k_rerank <= r.k_lexical + r.k_vector, {
43
+ message: "recall.k_rerank cannot exceed k_lexical + k_vector",
44
+ }),
45
+ output: z.object({
46
+ ambiguous_candidate_limit: z.number().int().positive().default(5),
47
+ }).strict().optional(),
33
48
  thresholds: z.object({
34
- candidate_limit: z.number().int().positive(),
49
+ candidate_limit: z.number().int().positive().optional(),
35
50
  match_score: z.number().optional(),
36
51
  match_margin: z.number().nonnegative().optional(),
37
52
  candidate_floor: z.number().optional(),
38
- }).strict(),
53
+ }).strict().optional(),
39
54
  inference: z.discriminatedUnion("mode", [
40
55
  z.object({
41
56
  mode: z.literal("local"),
@@ -85,11 +100,15 @@ const configSchema = z.object({
85
100
  export const LOCAL_BUNDLE_ID = "gte-small-v1";
86
101
 
87
102
  const DEFAULTS: Config = {
103
+ config: {
104
+ environment_overrides: true,
105
+ },
88
106
  vault_path: "~/skills",
89
107
  local_vault_paths: [],
90
108
  state_dir: "~/.local/state/skillmux",
91
- recall: { k_lexical: 20, k_vector: 20 },
109
+ recall: { k_lexical: 20, k_vector: 20, k_rerank: 10 },
92
110
  thresholds: { candidate_limit: 5 },
111
+ output: { ambiguous_candidate_limit: 5 },
93
112
  inference: {
94
113
  mode: "local",
95
114
  bundle: LOCAL_BUNDLE_ID,
@@ -259,25 +278,90 @@ export async function loadConfig(path?: string): Promise<Config> {
259
278
  if (!isPlainObject(parsed.inference.embedding)) {
260
279
  throw new Error("Remote inference requires an inference.embedding section.");
261
280
  }
262
- const withoutInference = { ...parsed };
263
- delete withoutInference.inference;
264
- merged = {
265
- ...deepMerge(baseConfig, withoutInference),
266
- inference: configSchema.shape.inference.parse(parsed.inference),
267
- };
281
+ }
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
+
292
+ if (parsed.inference && typeof parsed.inference === "object" && "mode" in parsed.inference) {
293
+ 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
+ const withoutInference = { ...parsed };
307
+ delete withoutInference.inference;
308
+ merged = {
309
+ ...deepMerge(baseConfig, withoutInference),
310
+ inference: configSchema.shape.inference.parse(parsed.inference),
311
+ };
312
+ } else {
313
+ merged = deepMerge(baseConfig, parsed);
314
+ }
268
315
  } else {
269
316
  merged = deepMerge(baseConfig, parsed);
270
317
  }
271
318
  }
272
319
 
273
- // Environment variable overrides.
274
- if (process.env.VAULT_PATH) {
275
- merged.vault_path = process.env.VAULT_PATH;
320
+ if (!merged.output) {
321
+ merged.output = { ambiguous_candidate_limit: merged.thresholds?.candidate_limit ?? 5 };
276
322
  }
277
- if (process.env.STATE_DIR) {
278
- merged.state_dir = process.env.STATE_DIR;
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;
279
327
  }
328
+
329
+ // Warn about deprecated generic environment variables regardless of override policy
330
+ const GENERIC_ENV_MAPPINGS: Record<string, string> = {
331
+ VAULT_PATH: "SKILLMUX_VAULT_PATH",
332
+ STATE_DIR: "SKILLMUX_STATE_DIR",
333
+ RECALL_K_LEXICAL: "SKILLMUX_RECALL_K_LEXICAL",
334
+ RECALL_K_VECTOR: "SKILLMUX_RECALL_K_VECTOR",
335
+ RECALL_K_RERANK: "SKILLMUX_RECALL_K_RERANK",
336
+ AMBIGUOUS_CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
337
+ CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
338
+ EMBED_MODEL: "SKILLMUX_EMBED_MODEL",
339
+ EMBED_ENDPOINT: "SKILLMUX_EMBED_ENDPOINT",
340
+ EMBED_DIMENSION: "SKILLMUX_EMBED_DIMENSION",
341
+ EMBED_DEVICE: "SKILLMUX_EMBED_DEVICE",
342
+ EMBED_DTYPE: "SKILLMUX_EMBED_DTYPE",
343
+ RERANK_MODEL: "SKILLMUX_RERANK_MODEL",
344
+ RERANK_ENDPOINT: "SKILLMUX_RERANK_ENDPOINT",
345
+ RERANK_ADAPTER: "SKILLMUX_RERANK_ADAPTER",
346
+ HTTP_AUTH_ENABLED: "SKILLMUX_HTTP_AUTH_ENABLED",
347
+ HTTP_AUTH_TOKEN_ENV: "SKILLMUX_HTTP_AUTH_TOKEN_ENV",
348
+ HTTP_ALLOWED_ORIGINS: "SKILLMUX_HTTP_ALLOWED_ORIGINS",
349
+ HTTP_HOSTNAME: "SKILLMUX_HTTP_HOSTNAME",
350
+ HTTP_RATE_LIMIT_ENABLED: "SKILLMUX_HTTP_RATE_LIMIT_ENABLED",
351
+ HTTP_RATE_LIMIT_RPM: "SKILLMUX_HTTP_RATE_LIMIT_RPM",
352
+ HTTP_RATE_LIMIT_TRUST_PROXY: "SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY",
353
+ };
354
+ for (const [generic, preferred] of Object.entries(GENERIC_ENV_MAPPINGS)) {
355
+ if (process.env[generic] !== undefined && !warnedEnv.has(generic)) {
356
+ warnedEnv.add(generic);
357
+ console.error(`skillmux: ${generic} is deprecated, use ${preferred} instead`);
358
+ }
359
+ }
360
+
361
+ const allowEnvOverrides = merged.config?.environment_overrides !== false;
362
+
280
363
  const getEnv = (newPrefixed: string, unprefixed: string) => {
364
+ if (!allowEnvOverrides) return undefined;
281
365
  const legacyPrefixed = newPrefixed.replace(/^SKILLMUX_/, "SKILL_ROUTER_");
282
366
  if (process.env[newPrefixed] !== undefined) return process.env[newPrefixed];
283
367
  if (process.env[legacyPrefixed] !== undefined) {
@@ -290,18 +374,57 @@ export async function loadConfig(path?: string): Promise<Config> {
290
374
  return process.env[unprefixed];
291
375
  };
292
376
 
377
+ // Environment variable overrides.
378
+ if (allowEnvOverrides) {
379
+ const vaultPath = getEnv("SKILLMUX_VAULT_PATH", "VAULT_PATH");
380
+ if (vaultPath) merged.vault_path = vaultPath;
381
+ const stateDir = getEnv("SKILLMUX_STATE_DIR", "STATE_DIR");
382
+ if (stateDir) merged.state_dir = stateDir;
383
+ const kLexicalStr = getEnv("SKILLMUX_RECALL_K_LEXICAL", "RECALL_K_LEXICAL");
384
+ if (kLexicalStr) {
385
+ const k = Number(kLexicalStr);
386
+ if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_lexical: ${kLexicalStr}`);
387
+ merged.recall.k_lexical = k;
388
+ }
389
+ const kVectorStr = getEnv("SKILLMUX_RECALL_K_VECTOR", "RECALL_K_VECTOR");
390
+ if (kVectorStr) {
391
+ const k = Number(kVectorStr);
392
+ if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_vector: ${kVectorStr}`);
393
+ merged.recall.k_vector = k;
394
+ }
395
+ const kRerankStr = getEnv("SKILLMUX_RECALL_K_RERANK", "RECALL_K_RERANK");
396
+ if (kRerankStr) {
397
+ const k = Number(kRerankStr);
398
+ if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_rerank: ${kRerankStr}`);
399
+ merged.recall.k_rerank = k;
400
+ }
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
+ }
410
+ }
411
+
293
412
  if (merged.inference.mode === "local") {
294
- let modelsDirEnv = process.env.SKILLMUX_MODELS_DIR;
295
- if (modelsDirEnv === undefined && process.env.SKILL_ROUTER_MODELS_DIR !== undefined) {
296
- if (!warnedEnv.has("SKILL_ROUTER_MODELS_DIR")) {
297
- warnedEnv.add("SKILL_ROUTER_MODELS_DIR");
298
- console.error("skillmux: SKILL_ROUTER_MODELS_DIR is deprecated, use SKILLMUX_MODELS_DIR instead");
413
+ if (allowEnvOverrides) {
414
+ let modelsDirEnv = process.env.SKILLMUX_MODELS_DIR;
415
+ if (modelsDirEnv === undefined && process.env.SKILL_ROUTER_MODELS_DIR !== undefined) {
416
+ if (!warnedEnv.has("SKILL_ROUTER_MODELS_DIR")) {
417
+ warnedEnv.add("SKILL_ROUTER_MODELS_DIR");
418
+ console.error("skillmux: SKILL_ROUTER_MODELS_DIR is deprecated, use SKILLMUX_MODELS_DIR instead");
419
+ }
420
+ modelsDirEnv = process.env.SKILL_ROUTER_MODELS_DIR;
299
421
  }
300
- modelsDirEnv = process.env.SKILL_ROUTER_MODELS_DIR;
422
+ if (modelsDirEnv) merged.inference.models_dir = modelsDirEnv;
423
+ const embedDevice = getEnv("SKILLMUX_EMBED_DEVICE", "EMBED_DEVICE");
424
+ if (embedDevice) merged.inference.embedding.device = embedDevice as ONNXDevice;
425
+ const embedDtype = getEnv("SKILLMUX_EMBED_DTYPE", "EMBED_DTYPE");
426
+ if (embedDtype) merged.inference.embedding.dtype = embedDtype as ONNXDtype;
301
427
  }
302
- if (modelsDirEnv) merged.inference.models_dir = modelsDirEnv;
303
- if (process.env.EMBED_DEVICE) merged.inference.embedding.device = process.env.EMBED_DEVICE as ONNXDevice;
304
- if (process.env.EMBED_DTYPE) merged.inference.embedding.dtype = process.env.EMBED_DTYPE as ONNXDtype;
305
428
  } else if (merged.inference.mode === "remote") {
306
429
  if (!merged.inference.embedding) {
307
430
  throw new Error("Remote inference requires an inference.embedding section.");
@@ -385,43 +508,49 @@ export async function loadConfig(path?: string): Promise<Config> {
385
508
 
386
509
  // HTTP server environment overrides
387
510
  if (merged.server) {
388
- if (process.env.HTTP_AUTH_ENABLED) {
389
- merged.server.auth_enabled = process.env.HTTP_AUTH_ENABLED === "true";
390
- }
391
- if (process.env.HTTP_AUTH_TOKEN_ENV) {
392
- merged.server.auth_token_env = process.env.HTTP_AUTH_TOKEN_ENV;
393
- }
394
- if (process.env.HTTP_ALLOWED_ORIGINS) {
395
- merged.server.allowed_origins = process.env.HTTP_ALLOWED_ORIGINS.split(",").map((o) => o.trim());
396
- }
397
- if (process.env.HTTP_HOSTNAME) {
398
- merged.server.hostname = process.env.HTTP_HOSTNAME;
399
- }
511
+ if (allowEnvOverrides) {
512
+ const authEnabledStr = getEnv("SKILLMUX_HTTP_AUTH_ENABLED", "HTTP_AUTH_ENABLED");
513
+ if (authEnabledStr !== undefined) {
514
+ merged.server.auth_enabled = authEnabledStr === "true";
515
+ }
516
+ const authTokenEnv = getEnv("SKILLMUX_HTTP_AUTH_TOKEN_ENV", "HTTP_AUTH_TOKEN_ENV");
517
+ if (authTokenEnv !== undefined) {
518
+ merged.server.auth_token_env = authTokenEnv;
519
+ }
520
+ const allowedOriginsStr = getEnv("SKILLMUX_HTTP_ALLOWED_ORIGINS", "HTTP_ALLOWED_ORIGINS");
521
+ if (allowedOriginsStr !== undefined) {
522
+ merged.server.allowed_origins = allowedOriginsStr.split(",").map((o) => o.trim());
523
+ }
524
+ const hostname = getEnv("SKILLMUX_HTTP_HOSTNAME", "HTTP_HOSTNAME");
525
+ if (hostname !== undefined) {
526
+ merged.server.hostname = hostname;
527
+ }
400
528
 
401
- if (!merged.server.rate_limit) {
402
- merged.server.rate_limit = { enabled: false, requests_per_minute: 60 };
403
- }
529
+ if (!merged.server.rate_limit) {
530
+ merged.server.rate_limit = { enabled: false, requests_per_minute: 60 };
531
+ }
404
532
 
405
- const rateLimitEnabledStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_ENABLED", "HTTP_RATE_LIMIT_ENABLED");
406
- if (rateLimitEnabledStr) {
407
- merged.server.rate_limit.enabled = rateLimitEnabledStr === "true";
408
- }
533
+ const rateLimitEnabledStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_ENABLED", "HTTP_RATE_LIMIT_ENABLED");
534
+ if (rateLimitEnabledStr) {
535
+ merged.server.rate_limit.enabled = rateLimitEnabledStr === "true";
536
+ }
409
537
 
410
- const rateLimitRPMStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_RPM", "HTTP_RATE_LIMIT_RPM");
411
- if (rateLimitRPMStr) {
412
- const rpm = Number(rateLimitRPMStr);
413
- if (!Number.isInteger(rpm)) {
414
- throw new Error(`Invalid rate limit RPM: ${rateLimitRPMStr}`);
538
+ const rateLimitRPMStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_RPM", "HTTP_RATE_LIMIT_RPM");
539
+ if (rateLimitRPMStr) {
540
+ const rpm = Number(rateLimitRPMStr);
541
+ if (!Number.isInteger(rpm)) {
542
+ throw new Error(`Invalid rate limit RPM: ${rateLimitRPMStr}`);
543
+ }
544
+ merged.server.rate_limit.requests_per_minute = rpm;
415
545
  }
416
- merged.server.rate_limit.requests_per_minute = rpm;
417
- }
418
546
 
419
- const rateLimitTrustProxyStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY", "HTTP_RATE_LIMIT_TRUST_PROXY");
420
- if (rateLimitTrustProxyStr) {
421
- merged.server.rate_limit.trust_proxy = rateLimitTrustProxyStr === "true";
547
+ const rateLimitTrustProxyStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY", "HTTP_RATE_LIMIT_TRUST_PROXY");
548
+ if (rateLimitTrustProxyStr) {
549
+ merged.server.rate_limit.trust_proxy = rateLimitTrustProxyStr === "true";
550
+ }
422
551
  }
423
552
 
424
- if (merged.server.rate_limit.enabled && merged.server.rate_limit.requests_per_minute === undefined) {
553
+ if (merged.server.rate_limit && merged.server.rate_limit.enabled && merged.server.rate_limit.requests_per_minute === undefined) {
425
554
  merged.server.rate_limit.requests_per_minute = 60;
426
555
  }
427
556
  }
package/src/db.ts CHANGED
@@ -46,6 +46,8 @@ export function openIndex(stateDir: string): Database {
46
46
  outcome TEXT NOT NULL CHECK (outcome IN ('matched', 'ambiguous', 'no_match')),
47
47
  degraded INTEGER NOT NULL,
48
48
  retrieval TEXT NOT NULL DEFAULT 'lexical',
49
+ degraded_from TEXT,
50
+ degradation_reason TEXT,
49
51
  candidates TEXT NOT NULL,
50
52
  selected_skill_id TEXT,
51
53
  latency_ms INTEGER NOT NULL
@@ -54,6 +56,12 @@ export function openIndex(stateDir: string): Database {
54
56
  if (!auditColumns.some((column) => column.name === "retrieval")) {
55
57
  db.run("ALTER TABLE audit ADD COLUMN retrieval TEXT NOT NULL DEFAULT 'lexical'");
56
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");
64
+ }
57
65
  db.run(`CREATE TABLE IF NOT EXISTS index_meta (
58
66
  key TEXT PRIMARY KEY,
59
67
  value TEXT NOT NULL
@@ -257,6 +265,8 @@ export interface AuditInsert {
257
265
  query: string;
258
266
  outcome: string;
259
267
  retrieval: AuditRow["retrieval"];
268
+ degraded_from?: string | null;
269
+ degradation_reason?: string | null;
260
270
  candidates: AuditCandidate[];
261
271
  selected_skill_id: string | null;
262
272
  latency_ms: number;
@@ -264,14 +274,16 @@ export interface AuditInsert {
264
274
 
265
275
  export function insertAudit(db: Database, row: AuditInsert): void {
266
276
  db.run(
267
- `INSERT INTO audit (ts, query, outcome, degraded, retrieval, candidates, selected_skill_id, latency_ms)
268
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
277
+ `INSERT INTO audit (ts, query, outcome, degraded, retrieval, degraded_from, degradation_reason, candidates, selected_skill_id, latency_ms)
278
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
269
279
  [
270
280
  row.ts,
271
281
  row.query,
272
282
  row.outcome,
273
- row.retrieval === "lexical" ? 1 : 0,
283
+ row.retrieval === "lexical" || row.degradation_reason ? 1 : 0,
274
284
  row.retrieval,
285
+ row.degraded_from ?? null,
286
+ row.degradation_reason ?? null,
275
287
  JSON.stringify(row.candidates),
276
288
  row.selected_skill_id,
277
289
  row.latency_ms,
package/src/doctor.ts CHANGED
@@ -90,6 +90,11 @@ function checkCalibration(config: Config): DoctorCheck {
90
90
  rerankerFingerprint(config) !== run.reranker_fingerprint ? "reranker" : null,
91
91
  embeddingFingerprint(config) !== run.embedding_fingerprint ? "embedding" : null,
92
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,
93
98
  ].filter((part): part is string => part !== null);
94
99
 
95
100
  if (stale.length > 0) {
@@ -109,8 +114,20 @@ export { describeDeployment };
109
114
  export async function diagnose(
110
115
  config: Config,
111
116
  environment: Record<string, string | undefined> = process.env,
117
+ sources: Record<string, "default" | "toml" | "environment" | "admin"> = {},
112
118
  ): Promise<DoctorReport> {
113
119
  const checks: DoctorCheck[] = [];
120
+ const envOverrides = config.config?.environment_overrides !== false;
121
+ checks.push({
122
+ name: "config_authority",
123
+ ok: true,
124
+ detail: envOverrides
125
+ ? "environment overrides enabled"
126
+ : "TOML authoritative (environment overrides disabled)",
127
+ });
128
+ for (const [key, source] of Object.entries(sources)) {
129
+ checks.push({ name: `config_source:${key}`, ok: true, detail: source });
130
+ }
114
131
  checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
115
132
 
116
133
  for (const localPath of config.local_vault_paths) {
package/src/eval.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
- import type { SkillRow } from "./db";
5
- import { ftsSearch, vectorTopK } from "./db";
6
- import { reciprocalRankFusion } from "./rrf";
7
- import { backfillEmbeddings, getRuntime } from "./router-core";
4
+ import {
5
+ backfillEmbeddings,
6
+ decideRetrievalResult,
7
+ getRuntime,
8
+ retrieveAndRerank,
9
+ } from "./router-core";
8
10
 
9
11
  export interface EvalCase {
10
12
  query: string;
@@ -26,10 +28,34 @@ export interface EvalMetrics {
26
28
  mrr: number;
27
29
  }
28
30
 
31
+ export interface CandidateEvalDetail {
32
+ skill_id: string;
33
+ lexical_rank: number | null;
34
+ fused_rank: number | null;
35
+ reranked_rank?: number | null;
36
+ }
37
+
38
+ export interface EvalCaseResult {
39
+ query: string;
40
+ expected: string[];
41
+ outcome: "matched" | "ambiguous" | "no_match";
42
+ retrieval: string;
43
+ degraded_from?: string | null;
44
+ degradation_reason?: string | null;
45
+ latency_ms: number;
46
+ recall_settings: {
47
+ k_lexical: number;
48
+ k_vector: number;
49
+ k_rerank: number;
50
+ };
51
+ candidates: CandidateEvalDetail[];
52
+ }
53
+
29
54
  export interface EvalReport {
30
55
  queries: number;
31
56
  lexical: EvalMetrics;
32
57
  hybrid: EvalMetrics;
58
+ cases?: EvalCaseResult[];
33
59
  }
34
60
 
35
61
  function metrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
@@ -70,24 +96,55 @@ export function loadEvalCases(path = join(import.meta.dir, "..", "eval", "querie
70
96
 
71
97
 
72
98
  export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
73
- const { config, db, clients } = await getRuntime();
74
- if (config.inference.mode !== "local") throw new Error('Default evaluation requires inference.mode = "local".');
99
+ const { config } = await getRuntime();
75
100
  await backfillEmbeddings();
76
101
 
77
102
  const lexicalRankings: string[][] = [];
78
103
  const hybridRankings: string[][] = [];
104
+ const caseResults: EvalCaseResult[] = [];
105
+ const kRerank = config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector);
106
+
79
107
  for (const evalCase of cases) {
80
- const lexical = ftsSearch(db, evalCase.query, config.recall.k_lexical);
81
- const vector = (await clients.embed([evalCase.query]))[0];
82
- if (!vector) throw new Error("Embedding client returned no query vector.");
83
- const semantic = vectorTopK(db, vector, config.recall.k_vector);
84
- lexicalRankings.push(lexical.map((row) => row.skill_id));
85
- hybridRankings.push(reciprocalRankFusion<SkillRow>(lexical, semantic).map((row) => row.skill_id));
108
+ const start = performance.now();
109
+ 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));
123
+
124
+ const latency_ms = Math.round(performance.now() - start);
125
+ const candidateDetails: CandidateEvalDetail[] = retrievalResult.trace;
126
+
127
+ caseResults.push({
128
+ query: evalCase.query,
129
+ expected: evalCase.expected,
130
+ outcome: decision.outcome,
131
+ retrieval: retrievalResult.retrieval,
132
+ degraded_from: retrievalResult.degraded_from ?? null,
133
+ degradation_reason: retrievalResult.degradation_reason ?? null,
134
+ latency_ms,
135
+ recall_settings: {
136
+ k_lexical: config.recall.k_lexical,
137
+ k_vector: config.recall.k_vector,
138
+ k_rerank: kRerank,
139
+ },
140
+ candidates: candidateDetails,
141
+ });
86
142
  }
87
143
 
88
144
  return {
89
145
  queries: cases.length,
90
146
  lexical: metrics(lexicalRankings, cases),
91
147
  hybrid: metrics(hybridRankings, cases),
148
+ cases: caseResults,
92
149
  };
93
150
  }
package/src/metrics.ts CHANGED
@@ -18,6 +18,7 @@ export class MetricsRegistry {
18
18
 
19
19
  private errors = 0;
20
20
  private rateLimitsExceeded = 0;
21
+ private degradations = new Map<string, number>();
21
22
  private readiness: ReadinessSnapshot | null = null;
22
23
  private deployment: MetricsDeploymentIdentity | null = null;
23
24
 
@@ -37,6 +38,11 @@ export class MetricsRegistry {
37
38
  this.outcomes.set(outcome, (this.outcomes.get(outcome) || 0) + 1);
38
39
  }
39
40
 
41
+ recordDegradation(stage: "embedding" | "reranker", reason: string) {
42
+ const key = `${stage}:${reason}`;
43
+ this.degradations.set(key, (this.degradations.get(key) || 0) + 1);
44
+ }
45
+
40
46
  recordResolveLatencySeconds(seconds: number) {
41
47
  this.latencySum += seconds;
42
48
  this.latencyCount++;
@@ -98,6 +104,14 @@ export class MetricsRegistry {
98
104
  lines.push("# TYPE skill_router_rate_limits_exceeded_total counter");
99
105
  lines.push(`skill_router_rate_limits_exceeded_total ${this.rateLimitsExceeded}`);
100
106
 
107
+ // Degraded retrieval total
108
+ lines.push("# HELP skill_router_degraded_retrieval_total Total count of degraded retrieval fallbacks labelled by stage and reason.");
109
+ lines.push("# TYPE skill_router_degraded_retrieval_total counter");
110
+ for (const [key, count] of this.degradations) {
111
+ const [stage, reason] = key.split(":");
112
+ lines.push(`skill_router_degraded_retrieval_total{stage="${stage}",reason="${reason}"} ${count}`);
113
+ }
114
+
101
115
  lines.push("# HELP skill_router_ready Whether the service is ready to route requests.");
102
116
  lines.push("# TYPE skill_router_ready gauge");
103
117
  lines.push(`skill_router_ready ${this.readiness?.status === "ready" ? 1 : 0}`);