@klhapp/skillmux 1.7.1 → 1.8.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
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import type { AuditCandidate, AuditRow } from "./types";
2
+ import type { AuditCandidate, AuditRow, FetchAuditRow } from "./types";
3
3
 
4
4
  export const SINCE_PATTERN = /^(\d+[hdwmy]|\d{4}-\d{2}-\d{2}([T ].+)?)$/;
5
5
 
@@ -20,6 +20,18 @@ export interface RetrievalTotals {
20
20
  lexical: number;
21
21
  }
22
22
 
23
+ export type AcceptanceSignal =
24
+ | { available: false; uncorrelated_fetch_count: number }
25
+ | {
26
+ available: true;
27
+ resolves_with_candidates: number;
28
+ accepted_count: number;
29
+ acceptance_rate: number;
30
+ observed_mrr: number;
31
+ top1_acceptance_rate: number;
32
+ uncorrelated_fetch_count: number;
33
+ };
34
+
23
35
  export interface StatsResponse {
24
36
  since: string;
25
37
  until: string;
@@ -31,6 +43,8 @@ export interface StatsResponse {
31
43
  average_latency_ms: number;
32
44
  skills: SkillStat[];
33
45
  top_empty_shortlist_queries: EmptyShortlistQuery[];
46
+ acceptance: AcceptanceSignal;
47
+ top_unused_shortlist_queries: EmptyShortlistQuery[];
34
48
  }
35
49
 
36
50
  const RELATIVE_WINDOW = /^(\d+)([hdwmy])$/;
@@ -63,10 +77,16 @@ function compareCodeUnits(a: string, b: string): number {
63
77
  return 0;
64
78
  }
65
79
 
66
- export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsResponse {
80
+ export function computeStats(
81
+ rows: AuditRow[],
82
+ since: Date,
83
+ until: Date,
84
+ fetchRows: FetchAuditRow[] = [],
85
+ ): StatsResponse {
67
86
  const retrieval_totals: RetrievalTotals = { exact: 0, reranked: 0, hybrid: 0, lexical: 0 };
68
87
  const skillCounts = new Map<string, number>();
69
88
  const emptyShortlistCounts = new Map<string, number>();
89
+ const resolvesWithCandidates = new Map<number, AuditRow>();
70
90
  let empty_shortlist_count = 0;
71
91
  let degraded_count = 0;
72
92
  let total_latency_ms = 0;
@@ -86,6 +106,7 @@ export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsR
86
106
  empty_shortlist_count++;
87
107
  emptyShortlistCounts.set(row.query, (emptyShortlistCounts.get(row.query) ?? 0) + 1);
88
108
  } else {
109
+ resolvesWithCandidates.set(row.id, row);
89
110
  const seenInRow = new Set<string>();
90
111
  for (const candidate of row.candidates) {
91
112
  if (!candidate.skill_id) continue;
@@ -96,6 +117,52 @@ export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsR
96
117
  }
97
118
  }
98
119
 
120
+ let uncorrelated_fetch_count = 0;
121
+ const firstFetchByResolve = new Map<number, FetchAuditRow>();
122
+ for (const fetch of fetchRows) {
123
+ if (fetch.resolve_audit_id === null) {
124
+ uncorrelated_fetch_count++;
125
+ continue;
126
+ }
127
+ if (!resolvesWithCandidates.has(fetch.resolve_audit_id)) continue;
128
+ const existing = firstFetchByResolve.get(fetch.resolve_audit_id);
129
+ if (!existing || fetch.ts < existing.ts) {
130
+ firstFetchByResolve.set(fetch.resolve_audit_id, fetch);
131
+ }
132
+ }
133
+
134
+ const acceptedResolveIds = new Set(firstFetchByResolve.keys());
135
+ const accepted_count = acceptedResolveIds.size;
136
+ const acceptance: AcceptanceSignal =
137
+ accepted_count > 0
138
+ ? (() => {
139
+ let reciprocalRankSum = 0;
140
+ let top1Count = 0;
141
+ for (const fetch of firstFetchByResolve.values()) {
142
+ const rank = fetch.rank_at_resolve;
143
+ if (rank !== null) {
144
+ reciprocalRankSum += 1 / rank;
145
+ if (rank === 1) top1Count++;
146
+ }
147
+ }
148
+ return {
149
+ available: true as const,
150
+ resolves_with_candidates: resolvesWithCandidates.size,
151
+ accepted_count,
152
+ acceptance_rate: accepted_count / resolvesWithCandidates.size,
153
+ observed_mrr: reciprocalRankSum / accepted_count,
154
+ top1_acceptance_rate: top1Count / accepted_count,
155
+ uncorrelated_fetch_count,
156
+ };
157
+ })()
158
+ : { available: false as const, uncorrelated_fetch_count };
159
+
160
+ const unusedShortlistCounts = new Map<string, number>();
161
+ for (const [id, row] of resolvesWithCandidates) {
162
+ if (acceptedResolveIds.has(id)) continue;
163
+ unusedShortlistCounts.set(row.query, (unusedShortlistCounts.get(row.query) ?? 0) + 1);
164
+ }
165
+
99
166
  const total_requests = rows.length;
100
167
  const empty_shortlist_rate = total_requests > 0 ? empty_shortlist_count / total_requests : 0;
101
168
  const average_latency_ms = total_requests > 0 ? total_latency_ms / total_requests : 0;
@@ -109,15 +176,8 @@ export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsR
109
176
  return compareCodeUnits(a.skill_id, b.skill_id);
110
177
  });
111
178
 
112
- const top_empty_shortlist_queries: EmptyShortlistQuery[] = [...emptyShortlistCounts.entries()]
113
- .map(([query, count]) => ({ query, 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
- })
120
- .slice(0, 20);
179
+ const top_empty_shortlist_queries = topQueryCounts(emptyShortlistCounts);
180
+ const top_unused_shortlist_queries = topQueryCounts(unusedShortlistCounts);
121
181
 
122
182
  return {
123
183
  since: since.toISOString(),
@@ -130,12 +190,27 @@ export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsR
130
190
  average_latency_ms,
131
191
  skills,
132
192
  top_empty_shortlist_queries,
193
+ acceptance,
194
+ top_unused_shortlist_queries,
133
195
  };
134
196
  }
135
197
 
198
+ function topQueryCounts(counts: Map<string, number>): EmptyShortlistQuery[] {
199
+ return [...counts.entries()]
200
+ .map(([query, count]) => ({ query, count }))
201
+ .sort((a, b) => {
202
+ if (b.count !== a.count) {
203
+ return b.count - a.count;
204
+ }
205
+ return compareCodeUnits(a.query, b.query);
206
+ })
207
+ .slice(0, 20);
208
+ }
209
+
136
210
  interface AuditTableRow {
137
211
  id: number;
138
212
  ts: string;
213
+ request_id: string | null;
139
214
  query: string;
140
215
  retrieval: AuditRow["retrieval"];
141
216
  degraded_from: string | null;
@@ -147,7 +222,7 @@ interface AuditTableRow {
147
222
  export function queryAuditRows(db: Database, sinceIso: string): AuditRow[] {
148
223
  const rows = db
149
224
  .query(
150
- "SELECT id, ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC",
225
+ "SELECT id, ts, request_id, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC",
151
226
  )
152
227
  .all(sinceIso) as AuditTableRow[];
153
228
 
@@ -178,6 +253,7 @@ export function queryAuditRows(db: Database, sinceIso: string): AuditRow[] {
178
253
  const result: AuditRow = {
179
254
  id: row.id,
180
255
  ts: row.ts,
256
+ request_id: row.request_id,
181
257
  query: row.query,
182
258
  retrieval: row.retrieval,
183
259
  candidates,
@@ -193,10 +269,19 @@ export function queryAuditRows(db: Database, sinceIso: string): AuditRow[] {
193
269
  });
194
270
  }
195
271
 
272
+ export function queryFetchRows(db: Database, sinceIso: string): FetchAuditRow[] {
273
+ return db
274
+ .query(
275
+ "SELECT id, ts, skill_id, request_id, resolve_audit_id, rank_at_resolve FROM fetch WHERE ts >= ? ORDER BY ts ASC",
276
+ )
277
+ .all(sinceIso) as FetchAuditRow[];
278
+ }
279
+
196
280
  export function getStats(db: Database, since: string, now: Date = new Date()): StatsResponse {
197
281
  const sinceDate = parseSince(since, now);
198
282
  const rows = queryAuditRows(db, sinceDate.toISOString());
199
- return computeStats(rows, sinceDate, now);
283
+ const fetchRows = queryFetchRows(db, sinceDate.toISOString());
284
+ return computeStats(rows, sinceDate, now, fetchRows);
200
285
  }
201
286
 
202
287
  export function renderStatsText(stats: StatsResponse): string {
@@ -230,5 +315,26 @@ export function renderStatsText(stats: StatsResponse): string {
230
315
  }
231
316
  }
232
317
 
318
+ if (stats.acceptance.available) {
319
+ lines.push(
320
+ `acceptance: acceptance_rate=${stats.acceptance.acceptance_rate.toFixed(3)} ` +
321
+ `observed_mrr=${stats.acceptance.observed_mrr.toFixed(3)} ` +
322
+ `top1_acceptance_rate=${stats.acceptance.top1_acceptance_rate.toFixed(3)} ` +
323
+ `(accepted=${stats.acceptance.accepted_count}/${stats.acceptance.resolves_with_candidates}, ` +
324
+ `uncorrelated_fetch_count=${stats.acceptance.uncorrelated_fetch_count})`,
325
+ );
326
+ } else {
327
+ lines.push(`acceptance: unavailable (uncorrelated_fetch_count=${stats.acceptance.uncorrelated_fetch_count})`);
328
+ }
329
+
330
+ lines.push("top unused shortlist queries:");
331
+ if (stats.top_unused_shortlist_queries.length === 0) {
332
+ lines.push(" (none)");
333
+ } else {
334
+ for (const entry of stats.top_unused_shortlist_queries) {
335
+ lines.push(` "${entry.query}" (${entry.count})`);
336
+ }
337
+ }
338
+
233
339
  return lines.join("\n");
234
340
  }
package/src/types.ts CHANGED
@@ -107,6 +107,11 @@ export interface ConfigPolicy {
107
107
  environment_overrides?: boolean;
108
108
  }
109
109
 
110
+ export interface AuditConfig {
111
+ /** Age in days beyond which audit rows are pruned. 0 disables pruning. */
112
+ retention_days: number;
113
+ }
114
+
110
115
  export interface Config {
111
116
  config?: ConfigPolicy;
112
117
  vault_path: string;
@@ -116,6 +121,7 @@ export interface Config {
116
121
  output: OutputConfig;
117
122
  inference: InferenceConfig;
118
123
  server?: ServerConfig;
124
+ audit?: AuditConfig;
119
125
  }
120
126
 
121
127
  export interface RankedCandidate {
@@ -136,6 +142,7 @@ export type DegradationReason =
136
142
  | "reranker_protocol_error";
137
143
 
138
144
  export interface ResolveResult {
145
+ request_id: string;
139
146
  retrieval: RetrievalCapability;
140
147
  degraded_from?: "reranked" | "hybrid";
141
148
  degradation_reason?: DegradationReason;
@@ -151,6 +158,7 @@ export interface ResolveSkillInput {
151
158
 
152
159
  export interface FetchSkillInput {
153
160
  skill_id: string;
161
+ request_id?: string;
154
162
  }
155
163
 
156
164
  export interface FetchSkillResult {
@@ -169,6 +177,8 @@ export interface AuditCandidate {
169
177
  export interface AuditRow {
170
178
  id: number;
171
179
  ts: string;
180
+ /** Null for rows written before request_id existed (AC4). */
181
+ request_id: string | null;
172
182
  query: string;
173
183
  retrieval: RetrievalCapability;
174
184
  degraded_from?: "reranked" | "hybrid" | null;
@@ -177,6 +187,18 @@ export interface AuditRow {
177
187
  latency_ms: number;
178
188
  }
179
189
 
190
+ export interface FetchAuditRow {
191
+ id: number;
192
+ ts: string;
193
+ skill_id: string;
194
+ /** Exactly as supplied by the caller, including an unknown value. Null when the caller sent none. */
195
+ request_id: string | null;
196
+ /** Null when the fetch is uncorrelated: no request_id, an unknown/malformed one, or a pruned resolve row. */
197
+ resolve_audit_id: number | null;
198
+ /** Rank of skill_id in the correlated resolve's shortlist. Null when uncorrelated or absent from the shortlist. */
199
+ rank_at_resolve: number | null;
200
+ }
201
+
180
202
  export interface Clients {
181
203
  embed(texts: string[]): Promise<Float32Array[]>;
182
204
  rerank?: (query: string, docs: { skill_id: string; text: string }[]) => Promise<number[]>;
Binary file