lynkr 9.7.3 → 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.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- 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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
package/src/routing/bandit.js
CHANGED
|
@@ -102,11 +102,18 @@ function _inv(M) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
class LinUCBBandit {
|
|
105
|
-
constructor({
|
|
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
|
-
|
|
143
|
-
|
|
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
|
+
};
|