lynkr 9.9.0 → 9.9.1

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,288 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mine empirically-labeled difficulty anchors from public router datasets.
4
+ *
5
+ * Sources (fetched via the HF datasets-server JSON API, no deps):
6
+ * - RouteWorks/RouterArena (full split): per-query empirical difficulty
7
+ * bands — easy (≥20/42 models correct), medium (5–19/42), hard (≤4/42).
8
+ * - routellm/gpt4_dataset: per-prompt mixtral_score 1–5 (GPT-4-judged
9
+ * quality of the weak model's answer).
10
+ *
11
+ * Label recipe (config B: local → GLM → Claude):
12
+ * - substantive (45, MEDIUM/ollama): RouterArena easy + mixtral_score 5
13
+ * - heavyweight (68, COMPLEX/GLM): RouterArena medium + mixtral_score ≤2
14
+ * - frontier (85, REASONING/Claude): RouterArena hard + hand examples
15
+ * - trivial stays hand-curated — benchmarks have no conversational prompts.
16
+ *
17
+ * Output: data/difficulty-anchors.json (gitignored, wins over config/) and
18
+ * data/difficulty-anchors.provenance.json. The bundled 13-anchor default in
19
+ * config/ is untouched.
20
+ *
21
+ * Requires local Ollama with nomic-embed-text (for diversity selection).
22
+ * Usage: node scripts/mine-difficulty-anchors.js [--dry-run]
23
+ */
24
+
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+
28
+ const DS_SERVER = "https://datasets-server.huggingface.co/rows";
29
+ const OLLAMA_EMBED = process.env.OLLAMA_EMBEDDINGS_ENDPOINT || "http://localhost:11434/api/embeddings";
30
+ const EMBED_MODEL = process.env.OLLAMA_EMBEDDINGS_MODEL || "nomic-embed-text";
31
+
32
+ const OUT_ANCHORS = path.join(__dirname, "../data/difficulty-anchors.json");
33
+ const OUT_PROVENANCE = path.join(__dirname, "../data/difficulty-anchors.provenance.json");
34
+ const CONFIG_ANCHORS = path.join(__dirname, "../config/difficulty-anchors.json");
35
+
36
+ // Per-class targets after diversity selection (mined only, hand anchors extra)
37
+ const TARGETS = { substantive: 130, heavyweight: 130, frontier: 100 };
38
+
39
+ // Hand-written frontier examples: engineering-flavored deep-reasoning asks.
40
+ // RouterArena's hard band skews competition-academic; real Lynkr frontier
41
+ // traffic looks like these. Both kinds go in.
42
+ const HAND_FRONTIER = [
43
+ "Prove the correctness of this lock-free queue implementation and identify any ABA hazards",
44
+ "Design a novel conflict-free replicated data type for collaborative rich-text editing and argue its convergence from first principles",
45
+ "Derive the optimal cache eviction policy for this access distribution and prove its competitive ratio",
46
+ "Diagnose this heisenbug: a race that only reproduces under load across three services, given these interleaved logs",
47
+ "Design a Byzantine-fault-tolerant consensus protocol variant that tolerates f faults with 2f+1 replicas by weakening liveness, and analyze exactly which guarantees are lost",
48
+ "Formally verify that this state machine can never deadlock, or produce a counterexample trace",
49
+ "Given these profiler traces, determine whether the tail latency is queueing-theoretic or GC-driven, and prove which by constructing a discriminating experiment",
50
+ "Reason through the security of this key-rotation scheme under an adversary who can observe but not modify traffic, and find the weakest assumption it relies on",
51
+ "Work out the exact memory ordering constraints this concurrent hashmap needs on ARM, and justify each barrier from the C++ memory model",
52
+ "Given this failing distributed transaction trace, determine whether the anomaly is write skew or lost update, and design the minimal isolation-level change that eliminates it",
53
+ "Derive the amortized complexity of this self-adjusting tree under this adversarial access pattern, and prove the bound is tight",
54
+ "Analyze whether this migration can be run online without violating any invariant the application relies on, enumerating the interleavings that could corrupt state",
55
+ "Reverse-engineer why this JIT deoptimizes on this hot path and design a fix that preserves the semantics, with reasoning about hidden class transitions",
56
+ "Prove this rate limiter is fair under concurrent refill, or construct the starvation schedule that breaks it",
57
+ "Design an exactly-once delivery layer on top of an at-least-once queue without idempotency keys, or prove it is impossible under these constraints",
58
+ "Given these heap dumps over time, reason to the retention path causing the leak and rule out the two most plausible alternative explanations",
59
+ "Reconcile these conflicting benchmark results by identifying the confound in the experimental setup, and design the experiment that isolates it",
60
+ "Determine the weakest consistency model under which this caching layer is still linearizable from the client's perspective, with proof",
61
+ "Plan a zero-downtime re-sharding of this 2TB database under sustained writes, reasoning through every failure mode and its recovery path",
62
+ "Explain step by step why this floating-point summation loses precision at scale and derive the compensated algorithm that bounds the error",
63
+ ];
64
+
65
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
66
+
67
+ async function fetchRows(dataset, config, split, offset, length) {
68
+ const url = `${DS_SERVER}?dataset=${encodeURIComponent(dataset)}&config=${config}&split=${split}&offset=${offset}&length=${length}`;
69
+ for (let attempt = 0; attempt < 6; attempt++) {
70
+ try {
71
+ const res = await fetch(url);
72
+ if (res.status === 429) { await sleep(2500 * (attempt + 1)); continue; }
73
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
74
+ const json = await res.json();
75
+ return { rows: json.rows?.map(r => r.row) || [], total: json.num_rows_total ?? null };
76
+ } catch (err) {
77
+ if (attempt === 5) {
78
+ console.warn(` page offset=${offset} failed after retries (${err.message}) — skipping`);
79
+ return { rows: [], total: null, failed: true };
80
+ }
81
+ await sleep(1500 * (attempt + 1));
82
+ }
83
+ }
84
+ return { rows: [], total: null, failed: true };
85
+ }
86
+
87
+ // --- candidate filters -------------------------------------------------------
88
+
89
+ function cleanCandidate(text) {
90
+ if (typeof text !== "string") return null;
91
+ const t = text.replace(/\s+/g, " ").trim();
92
+ if (t.length < 15 || t.length > 400) return null;
93
+ // Cloze / MCQ scaffolding reads nothing like a typed ask
94
+ if (/\(\s*\)/.test(t) || /_{3,}/.test(t)) return null;
95
+ if (/\b[A-D]\)\s/.test(t) || /which of the following/i.test(t)) return null;
96
+ // NLI/benchmark scaffolding (gpt4_dataset carries FLAN-style tasks)
97
+ if (/\b(premise|hypothesis)\b/i.test(t) && /\b(entail|inference|contradict)\b/i.test(t)) return null;
98
+ if (/natural language inference|answer with (yes|no)|options: -/i.test(t)) return null;
99
+ // Anonymization tokens (gpt4_dataset) and template junk
100
+ if (/NAME_\d|\{\{.*\}\}/.test(t)) return null;
101
+ // Mostly-English only: embeddings + our traffic are English
102
+ const nonAscii = (t.match(/[^\x20-\x7E]/g) || []).length;
103
+ if (nonAscii > t.length * 0.1) return null;
104
+ return t;
105
+ }
106
+
107
+ // Quiz-bowl clue style: leading demonstrative riddles and pronunciation
108
+ // guides. "Hard" trivia measures obscure recall, not reasoning depth — it
109
+ // would anchor the frontier class to the wrong concept entirely.
110
+ function isQuizbowlStyle(t) {
111
+ return /^(this |one |a \d{4} (book|work|paper)|fictional |in one (work|section))/i.test(t) ||
112
+ /\([A-Za-z]+-[A-Z][A-Za-z-]+\)/.test(t);
113
+ }
114
+
115
+ const TECH_DOMAIN_RE = /computer science|technology|engineering|mathematic|science/i;
116
+ // SuperGLUE tasks are benchmark scaffolding, not natural asks.
117
+ const DATASET_EXCLUDE_RE = /superglue|cloze|entailment/i;
118
+
119
+ // --- diversity selection (greedy k-center / max-min) -------------------------
120
+
121
+ async function embed(text) {
122
+ const res = await fetch(OLLAMA_EMBED, {
123
+ method: "POST",
124
+ headers: { "content-type": "application/json" },
125
+ body: JSON.stringify({ model: EMBED_MODEL, prompt: text }),
126
+ });
127
+ if (!res.ok) throw new Error(`embed HTTP ${res.status}`);
128
+ const json = await res.json();
129
+ return json.embedding;
130
+ }
131
+
132
+ function cosine(a, b) {
133
+ let dot = 0, na = 0, nb = 0;
134
+ for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
135
+ const d = Math.sqrt(na) * Math.sqrt(nb);
136
+ return d > 0 ? dot / d : 0;
137
+ }
138
+
139
+ async function diversitySelect(candidates, target, label) {
140
+ if (candidates.length <= target) return candidates.map(c => c.text);
141
+ process.stdout.write(` embedding ${candidates.length} ${label} candidates...`);
142
+ const embedded = [];
143
+ for (const c of candidates) {
144
+ try {
145
+ embedded.push({ ...c, vec: await embed(c.text) });
146
+ } catch { /* skip failures */ }
147
+ if (embedded.length % 200 === 0) process.stdout.write(".");
148
+ }
149
+ console.log(` ${embedded.length} embedded`);
150
+
151
+ // Greedy max-min: always keep the candidate farthest from the selected set.
152
+ const selected = [embedded[0]];
153
+ const minSim = new Array(embedded.length).fill(-1).map((_, i) => cosine(embedded[i].vec, embedded[0].vec));
154
+ while (selected.length < target) {
155
+ let bestIdx = -1, bestVal = Infinity;
156
+ for (let i = 0; i < embedded.length; i++) {
157
+ if (minSim[i] === Infinity) continue; // already selected
158
+ if (minSim[i] < bestVal) { bestVal = minSim[i]; bestIdx = i; }
159
+ }
160
+ if (bestIdx === -1) break;
161
+ const pick = embedded[bestIdx];
162
+ selected.push(pick);
163
+ minSim[bestIdx] = Infinity;
164
+ for (let i = 0; i < embedded.length; i++) {
165
+ if (minSim[i] === Infinity) continue;
166
+ const s = cosine(embedded[i].vec, pick.vec);
167
+ if (s > minSim[i]) minSim[i] = s;
168
+ }
169
+ }
170
+ return selected.map(s => s.text);
171
+ }
172
+
173
+ // --- main --------------------------------------------------------------------
174
+
175
+ async function main() {
176
+ const dryRun = process.argv.includes("--dry-run");
177
+ const pools = { substantive: [], heavyweight: [], frontier: [] };
178
+ const provenance = {};
179
+
180
+ // RouterArena: full split, paginate everything (~8.4k rows)
181
+ console.log("Fetching RouterArena (full split)...");
182
+ let raCount = 0;
183
+ let raTotal = null;
184
+ for (let offset = 0; raTotal === null || offset < raTotal; offset += 100) {
185
+ const { rows, total } = await fetchRows("RouteWorks/RouterArena", "default", "full", offset, 100);
186
+ if (total !== null) raTotal = total;
187
+ if (rows.length === 0 && raTotal === null) break;
188
+ raCount += rows.length;
189
+ for (const row of rows) {
190
+ if (row.Context && String(row.Context).trim()) continue; // passage-dependent
191
+ if (Array.isArray(row.Options) && row.Options.length > 0) continue; // MCQ
192
+ if (DATASET_EXCLUDE_RE.test(String(row["Dataset name"] || ""))) continue;
193
+ const text = cleanCandidate(row.Question);
194
+ if (!text || isQuizbowlStyle(text)) continue;
195
+ const technical = TECH_DOMAIN_RE.test(String(row.Domain || ""));
196
+ const diff = String(row.Difficulty || "").toLowerCase();
197
+ // hard→frontier and medium→heavyweight only from technical domains:
198
+ // non-technical "hard" is dominated by obscure-recall trivia, which
199
+ // anchors the expensive tiers to the wrong concept.
200
+ let cls = null;
201
+ if (diff === "easy") cls = "substantive";
202
+ else if (diff === "medium" && technical) cls = "heavyweight";
203
+ else if (diff === "hard" && technical) cls = "frontier";
204
+ if (!cls) continue;
205
+ pools[cls].push({ text, source: "RouterArena", label: `${row.Difficulty}/${row["Dataset name"]}`, technical });
206
+ }
207
+ if (offset % 2000 === 0 && offset > 0) console.log(` ...${raCount}/${raTotal ?? "?"} rows scanned`);
208
+ await sleep(250); // stay friendly to the API
209
+ }
210
+ console.log(`RouterArena: ${raCount} rows → pools: sub=${pools.substantive.length} heavy=${pools.heavyweight.length} frontier=${pools.frontier.length}`);
211
+
212
+ // gpt4_dataset: sample pages spread across the 109k-row train split
213
+ console.log("Sampling routellm/gpt4_dataset...");
214
+ const PAGES = 60;
215
+ const SPAN = 109000;
216
+ for (let p = 0; p < PAGES; p++) {
217
+ const offset = Math.floor((p * SPAN) / PAGES);
218
+ const { rows } = await fetchRows("routellm/gpt4_dataset", "default", "train", offset, 100);
219
+ for (const row of rows) {
220
+ const text = cleanCandidate(row.prompt);
221
+ if (!text) continue;
222
+ const score = Number(row.mixtral_score);
223
+ const technical = /code|function|script|debug|compile|regex|sql|api|server|deploy|git|test/i.test(text);
224
+ if (score === 5) {
225
+ pools.substantive.push({ text, source: "gpt4_dataset", label: `mixtral_score=${score}`, technical });
226
+ } else if (score <= 2 && score >= 1 && text.length >= 60) {
227
+ // ≥60 chars: low judge scores on one-liners are label noise
228
+ // ("What is the capital of Massachusetts?" scored 1), not difficulty.
229
+ pools.heavyweight.push({ text, source: "gpt4_dataset", label: `mixtral_score=${score}`, technical });
230
+ }
231
+ }
232
+ await sleep(250);
233
+ }
234
+ console.log(`After gpt4_dataset: sub=${pools.substantive.length} heavy=${pools.heavyweight.length} frontier=${pools.frontier.length}`);
235
+
236
+ // Bias ~60% technical where the pool allows: sort technical-first, then
237
+ // interleave so diversity selection sees both kinds.
238
+ for (const cls of Object.keys(pools)) {
239
+ const tech = pools[cls].filter(c => c.technical);
240
+ const gen = pools[cls].filter(c => !c.technical);
241
+ const mixed = [];
242
+ let ti = 0, gi = 0;
243
+ while (ti < tech.length || gi < gen.length) {
244
+ // 3 technical : 2 general cadence ≈ 60/40
245
+ for (let k = 0; k < 3 && ti < tech.length; k++) mixed.push(tech[ti++]);
246
+ for (let k = 0; k < 2 && gi < gen.length; k++) mixed.push(gen[gi++]);
247
+ }
248
+ pools[cls] = mixed;
249
+ }
250
+
251
+ if (dryRun) {
252
+ for (const cls of Object.keys(pools)) {
253
+ console.log(`\n=== ${cls} (${pools[cls].length} candidates), first 5:`);
254
+ for (const c of pools[cls].slice(0, 5)) console.log(` [${c.source}/${c.label}] ${c.text.slice(0, 100)}`);
255
+ }
256
+ return;
257
+ }
258
+
259
+ // Diversity-select down to targets
260
+ const selected = {};
261
+ for (const [cls, target] of Object.entries(TARGETS)) {
262
+ selected[cls] = await diversitySelect(pools[cls], target, cls);
263
+ for (const text of selected[cls]) {
264
+ const cand = pools[cls].find(c => c.text === text);
265
+ provenance[text] = { class: cls, source: cand?.source, label: cand?.label };
266
+ }
267
+ }
268
+
269
+ // Merge with the bundled hand anchors (they always survive)
270
+ const hand = JSON.parse(fs.readFileSync(CONFIG_ANCHORS, "utf8"));
271
+ const out = {
272
+ _comment: `Mined ${new Date().toISOString().slice(0, 10)} by scripts/mine-difficulty-anchors.js from RouteWorks/RouterArena (empirical difficulty bands) and routellm/gpt4_dataset (mixtral_score). Hand anchors from config/difficulty-anchors.json retained. Local only (data/ is gitignored and excluded from the npm tarball).`,
273
+ trivial: hand.trivial,
274
+ substantive: [...hand.substantive, ...selected.substantive],
275
+ heavyweight: [...hand.heavyweight, ...selected.heavyweight],
276
+ frontier: [...HAND_FRONTIER, ...selected.frontier],
277
+ };
278
+ for (const t of HAND_FRONTIER) provenance[t] = { class: "frontier", source: "hand", label: "engineering-frontier" };
279
+
280
+ fs.writeFileSync(OUT_ANCHORS, JSON.stringify(out, null, 2));
281
+ fs.writeFileSync(OUT_PROVENANCE, JSON.stringify(provenance, null, 2));
282
+ console.log(`\nWrote ${OUT_ANCHORS}:`);
283
+ for (const cls of ["trivial", "substantive", "heavyweight", "frontier"]) {
284
+ console.log(` ${cls}: ${out[cls].length} anchors`);
285
+ }
286
+ }
287
+
288
+ main().catch(err => { console.error(err); process.exit(1); });
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate the LLM difficulty classifier against the eval set.
4
+ *
5
+ * Runs data/difficulty-eval.jsonl through src/routing/difficulty-classifier.js
6
+ * (the SIMPLE tier model — currently minimax-m2.5:cloud via ollama).
7
+ * Reports overall + per-tier accuracy, confusion matrix, and lists the
8
+ * misclassifications so we can eyeball whether classifier or label is wrong.
9
+ *
10
+ * Bar to ship: ≥85% overall, zero MEDIUM→REASONING false positives.
11
+ *
12
+ * Usage: node scripts/validate-difficulty-classifier.js
13
+ */
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const { classifyDifficulty, _clearCacheForTests } = require("../src/routing/difficulty-classifier");
18
+
19
+ const EVAL_FILE = path.join(__dirname, "../data/difficulty-eval.jsonl");
20
+ const RESULTS_FILE = path.join(__dirname, "../data/difficulty-eval-results.jsonl");
21
+ const TIERS = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
22
+
23
+ async function main() {
24
+ _clearCacheForTests();
25
+ const lines = fs.readFileSync(EVAL_FILE, "utf8").split("\n").filter(Boolean);
26
+ const rows = lines.map(l => JSON.parse(l));
27
+ console.log(`Loaded ${rows.length} eval rows`);
28
+
29
+ // Persist incrementally so a crash mid-run preserves partial data.
30
+ const resultsFd = fs.openSync(RESULTS_FILE, "w");
31
+ const results = [];
32
+ const t0 = Date.now();
33
+ let done = 0;
34
+ for (const row of rows) {
35
+ const r = await classifyDifficulty(row.text);
36
+ const record = {
37
+ ...row,
38
+ predicted: r?.tier ?? null,
39
+ confidence: r?.confidence ?? null,
40
+ };
41
+ results.push(record);
42
+ fs.writeSync(resultsFd, JSON.stringify(record) + "\n");
43
+ done++;
44
+ if (done % 25 === 0) {
45
+ const elapsed = (Date.now() - t0) / 1000;
46
+ process.stdout.write(` ${done}/${rows.length} (${elapsed.toFixed(0)}s, avg ${(elapsed / done * 1000).toFixed(0)}ms/prompt)\n`);
47
+ }
48
+ }
49
+ fs.closeSync(resultsFd);
50
+ console.log(`\nDone in ${((Date.now() - t0) / 1000).toFixed(0)}s (results saved to ${RESULTS_FILE})`);
51
+
52
+ // Overall + per-tier accuracy
53
+ const perTier = {};
54
+ const confusion = {};
55
+ for (const t of TIERS) {
56
+ perTier[t] = { total: 0, correct: 0 };
57
+ confusion[t] = { SIMPLE: 0, MEDIUM: 0, COMPLEX: 0, REASONING: 0, null: 0 };
58
+ }
59
+ let overall = 0;
60
+ let classified = 0;
61
+ let skipped = 0;
62
+ for (const r of results) {
63
+ if (r.predicted === null) { skipped++; continue; }
64
+ classified++;
65
+ perTier[r.tier].total++;
66
+ confusion[r.tier][r.predicted] = (confusion[r.tier][r.predicted] || 0) + 1;
67
+ if (r.predicted === r.tier) { overall++; perTier[r.tier].correct++; }
68
+ }
69
+
70
+ console.log(`\n=== Accuracy ===`);
71
+ console.log(`Overall: ${overall}/${classified} (${(overall / classified * 100).toFixed(1)}%)`);
72
+ console.log(`Skipped (short text / classifier disabled): ${skipped}`);
73
+ console.log(`\nPer-tier:`);
74
+ for (const t of TIERS) {
75
+ const p = perTier[t];
76
+ if (p.total === 0) continue;
77
+ console.log(` ${t}: ${p.correct}/${p.total} (${(p.correct / p.total * 100).toFixed(1)}%)`);
78
+ }
79
+
80
+ // Per-source accuracy: hand labels are trusted; benchmark labels have known
81
+ // difficulty-vs-tier bias (RouterArena's "easy" band ≠ MEDIUM tier; gpt4's
82
+ // mixtral_score ≠ tier). Report separately for an honest signal.
83
+ console.log(`\n=== Per-source ===`);
84
+ const sources = {};
85
+ for (const r of results) {
86
+ if (r.predicted === null) continue;
87
+ const s = r.source || 'unknown';
88
+ if (!sources[s]) sources[s] = { total: 0, correct: 0 };
89
+ sources[s].total++;
90
+ if (r.predicted === r.tier) sources[s].correct++;
91
+ }
92
+ for (const [s, v] of Object.entries(sources)) {
93
+ console.log(` ${s}: ${v.correct}/${v.total} (${(v.correct / v.total * 100).toFixed(1)}%)`);
94
+ }
95
+
96
+ console.log(`\n=== Confusion matrix (rows = true, cols = predicted) ===`);
97
+ console.log(` ${TIERS.map(t => t.padStart(9)).join("")}`);
98
+ for (const t of TIERS) {
99
+ const row = TIERS.map(pred => String(confusion[t][pred] || 0).padStart(9)).join("");
100
+ console.log(`${t.padStart(9)}${row}`);
101
+ }
102
+
103
+ // Critical failure mode: MEDIUM→REASONING (over-routing to expensive tier)
104
+ const overRouted = confusion.MEDIUM?.REASONING || 0;
105
+ const simpleToReasoning = confusion.SIMPLE?.REASONING || 0;
106
+ console.log(`\n=== Critical false positives ===`);
107
+ console.log(`MEDIUM→REASONING: ${overRouted} (must be 0 to ship)`);
108
+ console.log(`SIMPLE→REASONING: ${simpleToReasoning}`);
109
+ console.log(`MEDIUM→COMPLEX: ${confusion.MEDIUM?.COMPLEX || 0}`);
110
+
111
+ // List misclassifications (cap at 20 per tier)
112
+ console.log(`\n=== Misclassifications (up to 5 per tier) ===`);
113
+ for (const t of TIERS) {
114
+ const errors = results.filter(r => r.tier === t && r.predicted && r.predicted !== t).slice(0, 5);
115
+ if (errors.length === 0) continue;
116
+ console.log(`\n${t}:`);
117
+ for (const e of errors) {
118
+ console.log(` → ${e.predicted} (conf ${e.confidence?.toFixed(2)}): ${e.text.slice(0, 90)}`);
119
+ }
120
+ }
121
+ }
122
+
123
+ main().catch(err => { console.error(err); process.exit(1); });
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate new anchors against old (config/ vs data/) on a held-out eval set.
4
+ *
5
+ * Mines ~40 real prompts from logs + hand-picked coding-agent asks, assigns
6
+ * target tiers, scores with both anchor sets, reports accuracy and tier-shift
7
+ * delta. Use to tune FRONTIER_MIN_SIM and confirm the mined anchors beat the
8
+ * 13-anchor baseline without false-positive REASONING routing.
9
+ *
10
+ * Usage: node scripts/validate-intent-anchors.js [--baseline|--new]
11
+ * --baseline: score eval set with config/difficulty-anchors.json only
12
+ * --new: score eval set with data/difficulty-anchors.json only
13
+ * (no flag): compare both and report delta
14
+ */
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+
19
+ const CONFIG_ANCHORS = path.join(__dirname, "../config/difficulty-anchors.json");
20
+ const DATA_ANCHORS = path.join(__dirname, "../data/difficulty-anchors.json");
21
+
22
+ // Tier bands (from model-tiers.js defaults — verify if calibrated)
23
+ const TIER_BANDS = {
24
+ SIMPLE: [0, 19],
25
+ MEDIUM: [20, 50],
26
+ COMPLEX: [51, 75],
27
+ REASONING: [76, 100],
28
+ };
29
+
30
+ function tierFromScore(score) {
31
+ for (const [tier, [lo, hi]] of Object.entries(TIER_BANDS)) {
32
+ if (score >= lo && score <= hi) return tier;
33
+ }
34
+ return "UNKNOWN";
35
+ }
36
+
37
+ // Eval set: real prompts from logs + coding-agent asks, with ground-truth tier.
38
+ // Target tier is what config B SHOULD route (local/GLM/Claude) given intent.
39
+ const EVAL_SET = [
40
+ // SIMPLE (local/ollama)
41
+ { text: "hi", target: "SIMPLE" },
42
+ { text: "ok thanks", target: "SIMPLE" },
43
+ { text: "yes continue", target: "SIMPLE" },
44
+ { text: "what time is it", target: "SIMPLE" },
45
+ { text: "got it", target: "SIMPLE" },
46
+ // MEDIUM (ollama)
47
+ { text: "Run the unit tests and summarize failures", target: "MEDIUM" },
48
+ { text: "What does this function do", target: "MEDIUM" },
49
+ { text: "Show me the git diff", target: "MEDIUM" },
50
+ { text: "List the exports from this file", target: "MEDIUM" },
51
+ { text: "Fix the linter warnings", target: "MEDIUM" },
52
+ { text: "Add error handling to this try block", target: "MEDIUM" },
53
+ { text: "Write a test for the login function", target: "MEDIUM" },
54
+ { text: "Explain this regex pattern", target: "MEDIUM" },
55
+ { text: "Debug this null reference error", target: "MEDIUM" },
56
+ { text: "Refactor this into smaller functions", target: "MEDIUM" },
57
+ // COMPLEX (GLM)
58
+ { text: "Do an architecture review of the orchestrator", target: "COMPLEX" },
59
+ { text: "Review this retry helper for bugs", target: "COMPLEX" },
60
+ { text: "Refactor the entire ingestion pipeline and give me the plan", target: "COMPLEX" },
61
+ { text: "Design a horizontally scalable architecture for the router", target: "COMPLEX" },
62
+ { text: "Code review the PR #84 routing hardening changes", target: "COMPLEX" },
63
+ { text: "Analyze every module in src/ for circular dependencies", target: "COMPLEX" },
64
+ { text: "Debug this complex race condition in the connection pool", target: "COMPLEX" },
65
+ { text: "Plan a zero-downtime migration to the new schema", target: "COMPLEX" },
66
+ { text: "Implement a distributed rate limiter with Redis", target: "COMPLEX" },
67
+ { text: "Design the caching strategy for this API gateway", target: "COMPLEX" },
68
+ // REASONING (Claude)
69
+ { text: "Prove the correctness of this lock-free queue implementation", target: "REASONING" },
70
+ { text: "Security audit the authentication middleware", target: "REASONING" },
71
+ { text: "Derive the optimal cache eviction policy and prove its competitive ratio", target: "REASONING" },
72
+ { text: "Formal verification of the state machine — prove it never deadlocks", target: "REASONING" },
73
+ { text: "From first principles, design a Byzantine-fault-tolerant consensus protocol variant", target: "REASONING" },
74
+ { text: "Think deeply about why this floating-point summation loses precision at scale", target: "REASONING" },
75
+ { text: "Prove this rate limiter is fair under concurrent refill or construct the starvation schedule", target: "REASONING" },
76
+ { text: "Reason through the exact memory ordering constraints this hashmap needs on ARM", target: "REASONING" },
77
+ { text: "Given these heap dumps, reason to the retention path causing the leak", target: "REASONING" },
78
+ { text: "Ultrathink: analyze whether this migration can run online without violating invariants", target: "REASONING" },
79
+ ];
80
+
81
+ async function scoreWithAnchors(anchorsPath) {
82
+ const { scoreIntent, buildCentroids, CLASS_VALUES } = require("../src/routing/intent-score");
83
+ const { getKnnRouter } = require("../src/routing/knn-router");
84
+ const router = getKnnRouter();
85
+ const embedFn = (t) => router.embed(t);
86
+
87
+ let anchors = JSON.parse(fs.readFileSync(anchorsPath, "utf8"));
88
+ // Old 3-class anchors lack frontier — stub it with a heavyweight anchor so
89
+ // buildCentroids gets 4 centroids (it requires centroid count === CLASS_VALUES
90
+ // count). The stub's embedding pulls frontier sim down, keeping the 3-class
91
+ // baseline behavior (frontier excluded from blend via FRONTIER_MIN_SIM floor).
92
+ if (!anchors.frontier && CLASS_VALUES.frontier) {
93
+ anchors = { ...anchors, frontier: [anchors.heavyweight[0]] };
94
+ }
95
+ const centroids = await buildCentroids(anchors, embedFn);
96
+ if (!centroids) throw new Error(`Failed to build centroids from ${anchorsPath}`);
97
+
98
+ const results = [];
99
+ for (const item of EVAL_SET) {
100
+ const payload = { messages: [{ role: "user", content: item.text }] };
101
+ const r = await scoreIntent(payload, { embedFn, centroids });
102
+ const predicted = tierFromScore(r.score);
103
+ const correct = predicted === item.target;
104
+ results.push({ ...item, score: r.score, class: r.class, predicted, correct });
105
+ }
106
+ return results;
107
+ }
108
+
109
+ function reportResults(label, results) {
110
+ const correct = results.filter(r => r.correct).length;
111
+ const acc = (correct / results.length * 100).toFixed(1);
112
+ console.log(`\n=== ${label} ===`);
113
+ console.log(`Accuracy: ${correct}/${results.length} (${acc}%)`);
114
+
115
+ const byTarget = {};
116
+ for (const r of results) {
117
+ if (!byTarget[r.target]) byTarget[r.target] = { total: 0, correct: 0 };
118
+ byTarget[r.target].total++;
119
+ if (r.correct) byTarget[r.target].correct++;
120
+ }
121
+ console.log("\nPer-tier accuracy:");
122
+ for (const [tier, stats] of Object.entries(byTarget)) {
123
+ const pct = (stats.correct / stats.total * 100).toFixed(1);
124
+ console.log(` ${tier}: ${stats.correct}/${stats.total} (${pct}%)`);
125
+ }
126
+
127
+ const errors = results.filter(r => !r.correct);
128
+ if (errors.length > 0) {
129
+ console.log(`\nMisclassified (${errors.length}):`);
130
+ for (const e of errors) {
131
+ console.log(` [${e.target}→${e.predicted}, score=${e.score}] ${e.text.slice(0, 60)}`);
132
+ }
133
+ }
134
+ }
135
+
136
+ function compareResults(baseline, neu) {
137
+ console.log("\n=== Δ (new vs baseline) ===");
138
+ const baseCorrect = baseline.filter(r => r.correct).length;
139
+ const neuCorrect = neu.filter(r => r.correct).length;
140
+ const delta = neuCorrect - baseCorrect;
141
+ const sign = delta > 0 ? "+" : "";
142
+ console.log(`Accuracy: ${sign}${delta} (baseline ${baseCorrect}/${baseline.length}, new ${neuCorrect}/${neu.length})`);
143
+
144
+ const improved = [];
145
+ const regressed = [];
146
+ for (let i = 0; i < baseline.length; i++) {
147
+ if (!baseline[i].correct && neu[i].correct) improved.push(neu[i]);
148
+ if (baseline[i].correct && !neu[i].correct) regressed.push(neu[i]);
149
+ }
150
+ if (improved.length > 0) {
151
+ console.log(`\nFixed by new anchors (${improved.length}):`);
152
+ for (const r of improved) console.log(` [${r.target}, score=${r.score}] ${r.text.slice(0, 60)}`);
153
+ }
154
+ if (regressed.length > 0) {
155
+ console.log(`\nBroken by new anchors (${regressed.length}):`);
156
+ for (const r of regressed) console.log(` [${r.target}→${r.predicted}, score=${r.score}] ${r.text.slice(0, 60)}`);
157
+ }
158
+ }
159
+
160
+ async function main() {
161
+ const mode = process.argv[2];
162
+ if (mode === "--baseline") {
163
+ const results = await scoreWithAnchors(CONFIG_ANCHORS);
164
+ reportResults("Baseline (config/difficulty-anchors.json)", results);
165
+ } else if (mode === "--new") {
166
+ if (!fs.existsSync(DATA_ANCHORS)) {
167
+ console.error(`${DATA_ANCHORS} does not exist — run scripts/mine-difficulty-anchors.js first`);
168
+ process.exit(1);
169
+ }
170
+ const results = await scoreWithAnchors(DATA_ANCHORS);
171
+ reportResults("New (data/difficulty-anchors.json)", results);
172
+ } else {
173
+ if (!fs.existsSync(DATA_ANCHORS)) {
174
+ console.error(`${DATA_ANCHORS} does not exist — run scripts/mine-difficulty-anchors.js first`);
175
+ process.exit(1);
176
+ }
177
+ console.log("Scoring eval set with both anchor sets...");
178
+ const baseline = await scoreWithAnchors(CONFIG_ANCHORS);
179
+ const neu = await scoreWithAnchors(DATA_ANCHORS);
180
+ reportResults("Baseline (config/)", baseline);
181
+ reportResults("New (data/)", neu);
182
+ compareResults(baseline, neu);
183
+ }
184
+ }
185
+
186
+ main().catch(err => { console.error(err); process.exit(1); });
package/src/api/router.js CHANGED
@@ -53,6 +53,8 @@ const rateLimiter = createRateLimiter();
53
53
  * - force_local / force_cloud regex shortcuts
54
54
  * - risk classifier (high-risk → forced COMPLEX)
55
55
  * - complexity scoring (weighted heuristic)
56
+ * - thinking-budget trigger (≥10k tokens → REASONING, Claude Code ultrathink)
57
+ * - force patterns (ultrathink/prove → REASONING; architecture review → COMPLEX; hi → SIMPLE)
56
58
  * - agentic-workflow detector (may bump min-tier)
57
59
  * - kNN router (embedding-based nearest-neighbors of historical queries)
58
60
  * - LinUCB contextual bandit (intra-tier model selection, learns from reward)
@@ -63,6 +65,21 @@ const rateLimiter = createRateLimiter();
63
65
  * Plus telemetry — every decision is recorded so kNN/bandit improve over time.
64
66
  */
65
67
  async function pickTierByIntent(body) {
68
+ // thinking.budget_tokens is NOT a reliable routing signal. Claude Code
69
+ // Enterprise on Haiku 4.5 attaches budget_tokens=31999 to EVERY request
70
+ // as its default extended-thinking behavior — the model's baseline, not
71
+ // an intent signal from the user. Trying to threshold this dragged every
72
+ // casual "hi" into REASONING via passthrough (live-verified 2026-07-19,
73
+ // fp-e33173b… session logs).
74
+ //
75
+ // Explicit user intent (ultrathink / prove / security audit / …) is
76
+ // caught by FORCE_REASONING_PATTERNS on the *text*, which is unambiguous.
77
+ // No thinking-budget trigger here on purpose. Kept a debug log of the
78
+ // observed value so future tuning has data.
79
+ const thinkingBudget = body?.thinking?.budget_tokens;
80
+ if (typeof thinkingBudget === 'number') {
81
+ logger.debug({ thinkingBudget }, '[OAuthIntent] thinking budget present (informational, not used for routing)');
82
+ }
66
83
  // Build a user-intent payload. We INCLUDE the tools array (signals agentic
67
84
  // intent — a request with 12 tools attached is meaningfully different from
68
85
  // a chat-only one, even if both messages look short) but EXCLUDE the system