lynkr 9.9.0 → 9.10.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 (73) hide show
  1. package/README.md +92 -23
  2. package/bin/cli.js +13 -7
  3. package/bin/lynkr-init.js +34 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/scripts/build-eval-set.js +256 -0
  8. package/scripts/mine-difficulty-anchors.js +288 -0
  9. package/scripts/validate-difficulty-classifier.js +144 -0
  10. package/scripts/validate-intent-anchors.js +186 -0
  11. package/src/api/providers-handler.js +0 -1
  12. package/src/api/router.js +292 -160
  13. package/src/clients/databricks.js +95 -6
  14. package/src/config/index.js +3 -43
  15. package/src/context/tool-result-compressor.js +883 -40
  16. package/src/orchestrator/index.js +117 -1235
  17. package/src/orchestrator/passthrough-stream.js +382 -0
  18. package/src/orchestrator/sse-transformer.js +408 -0
  19. package/src/routing/affinity-store.js +17 -3
  20. package/src/routing/classifier-setup.js +207 -0
  21. package/src/routing/complexity-analyzer.js +40 -4
  22. package/src/routing/difficulty-classifier.js +261 -0
  23. package/src/routing/index.js +70 -7
  24. package/src/routing/intent-score.js +132 -12
  25. package/src/routing/session-affinity.js +8 -1
  26. package/src/routing/side-channel-detector.js +103 -0
  27. package/src/server.js +20 -46
  28. package/src/agents/context-manager.js +0 -236
  29. package/src/agents/decomposition/dispatcher.js +0 -185
  30. package/src/agents/decomposition/gate.js +0 -136
  31. package/src/agents/decomposition/index.js +0 -183
  32. package/src/agents/decomposition/model-call.js +0 -75
  33. package/src/agents/decomposition/planner.js +0 -223
  34. package/src/agents/decomposition/synthesizer.js +0 -89
  35. package/src/agents/decomposition/telemetry.js +0 -55
  36. package/src/agents/definitions/loader.js +0 -653
  37. package/src/agents/executor.js +0 -457
  38. package/src/agents/index.js +0 -165
  39. package/src/agents/parallel-coordinator.js +0 -68
  40. package/src/agents/reflector.js +0 -331
  41. package/src/agents/skillbook.js +0 -331
  42. package/src/agents/store.js +0 -259
  43. package/src/edits/index.js +0 -171
  44. package/src/indexer/babel-parser.js +0 -213
  45. package/src/indexer/index.js +0 -1629
  46. package/src/indexer/navigation/index.js +0 -32
  47. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  48. package/src/indexer/parser.js +0 -443
  49. package/src/tasks/store.js +0 -349
  50. package/src/tests/coverage.js +0 -173
  51. package/src/tests/index.js +0 -171
  52. package/src/tests/store.js +0 -213
  53. package/src/tools/agent-task.js +0 -145
  54. package/src/tools/code-mode.js +0 -304
  55. package/src/tools/decompose.js +0 -91
  56. package/src/tools/edits.js +0 -94
  57. package/src/tools/execution.js +0 -171
  58. package/src/tools/git.js +0 -1346
  59. package/src/tools/index.js +0 -306
  60. package/src/tools/indexer.js +0 -360
  61. package/src/tools/lazy-loader.js +0 -366
  62. package/src/tools/mcp-remote.js +0 -88
  63. package/src/tools/mcp.js +0 -116
  64. package/src/tools/process.js +0 -167
  65. package/src/tools/smart-selection.js +0 -180
  66. package/src/tools/stubs.js +0 -55
  67. package/src/tools/tasks.js +0 -260
  68. package/src/tools/tests.js +0 -132
  69. package/src/tools/tinyfish.js +0 -358
  70. package/src/tools/truncate.js +0 -106
  71. package/src/tools/web-client.js +0 -71
  72. package/src/tools/web.js +0 -415
  73. package/src/tools/workspace.js +0 -204
@@ -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,144 @@
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 TIERS = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
21
+
22
+ async function main() {
23
+ // --file=data/xyz.jsonl runs an alternate eval set (e.g. the follow-up
24
+ // slice with conversation context). Results land next to the input file.
25
+ const fileArg = process.argv.find((a) => a.startsWith("--file="));
26
+ const evalFile = fileArg ? path.resolve(fileArg.slice(7)) : EVAL_FILE;
27
+ const resultsFile = evalFile.replace(/\.jsonl$/, "-results.jsonl");
28
+
29
+ _clearCacheForTests();
30
+ const lines = fs.readFileSync(evalFile, "utf8").split("\n").filter(Boolean);
31
+ const rows = lines.map(l => JSON.parse(l));
32
+ console.log(`Loaded ${rows.length} eval rows from ${path.basename(evalFile)}`);
33
+
34
+ // Persist incrementally so a crash mid-run preserves partial data.
35
+ const resultsFd = fs.openSync(resultsFile, "w");
36
+ const results = [];
37
+ const t0 = Date.now();
38
+ let done = 0;
39
+ for (const row of rows) {
40
+ // context is threaded the same way production does (router window loop
41
+ // → scoreIntent → classifyDifficulty); absent for standalone prompts.
42
+ const r = await classifyDifficulty(row.text, { context: row.context });
43
+ const record = {
44
+ ...row,
45
+ predicted: r?.tier ?? null,
46
+ confidence: r?.confidence ?? null,
47
+ };
48
+ results.push(record);
49
+ fs.writeSync(resultsFd, JSON.stringify(record) + "\n");
50
+ done++;
51
+ if (done % 25 === 0) {
52
+ const elapsed = (Date.now() - t0) / 1000;
53
+ process.stdout.write(` ${done}/${rows.length} (${elapsed.toFixed(0)}s, avg ${(elapsed / done * 1000).toFixed(0)}ms/prompt)\n`);
54
+ }
55
+ }
56
+ fs.closeSync(resultsFd);
57
+ console.log(`\nDone in ${((Date.now() - t0) / 1000).toFixed(0)}s (results saved to ${resultsFile})`);
58
+
59
+ // Overall + per-tier accuracy
60
+ const perTier = {};
61
+ const confusion = {};
62
+ for (const t of TIERS) {
63
+ perTier[t] = { total: 0, correct: 0 };
64
+ confusion[t] = { SIMPLE: 0, MEDIUM: 0, COMPLEX: 0, REASONING: 0, null: 0 };
65
+ }
66
+ let overall = 0;
67
+ let classified = 0;
68
+ let skipped = 0;
69
+ for (const r of results) {
70
+ if (r.predicted === null) { skipped++; continue; }
71
+ classified++;
72
+ perTier[r.tier].total++;
73
+ confusion[r.tier][r.predicted] = (confusion[r.tier][r.predicted] || 0) + 1;
74
+ if (r.predicted === r.tier) { overall++; perTier[r.tier].correct++; }
75
+ }
76
+
77
+ console.log(`\n=== Accuracy ===`);
78
+ console.log(`Overall: ${overall}/${classified} (${(overall / classified * 100).toFixed(1)}%)`);
79
+ console.log(`Skipped (short text / classifier disabled): ${skipped}`);
80
+ console.log(`\nPer-tier:`);
81
+ for (const t of TIERS) {
82
+ const p = perTier[t];
83
+ if (p.total === 0) continue;
84
+ console.log(` ${t}: ${p.correct}/${p.total} (${(p.correct / p.total * 100).toFixed(1)}%)`);
85
+ }
86
+
87
+ // Per-source accuracy: hand labels are trusted; benchmark labels have known
88
+ // difficulty-vs-tier bias (RouterArena's "easy" band ≠ MEDIUM tier; gpt4's
89
+ // mixtral_score ≠ tier). Report separately for an honest signal.
90
+ console.log(`\n=== Per-source ===`);
91
+ const sources = {};
92
+ for (const r of results) {
93
+ if (r.predicted === null) continue;
94
+ const s = r.source || 'unknown';
95
+ if (!sources[s]) sources[s] = { total: 0, correct: 0 };
96
+ sources[s].total++;
97
+ if (r.predicted === r.tier) sources[s].correct++;
98
+ }
99
+ for (const [s, v] of Object.entries(sources)) {
100
+ console.log(` ${s}: ${v.correct}/${v.total} (${(v.correct / v.total * 100).toFixed(1)}%)`);
101
+ }
102
+
103
+ console.log(`\n=== Confusion matrix (rows = true, cols = predicted) ===`);
104
+ console.log(` ${TIERS.map(t => t.padStart(9)).join("")}`);
105
+ for (const t of TIERS) {
106
+ const row = TIERS.map(pred => String(confusion[t][pred] || 0).padStart(9)).join("");
107
+ console.log(`${t.padStart(9)}${row}`);
108
+ }
109
+
110
+ // Critical failure mode: MEDIUM→REASONING (over-routing to expensive tier)
111
+ const overRouted = confusion.MEDIUM?.REASONING || 0;
112
+ const simpleToReasoning = confusion.SIMPLE?.REASONING || 0;
113
+ console.log(`\n=== Critical false positives ===`);
114
+ console.log(`MEDIUM→REASONING: ${overRouted} (must be 0 to ship)`);
115
+ console.log(`SIMPLE→REASONING: ${simpleToReasoning}`);
116
+ console.log(`MEDIUM→COMPLEX: ${confusion.MEDIUM?.COMPLEX || 0}`);
117
+
118
+ // Confidence histogram — validates whether the confidence gate in
119
+ // _reconcile carries signal. A degenerate distribution (everything ≥0.9)
120
+ // means confidence is decorative and the band cap is the only real guard.
121
+ const buckets = { "<0.6": 0, "0.6-0.8": 0, "0.8-0.9": 0, "0.9-1.0": 0 };
122
+ for (const r of results) {
123
+ if (r.confidence == null) continue;
124
+ if (r.confidence < 0.6) buckets["<0.6"]++;
125
+ else if (r.confidence < 0.8) buckets["0.6-0.8"]++;
126
+ else if (r.confidence < 0.9) buckets["0.8-0.9"]++;
127
+ else buckets["0.9-1.0"]++;
128
+ }
129
+ console.log(`\n=== Confidence histogram ===`);
130
+ for (const [b, n] of Object.entries(buckets)) console.log(` ${b}: ${n}`);
131
+
132
+ // List misclassifications (cap at 20 per tier)
133
+ console.log(`\n=== Misclassifications (up to 5 per tier) ===`);
134
+ for (const t of TIERS) {
135
+ const errors = results.filter(r => r.tier === t && r.predicted && r.predicted !== t).slice(0, 5);
136
+ if (errors.length === 0) continue;
137
+ console.log(`\n${t}:`);
138
+ for (const e of errors) {
139
+ console.log(` → ${e.predicted} (conf ${e.confidence?.toFixed(2)}): ${e.text.slice(0, 90)}`);
140
+ }
141
+ }
142
+ }
143
+
144
+ 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); });
@@ -401,7 +401,6 @@ router.get("/config", (req, res) => {
401
401
  fallback_provider: config.modelProvider?.fallbackProvider || null,
402
402
  fallback_enabled: config.modelProvider?.fallbackEnabled || false,
403
403
  tier_routing_enabled: config.modelTiers?.enabled || false,
404
- tool_execution_mode: config.toolExecutionMode || "server",
405
404
  configured_providers: providers.map(p => p.name),
406
405
  memory_enabled: config.memory?.enabled || false,
407
406
  smart_tool_selection: config.smartToolSelection?.enabled || false