lynkr 9.7.2 → 9.9.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.
Files changed (80) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/agents/context-manager.js +18 -2
  15. package/src/agents/definitions/loader.js +90 -0
  16. package/src/agents/executor.js +24 -2
  17. package/src/agents/index.js +9 -1
  18. package/src/agents/parallel-coordinator.js +2 -2
  19. package/src/agents/reflector.js +11 -1
  20. package/src/api/middleware/loop-guard.js +87 -0
  21. package/src/api/middleware/request-logging.js +5 -64
  22. package/src/api/middleware/session.js +0 -0
  23. package/src/api/openai-router.js +120 -101
  24. package/src/api/providers-handler.js +27 -2
  25. package/src/api/router.js +450 -125
  26. package/src/budget/index.js +2 -19
  27. package/src/cache/semantic.js +9 -0
  28. package/src/clients/databricks.js +459 -146
  29. package/src/clients/gpt-utils.js +11 -105
  30. package/src/clients/openai-format.js +10 -3
  31. package/src/clients/openrouter-utils.js +49 -24
  32. package/src/clients/prompt-cache-injection.js +1 -0
  33. package/src/clients/provider-capabilities.js +1 -1
  34. package/src/clients/responses-format.js +34 -3
  35. package/src/clients/routing.js +15 -0
  36. package/src/config/index.js +36 -2
  37. package/src/context/gcf.js +275 -0
  38. package/src/context/tool-result-compressor.js +51 -9
  39. package/src/dashboard/api.js +1 -0
  40. package/src/logger/index.js +14 -1
  41. package/src/memory/search.js +12 -40
  42. package/src/memory/tools.js +3 -24
  43. package/src/orchestrator/bypass.js +4 -2
  44. package/src/orchestrator/index.js +144 -88
  45. package/src/routing/affinity-store.js +194 -0
  46. package/src/routing/agentic-detector.js +36 -6
  47. package/src/routing/bandit.js +25 -6
  48. package/src/routing/calibration.js +212 -0
  49. package/src/routing/client-profiles.js +292 -0
  50. package/src/routing/complexity-analyzer.js +48 -11
  51. package/src/routing/deescalator.js +148 -0
  52. package/src/routing/degradation.js +109 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +897 -87
  55. package/src/routing/intent-score.js +339 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +298 -10
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +66 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. package/src/workers/helpers.js +0 -185
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Session-Pin Persistence
3
+ *
4
+ * Persists sticky-session routing pins to SQLite so they survive process
5
+ * restarts. Shares the telemetry DB handle (see telemetry.getDb) to avoid
6
+ * opening a second WAL connection to the same file.
7
+ *
8
+ * A "pin" records the provider/model/tier a session was routed to plus enough
9
+ * state (message_count, prompt_tokens_est, ts) for the wrapper in
10
+ * `session-affinity.js` to decide when to re-route:
11
+ *
12
+ * - compaction detected (messages shrank ⇒ cache reset ⇒ free to re-route)
13
+ * - guard escalation (context/vision needs pin can't satisfy)
14
+ * - economic downgrade (fresh decision is cheaper AND prompt is small
15
+ * enough that the cold-cache re-read is affordable)
16
+ *
17
+ * All I/O is best-effort: any failure is recorded via degradation.record and
18
+ * falls back to the in-memory Map in session-affinity.js.
19
+ *
20
+ * @module routing/affinity-store
21
+ */
22
+
23
+ const telemetry = require("./telemetry");
24
+ const degradation = require("./degradation");
25
+ const logger = require("../logger");
26
+
27
+ let schemaEnsured = false;
28
+
29
+ function _db() {
30
+ const db = telemetry.getDb();
31
+ if (!db) return null;
32
+ if (!schemaEnsured) {
33
+ try {
34
+ db.exec(`
35
+ CREATE TABLE IF NOT EXISTS session_pins (
36
+ session_id TEXT PRIMARY KEY,
37
+ provider TEXT NOT NULL,
38
+ model TEXT,
39
+ tier TEXT,
40
+ score REAL,
41
+ message_count INTEGER,
42
+ prompt_tokens_est INTEGER,
43
+ ts INTEGER NOT NULL
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_session_pins_ts ON session_pins(ts);
46
+ `);
47
+ // Additive migration for DBs created before `score` was added.
48
+ const cols = new Set(db.prepare("PRAGMA table_info(session_pins)").all().map((c) => c.name));
49
+ if (!cols.has("score")) {
50
+ db.exec("ALTER TABLE session_pins ADD COLUMN score REAL");
51
+ }
52
+ schemaEnsured = true;
53
+ } catch (err) {
54
+ degradation.record("feedback", err);
55
+ return null;
56
+ }
57
+ }
58
+ return db;
59
+ }
60
+
61
+ const stmts = new Map();
62
+ function _stmt(db, key, sql) {
63
+ const cacheKey = `${key}`;
64
+ if (!stmts.has(cacheKey)) stmts.set(cacheKey, db.prepare(sql));
65
+ return stmts.get(cacheKey);
66
+ }
67
+
68
+ /**
69
+ * Load a pin by session id. Returns null if missing, expired against `ttlMs`,
70
+ * or the DB is unavailable.
71
+ *
72
+ * @param {string} sessionId
73
+ * @param {number} [ttlMs]
74
+ * @returns {{provider:string, model:string|null, tier:string|null, messageCount:number|null, promptTokensEst:number|null, ts:number}|null}
75
+ */
76
+ function load(sessionId, ttlMs) {
77
+ if (!sessionId) return null;
78
+ const db = _db();
79
+ if (!db) return null;
80
+ try {
81
+ const row = _stmt(
82
+ db,
83
+ "load",
84
+ "SELECT provider, model, tier, score, message_count, prompt_tokens_est, ts FROM session_pins WHERE session_id = ?"
85
+ ).get(sessionId);
86
+ if (!row) return null;
87
+ if (ttlMs && Date.now() - row.ts > ttlMs) {
88
+ // Expired: delete lazily so a subsequent save doesn't pick up a stale ts.
89
+ try {
90
+ _stmt(db, "delete", "DELETE FROM session_pins WHERE session_id = ?").run(sessionId);
91
+ } catch { /* best-effort */ }
92
+ return null;
93
+ }
94
+ return {
95
+ provider: row.provider,
96
+ model: row.model,
97
+ tier: row.tier,
98
+ score: row.score,
99
+ messageCount: row.message_count,
100
+ promptTokensEst: row.prompt_tokens_est,
101
+ ts: row.ts,
102
+ };
103
+ } catch (err) {
104
+ degradation.record("feedback", err);
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Upsert a pin. Silently no-ops if the DB is unavailable — the in-memory Map
111
+ * in session-affinity remains authoritative in that case.
112
+ *
113
+ * @param {string} sessionId
114
+ * @param {{provider:string, model?:string|null, tier?:string|null, messageCount?:number|null, promptTokensEst?:number|null, ts?:number}} pin
115
+ */
116
+ function save(sessionId, pin) {
117
+ if (!sessionId || !pin?.provider) return;
118
+ const db = _db();
119
+ if (!db) return;
120
+ try {
121
+ _stmt(
122
+ db,
123
+ "upsert",
124
+ `INSERT INTO session_pins (session_id, provider, model, tier, score, message_count, prompt_tokens_est, ts)
125
+ VALUES (@session_id, @provider, @model, @tier, @score, @message_count, @prompt_tokens_est, @ts)
126
+ ON CONFLICT(session_id) DO UPDATE SET
127
+ provider = excluded.provider,
128
+ model = excluded.model,
129
+ tier = excluded.tier,
130
+ score = excluded.score,
131
+ message_count = excluded.message_count,
132
+ prompt_tokens_est = excluded.prompt_tokens_est,
133
+ ts = excluded.ts`
134
+ ).run({
135
+ session_id: sessionId,
136
+ provider: pin.provider,
137
+ model: pin.model ?? null,
138
+ tier: pin.tier ?? null,
139
+ score: typeof pin.score === 'number' ? pin.score : null,
140
+ message_count: pin.messageCount ?? null,
141
+ prompt_tokens_est: pin.promptTokensEst ?? null,
142
+ ts: pin.ts ?? Date.now(),
143
+ });
144
+ } catch (err) {
145
+ degradation.record("feedback", err);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Remove a pin.
151
+ * @param {string} sessionId
152
+ */
153
+ function remove(sessionId) {
154
+ if (!sessionId) return;
155
+ const db = _db();
156
+ if (!db) return;
157
+ try {
158
+ _stmt(db, "delete", "DELETE FROM session_pins WHERE session_id = ?").run(sessionId);
159
+ } catch (err) {
160
+ degradation.record("feedback", err);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Delete pins older than ttlMs. Called from the same scheduler that runs
166
+ * telemetry.cleanup.
167
+ *
168
+ * @param {number} ttlMs
169
+ * @returns {number} rows deleted
170
+ */
171
+ function cleanup(ttlMs) {
172
+ const db = _db();
173
+ if (!db) return 0;
174
+ try {
175
+ const threshold = Date.now() - ttlMs;
176
+ const result = _stmt(db, "cleanup", "DELETE FROM session_pins WHERE ts < ?").run(threshold);
177
+ logger.debug({ deleted: result.changes }, "[AffinityStore] pin cleanup");
178
+ return result.changes;
179
+ } catch (err) {
180
+ degradation.record("feedback", err);
181
+ return 0;
182
+ }
183
+ }
184
+
185
+ /** Test helper — wipe all pins. */
186
+ function _clear() {
187
+ const db = _db();
188
+ if (!db) return;
189
+ try {
190
+ db.prepare("DELETE FROM session_pins").run();
191
+ } catch { /* best-effort */ }
192
+ }
193
+
194
+ module.exports = { load, save, remove, cleanup, _clear };
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  const logger = require('../logger');
8
+ const clientProfiles = require('./client-profiles');
8
9
 
9
10
  // Agent type classification with tier requirements
10
11
  const AGENT_TYPES = {
@@ -41,8 +42,10 @@ const PATTERNS = {
41
42
  // Iterative work indicators
42
43
  iterative: /\b(keep\s+trying|until|repeat|loop|retry|iterate|fix.*again|try.*different|debug)\b/i,
43
44
 
44
- // Autonomous work indicators
45
- autonomous: /\b(figure\s+out|solve|complete\s+the\s+task|do\s+whatever|make\s+it\s+work|find\s+a\s+way|whatever\s+it\s+takes)\b/i,
45
+ // Autonomous work indicators. Must include the literal "autonomous(ly)":
46
+ // under client profiles the tool-count score sits below the 60 gate, so
47
+ // the phrase gate is the only path to AUTONOMOUS.
48
+ autonomous: /\b(figure\s+out|solve|complete\s+the\s+task|do\s+whatever|make\s+it\s+work|find\s+a\s+way|whatever\s+it\s+takes|autonomous(ly)?|on\s+your\s+own|without\s+(asking|supervision)|keep\s+(going|iterating|working)\s+until)\b/i,
46
49
 
47
50
  // Multi-file work
48
51
  multiFile: /\b(multiple\s+files?|across\s+(the\s+)?codebase|all\s+files?|refactor\s+entire|whole\s+project|everywhere)\b/i,
@@ -91,18 +94,41 @@ class AgenticDetector {
91
94
  /**
92
95
  * Detect agentic workflow patterns
93
96
  * @param {Object} payload - Request payload with messages and tools
97
+ * @param {Object} [opts]
98
+ * @param {Object} [opts.clientProfile] - Client harness profile (WS3.1).
99
+ * When present, Signals 1 & 2 score only tools BEYOND the harness's
100
+ * baseline loadout — Claude Code's 11 always-attached tools shouldn't
101
+ * count as "agentic intent" on their own.
94
102
  * @returns {Object} Detection result
95
103
  */
96
- detect(payload) {
104
+ detect(payload, opts = {}) {
97
105
  const messages = payload?.messages || [];
98
- const tools = payload?.tools || [];
106
+ const rawTools = payload?.tools || [];
99
107
  const content = this._extractContent(messages);
108
+ const profile = opts.clientProfile || null;
109
+
110
+ // WS3.2 — subtract known-harness baseline tools before scoring tool-based
111
+ // signals. Signals 3-6 (tool_result depth, patterns, message depth,
112
+ // length) are genuine agentic evidence and always use the full payload.
113
+ let toolsForScoring;
114
+ let scoringNote = null;
115
+ if (profile) {
116
+ toolsForScoring = clientProfiles.effectiveTools(payload, profile);
117
+ scoringNote = `profile:${profile.name}`;
118
+ } else if (clientProfiles.allToolsAreBaseline(payload) && rawTools.length >= 10) {
119
+ // Unknown harness that looks like Claude Code / Cursor / Codex —
120
+ // zero out the tool-count signals to avoid the same trap.
121
+ toolsForScoring = [];
122
+ scoringNote = 'unknown_harness_guard';
123
+ } else {
124
+ toolsForScoring = rawTools;
125
+ }
100
126
 
101
127
  let score = 0;
102
128
  const signals = [];
103
129
 
104
130
  // Signal 1: Tool count (many tools = likely multi-step)
105
- const toolCount = tools.length;
131
+ const toolCount = toolsForScoring.length;
106
132
  if (toolCount > 10) {
107
133
  score += 25;
108
134
  signals.push({ signal: 'very_high_tool_count', value: toolCount, weight: 25 });
@@ -115,7 +141,7 @@ class AgenticDetector {
115
141
  }
116
142
 
117
143
  // Signal 2: Agentic tools present (Bash, Write, Edit, Task)
118
- const agenticToolCount = tools.filter(t => {
144
+ const agenticToolCount = toolsForScoring.filter(t => {
119
145
  const name = t.name || t.function?.name || '';
120
146
  return AGENTIC_TOOLS.has(name);
121
147
  }).length;
@@ -207,6 +233,8 @@ class AgenticDetector {
207
233
  minTier: AGENT_TYPES[agentType].minTier,
208
234
  scoreBoost: AGENT_TYPES[agentType].scoreBoost,
209
235
  description: AGENT_TYPES[agentType].description,
236
+ clientProfile: profile?.name ?? null,
237
+ scoringNote,
210
238
  };
211
239
 
212
240
  if (isAgentic) {
@@ -215,7 +243,9 @@ class AgenticDetector {
215
243
  score,
216
244
  signalCount: signals.length,
217
245
  toolCount,
246
+ rawToolCount: rawTools.length,
218
247
  toolResultCount,
248
+ scoringNote,
219
249
  }, '[AgenticDetector] Agentic workflow detected');
220
250
  }
221
251
 
@@ -102,11 +102,18 @@ function _inv(M) {
102
102
  }
103
103
 
104
104
  class LinUCBBandit {
105
- constructor({ alpha = DEFAULT_ALPHA, lambda = DEFAULT_LAMBDA, mu = DEFAULT_MU, dim = FEATURE_DIM } = {}) {
105
+ constructor({
106
+ alpha = DEFAULT_ALPHA,
107
+ lambda = DEFAULT_LAMBDA,
108
+ mu = DEFAULT_MU,
109
+ dim = FEATURE_DIM,
110
+ explorationRate = EXPLORATION_RATE,
111
+ } = {}) {
106
112
  this.alpha = alpha;
107
113
  this.lambda = lambda;
108
114
  this.mu = mu;
109
115
  this.dim = dim;
116
+ this.explorationRate = explorationRate;
110
117
  /** arms: Map<armKey, { A: number[][], b: number[], count: number }> */
111
118
  this.arms = new Map();
112
119
  this.steps = 0;
@@ -126,10 +133,18 @@ class LinUCBBandit {
126
133
 
127
134
  /**
128
135
  * Pick an arm for a given tier and context.
136
+ *
137
+ * Returns `propensity` — the probability, under the current policy, that
138
+ * this specific arm was chosen. WS4 uses this for off-policy evaluation
139
+ * from telemetry alone: an explored pick has probability `ε/K`; an
140
+ * exploited pick has `1 − ε + ε/K` (the ε-branch could still have
141
+ * landed on it uniformly). With K=1 the ε-branch trivially chooses the
142
+ * only arm, so both cases collapse to 1.0.
143
+ *
129
144
  * @param {string} tier
130
145
  * @param {Array<{ provider: string, model: string }>} candidates — qualifying arms
131
146
  * @param {number[]} context — feature vector
132
- * @returns {{ provider, model, ucb, explored }} chosen arm
147
+ * @returns {{ provider, model, ucb, explored, propensity }} chosen arm
133
148
  */
134
149
  pick(tier, candidates, context) {
135
150
  if (!candidates || candidates.length === 0) return null;
@@ -139,10 +154,13 @@ class LinUCBBandit {
139
154
  while (context.length < this.dim) context.push(0);
140
155
  }
141
156
 
142
- // ε-greedy: 5% pure exploration
143
- if (Math.random() < EXPLORATION_RATE) {
157
+ const K = candidates.length;
158
+ const eps = this.explorationRate;
159
+
160
+ // ε-greedy: EXPLORATION_RATE pure exploration
161
+ if (Math.random() < eps) {
144
162
  const random = candidates[Math.floor(Math.random() * candidates.length)];
145
- return { ...random, ucb: null, explored: true };
163
+ return { ...random, ucb: null, explored: true, propensity: eps / K };
146
164
  }
147
165
 
148
166
  let best = null;
@@ -165,6 +183,7 @@ class LinUCBBandit {
165
183
  best = { ...c, ucb, explored: false };
166
184
  }
167
185
  }
186
+ if (best) best.propensity = (1 - eps) + eps / K;
168
187
  return best;
169
188
  }
170
189
 
@@ -243,4 +262,4 @@ function getBandit() {
243
262
  return _instance;
244
263
  }
245
264
 
246
- module.exports = { LinUCBBandit, getBandit, FEATURE_DIM };
265
+ module.exports = { LinUCBBandit, getBandit, FEATURE_DIM, EXPLORATION_RATE };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * WS5.6 — auto-calibration.
3
+ *
4
+ * The core logic of `scripts/calibrate-thresholds.js` extracted so it can
5
+ * run inside the server process on a schedule, not just from the CLI.
6
+ *
7
+ * Read quality_score history from the routing_telemetry table, bucket by
8
+ * (tier, complexity_score) with width-5 buckets, and shrink each tier's
9
+ * upper bound to just below the first bucket whose median quality falls
10
+ * under a per-tier floor. Ranges are then re-stitched to leave no gaps
11
+ * before writing to `data/calibrated-thresholds.json`.
12
+ *
13
+ * The CLI script (`scripts/calibrate-thresholds.js`) is now a thin wrapper
14
+ * around `runCalibration()` — same defaults, same output format.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const DEFAULT_DAYS = 7;
21
+ const MIN_SAMPLES = 100;
22
+
23
+ /** Quality score below which a complexity bucket is "underperforming" for its tier. */
24
+ const QUALITY_FLOOR = {
25
+ SIMPLE: 55,
26
+ MEDIUM: 60,
27
+ COMPLEX: 65,
28
+ REASONING: 70,
29
+ };
30
+
31
+ const DEFAULT_RANGES = {
32
+ // SIMPLE/MEDIUM boundary at 20 — mirrors model-tiers.js TIER_DEFINITIONS
33
+ // (see the rationale comment there; RouterArena-diagnosed 2026-07-16).
34
+ SIMPLE: [0, 19],
35
+ MEDIUM: [20, 50],
36
+ COMPLEX: [51, 75],
37
+ REASONING: [76, 100],
38
+ };
39
+
40
+ const OUTPUT_PATH = path.join(__dirname, '../../data/calibrated-thresholds.json');
41
+ const TELEMETRY_DB_CANDIDATES = [
42
+ path.join(__dirname, '../../.lynkr/telemetry.db'),
43
+ path.join(__dirname, '../../data/lynkr.db'),
44
+ ];
45
+
46
+ function _findDb(dbPath) {
47
+ if (dbPath) return fs.existsSync(dbPath) ? dbPath : null;
48
+ for (const p of TELEMETRY_DB_CANDIDATES) {
49
+ if (fs.existsSync(p)) return p;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function _openDb(dbPath) {
55
+ let Database;
56
+ try {
57
+ Database = require('better-sqlite3');
58
+ } catch (err) {
59
+ throw new Error(
60
+ 'better-sqlite3 not installed. Install with: npm install --save-optional better-sqlite3'
61
+ );
62
+ }
63
+ return new Database(dbPath, { readonly: true, fileMustExist: true });
64
+ }
65
+
66
+ function _median(arr) {
67
+ const s = arr.slice().sort((a, b) => a - b);
68
+ const m = Math.floor(s.length / 2);
69
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
70
+ }
71
+
72
+ /**
73
+ * Run calibration against a telemetry DB and (unless dryRun) write the
74
+ * result to `data/calibrated-thresholds.json`.
75
+ *
76
+ * @param {object} [opts]
77
+ * @param {number} [opts.days=7]
78
+ * @param {boolean} [opts.dryRun=false]
79
+ * @param {string} [opts.dbPath] — explicit DB path (else auto-discover).
80
+ * @param {string} [opts.outputPath] — override output path (tests set this).
81
+ * @returns {object} — either `{skipped:true, reason}` or a full result
82
+ * `{calibratedAt, days, sampleCount, ranges, stats}` (with `dryRun:true`
83
+ * when appropriate).
84
+ */
85
+ function runCalibration({ days = DEFAULT_DAYS, dryRun = false, dbPath, outputPath = OUTPUT_PATH } = {}) {
86
+ const resolvedDb = _findDb(dbPath);
87
+ if (!resolvedDb) {
88
+ return { skipped: true, reason: 'no_db' };
89
+ }
90
+
91
+ let db;
92
+ try {
93
+ db = _openDb(resolvedDb);
94
+ } catch (err) {
95
+ return { skipped: true, reason: 'db_open_failed', error: err.message };
96
+ }
97
+
98
+ const since = Date.now() - days * 24 * 3600 * 1000;
99
+ let rows;
100
+ try {
101
+ rows = db
102
+ .prepare(
103
+ `SELECT tier, complexity_score AS score, quality_score AS q
104
+ FROM routing_telemetry
105
+ WHERE timestamp >= ?
106
+ AND quality_score IS NOT NULL
107
+ AND complexity_score IS NOT NULL
108
+ AND tier IS NOT NULL`
109
+ )
110
+ .all(since);
111
+ } catch (err) {
112
+ return { skipped: true, reason: 'query_failed', error: err.message };
113
+ } finally {
114
+ try { db.close(); } catch { /* ignore */ }
115
+ }
116
+
117
+ if (!rows || rows.length < MIN_SAMPLES) {
118
+ return {
119
+ skipped: true,
120
+ reason: 'insufficient_samples',
121
+ count: rows ? rows.length : 0,
122
+ minSamples: MIN_SAMPLES,
123
+ };
124
+ }
125
+
126
+ // Bucket by score (0-100 in width-5 buckets) per tier, compute median quality.
127
+ const buckets = new Map(); // tier -> Map<bucketLowerBound, q-values[]>
128
+ for (const row of rows) {
129
+ const s = Math.max(0, Math.min(100, Math.floor(row.score)));
130
+ const bucket = Math.floor(s / 5) * 5;
131
+ if (!buckets.has(row.tier)) buckets.set(row.tier, new Map());
132
+ const b = buckets.get(row.tier);
133
+ if (!b.has(bucket)) b.set(bucket, []);
134
+ b.get(bucket).push(row.q);
135
+ }
136
+
137
+ const ranges = { ...DEFAULT_RANGES };
138
+ const tierOrder = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
139
+ const stats = {};
140
+
141
+ for (const tier of tierOrder) {
142
+ const floor = QUALITY_FLOOR[tier];
143
+ const tierBuckets = buckets.get(tier);
144
+ if (!tierBuckets) {
145
+ stats[tier] = { samples: 0, adjusted: false };
146
+ continue;
147
+ }
148
+ const ordered = Array.from(tierBuckets.entries()).sort((a, b) => a[0] - b[0]);
149
+ let suggestedUpper = DEFAULT_RANGES[tier][1];
150
+ const bucketsSummary = [];
151
+ for (const [lo, vals] of ordered) {
152
+ if (vals.length < 5) {
153
+ bucketsSummary.push({ bucket: lo, samples: vals.length, median: null });
154
+ continue;
155
+ }
156
+ const med = _median(vals);
157
+ bucketsSummary.push({ bucket: lo, samples: vals.length, median: med });
158
+ if (med < floor && lo + 4 < suggestedUpper) {
159
+ // shrink tier upper bound just below the failing bucket
160
+ suggestedUpper = lo + 4;
161
+ }
162
+ }
163
+ if (suggestedUpper !== DEFAULT_RANGES[tier][1]) {
164
+ ranges[tier] = [DEFAULT_RANGES[tier][0], suggestedUpper];
165
+ stats[tier] = {
166
+ samples: ordered.reduce((s, [, v]) => s + v.length, 0),
167
+ adjusted: true,
168
+ buckets: bucketsSummary,
169
+ };
170
+ } else {
171
+ stats[tier] = {
172
+ samples: ordered.reduce((s, [, v]) => s + v.length, 0),
173
+ adjusted: false,
174
+ buckets: bucketsSummary,
175
+ };
176
+ }
177
+ }
178
+
179
+ // Re-stitch ranges so they don't overlap or leave gaps.
180
+ for (let i = 1; i < tierOrder.length; i++) {
181
+ const prev = ranges[tierOrder[i - 1]];
182
+ const cur = ranges[tierOrder[i]];
183
+ if (cur[0] !== prev[1] + 1) cur[0] = prev[1] + 1;
184
+ if (cur[0] > cur[1]) cur[1] = cur[0]; // collapsed; tier disabled in practice
185
+ }
186
+
187
+ const out = {
188
+ calibratedAt: new Date().toISOString(),
189
+ days,
190
+ sampleCount: rows.length,
191
+ ranges,
192
+ stats,
193
+ };
194
+
195
+ if (dryRun) return { ...out, dryRun: true };
196
+
197
+ try {
198
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
199
+ fs.writeFileSync(outputPath, JSON.stringify(out, null, 2));
200
+ return { ...out, writtenTo: outputPath };
201
+ } catch (err) {
202
+ return { skipped: true, reason: 'write_failed', error: err.message };
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ runCalibration,
208
+ MIN_SAMPLES,
209
+ DEFAULT_RANGES,
210
+ QUALITY_FLOOR,
211
+ OUTPUT_PATH,
212
+ };