@klhapp/skillmux 1.5.2 → 1.7.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/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 -45
- 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 -422
- package/src/audit.ts +1 -3
- package/src/cli.ts +19 -229
- 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 -162
- package/src/calibrate.ts +0 -1594
- package/src/config-mutation.ts +0 -65
- package/src/dataset-generator.ts +0 -119
- package/src/decision.ts +0 -45
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
|
}
|
package/src/init.ts
CHANGED
|
@@ -357,15 +357,14 @@ export function applyInit(
|
|
|
357
357
|
return manifest;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
/** Verbatim from docs/sdd/skr-cli/think.md §3.3 — the shared instruction-stack paragraph. */
|
|
361
360
|
export const DISCOVERY_PARAGRAPH =
|
|
362
361
|
"Skills: only a curated core is loaded statically. Before improvising a " +
|
|
363
362
|
"multi-step workflow, or when a task smells like a domain you have no loaded " +
|
|
364
363
|
"skill for (career/resume, trading, SEO, i18n, design, one-off tooling), " +
|
|
365
|
-
"call `resolve_skill` with a one-line task description. `
|
|
366
|
-
"
|
|
367
|
-
"`fetch_skill
|
|
368
|
-
"
|
|
364
|
+
"call `resolve_skill` with a one-line task description. `resolve_skill` " +
|
|
365
|
+
"returns a ranked shortlist; inspect candidates, fetch one or more " +
|
|
366
|
+
"relevant skills with `fetch_skill`, or ignore all and proceed normally " +
|
|
367
|
+
"when none are useful.";
|
|
369
368
|
|
|
370
369
|
export const MCP_REGISTRATION_SNIPPET = JSON.stringify(
|
|
371
370
|
{ mcpServers: { "skillmux": { command: "skillmux", args: ["serve"] } } },
|
package/src/metrics.ts
CHANGED
|
@@ -8,8 +8,7 @@ type MetricsDeploymentIdentity = Pick<
|
|
|
8
8
|
|
|
9
9
|
export class MetricsRegistry {
|
|
10
10
|
private requests = new Map<string, number>();
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
|
|
13
12
|
private buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
|
|
14
13
|
private latencyBuckets = new Array(this.buckets.length).fill(0);
|
|
15
14
|
private latencyInf = 0;
|
|
@@ -34,10 +33,6 @@ export class MetricsRegistry {
|
|
|
34
33
|
this.requests.set(method, (this.requests.get(method) || 0) + 1);
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
recordResolveOutcome(outcome: string) {
|
|
38
|
-
this.outcomes.set(outcome, (this.outcomes.get(outcome) || 0) + 1);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
36
|
recordDegradation(stage: "embedding" | "reranker", reason: string) {
|
|
42
37
|
const key = `${stage}:${reason}`;
|
|
43
38
|
this.degradations.set(key, (this.degradations.get(key) || 0) + 1);
|
|
@@ -72,13 +67,6 @@ export class MetricsRegistry {
|
|
|
72
67
|
lines.push(`skill_router_requests_total{method="${method}"} ${count}`);
|
|
73
68
|
}
|
|
74
69
|
|
|
75
|
-
// Resolve outcomes total
|
|
76
|
-
lines.push("# HELP skill_router_resolve_outcomes_total Total count of resolve_skill query outcomes.");
|
|
77
|
-
lines.push("# TYPE skill_router_resolve_outcomes_total counter");
|
|
78
|
-
for (const [outcome, count] of this.outcomes) {
|
|
79
|
-
lines.push(`skill_router_resolve_outcomes_total{outcome="${outcome}"} ${count}`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
70
|
// Latency histogram
|
|
83
71
|
lines.push("# HELP skill_router_resolve_latency_seconds Latency histogram of resolve_skill executions.");
|
|
84
72
|
lines.push("# TYPE skill_router_resolve_latency_seconds histogram");
|