@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/stats.ts
CHANGED
|
@@ -5,22 +5,32 @@ export const SINCE_PATTERN = /^(\d+[hdwmy]|\d{4}-\d{2}-\d{2}([T ].+)?)$/;
|
|
|
5
5
|
|
|
6
6
|
export interface SkillStat {
|
|
7
7
|
skill_id: string;
|
|
8
|
-
matched_count: number;
|
|
9
8
|
candidate_count: number;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
export interface
|
|
11
|
+
export interface EmptyShortlistQuery {
|
|
13
12
|
query: string;
|
|
14
13
|
count: number;
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
export interface RetrievalTotals {
|
|
17
|
+
exact: number;
|
|
18
|
+
reranked: number;
|
|
19
|
+
hybrid: number;
|
|
20
|
+
lexical: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export interface StatsResponse {
|
|
18
24
|
since: string;
|
|
19
25
|
until: string;
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
total_requests: number;
|
|
27
|
+
empty_shortlist_count: number;
|
|
28
|
+
empty_shortlist_rate: number;
|
|
29
|
+
retrieval_totals: RetrievalTotals;
|
|
30
|
+
degraded_count: number;
|
|
31
|
+
average_latency_ms: number;
|
|
22
32
|
skills: SkillStat[];
|
|
23
|
-
|
|
33
|
+
top_empty_shortlist_queries: EmptyShortlistQuery[];
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
const RELATIVE_WINDOW = /^(\d+)([hdwmy])$/;
|
|
@@ -47,58 +57,79 @@ export function parseSince(since: string, now: Date = new Date()): Date {
|
|
|
47
57
|
return parsed;
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
function compareCodeUnits(a: string, b: string): number {
|
|
61
|
+
if (a < b) return -1;
|
|
62
|
+
if (a > b) return 1;
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsResponse {
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!stat) {
|
|
58
|
-
stat = { matched_count: 0, candidate_count: 0 };
|
|
59
|
-
skillStats.set(skillId, stat);
|
|
60
|
-
}
|
|
61
|
-
return stat;
|
|
62
|
-
}
|
|
67
|
+
const retrieval_totals: RetrievalTotals = { exact: 0, reranked: 0, hybrid: 0, lexical: 0 };
|
|
68
|
+
const skillCounts = new Map<string, number>();
|
|
69
|
+
const emptyShortlistCounts = new Map<string, number>();
|
|
70
|
+
let empty_shortlist_count = 0;
|
|
71
|
+
let degraded_count = 0;
|
|
72
|
+
let total_latency_ms = 0;
|
|
63
73
|
|
|
64
74
|
for (const row of rows) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const seenInRow = new Set<string>();
|
|
68
|
-
for (const candidate of row.candidates) {
|
|
69
|
-
if (seenInRow.has(candidate.skill_id)) continue;
|
|
70
|
-
seenInRow.add(candidate.skill_id);
|
|
71
|
-
statFor(candidate.skill_id).candidate_count++;
|
|
75
|
+
if (row.retrieval in retrieval_totals) {
|
|
76
|
+
retrieval_totals[row.retrieval]++;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
if (row.
|
|
75
|
-
|
|
79
|
+
if (row.degraded_from || row.degradation_reason) {
|
|
80
|
+
degraded_count++;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
total_latency_ms += row.latency_ms;
|
|
84
|
+
|
|
85
|
+
if (row.candidates.length === 0) {
|
|
86
|
+
empty_shortlist_count++;
|
|
87
|
+
emptyShortlistCounts.set(row.query, (emptyShortlistCounts.get(row.query) ?? 0) + 1);
|
|
88
|
+
} else {
|
|
89
|
+
const seenInRow = new Set<string>();
|
|
90
|
+
for (const candidate of row.candidates) {
|
|
91
|
+
if (!candidate.skill_id) continue;
|
|
92
|
+
if (seenInRow.has(candidate.skill_id)) continue;
|
|
93
|
+
seenInRow.add(candidate.skill_id);
|
|
94
|
+
skillCounts.set(candidate.skill_id, (skillCounts.get(candidate.skill_id) ?? 0) + 1);
|
|
95
|
+
}
|
|
80
96
|
}
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
const
|
|
84
|
-
const
|
|
99
|
+
const total_requests = rows.length;
|
|
100
|
+
const empty_shortlist_rate = total_requests > 0 ? empty_shortlist_count / total_requests : 0;
|
|
101
|
+
const average_latency_ms = total_requests > 0 ? total_latency_ms / total_requests : 0;
|
|
85
102
|
|
|
86
|
-
const skills = [...
|
|
87
|
-
.map(([skill_id,
|
|
88
|
-
.sort((a, b) =>
|
|
103
|
+
const skills: SkillStat[] = [...skillCounts.entries()]
|
|
104
|
+
.map(([skill_id, candidate_count]) => ({ skill_id, candidate_count }))
|
|
105
|
+
.sort((a, b) => {
|
|
106
|
+
if (b.candidate_count !== a.candidate_count) {
|
|
107
|
+
return b.candidate_count - a.candidate_count;
|
|
108
|
+
}
|
|
109
|
+
return compareCodeUnits(a.skill_id, b.skill_id);
|
|
110
|
+
});
|
|
89
111
|
|
|
90
|
-
const
|
|
112
|
+
const top_empty_shortlist_queries: EmptyShortlistQuery[] = [...emptyShortlistCounts.entries()]
|
|
91
113
|
.map(([query, count]) => ({ query, count }))
|
|
92
|
-
.sort((a, b) =>
|
|
114
|
+
.sort((a, b) => {
|
|
115
|
+
if (b.count !== a.count) {
|
|
116
|
+
return b.count - a.count;
|
|
117
|
+
}
|
|
118
|
+
return compareCodeUnits(a.query, b.query);
|
|
119
|
+
})
|
|
93
120
|
.slice(0, 20);
|
|
94
121
|
|
|
95
122
|
return {
|
|
96
123
|
since: since.toISOString(),
|
|
97
124
|
until: until.toISOString(),
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
total_requests,
|
|
126
|
+
empty_shortlist_count,
|
|
127
|
+
empty_shortlist_rate,
|
|
128
|
+
retrieval_totals,
|
|
129
|
+
degraded_count,
|
|
130
|
+
average_latency_ms,
|
|
100
131
|
skills,
|
|
101
|
-
|
|
132
|
+
top_empty_shortlist_queries,
|
|
102
133
|
};
|
|
103
134
|
}
|
|
104
135
|
|
|
@@ -106,27 +137,60 @@ interface AuditTableRow {
|
|
|
106
137
|
id: number;
|
|
107
138
|
ts: string;
|
|
108
139
|
query: string;
|
|
109
|
-
outcome: AuditRow["outcome"];
|
|
110
140
|
retrieval: AuditRow["retrieval"];
|
|
141
|
+
degraded_from: string | null;
|
|
142
|
+
degradation_reason: string | null;
|
|
111
143
|
candidates: string;
|
|
112
|
-
selected_skill_id: string | null;
|
|
113
144
|
latency_ms: number;
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
export function queryAuditRows(db: Database, sinceIso: string): AuditRow[] {
|
|
117
148
|
const rows = db
|
|
118
|
-
.query(
|
|
149
|
+
.query(
|
|
150
|
+
"SELECT id, ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC",
|
|
151
|
+
)
|
|
119
152
|
.all(sinceIso) as AuditTableRow[];
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
|
|
154
|
+
return rows.map((row) => {
|
|
155
|
+
let parsed: unknown;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(row.candidates);
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(`Failed to parse candidates JSON for audit row ${row.id}`);
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(parsed)) {
|
|
162
|
+
throw new Error(`Invalid candidates JSON for audit row ${row.id}: expected array, got ${typeof parsed}`);
|
|
163
|
+
}
|
|
164
|
+
const candidates: AuditCandidate[] = parsed.map((c: any, index: number) => {
|
|
165
|
+
if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.skill_id !== "string") {
|
|
166
|
+
throw new Error(`Invalid candidate at index ${index} for audit row ${row.id}: missing or invalid skill_id`);
|
|
167
|
+
}
|
|
168
|
+
const score = c.score;
|
|
169
|
+
if (score !== null && (typeof score !== "number" || !Number.isFinite(score))) {
|
|
170
|
+
throw new Error(`Invalid candidate at index ${index} for audit row ${row.id}: missing or invalid score`);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
skill_id: c.skill_id,
|
|
174
|
+
score,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result: AuditRow = {
|
|
179
|
+
id: row.id,
|
|
180
|
+
ts: row.ts,
|
|
181
|
+
query: row.query,
|
|
182
|
+
retrieval: row.retrieval,
|
|
183
|
+
candidates,
|
|
184
|
+
latency_ms: row.latency_ms,
|
|
185
|
+
};
|
|
186
|
+
if (row.degraded_from !== null && row.degraded_from !== undefined) {
|
|
187
|
+
result.degraded_from = row.degraded_from as AuditRow["degraded_from"];
|
|
188
|
+
}
|
|
189
|
+
if (row.degradation_reason !== null && row.degradation_reason !== undefined) {
|
|
190
|
+
result.degradation_reason = row.degradation_reason as AuditRow["degradation_reason"];
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
});
|
|
130
194
|
}
|
|
131
195
|
|
|
132
196
|
export function getStats(db: Database, since: string, now: Date = new Date()): StatsResponse {
|
|
@@ -139,8 +203,13 @@ export function renderStatsText(stats: StatsResponse): string {
|
|
|
139
203
|
const lines: string[] = [];
|
|
140
204
|
lines.push(`window: ${stats.since} .. ${stats.until}`);
|
|
141
205
|
lines.push(
|
|
142
|
-
`
|
|
143
|
-
`
|
|
206
|
+
`requests: total=${stats.total_requests} empty_shortlist=${stats.empty_shortlist_count} ` +
|
|
207
|
+
`(empty_shortlist_rate=${stats.empty_shortlist_rate.toFixed(3)}) ` +
|
|
208
|
+
`degraded=${stats.degraded_count} avg_latency_ms=${stats.average_latency_ms.toFixed(1)}`,
|
|
209
|
+
);
|
|
210
|
+
lines.push(
|
|
211
|
+
`retrieval: exact=${stats.retrieval_totals.exact} reranked=${stats.retrieval_totals.reranked} ` +
|
|
212
|
+
`hybrid=${stats.retrieval_totals.hybrid} lexical=${stats.retrieval_totals.lexical}`,
|
|
144
213
|
);
|
|
145
214
|
|
|
146
215
|
lines.push("skills:");
|
|
@@ -148,15 +217,15 @@ export function renderStatsText(stats: StatsResponse): string {
|
|
|
148
217
|
lines.push(" (none)");
|
|
149
218
|
} else {
|
|
150
219
|
for (const skill of stats.skills) {
|
|
151
|
-
lines.push(` ${skill.skill_id}
|
|
220
|
+
lines.push(` ${skill.skill_id} candidate=${skill.candidate_count}`);
|
|
152
221
|
}
|
|
153
222
|
}
|
|
154
223
|
|
|
155
|
-
lines.push("top
|
|
156
|
-
if (stats.
|
|
224
|
+
lines.push("top empty shortlist queries:");
|
|
225
|
+
if (stats.top_empty_shortlist_queries.length === 0) {
|
|
157
226
|
lines.push(" (none)");
|
|
158
227
|
} else {
|
|
159
|
-
for (const entry of stats.
|
|
228
|
+
for (const entry of stats.top_empty_shortlist_queries) {
|
|
160
229
|
lines.push(` "${entry.query}" (${entry.count})`);
|
|
161
230
|
}
|
|
162
231
|
}
|
package/src/types.ts
CHANGED
|
@@ -5,11 +5,12 @@ export interface RecallConfig {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export interface OutputConfig {
|
|
8
|
-
|
|
8
|
+
top_k: number;
|
|
9
|
+
max_top_k: number;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export interface Thresholds {
|
|
12
|
-
/** @deprecated Use output.
|
|
13
|
+
/** @deprecated Use output.top_k instead */
|
|
13
14
|
candidate_limit?: number;
|
|
14
15
|
match_score?: number;
|
|
15
16
|
match_margin?: number;
|
|
@@ -78,8 +79,6 @@ export interface RemoteInferenceConfig {
|
|
|
78
79
|
timeout_ms: number;
|
|
79
80
|
embedding: RemoteEmbeddingConfig;
|
|
80
81
|
reranker?: RemoteRerankerConfig;
|
|
81
|
-
thresholds?: Required<Omit<Thresholds, "candidate_limit">>;
|
|
82
|
-
calibration?: { run_id: string };
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
export type InferenceConfig = LocalInferenceConfig | RemoteInferenceConfig;
|
|
@@ -114,19 +113,15 @@ export interface Config {
|
|
|
114
113
|
local_vault_paths: string[];
|
|
115
114
|
state_dir: string;
|
|
116
115
|
recall: RecallConfig;
|
|
117
|
-
thresholds: Thresholds;
|
|
118
116
|
output: OutputConfig;
|
|
119
117
|
inference: InferenceConfig;
|
|
120
118
|
server?: ServerConfig;
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
export interface
|
|
121
|
+
export interface RankedCandidate {
|
|
122
|
+
rank: number;
|
|
124
123
|
skill_id: string;
|
|
125
|
-
title: string;
|
|
126
124
|
description: string;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export interface RankedCandidate extends Candidate {
|
|
130
125
|
score: number | null;
|
|
131
126
|
}
|
|
132
127
|
|
|
@@ -140,40 +135,16 @@ export type DegradationReason =
|
|
|
140
135
|
| "reranker_unavailable"
|
|
141
136
|
| "reranker_protocol_error";
|
|
142
137
|
|
|
143
|
-
export interface
|
|
144
|
-
outcome: "matched";
|
|
145
|
-
retrieval: "exact" | "reranked";
|
|
146
|
-
degraded_from?: "reranked" | "hybrid";
|
|
147
|
-
degradation_reason?: DegradationReason;
|
|
148
|
-
skill_id: string;
|
|
149
|
-
title: string;
|
|
150
|
-
content_sha256: string;
|
|
151
|
-
score: number;
|
|
152
|
-
margin: number;
|
|
153
|
-
body: string;
|
|
154
|
-
files: string[];
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export interface AmbiguousResult {
|
|
158
|
-
outcome: "ambiguous";
|
|
138
|
+
export interface ResolveResult {
|
|
159
139
|
retrieval: RetrievalCapability;
|
|
160
140
|
degraded_from?: "reranked" | "hybrid";
|
|
161
141
|
degradation_reason?: DegradationReason;
|
|
162
|
-
candidates:
|
|
142
|
+
candidates: RankedCandidate[];
|
|
163
143
|
}
|
|
164
144
|
|
|
165
|
-
export interface NoMatchResult {
|
|
166
|
-
outcome: "no_match";
|
|
167
|
-
retrieval: RetrievalCapability;
|
|
168
|
-
degraded_from?: "reranked" | "hybrid";
|
|
169
|
-
degradation_reason?: DegradationReason;
|
|
170
|
-
message: string;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export type ResolveResult = MatchedResult | AmbiguousResult | NoMatchResult;
|
|
174
|
-
|
|
175
145
|
export interface ResolveSkillInput {
|
|
176
146
|
query: string;
|
|
147
|
+
top_k?: number;
|
|
177
148
|
/** Test/ops escape hatch: use lexical retrieval only. Not exposed on the MCP wire. */
|
|
178
149
|
forceLexical?: boolean;
|
|
179
150
|
}
|
|
@@ -199,12 +170,10 @@ export interface AuditRow {
|
|
|
199
170
|
id: number;
|
|
200
171
|
ts: string;
|
|
201
172
|
query: string;
|
|
202
|
-
outcome: "matched" | "ambiguous" | "no_match";
|
|
203
173
|
retrieval: RetrievalCapability;
|
|
204
174
|
degraded_from?: "reranked" | "hybrid" | null;
|
|
205
175
|
degradation_reason?: DegradationReason | null;
|
|
206
176
|
candidates: AuditCandidate[];
|
|
207
|
-
selected_skill_id: string | null;
|
|
208
177
|
latency_ms: number;
|
|
209
178
|
}
|
|
210
179
|
|
package/docs/calibration.md
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
# Policy calibration
|
|
2
|
-
|
|
3
|
-
Calibration selects the three reranker-score thresholds that turn an ordered
|
|
4
|
-
shortlist into `matched`, `ambiguous`, or `no_match`. It is an operator action,
|
|
5
|
-
not background learning, and it currently runs only against a local Skillmux
|
|
6
|
-
target.
|
|
7
|
-
|
|
8
|
-
Read [MCP routing](mcp-routing.md#retrieval-pipeline) before calibrating a new
|
|
9
|
-
retrieval deployment.
|
|
10
|
-
|
|
11
|
-
## Lifecycle
|
|
12
|
-
|
|
13
|
-
The complete workflow is:
|
|
14
|
-
|
|
15
|
-
```text
|
|
16
|
-
install CLI → configure vault/index/embedding/reranker → obtain labelled dataset
|
|
17
|
-
→ calibrate run → review calibrate show RUN_ID → calibrate apply RUN_ID
|
|
18
|
-
→ live-reloaded policy handles subsequent requests
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
First configure and index the same vault, embedding model, and reranker that
|
|
22
|
-
will serve requests. Supply a reviewed dataset, or generate a starting point
|
|
23
|
-
and review every label:
|
|
24
|
-
|
|
25
|
-
```sh
|
|
26
|
-
skillmux calibrate generate-dataset --out ./eval/queries.json
|
|
27
|
-
skillmux calibrate run --dataset ./eval/queries.json
|
|
28
|
-
skillmux calibrate show RUN_ID
|
|
29
|
-
skillmux calibrate apply RUN_ID
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Skillmux retrieves candidates and reranks exactly once for each evaluation
|
|
33
|
-
query. It runs four queries at a time by default. Set a different positive
|
|
34
|
-
worker limit with `--concurrency N`. The CLI writes completed-case progress to
|
|
35
|
-
stderr without exposing query text.
|
|
36
|
-
|
|
37
|
-
### Timing report
|
|
38
|
-
|
|
39
|
-
Add `--timing` to any `calibrate run` invocation to write an aggregate
|
|
40
|
-
performance report to **stderr** after the run finishes (with a completed or
|
|
41
|
-
failed-gates result; a thrown error produces no report). Stdout remains valid
|
|
42
|
-
JSON under `--json --timing`.
|
|
43
|
-
|
|
44
|
-
```sh
|
|
45
|
-
skillmux calibrate run --dataset ./eval/queries.json --timing
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
The report uses stable snake_case field names in milliseconds:
|
|
49
|
-
|
|
50
|
-
| Field | Description |
|
|
51
|
-
|---|---|
|
|
52
|
-
| `cases_total` | Total dataset cases |
|
|
53
|
-
| `cases_executed` | Cases retrieved in this invocation |
|
|
54
|
-
| `cases_reused` | Cases loaded from a prior interrupted run (resume) |
|
|
55
|
-
| `wall_ms` | Wall-clock duration of the full calibrateRun operation |
|
|
56
|
-
| `vault_sync_ms` | One-time vault synchronization before retrieval |
|
|
57
|
-
| `cumulative_embedding_ms` | Total worker time in embedding across all queries |
|
|
58
|
-
| `cumulative_lexical_ms` | Total worker time in lexical search across all queries |
|
|
59
|
-
| `cumulative_vector_ms` | Total worker time in vector search across all queries |
|
|
60
|
-
| `cumulative_reranker_ms` | Total worker time in reranking across all queries |
|
|
61
|
-
| `cumulative_checkpoint_ms` | Total worker time writing observation checkpoints |
|
|
62
|
-
| `policy_evaluation_ms` | Threshold selection and test-split certification |
|
|
63
|
-
|
|
64
|
-
**Cumulative vs wall time.** The cumulative fields (`cumulative_embedding_ms`,
|
|
65
|
-
`cumulative_lexical_ms`, `cumulative_vector_ms`, `cumulative_reranker_ms`,
|
|
66
|
-
`cumulative_checkpoint_ms`) are total _worker time_ summed across all concurrent
|
|
67
|
-
query retrievals. Because multiple queries run at the same time, the sum of these
|
|
68
|
-
fields typically exceeds `wall_ms`. They measure how much time each stage
|
|
69
|
-
consumed across all workers, not how much wall-clock time each stage accounted
|
|
70
|
-
for. `cases_executed + cases_reused = cases_total`.
|
|
71
|
-
|
|
72
|
-
Timing collection is fully disabled when `--timing` is absent; it does not affect
|
|
73
|
-
calibration results, resume behavior, checkpoint durability, or JSON schemas.
|
|
74
|
-
Skillmux checkpoints each observation in the calibration evidence database.
|
|
75
|
-
If inference fails or you interrupt the process, find the `running` run with
|
|
76
|
-
`calibrate list` and resume it with the same dataset and certification flags:
|
|
77
|
-
|
|
78
|
-
```sh
|
|
79
|
-
skillmux calibrate run --dataset ./eval/queries.json --resume RUN_ID
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Resume rejects changes to the dataset, corpus, inference models, recall
|
|
83
|
-
settings, candidate limit, or certification gates. After all observations
|
|
84
|
-
exist, Skillmux searches thresholds on the `tune` split and certifies the
|
|
85
|
-
selected policy on the frozen `test` split. Calibration starts only when an
|
|
86
|
-
operator invokes `calibrate run`.
|
|
87
|
-
|
|
88
|
-
The operator owns the labels: supply or review the cases, start the run,
|
|
89
|
-
inspect its evidence, and explicitly apply an acceptable result. A successful
|
|
90
|
-
run never changes live thresholds by itself.
|
|
91
|
-
|
|
92
|
-
## Certification gates and preflight feasibility
|
|
93
|
-
|
|
94
|
-
Calibration certifies threshold policies against statistical confidence gates before allowing them to be applied:
|
|
95
|
-
|
|
96
|
-
| Flag | Default | Description |
|
|
97
|
-
|---|---|---|
|
|
98
|
-
| `--min-auto-match-precision` | `0.75` | Minimum 95% Wilson score lower confidence bound on auto-match precision |
|
|
99
|
-
| `--min-auto-match-count` | `15` | Minimum number of auto-matches required in evaluation |
|
|
100
|
-
| `--min-retrieval-recall-at-k` | `0.95` | Minimum top-k retrieval recall on matchable queries |
|
|
101
|
-
| `--min-delivered-shortlist-recall-at-k` | `0.95` | Minimum delivered shortlist recall on matchable queries |
|
|
102
|
-
| `--tune-auto-match-precision-buffer` | `0.03` | Tune-only selection buffer for Wilson auto-match precision lower bound |
|
|
103
|
-
| `--tune-auto-match-count-buffer` | `3` | Tune-only selection buffer for minimum auto-match count |
|
|
104
|
-
| `--tune-delivered-shortlist-recall-buffer` | `0.02` | Tune-only selection buffer for delivered shortlist recall |
|
|
105
|
-
|
|
106
|
-
### Tune selection buffers vs production gates
|
|
107
|
-
|
|
108
|
-
Tune selection buffers ensure that threshold optimization selects policies with sufficient headroom beyond production gates. A candidate policy during tune search must satisfy the production gates plus their respective tune selection buffers. Test-split certification evaluates selected policies against the original production gates without selection buffers.
|
|
109
|
-
|
|
110
|
-
### Wilson lower confidence bound and evidence size
|
|
111
|
-
|
|
112
|
-
`min-auto-match-precision` is evaluated not as raw sample accuracy, but as a **95% Wilson score lower confidence bound** ($z \approx 1.960$). This accounts for statistical uncertainty in small datasets.
|
|
113
|
-
|
|
114
|
-
Because the Wilson lower bound penalizes small sample sizes:
|
|
115
|
-
- A gate of **0.75** lower bound requires at least **15** flawless (15/15) auto-matches ($\text{Wilson}(15, 15) \approx 0.7961$).
|
|
116
|
-
- A 20-case tune matched split can achieve at most $\text{Wilson}(20, 20) \approx 0.8389$.
|
|
117
|
-
- A gate of **0.99** lower bound is statistically impossible on small datasets; it requires at least **381** flawless auto-matches ($\text{Wilson}(381, 381) \approx 0.9900$).
|
|
118
|
-
|
|
119
|
-
### Preflight feasibility check
|
|
120
|
-
|
|
121
|
-
To avoid running expensive remote embeddings and rerankings on gates that can never pass, Skillmux executes a **preflight feasibility calculation** immediately after loading the dataset and before creating a running calibration record:
|
|
122
|
-
|
|
123
|
-
$$\text{effective\_trials} = \max(N_{\text{tune\_matched}}, \text{minAutoMatchCount})$$
|
|
124
|
-
$$\text{max\_attainable\_precision} = \text{WilsonLowerBound}(N_{\text{tune\_matched}}, \text{effective\_trials})$$
|
|
125
|
-
|
|
126
|
-
If $\text{max\_attainable\_precision} < \text{minAutoMatchPrecision}$, calibration fails immediately with an actionable error indicating the requested precision, requested count, available tune matched cases, and maximum attainable lower bound.
|
|
127
|
-
|
|
128
|
-
## Reading a run
|
|
129
|
-
|
|
130
|
-
A `run_id` identifies one immutable calibration attempt and its evidence.
|
|
131
|
-
`calibrate show RUN_ID` is read-only. It reports:
|
|
132
|
-
|
|
133
|
-
- selected thresholds and tune/test metrics;
|
|
134
|
-
- auto-match precision confidence and sample counts;
|
|
135
|
-
- retrieval and delivered-shortlist recall;
|
|
136
|
-
- a closed failure reason when certification fails;
|
|
137
|
-
- reranker, embedding, corpus, and dataset fingerprints;
|
|
138
|
-
- dataset provenance and the number of human-labelled cases; and
|
|
139
|
-
- the attempt count for the dataset hash.
|
|
140
|
-
|
|
141
|
-
`calibrate apply RUN_ID` accepts only a completed, test-certified run. It
|
|
142
|
-
rechecks the reranker fingerprint, rejects thresholds masked by environment
|
|
143
|
-
variables, atomically updates the TOML file, and lets the config watcher
|
|
144
|
-
activate the new snapshot.
|
|
145
|
-
|
|
146
|
-
## Dataset responsibilities
|
|
147
|
-
|
|
148
|
-
Each case needs a query, expected outcome, relevant skill ids, and a fixed
|
|
149
|
-
`tune` or `test` split. Unknown skill ids are rejected. Keep a skill entirely
|
|
150
|
-
within one split so the test set measures generalization rather than memorized
|
|
151
|
-
skill wording.
|
|
152
|
-
|
|
153
|
-
Generated datasets are scaffolding, not ground truth. Review paraphrases,
|
|
154
|
-
near-miss negatives, and ambiguous cases before using them for certification.
|
|
155
|
-
Audit-derived cases require an explicit human label and provenance. Raw audit
|
|
156
|
-
queries are excluded unless the importer is deliberately configured to retain
|
|
157
|
-
them.
|
|
158
|
-
|
|
159
|
-
## When to recalibrate
|
|
160
|
-
|
|
161
|
-
Re-run calibration after a material change to the corpus, embedding or
|
|
162
|
-
retrieval behavior, reranker adapter or model, or after collecting enough new
|
|
163
|
-
human-labelled feedback. Do not recalibrate per user request. Every rerun gets
|
|
164
|
-
a new `run_id`; the active policy remains unchanged until one is applied.
|
|
165
|
-
|
|
166
|
-
## Local and remote targets
|
|
167
|
-
|
|
168
|
-
Here, `local` and `remote` name CLI administration targets, not inference
|
|
169
|
-
locations or MCP transports. Calibration is local-target-only in this release.
|
|
170
|
-
Local commands operate on the
|
|
171
|
-
configured local vault, index, inference endpoints, dataset path, evidence
|
|
172
|
-
database, and TOML file. Human output always prints `Target: local`; JSON output
|
|
173
|
-
uses `"target": "local"`.
|
|
174
|
-
|
|
175
|
-
Remote servers advertise `"calibration": false`. Every
|
|
176
|
-
`/admin/v1/calibrations` route returns HTTP `501` with
|
|
177
|
-
`error: "not_implemented"`, and the CLI rejects remote calibration before
|
|
178
|
-
uploading or claiming to execute a local dataset path. This also prevents raw
|
|
179
|
-
evaluation queries from being exposed through the admin API.
|
|
180
|
-
|
|
181
|
-
## Reference starting profile
|
|
182
|
-
|
|
183
|
-
Reranker scores are not portable across models, adapters, model revisions, or
|
|
184
|
-
corpora. The profile below is published only to make the checked-in BGE example
|
|
185
|
-
concrete; it is not a certified substitute for calibration.
|
|
186
|
-
|
|
187
|
-
| Model | Adapter | `match_score` | `match_margin` | `candidate_floor` |
|
|
188
|
-
|---|---|---:|---:|---:|
|
|
189
|
-
| `BAAI/bge-reranker-v2-m3` | `jina-v1` | `0.90` | `0.20` | `0.40` |
|
|
190
|
-
|
|
191
|
-
Provenance: the small synthetic corpus and labelled decision cases in
|
|
192
|
-
[`tests/router-core.spec.test.ts`](../tests/router-core.spec.test.ts), with the
|
|
193
|
-
wire contract captured by
|
|
194
|
-
[`tests/fixtures/reranker/jina-v1-request.json`](../tests/fixtures/reranker/jina-v1-request.json).
|
|
195
|
-
That fixture is below the default 15-auto-match certification minimum, so the
|
|
196
|
-
values are a smoke-test/reference profile, not a completed calibration run.
|
|
197
|
-
Run the lifecycle above against the deployment's real corpus before enabling
|
|
198
|
-
automatic matches in production.
|