@klhapp/skillmux 0.2.1 → 0.4.3

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.
@@ -0,0 +1,797 @@
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { z } from "zod";
5
+
6
+ export { generateDataset, type GenerateDatasetOptions } from "./dataset-generator";
7
+
8
+ // ---------------------------------------------------------------------------
9
+
10
+ // Decision-policy dataset types (AC1)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type DecisionSplit = "tune" | "test";
14
+ export type DecisionOutcome = "matched" | "ambiguous" | "no_match";
15
+
16
+ export interface DecisionCase {
17
+ query: string;
18
+ split: DecisionSplit;
19
+ expected_outcome: DecisionOutcome;
20
+ relevant_skill_ids: string[];
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Raw Zod schema — field-level validation only (cross-field rules below)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const rawCaseSchema = z.object({
28
+ query: z.string(),
29
+ split: z.enum(["tune", "test"]),
30
+ expected_outcome: z.enum(["matched", "ambiguous", "no_match"]),
31
+ relevant_skill_ids: z.array(z.string()),
32
+ }).strict();
33
+
34
+ type RawCase = z.infer<typeof rawCaseSchema>;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Cross-field validation helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function validateCase(raw: RawCase, idx: number): DecisionCase {
41
+ if (!raw.query) {
42
+ throw new Error(`Validation error at case ${idx}: field "query" must be a non-empty string`);
43
+ }
44
+
45
+ const { expected_outcome, relevant_skill_ids } = raw;
46
+
47
+ if (expected_outcome === "matched") {
48
+ if (relevant_skill_ids.length !== 1) {
49
+ throw new Error(
50
+ `Validation error at case ${idx}: field "relevant_skill_ids" must contain exactly one entry for outcome "matched"`,
51
+ );
52
+ }
53
+ } else if (expected_outcome === "ambiguous") {
54
+ if (relevant_skill_ids.length < 1) {
55
+ throw new Error(
56
+ `Validation error at case ${idx}: field "relevant_skill_ids" must contain at least one entry for outcome "ambiguous"`,
57
+ );
58
+ }
59
+ } else {
60
+ // no_match
61
+ if (relevant_skill_ids.length !== 0) {
62
+ throw new Error(
63
+ `Validation error at case ${idx}: field "relevant_skill_ids" must be empty for outcome "no_match"`,
64
+ );
65
+ }
66
+ }
67
+
68
+ return raw as DecisionCase;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Dataset-level completeness checks
73
+ // ---------------------------------------------------------------------------
74
+
75
+ type SplitOutcomeSet = Record<DecisionSplit, Set<DecisionOutcome>>;
76
+
77
+ function validateDatasetCompleteness(cases: DecisionCase[]): void {
78
+ const present: SplitOutcomeSet = { tune: new Set(), test: new Set() };
79
+
80
+ for (const c of cases) {
81
+ present[c.split].add(c.expected_outcome);
82
+ }
83
+
84
+ for (const split of ["tune", "test"] as DecisionSplit[]) {
85
+ if (present[split].size === 0) {
86
+ throw new Error(
87
+ `Dataset must include cases for both "tune" and "test" splits — missing "${split}"`,
88
+ );
89
+ }
90
+ for (const outcome of ["matched", "ambiguous", "no_match"] as DecisionOutcome[]) {
91
+ if (!present[split].has(outcome)) {
92
+ throw new Error(
93
+ `Dataset must include "${outcome}" cases in the "${split}" split`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Public API
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Parse and validate an array of raw objects as a decision-policy dataset.
106
+ *
107
+ * Throws a descriptive error (including case index and field name) on the
108
+ * first validation failure. Validates:
109
+ * - Required fields and their types/enums (Zod)
110
+ * - Cross-field constraints (matched → exactly 1 skill, ambiguous → ≥1,
111
+ * no_match → 0)
112
+ * - Dataset completeness (both splits, all outcome types in each split)
113
+ */
114
+ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
115
+ const parsed: DecisionCase[] = [];
116
+
117
+ for (let i = 0; i < raw.length; i++) {
118
+ const item = raw[i];
119
+ const result = rawCaseSchema.safeParse(item);
120
+
121
+ if (!result.success) {
122
+ const firstIssue = result.error.issues[0]!;
123
+ const fieldPath = firstIssue.path.join(".") || "unknown";
124
+ throw new Error(
125
+ `Validation error at case ${i}: field "${fieldPath}" — ${firstIssue.message}`,
126
+ );
127
+ }
128
+
129
+ parsed.push(validateCase(result.data, i));
130
+ }
131
+
132
+ validateDatasetCompleteness(parsed);
133
+ return parsed;
134
+ }
135
+
136
+ /**
137
+ * Read a JSON file from disk and validate it as a decision-policy dataset.
138
+ * Throws if the file cannot be read or the contents fail validation.
139
+ */
140
+ export function loadDecisionCasesFromFile(path: string): DecisionCase[] {
141
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown[];
142
+ return loadDecisionCases(raw);
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Calibration run — types (AC2, AC3, AC4)
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export interface CandidateDoc {
150
+ skill_id: string;
151
+ text: string;
152
+ }
153
+
154
+ /** A single cached observation for one query. */
155
+ export interface QueryObservation {
156
+ query: string;
157
+ split: DecisionSplit;
158
+ expected_outcome: DecisionOutcome;
159
+ relevant_skill_ids: string[];
160
+ /** Candidates in descending score order after reranking. */
161
+ ranked: Array<{ skill_id: string; score: number }>;
162
+ }
163
+
164
+ export interface SelectedThresholds {
165
+ match_score: number;
166
+ match_margin: number;
167
+ candidate_floor: number;
168
+ }
169
+
170
+ export interface CalibrationMetrics {
171
+ auto_match_precision: number;
172
+ auto_match_coverage: number;
173
+ shortlist_recall_at_5: number;
174
+ false_no_match_rate: number;
175
+ }
176
+
177
+ export interface ConfusionMatrix {
178
+ matched: Record<DecisionOutcome, number>;
179
+ ambiguous: Record<DecisionOutcome, number>;
180
+ no_match: Record<DecisionOutcome, number>;
181
+ }
182
+
183
+ export interface CalibrationTestMetrics extends CalibrationMetrics {
184
+ confusion_matrix: ConfusionMatrix;
185
+ }
186
+
187
+ export type CalibrationStatus = "completed" | "failed_gates";
188
+
189
+ export interface CalibrationResult {
190
+ status: CalibrationStatus;
191
+ observations: QueryObservation[];
192
+ selected_thresholds?: SelectedThresholds;
193
+ tune_metrics?: CalibrationMetrics;
194
+ test_metrics?: CalibrationTestMetrics;
195
+ }
196
+
197
+ export interface RunCalibrationOptions {
198
+ cases: DecisionCase[];
199
+ getCandidates: (query: string) => Promise<CandidateDoc[]>;
200
+ reranker:
201
+ | ((query: string, docs: CandidateDoc[]) => Promise<number[]>)
202
+ | undefined;
203
+ /** Default: 0.99 */
204
+ minAutoMatchPrecision?: number;
205
+ /** Default: 0.95 */
206
+ minShortlistRecallAt5?: number;
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Decision simulation using cached observations
211
+ // ---------------------------------------------------------------------------
212
+
213
+ type SimulatedDecision = "matched" | "ambiguous" | "no_match";
214
+
215
+ function simulateDecision(
216
+ obs: QueryObservation,
217
+ thresholds: SelectedThresholds,
218
+ candidateLimit: number,
219
+ ): SimulatedDecision {
220
+ const { match_score, match_margin, candidate_floor } = thresholds;
221
+ const eligible = obs.ranked.filter((c) => c.score >= candidate_floor);
222
+ if (eligible.length === 0) return "no_match";
223
+
224
+ const top = eligible[0]!;
225
+ const second = obs.ranked[1];
226
+ const margin = second ? top.score - second.score : top.score;
227
+
228
+ if (top.score >= match_score && margin >= match_margin) return "matched";
229
+ if (eligible.slice(0, candidateLimit).length > 0) return "ambiguous";
230
+ return "no_match";
231
+ }
232
+
233
+ function computeMetrics(
234
+ observations: QueryObservation[],
235
+ thresholds: SelectedThresholds,
236
+ candidateLimit = 5,
237
+ ): CalibrationMetrics {
238
+ let autoMatchCount = 0;
239
+ let correctAutoMatch = 0;
240
+ let shortlistHit = 0;
241
+ let falseNoMatch = 0;
242
+ const matchableCases = observations.filter((o) => o.expected_outcome !== "no_match");
243
+
244
+ for (const obs of observations) {
245
+ const decision = simulateDecision(obs, thresholds, candidateLimit);
246
+ if (decision === "matched") {
247
+ autoMatchCount++;
248
+ // Correct if the top candidate is in relevant_skill_ids
249
+ const top = obs.ranked[0];
250
+ if (top && obs.relevant_skill_ids.includes(top.skill_id)) correctAutoMatch++;
251
+ }
252
+ if (obs.expected_outcome !== "no_match") {
253
+ // Shortlist recall: at least one relevant skill in top 5
254
+ const top5 = obs.ranked.slice(0, 5).map((c) => c.skill_id);
255
+ if (obs.relevant_skill_ids.some((id) => top5.includes(id))) shortlistHit++;
256
+ }
257
+ if (obs.expected_outcome !== "no_match" && decision === "no_match") {
258
+ falseNoMatch++;
259
+ }
260
+ }
261
+
262
+ const auto_match_precision = autoMatchCount === 0 ? 1.0 : correctAutoMatch / autoMatchCount;
263
+ const auto_match_coverage = matchableCases.length === 0
264
+ ? 0
265
+ : autoMatchCount / matchableCases.length;
266
+ const shortlist_recall_at_5 = matchableCases.length === 0
267
+ ? 1.0
268
+ : shortlistHit / matchableCases.length;
269
+ const false_no_match_rate = matchableCases.length === 0
270
+ ? 0
271
+ : falseNoMatch / matchableCases.length;
272
+
273
+ return { auto_match_precision, auto_match_coverage, shortlist_recall_at_5, false_no_match_rate };
274
+ }
275
+
276
+ function computeTestMetrics(
277
+ observations: QueryObservation[],
278
+ thresholds: SelectedThresholds,
279
+ candidateLimit = 5,
280
+ ): CalibrationTestMetrics {
281
+ const base = computeMetrics(observations, thresholds, candidateLimit);
282
+
283
+ // Build confusion matrix: rows = expected, cols = predicted
284
+ const emptyRow = (): Record<DecisionOutcome, number> => ({ matched: 0, ambiguous: 0, no_match: 0 });
285
+ const matrix: ConfusionMatrix = { matched: emptyRow(), ambiguous: emptyRow(), no_match: emptyRow() };
286
+
287
+ for (const obs of observations) {
288
+ const predicted = simulateDecision(obs, thresholds, candidateLimit);
289
+ matrix[obs.expected_outcome][predicted]++;
290
+ }
291
+
292
+ return { ...base, confusion_matrix: matrix };
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Threshold search space derivation (AC3)
297
+ // ---------------------------------------------------------------------------
298
+
299
+ function uniqueSorted(values: number[]): number[] {
300
+ return [...new Set(values)].sort((a, b) => a - b);
301
+ }
302
+
303
+ function deriveThresholdCandidates(observations: QueryObservation[]): {
304
+ scoreBreakpoints: number[];
305
+ marginBreakpoints: number[];
306
+ floorBreakpoints: number[];
307
+ } {
308
+ const scores: number[] = [];
309
+ const margins: number[] = [];
310
+
311
+ for (const obs of observations) {
312
+ if (obs.ranked.length === 0) continue;
313
+ const top = obs.ranked[0]!;
314
+ scores.push(top.score);
315
+ const second = obs.ranked[1];
316
+ margins.push(second ? top.score - second.score : top.score);
317
+ }
318
+
319
+ // Breakpoints: observed values + a small epsilon step below each
320
+ const epsilon = 0.001;
321
+ const scoreBreakpoints = uniqueSorted([
322
+ ...scores.map((s) => Math.max(0, s - epsilon)),
323
+ ...scores,
324
+ ]);
325
+ const marginBreakpoints = uniqueSorted([
326
+ ...margins.map((m) => Math.max(0, m - epsilon)),
327
+ ...margins,
328
+ ]);
329
+ const floorBreakpoints = uniqueSorted([
330
+ ...scores.map((s) => Math.max(0, s - epsilon)),
331
+ ...scores,
332
+ ]);
333
+
334
+ return { scoreBreakpoints, marginBreakpoints, floorBreakpoints };
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Deterministic optimizer (AC3)
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Find the threshold triple that:
343
+ * 1. Satisfies minAutoMatchPrecision AND minShortlistRecallAt5 gates
344
+ * 2. Among those: maximizes auto_match_coverage
345
+ * 3. Ties broken by: higher auto_match_precision, then higher shortlist_recall_at_5,
346
+ * then lower auto_match_coverage (as coverage the tiebreak)
347
+ */
348
+ function selectThresholds(
349
+ tuneObservations: QueryObservation[],
350
+ gates: { minAutoMatchPrecision: number; minShortlistRecallAt5: number },
351
+ candidateLimit: number,
352
+ ): SelectedThresholds | undefined {
353
+ const { scoreBreakpoints, marginBreakpoints, floorBreakpoints } =
354
+ deriveThresholdCandidates(tuneObservations);
355
+
356
+ let best:
357
+ | { thresholds: SelectedThresholds; metrics: CalibrationMetrics }
358
+ | undefined;
359
+
360
+ for (const floor of floorBreakpoints) {
361
+ for (const score of scoreBreakpoints) {
362
+ if (score < floor) continue;
363
+ for (const margin of marginBreakpoints) {
364
+ const candidate: SelectedThresholds = { match_score: score, match_margin: margin, candidate_floor: floor };
365
+ const m = computeMetrics(tuneObservations, candidate, candidateLimit);
366
+
367
+ if (
368
+ m.auto_match_precision < gates.minAutoMatchPrecision ||
369
+ m.shortlist_recall_at_5 < gates.minShortlistRecallAt5
370
+ ) {
371
+ continue;
372
+ }
373
+
374
+ if (!best) {
375
+ best = { thresholds: candidate, metrics: m };
376
+ continue;
377
+ }
378
+
379
+ // Prefer higher coverage, then higher precision, then higher recall, then lower coverage (impossible but symmetry)
380
+ const bm = best.metrics;
381
+ if (m.auto_match_coverage > bm.auto_match_coverage) {
382
+ best = { thresholds: candidate, metrics: m };
383
+ } else if (m.auto_match_coverage === bm.auto_match_coverage) {
384
+ if (m.auto_match_precision > bm.auto_match_precision) {
385
+ best = { thresholds: candidate, metrics: m };
386
+ } else if (
387
+ m.auto_match_precision === bm.auto_match_precision &&
388
+ m.shortlist_recall_at_5 > bm.shortlist_recall_at_5
389
+ ) {
390
+ best = { thresholds: candidate, metrics: m };
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ return best?.thresholds;
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // Public API — runCalibration (AC2, AC3, AC4)
402
+ // ---------------------------------------------------------------------------
403
+
404
+ /**
405
+ * Run an in-memory calibration:
406
+ * 1. Require a configured reranker (AC2)
407
+ * 2. Collect and cache per-query observations via hybrid retrieval + reranking (AC2)
408
+ * 3. Search cached observations for optimal thresholds (AC3)
409
+ * 4. Evaluate selected thresholds on untouched test split (AC4)
410
+ */
411
+ export async function runCalibration(opts: RunCalibrationOptions): Promise<CalibrationResult> {
412
+ const {
413
+ cases,
414
+ getCandidates,
415
+ reranker,
416
+ minAutoMatchPrecision = 0.99,
417
+ minShortlistRecallAt5 = 0.95,
418
+ } = opts;
419
+
420
+ if (!reranker) {
421
+ throw new Error(
422
+ "A configured reranker is required to run calibration. " +
423
+ "Configure inference.reranker in your TOML config.",
424
+ );
425
+ }
426
+
427
+ // --- Step 1: Cache observations (reranker called exactly once per query) ---
428
+ const observations: QueryObservation[] = [];
429
+ for (const c of cases) {
430
+ const docs = await getCandidates(c.query);
431
+ const scores = await reranker(c.query, docs);
432
+ const ranked = docs
433
+ .map((d, i) => ({ skill_id: d.skill_id, score: scores[i] ?? 0 }))
434
+ .sort((a, b) => b.score - a.score);
435
+ observations.push({
436
+ query: c.query,
437
+ split: c.split,
438
+ expected_outcome: c.expected_outcome,
439
+ relevant_skill_ids: c.relevant_skill_ids,
440
+ ranked,
441
+ });
442
+ }
443
+
444
+ // --- Step 2: Select thresholds from tune split only ---
445
+ const tuneObs = observations.filter((o) => o.split === "tune");
446
+ const selected = selectThresholds(
447
+ tuneObs,
448
+ { minAutoMatchPrecision, minShortlistRecallAt5 },
449
+ 5,
450
+ );
451
+
452
+ if (!selected) {
453
+ return { status: "failed_gates", observations };
454
+ }
455
+
456
+ // --- Step 3: Report tune metrics ---
457
+ const tune_metrics = computeMetrics(tuneObs, selected);
458
+
459
+ // --- Step 4: Evaluate untouched test split ---
460
+ const testObs = observations.filter((o) => o.split === "test");
461
+ const test_metrics = computeTestMetrics(testObs, selected);
462
+
463
+ return { status: "completed", observations, selected_thresholds: selected, tune_metrics, test_metrics };
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // SQLite evidence store (AC5, AC6)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /**
471
+ * All fields stored for a single calibration run.
472
+ * SQLite is evidence and history only — never read on the resolve_skill path.
473
+ */
474
+ export interface CalibrationRunRecord {
475
+ run_id: string;
476
+ created_at: string;
477
+ status: CalibrationStatus;
478
+ reranker_fingerprint: string;
479
+ embedding_fingerprint: string;
480
+ corpus_fingerprint: string;
481
+ dataset_hash: string;
482
+ min_auto_match_precision: number;
483
+ min_shortlist_recall_at_5: number;
484
+ selected_thresholds?: SelectedThresholds;
485
+ tune_metrics?: CalibrationMetrics;
486
+ test_metrics?: CalibrationTestMetrics;
487
+ observations: QueryObservation[];
488
+ }
489
+
490
+ /** Summary row returned by listCalibrationRuns (no observations blob). */
491
+ export interface CalibrationRunSummary {
492
+ run_id: string;
493
+ created_at: string;
494
+ status: CalibrationStatus;
495
+ reranker_fingerprint: string;
496
+ embedding_fingerprint: string;
497
+ corpus_fingerprint: string;
498
+ dataset_hash: string;
499
+ min_auto_match_precision: number;
500
+ min_shortlist_recall_at_5: number;
501
+ }
502
+
503
+ /**
504
+ * Open (or create) the calibration evidence database in `stateDir`.
505
+ * Uses a separate `calibrate.sqlite3` file — never the index.sqlite3 used
506
+ * on the resolve_skill request path.
507
+ */
508
+ export function openCalibrateDb(stateDir: string): Database {
509
+ mkdirSync(stateDir, { recursive: true });
510
+ const db = new Database(join(stateDir, "calibrate.sqlite3"), { create: true });
511
+ db.run("PRAGMA journal_mode = WAL");
512
+ db.run("PRAGMA busy_timeout = 2000");
513
+ db.run(`CREATE TABLE IF NOT EXISTS calibration_runs (
514
+ run_id TEXT PRIMARY KEY,
515
+ created_at TEXT NOT NULL,
516
+ status TEXT NOT NULL CHECK (status IN ('completed', 'failed_gates')),
517
+ reranker_fingerprint TEXT NOT NULL,
518
+ embedding_fingerprint TEXT NOT NULL,
519
+ corpus_fingerprint TEXT NOT NULL,
520
+ dataset_hash TEXT NOT NULL,
521
+ min_auto_match_precision REAL NOT NULL,
522
+ min_shortlist_recall_at_5 REAL NOT NULL,
523
+ selected_thresholds TEXT,
524
+ tune_metrics TEXT,
525
+ test_metrics TEXT,
526
+ observations TEXT NOT NULL
527
+ )`);
528
+ return db;
529
+ }
530
+
531
+ /** Persist a calibration run (all fields) to the evidence store. */
532
+ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): void {
533
+ db.run(
534
+ `INSERT INTO calibration_runs (
535
+ run_id, created_at, status,
536
+ reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
537
+ min_auto_match_precision, min_shortlist_recall_at_5,
538
+ selected_thresholds, tune_metrics, test_metrics, observations
539
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
540
+ [
541
+ run.run_id,
542
+ run.created_at,
543
+ run.status,
544
+ run.reranker_fingerprint,
545
+ run.embedding_fingerprint,
546
+ run.corpus_fingerprint,
547
+ run.dataset_hash,
548
+ run.min_auto_match_precision,
549
+ run.min_shortlist_recall_at_5,
550
+ run.selected_thresholds != null ? JSON.stringify(run.selected_thresholds) : null,
551
+ run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
552
+ run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
553
+ JSON.stringify(run.observations),
554
+ ],
555
+ );
556
+ }
557
+
558
+ interface RawCalibrationRow {
559
+ run_id: string;
560
+ created_at: string;
561
+ status: string;
562
+ reranker_fingerprint: string;
563
+ embedding_fingerprint: string;
564
+ corpus_fingerprint: string;
565
+ dataset_hash: string;
566
+ min_auto_match_precision: number;
567
+ min_shortlist_recall_at_5: number;
568
+ selected_thresholds: string | null;
569
+ tune_metrics: string | null;
570
+ test_metrics: string | null;
571
+ observations: string;
572
+ }
573
+
574
+ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
575
+ return {
576
+ run_id: row.run_id,
577
+ created_at: row.created_at,
578
+ status: row.status as CalibrationStatus,
579
+ reranker_fingerprint: row.reranker_fingerprint,
580
+ embedding_fingerprint: row.embedding_fingerprint,
581
+ corpus_fingerprint: row.corpus_fingerprint,
582
+ dataset_hash: row.dataset_hash,
583
+ min_auto_match_precision: row.min_auto_match_precision,
584
+ min_shortlist_recall_at_5: row.min_shortlist_recall_at_5,
585
+ selected_thresholds: row.selected_thresholds != null
586
+ ? (JSON.parse(row.selected_thresholds) as SelectedThresholds)
587
+ : undefined,
588
+ tune_metrics: row.tune_metrics != null
589
+ ? (JSON.parse(row.tune_metrics) as CalibrationMetrics)
590
+ : undefined,
591
+ test_metrics: row.test_metrics != null
592
+ ? (JSON.parse(row.test_metrics) as CalibrationTestMetrics)
593
+ : undefined,
594
+ observations: JSON.parse(row.observations) as QueryObservation[],
595
+ };
596
+ }
597
+
598
+ /** Retrieve a full run record by run_id. Returns null if not found. */
599
+ export function getCalibrationRun(db: Database, runId: string): CalibrationRunRecord | null {
600
+ const row = db
601
+ .query("SELECT * FROM calibration_runs WHERE run_id = ?")
602
+ .get(runId) as RawCalibrationRow | null;
603
+ return row ? rowToRecord(row) : null;
604
+ }
605
+
606
+ /** List all runs ordered by created_at descending (excludes observations blob). */
607
+ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
608
+ return db
609
+ .query(
610
+ `SELECT run_id, created_at, status,
611
+ reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
612
+ min_auto_match_precision, min_shortlist_recall_at_5
613
+ FROM calibration_runs ORDER BY created_at DESC`,
614
+ )
615
+ .all() as CalibrationRunSummary[];
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // calibrate apply — gated TOML write (AC6, AC7)
620
+ // ---------------------------------------------------------------------------
621
+
622
+ /** Structured error for apply rejections — always includes a reason string. */
623
+ export class ApplyCalibrationError extends Error {
624
+ constructor(
625
+ message: string,
626
+ public readonly reason: string,
627
+ ) {
628
+ super(message);
629
+ this.name = "ApplyCalibrationError";
630
+ }
631
+ }
632
+
633
+ export interface ApplyCalibrationOptions {
634
+ /** If provided, checked against run.reranker_fingerprint; mismatch rejects. */
635
+ currentRerankerFingerprint?: string;
636
+ /** If provided, checked against run.embedding_fingerprint; mismatch rejects. */
637
+ currentEmbeddingFingerprint?: string;
638
+ /** If provided, checked against run.corpus_fingerprint; mismatch rejects. */
639
+ currentCorpusFingerprint?: string;
640
+ /**
641
+ * Keys currently masked by environment variable overrides.
642
+ * If any of the three threshold keys appear here, the apply is rejected
643
+ * (the write would be invisible at runtime).
644
+ */
645
+ maskedEnvKeys?: string[];
646
+ }
647
+
648
+ const THRESHOLD_KEYS = [
649
+ "inference.thresholds.match_score",
650
+ "inference.thresholds.match_margin",
651
+ "inference.thresholds.candidate_floor",
652
+ ] as const;
653
+
654
+ /**
655
+ * Atomically write calibration thresholds and provenance to the TOML config.
656
+ *
657
+ * Rejects (ApplyCalibrationError) when:
658
+ * - run_id not found in the evidence store
659
+ * - run status is failed_gates
660
+ * - any fingerprint check fails (reranker, embedding, corpus)
661
+ * - any threshold key is masked by an environment variable override
662
+ *
663
+ * On success: reads the existing TOML, patches [inference.thresholds] and
664
+ * [inference.calibration] sections, and atomically writes via rename.
665
+ */
666
+ export async function applyCalibrationRun(
667
+ db: Database,
668
+ runId: string,
669
+ tomlPath: string,
670
+ opts: ApplyCalibrationOptions,
671
+ ): Promise<void> {
672
+ // --- Gate 1: run must exist ---
673
+ const run = getCalibrationRun(db, runId);
674
+ if (!run) {
675
+ throw new ApplyCalibrationError(
676
+ `Calibration run "${runId}" not found`,
677
+ "run_not_found",
678
+ );
679
+ }
680
+
681
+ // --- Gate 2: run must have completed successfully ---
682
+ if (run.status !== "completed" || !run.selected_thresholds) {
683
+ throw new ApplyCalibrationError(
684
+ `Calibration run "${runId}" has status "${run.status}" and cannot be applied`,
685
+ "failed_gates",
686
+ );
687
+ }
688
+
689
+ // --- Gate 3: fingerprint staleness ---
690
+ if (
691
+ opts.currentRerankerFingerprint !== undefined &&
692
+ opts.currentRerankerFingerprint !== run.reranker_fingerprint
693
+ ) {
694
+ throw new ApplyCalibrationError(
695
+ `Reranker fingerprint mismatch: run was calibrated with "${run.reranker_fingerprint}" ` +
696
+ `but current config has "${opts.currentRerankerFingerprint}"`,
697
+ "stale_reranker_fingerprint",
698
+ );
699
+ }
700
+ if (
701
+ opts.currentEmbeddingFingerprint !== undefined &&
702
+ opts.currentEmbeddingFingerprint !== run.embedding_fingerprint
703
+ ) {
704
+ throw new ApplyCalibrationError(
705
+ `Embedding fingerprint mismatch: run was calibrated with "${run.embedding_fingerprint}" ` +
706
+ `but current config has "${opts.currentEmbeddingFingerprint}"`,
707
+ "stale_embedding_fingerprint",
708
+ );
709
+ }
710
+ if (
711
+ opts.currentCorpusFingerprint !== undefined &&
712
+ opts.currentCorpusFingerprint !== run.corpus_fingerprint
713
+ ) {
714
+ throw new ApplyCalibrationError(
715
+ `Corpus fingerprint mismatch: run was calibrated against "${run.corpus_fingerprint}" ` +
716
+ `but current vault has "${opts.currentCorpusFingerprint}"`,
717
+ "stale_corpus_fingerprint",
718
+ );
719
+ }
720
+
721
+ // --- Gate 4: env-masked key check (AC7) ---
722
+ const masked = opts.maskedEnvKeys ?? [];
723
+ const maskedThresholdKeys = THRESHOLD_KEYS.filter((k) => masked.includes(k));
724
+ if (maskedThresholdKeys.length > 0) {
725
+ throw new ApplyCalibrationError(
726
+ `Cannot apply: the following threshold keys are masked by environment variable overrides ` +
727
+ `and the TOML write would be invisible at runtime: ${maskedThresholdKeys.join(", ")}`,
728
+ "env_masked_keys",
729
+ );
730
+ }
731
+
732
+ // --- Atomic TOML write ---
733
+ const { match_score, match_margin, candidate_floor } = run.selected_thresholds;
734
+
735
+ const existing = await Bun.file(tomlPath).text();
736
+ const patched = patchToml(existing, runId, match_score, match_margin, candidate_floor);
737
+
738
+ // Write to a temp file then rename for atomicity
739
+ const tmpPath = `${tomlPath}.${process.pid}.tmp`;
740
+ await Bun.write(tmpPath, patched);
741
+ const { renameSync } = await import("node:fs");
742
+ renameSync(tmpPath, tomlPath);
743
+ }
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // TOML patch helpers (surgical text manipulation)
747
+ // ---------------------------------------------------------------------------
748
+
749
+ /**
750
+ * Patch an existing TOML string to set [inference.thresholds] values and
751
+ * add/update [inference.calibration] with run_id.
752
+ *
753
+ * Strategy:
754
+ * 1. Replace any existing [inference.thresholds] section content
755
+ * 2. Add/replace [inference.calibration] section
756
+ */
757
+ function patchToml(
758
+ source: string,
759
+ runId: string,
760
+ matchScore: number,
761
+ matchMargin: number,
762
+ candidateFloor: number,
763
+ ): string {
764
+ const thresholdsBlock = `[inference.thresholds]\nmatch_score = ${matchScore}\nmatch_margin = ${matchMargin}\ncandidate_floor = ${candidateFloor}\n`;
765
+ const calibrationBlock = `[inference.calibration]\nrun_id = "${runId}"\n`;
766
+
767
+ // Remove existing [inference.thresholds] section
768
+ let result = removeSectionBlock(source, "[inference.thresholds]");
769
+ // Remove existing [inference.calibration] section
770
+ result = removeSectionBlock(result, "[inference.calibration]");
771
+ // Append both sections
772
+ result = result.trimEnd() + "\n\n" + thresholdsBlock + "\n" + calibrationBlock;
773
+ return result;
774
+ }
775
+
776
+ /**
777
+ * Remove a TOML section header and all lines until the next section header
778
+ * (or end of file). Matches exact header string at start of line.
779
+ */
780
+ function removeSectionBlock(source: string, header: string): string {
781
+ const lines = source.split("\n");
782
+ const out: string[] = [];
783
+ let skipping = false;
784
+ for (const line of lines) {
785
+ if (line.trimEnd() === header) {
786
+ skipping = true;
787
+ continue;
788
+ }
789
+ if (skipping && line.startsWith("[")) {
790
+ skipping = false;
791
+ }
792
+ if (!skipping) out.push(line);
793
+ }
794
+ return out.join("\n");
795
+ }
796
+
797
+