@klhapp/skillmux 1.6.0 → 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/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 NoMatchQuery {
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
- outcome_totals: { matched: number; ambiguous: number; no_match: number };
21
- ambiguous_rate: number;
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
- top_no_match_queries: NoMatchQuery[];
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 outcome_totals = { matched: 0, ambiguous: 0, no_match: 0 };
52
- const skillStats = new Map<string, { matched_count: number; candidate_count: number }>();
53
- const noMatchCounts = new Map<string, number>();
54
-
55
- function statFor(skillId: string) {
56
- let stat = skillStats.get(skillId);
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
- outcome_totals[row.outcome]++;
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.outcome === "matched" && row.selected_skill_id) {
75
- statFor(row.selected_skill_id).matched_count++;
79
+ if (row.degraded_from || row.degradation_reason) {
80
+ degraded_count++;
76
81
  }
77
82
 
78
- if (row.outcome === "no_match") {
79
- noMatchCounts.set(row.query, (noMatchCounts.get(row.query) ?? 0) + 1);
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 total = outcome_totals.matched + outcome_totals.ambiguous + outcome_totals.no_match;
84
- const ambiguous_rate = total > 0 ? outcome_totals.ambiguous / total : 0;
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 = [...skillStats.entries()]
87
- .map(([skill_id, stat]) => ({ skill_id, ...stat }))
88
- .sort((a, b) => b.matched_count - a.matched_count);
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 top_no_match_queries = [...noMatchCounts.entries()]
112
+ const top_empty_shortlist_queries: EmptyShortlistQuery[] = [...emptyShortlistCounts.entries()]
91
113
  .map(([query, count]) => ({ query, count }))
92
- .sort((a, b) => b.count - a.count)
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
- outcome_totals,
99
- ambiguous_rate,
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
- top_no_match_queries,
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("SELECT id, ts, query, outcome, retrieval, candidates, selected_skill_id, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC")
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
- return rows.map((row) => ({
121
- id: row.id,
122
- ts: row.ts,
123
- query: row.query,
124
- outcome: row.outcome,
125
- retrieval: row.retrieval,
126
- candidates: JSON.parse(row.candidates) as AuditCandidate[],
127
- selected_skill_id: row.selected_skill_id,
128
- latency_ms: row.latency_ms,
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
- `outcomes: matched=${stats.outcome_totals.matched} ambiguous=${stats.outcome_totals.ambiguous} ` +
143
- `no_match=${stats.outcome_totals.no_match} (ambiguous_rate=${stats.ambiguous_rate.toFixed(3)})`,
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} matched=${skill.matched_count} candidate=${skill.candidate_count}`);
220
+ lines.push(` ${skill.skill_id} candidate=${skill.candidate_count}`);
152
221
  }
153
222
  }
154
223
 
155
- lines.push("top no_match queries:");
156
- if (stats.top_no_match_queries.length === 0) {
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.top_no_match_queries) {
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
- ambiguous_candidate_limit: number;
8
+ top_k: number;
9
+ max_top_k: number;
9
10
  }
10
11
 
11
12
  export interface Thresholds {
12
- /** @deprecated Use output.ambiguous_candidate_limit instead */
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 Candidate {
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 MatchedResult {
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: Candidate[];
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
 
@@ -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.