lynkr 9.14.17 → 9.14.19

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,433 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Calibrate shortfall capability profiles from measured task outcomes.
4
+ *
5
+ * Why: config/model-capabilities.json ships profiles seeded from public
6
+ * leaderboards (scripts/seed-capabilities.js) and a fixed tolerance from a
7
+ * paper. Neither was measured on YOUR tasks — live incident: the shipped
8
+ * seed put glm-5.3-flash at ~0.65 (full GLM-5.3 family), so the cheap tier
9
+ * "covered" every requirement and cheapest-covering demoted tasks the
10
+ * strong model had solved. This script replaces guesses with a pass/fail
11
+ * matrix: one solo benchmark run per model, replayed through the router.
12
+ *
13
+ * Phases (each skipped when its output already exists):
14
+ * 1. run — for every --model WITHOUT an existing run dir, start a
15
+ * throwaway Lynkr on a spare port with all four tiers pinned
16
+ * to that model and run Terminal-Bench against it. Needs
17
+ * --run (spends money; ~1h and $1–5 per model).
18
+ * 2. replay — score every task's first prompt through the live router
19
+ * (determineProviderSmart) to get the lifted requirement
20
+ * vector per head. Cached in <out>/requirements.json.
21
+ * 3. fit — per model/head: capability = highest difficulty level at
22
+ * which the model still passes >= --floor of tasks (and
23
+ * >= --rel × the best model at that level); tau = median gap
24
+ * from capability to the collapse point. Hold-out check:
25
+ * shortfall with the fitted numbers vs "best model
26
+ * everywhere" on accuracy and cost.
27
+ * 4. write — <out>/report.md + <out>/model-capabilities.proposed.json;
28
+ * --apply merges modelOverrides + tau into the live config
29
+ * (backup first).
30
+ *
31
+ * Usage:
32
+ * node scripts/calibrate-capabilities.js \
33
+ * --model openrouter:z-ai/glm-5.3-flash \
34
+ * --model openrouter:deepseek/deepseek-v4.1-flash=~/tb-runs-auto-v2 \
35
+ * [--out ~/lynkr-calibration] [--dataset terminal-bench-core==0.1.1]
36
+ * [--concurrency 8] [--port 8091] [--price model=in/cache/out] \
37
+ * [--floor 0.5] [--rel 0.85] [--run] [--apply] [--dry-run]
38
+ *
39
+ * model=path reuse an existing Terminal-Bench run directory (a directory
40
+ * containing results.json, or its parent) instead of running.
41
+ */
42
+ 'use strict';
43
+
44
+ const fs = require('fs');
45
+ const os = require('os');
46
+ const path = require('path');
47
+ const { spawn, spawnSync } = require('child_process');
48
+
49
+ const ROOT = path.resolve(__dirname, '..');
50
+ const HEADS = ['reasoning', 'codegen', 'debugging', 'tool_use'];
51
+ const CONFIG_PATH = path.join(ROOT, 'config', 'model-capabilities.json');
52
+
53
+ // $ per 1M tokens: input / cache-read / output. Extend with --price.
54
+ const DEFAULT_PRICES = {
55
+ 'deepseek-v4.1-flash': [0.15, 0.003, 0.60],
56
+ 'deepseek-v4p1-flash': [0.30, 0.006, 1.20],
57
+ 'glm-5.3-flash': [0.15, 0.03, 0.50],
58
+ 'glm-5p3-flash': [0.15, 0.03, 0.50],
59
+ };
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // args
63
+ // ---------------------------------------------------------------------------
64
+ function parseArgs(argv) {
65
+ const out = {
66
+ models: [], out: path.join(os.homedir(), 'lynkr-calibration'),
67
+ dataset: 'terminal-bench-core==0.1.1', concurrency: 8, port: 8091,
68
+ prices: { ...DEFAULT_PRICES }, floor: 0.5, rel: 0.85,
69
+ run: false, apply: false, dryRun: false, holdoutEvery: 4,
70
+ };
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const a = argv[i];
73
+ const next = () => argv[++i];
74
+ if (a === '--model') {
75
+ const v = next(); const eq = v.indexOf('=');
76
+ out.models.push(eq > 0 ? { spec: v.slice(0, eq), runDir: expand(v.slice(eq + 1)) } : { spec: v, runDir: null });
77
+ } else if (a === '--out') out.out = expand(next());
78
+ else if (a === '--dataset') out.dataset = next();
79
+ else if (a === '--concurrency') out.concurrency = Number(next()) || 8;
80
+ else if (a === '--port') out.port = Number(next()) || 8091;
81
+ else if (a === '--floor') out.floor = Number(next());
82
+ else if (a === '--rel') out.rel = Number(next());
83
+ else if (a === '--holdout-every') out.holdoutEvery = Number(next()) || 4;
84
+ else if (a === '--price') {
85
+ const v = next(); const eq = v.indexOf('=');
86
+ const nums = v.slice(eq + 1).split('/').map(Number);
87
+ if (eq > 0 && nums.length === 3 && nums.every(Number.isFinite)) out.prices[v.slice(0, eq)] = nums;
88
+ else die(`bad --price "${v}" (want model=in/cache/out, $ per 1M)`);
89
+ } else if (a === '--run') out.run = true;
90
+ else if (a === '--apply') out.apply = true;
91
+ else if (a === '--dry-run') out.dryRun = true;
92
+ else if (a === '--help' || a === '-h') { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].replace(/^\/\*\*?\s?/, '').replace(/^ \* ?/gm, '')); process.exit(0); }
93
+ else die(`unknown arg ${a}`);
94
+ }
95
+ if (out.models.length === 0) die('at least one --model is required');
96
+ for (const m of out.models) {
97
+ const c = m.spec.indexOf(':');
98
+ if (c < 0) die(`--model must be provider:model (got "${m.spec}")`);
99
+ m.provider = m.spec.slice(0, c); m.model = m.spec.slice(c + 1);
100
+ m.short = m.model.split('/').pop();
101
+ m.key = `${m.provider}:${m.model}`;
102
+ }
103
+ return out;
104
+ }
105
+ function expand(p) { return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p); }
106
+ function die(msg) { console.error(`calibrate-capabilities: ${msg}`); process.exit(2); }
107
+ function log(msg) { console.log(`[calibrate] ${msg}`); }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // run dirs / results
111
+ // ---------------------------------------------------------------------------
112
+ function findRunRoot(dir) {
113
+ if (!dir || !fs.existsSync(dir)) return null;
114
+ if (fs.existsSync(path.join(dir, 'results.json'))) return dir;
115
+ const subs = fs.readdirSync(dir).filter((d) => fs.existsSync(path.join(dir, d, 'results.json')))
116
+ .sort().reverse();
117
+ return subs.length ? path.join(dir, subs[0]) : null;
118
+ }
119
+
120
+ function readUsageAndModel(debugJsonPath) {
121
+ // Terminus writes LiteLLM's debug record; original_response is either a
122
+ // Python repr (direct OpenRouter arm) or the Anthropic JSON Lynkr returned.
123
+ let s;
124
+ try { s = JSON.parse(fs.readFileSync(debugJsonPath, 'utf8')).original_response || ''; } catch { return null; }
125
+ const num = (re) => { const m = re.exec(s); return m ? Number(m[1]) : null; };
126
+ const model = (/model='([^']+)'/.exec(s) || /"model":"([^"]+)"/.exec(s) || [])[1] || null;
127
+ const cost = num(/\bcost=([0-9.eE+-]+)/);
128
+ let inTok = num(/prompt_tokens=(\d+)/), outTok = num(/completion_tokens=(\d+)/), cached = num(/cached_tokens=(\d+)/) ?? 0;
129
+ if (inTok === null) { // Anthropic shape
130
+ inTok = num(/"input_tokens":(\d+)/); outTok = num(/"output_tokens":(\d+)/); cached = num(/"cache_read_input_tokens":(\d+)/) ?? 0;
131
+ if (inTok !== null) inTok += cached; // Anthropic input excludes cache reads; normalise to "prompt" total
132
+ }
133
+ return { model, cost, inTok: inTok ?? 0, outTok: outTok ?? 0, cached: cached ?? 0 };
134
+ }
135
+
136
+ /** Per task: resolved, served-model share, tokens, provider cost, first prompt path. */
137
+ function loadRun(runRoot, wantShort) {
138
+ const results = JSON.parse(fs.readFileSync(path.join(runRoot, 'results.json'), 'utf8')).results || [];
139
+ const tasks = {};
140
+ for (const r of results) {
141
+ const t = r.task_id; const tdir = path.join(runRoot, t);
142
+ let trial = null;
143
+ try { trial = fs.readdirSync(tdir).find((x) => fs.existsSync(path.join(tdir, x, 'agent-logs'))); } catch { /* none */ }
144
+ const eps = trial ? fs.readdirSync(path.join(tdir, trial, 'agent-logs')).filter((e) => e.startsWith('episode-'))
145
+ .sort((a, b) => Number(a.split('-')[1]) - Number(b.split('-')[1])) : [];
146
+ let served = 0, total = 0, inTok = 0, outTok = 0, cached = 0, cost = 0, costKnown = true;
147
+ for (const e of eps) {
148
+ const u = readUsageAndModel(path.join(tdir, trial, 'agent-logs', e, 'debug.json'));
149
+ if (!u) continue;
150
+ total++;
151
+ if (u.model && u.model.toLowerCase().includes(wantShort.toLowerCase())) served++;
152
+ inTok += u.inTok; outTok += u.outTok; cached += u.cached;
153
+ if (u.cost === null) costKnown = false; else cost += u.cost;
154
+ }
155
+ tasks[t] = {
156
+ resolved: !!r.is_resolved, failureMode: r.failure_mode || null,
157
+ episodes: eps.length, servedShare: total ? served / total : 0,
158
+ inTok, outTok, cached, providerCost: costKnown && total ? cost : null,
159
+ promptPath: eps.length ? path.join(tdir, trial, 'agent-logs', eps[0], 'prompt.txt') : null,
160
+ };
161
+ }
162
+ return tasks;
163
+ }
164
+
165
+ function taskCost(entry, price) {
166
+ if (entry.providerCost !== null && entry.providerCost !== undefined) return entry.providerCost;
167
+ if (!price) return null;
168
+ const [pin, pcache, pout] = price;
169
+ const uncached = Math.max(0, entry.inTok - entry.cached);
170
+ return (uncached * pin + entry.cached * pcache + entry.outTok * pout) / 1e6;
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // phase 1: run
175
+ // ---------------------------------------------------------------------------
176
+ function tbBinary() {
177
+ const cands = [path.join(os.homedir(), '.local', 'bin', 'tb'), '/usr/local/bin/tb', 'tb'];
178
+ for (const c of cands) { const r = spawnSync(c, ['--help'], { stdio: 'ignore' }); if (r.status === 0) return c; }
179
+ return null;
180
+ }
181
+
182
+ function portFree(port) {
183
+ const r = spawnSync('ss', ['-tln'], { encoding: 'utf8' });
184
+ return !(r.stdout || '').includes(`:${port} `);
185
+ }
186
+
187
+ async function waitFor(fn, ms, every = 1000) {
188
+ const t0 = Date.now();
189
+ while (Date.now() - t0 < ms) { if (await fn()) return true; await new Promise((r) => setTimeout(r, every)); }
190
+ return false;
191
+ }
192
+
193
+ async function runModel(m, opts) {
194
+ const tb = tbBinary(); if (!tb) die('tb (terminal-bench CLI) not found; install it or pass model=run-dir');
195
+ if (!portFree(opts.port)) die(`port ${opts.port} is in use; pass --port`);
196
+ const home = path.join(opts.out, 'runs', m.short, 'lynkr-home');
197
+ const outDir = path.join(opts.out, 'runs', m.short, 'tb');
198
+ fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
199
+ // Throwaway Lynkr: copy the operator .env, pin all tiers to this model,
200
+ // own port, own telemetry dir (dotenv + telemetry both key off cwd).
201
+ const srcEnv = fs.existsSync(path.join(process.cwd(), '.env')) ? path.join(process.cwd(), '.env') : path.join(os.homedir(), '.env');
202
+ let env = fs.existsSync(srcEnv) ? fs.readFileSync(srcEnv, 'utf8') : '';
203
+ env = env.split('\n').filter((l) => !/^(PORT|TIER_SIMPLE|TIER_MEDIUM|TIER_COMPLEX|TIER_REASONING)=/.test(l)).join('\n');
204
+ env += `\n# --- calibrate-capabilities: solo run for ${m.key} ---\nPORT=${opts.port}\n` +
205
+ ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'].map((t) => `TIER_${t}=${m.key}`).join('\n') + '\n';
206
+ fs.writeFileSync(path.join(home, '.env'), env);
207
+ log(`starting throwaway Lynkr on :${opts.port} for ${m.key} (cwd ${home})`);
208
+ const lynkr = spawn(process.execPath, [path.join(ROOT, 'index.js')], {
209
+ cwd: home, env: { ...process.env, PORT: String(opts.port) }, stdio: ['ignore', fs.openSync(path.join(home, 'lynkr.log'), 'a'), fs.openSync(path.join(home, 'lynkr.log'), 'a')], detached: false,
210
+ });
211
+ const up = await waitFor(() => !portFree(opts.port), 60000);
212
+ if (!up) { lynkr.kill(); die(`Lynkr did not come up on :${opts.port}; see ${path.join(home, 'lynkr.log')}`); }
213
+ log(`running ${opts.dataset} against ${m.key} (concurrency ${opts.concurrency}) → ${outDir}`);
214
+ const t0 = Date.now();
215
+ const r = spawnSync(tb, ['run', '--dataset', opts.dataset, '--agent', 'terminus', '--model', 'anthropic/claude-sonnet-4-5',
216
+ '--n-concurrent-trials', String(opts.concurrency), '--output-path', outDir], {
217
+ env: { ...process.env, ANTHROPIC_API_BASE: `http://localhost:${opts.port}`, ANTHROPIC_API_KEY: 'lynkr-calibration' },
218
+ stdio: ['ignore', fs.openSync(path.join(outDir, 'tb-stdout.log'), 'a'), fs.openSync(path.join(outDir, 'tb-stdout.log'), 'a')],
219
+ maxBuffer: 1 << 26,
220
+ });
221
+ lynkr.kill();
222
+ log(`run finished in ${Math.round((Date.now() - t0) / 60000)} min (tb exit ${r.status})`);
223
+ const root = findRunRoot(outDir);
224
+ if (!root) die(`no results.json under ${outDir}`);
225
+ return root;
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // phase 2: replay
230
+ // ---------------------------------------------------------------------------
231
+ async function replayRequirements(taskPrompts, cachePath) {
232
+ let cache = {};
233
+ if (fs.existsSync(cachePath)) { try { cache = JSON.parse(fs.readFileSync(cachePath, 'utf8')); } catch { cache = {}; } }
234
+ const todo = Object.entries(taskPrompts).filter(([t]) => !cache[t]);
235
+ if (todo.length === 0) { log(`requirements cached for ${Object.keys(cache).length} tasks`); return cache; }
236
+ // Load the operator .env like the server does, then the router.
237
+ require('dotenv').config({ path: fs.existsSync(path.join(process.cwd(), '.env')) ? path.join(process.cwd(), '.env') : path.join(os.homedir(), '.env') });
238
+ process.env.LOG_FILE_ENABLED = 'false';
239
+ process.env.LOG_LEVEL = 'silent'; // after dotenv: the operator .env usually sets info
240
+ const { determineProviderSmart } = require(path.join(ROOT, 'src', 'routing'));
241
+ const { buildRequirementVector } = require(path.join(ROOT, 'src', 'routing', 'capabilities'));
242
+ const sfmod = require(path.join(ROOT, 'src', 'routing', 'shortfall'));
243
+ log(`replaying ${todo.length} task prompts through the router`);
244
+ for (const [t, promptPath] of todo) {
245
+ try {
246
+ const raw = fs.readFileSync(promptPath, 'utf8');
247
+ const d = await determineProviderSmart({ messages: [{ role: 'user', content: raw }], tools: [] }, {});
248
+ let sf = d.shortfall || null;
249
+ if (!sf || !sf.req) {
250
+ // The router only computes shortfall in weighted mode below risk-high;
251
+ // rebuild the same lifted vector here so every task gets scored.
252
+ const structural = buildRequirementVector({ dimensions: d.analysis?.breakdown || {}, agenticResult: d.agenticResult || null });
253
+ const lifted = sfmod.liftRequirement(structural, { anchorScore: d.analysis?.anchorScore, jev: d.analysis?.jev });
254
+ sf = { req: lifted.req, structuralReq: structural, lift: lifted.lift.applied, rebuilt: true };
255
+ }
256
+ cache[t] = {
257
+ req: sf.req || null, structuralReq: sf.structuralReq || null, lift: sf.lift || [], rebuilt: !!sf.rebuilt,
258
+ liveTier: d.tier || null, anchorScore: d.analysis?.anchorScore ?? null,
259
+ jev: d.analysis?.jev ? { tier: d.analysis.jev.tier, confidence: d.analysis.jev.confidence, probabilities: d.analysis.jev.probabilities } : null,
260
+ };
261
+ } catch (err) { cache[t] = { req: null, error: String(err && err.message || err) }; }
262
+ fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
263
+ }
264
+ return cache;
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // phase 3: fit
269
+ // ---------------------------------------------------------------------------
270
+ function passRateAt(level, rows, head, win = 0.1) {
271
+ const sel = rows.filter((r) => Math.abs(r.req[head] - level) <= win);
272
+ if (sel.length < 5) return null;
273
+ return { n: sel.length, rate: sel.filter((r) => r.pass).length / sel.length };
274
+ }
275
+
276
+ function fitModel(rows, bestRows, floor, rel) {
277
+ // rows: [{task, req, pass}] for this model; bestRows: same shape, strongest model.
278
+ const levels = []; for (let l = 0.1; l <= 0.951; l += 0.05) levels.push(Math.round(l * 100) / 100);
279
+ const caps = {}, detail = {};
280
+ for (const h of HEADS) {
281
+ let cap = null, collapse = null; const curve = [];
282
+ for (const l of levels) {
283
+ const p = passRateAt(l, rows, h); if (!p) continue;
284
+ const b = bestRows ? passRateAt(l, bestRows, h) : null;
285
+ const okAbs = p.rate >= floor;
286
+ const okRel = !b || b.rate === 0 || p.rate >= rel * b.rate;
287
+ curve.push({ level: l, n: p.n, rate: Math.round(p.rate * 100) / 100, best: b ? Math.round(b.rate * 100) / 100 : null, ok: okAbs && okRel });
288
+ if (okAbs && okRel) cap = l;
289
+ if (cap !== null && l > cap && p.rate < floor / 2 && collapse === null) collapse = l;
290
+ }
291
+ if (cap === null) {
292
+ // never met the floor in any window: use the overall pass rate as a
293
+ // pessimistic scalar (a model that passes 30% overall gets ~0.3)
294
+ const overall = rows.length ? rows.filter((r) => r.pass).length / rows.length : 0;
295
+ cap = Math.max(0.1, Math.round(overall * 100) / 100);
296
+ }
297
+ caps[h] = cap; detail[h] = { curve, collapse };
298
+ }
299
+ return { caps, detail };
300
+ }
301
+
302
+ function fitTau(fits) {
303
+ const gaps = [];
304
+ for (const f of Object.values(fits)) for (const h of HEADS) {
305
+ const c = f.detail[h].collapse; if (c !== null && c !== undefined) gaps.push(c - f.caps[h]);
306
+ }
307
+ if (gaps.length === 0) return { tau: 0.15, source: 'default (no collapse points observed)' };
308
+ gaps.sort((a, b) => a - b);
309
+ const med = gaps[Math.floor(gaps.length / 2)];
310
+ return { tau: Math.max(0.05, Math.min(0.3, Math.round(med * 100) / 100)), source: `median collapse gap over ${gaps.length} head curves` };
311
+ }
312
+
313
+ function simulateShortfall(req, candidates, overrides, tau) {
314
+ const sf = require(path.join(ROOT, 'src', 'routing', 'shortfall'));
315
+ sf._setProfilesForTests({ modelOverrides: overrides, tau });
316
+ const r = sf.selectByShortfall(req, candidates, { tau });
317
+ sf._resetProfilesCache();
318
+ return r ? r.selected : null;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // main
323
+ // ---------------------------------------------------------------------------
324
+ (async () => {
325
+ const opts = parseArgs(process.argv.slice(2));
326
+ fs.mkdirSync(opts.out, { recursive: true });
327
+
328
+ // Resolve run dirs / plan
329
+ const plan = [];
330
+ for (const m of opts.models) {
331
+ const existing = m.runDir ? findRunRoot(m.runDir) : findRunRoot(path.join(opts.out, 'runs', m.short, 'tb'));
332
+ if (m.runDir && !existing) die(`${m.spec}: no results.json under ${m.runDir}`);
333
+ m.runRoot = existing;
334
+ plan.push(`${m.key.padEnd(44)} ${existing ? 'reuse ' + existing : (opts.run ? 'RUN (~1h, est. $1–5)' : 'needs run — pass --run')}`);
335
+ }
336
+ console.log('\nPlan:\n ' + plan.join('\n ') + `\n out: ${opts.out} floor ${opts.floor} rel ${opts.rel}\n`);
337
+ if (opts.dryRun) { log('dry run — nothing executed'); return; }
338
+
339
+ // Phase 1
340
+ for (const m of opts.models) {
341
+ if (m.runRoot) continue;
342
+ if (!opts.run) die(`${m.key} has no run; re-run with --run to benchmark it (spends money) or pass model=run-dir`);
343
+ m.runRoot = await runModel(m, opts);
344
+ }
345
+
346
+ // Load runs
347
+ for (const m of opts.models) {
348
+ m.tasks = loadRun(m.runRoot, m.short);
349
+ const n = Object.keys(m.tasks).length;
350
+ const solo = Object.values(m.tasks).filter((t) => t.servedShare >= 0.8);
351
+ log(`${m.key}: ${n} tasks in run, ${solo.length} served ≥80% by this model, ${solo.filter((t) => t.resolved).length} passed`);
352
+ }
353
+
354
+ // Phase 2
355
+ const prompts = {};
356
+ for (const m of opts.models) for (const [t, e] of Object.entries(m.tasks)) if (e.promptPath && !prompts[t]) prompts[t] = e.promptPath;
357
+ const reqs = await replayRequirements(prompts, path.join(opts.out, 'requirements.json'));
358
+ const scored = Object.entries(reqs).filter(([, r]) => r.req).map(([t]) => t);
359
+ log(`${scored.length} tasks have requirement vectors`);
360
+
361
+ // Phase 3
362
+ const rowsFor = (m) => scored.filter((t) => m.tasks[t] && m.tasks[t].servedShare >= 0.8)
363
+ .map((t) => ({ task: t, req: reqs[t].req, pass: m.tasks[t].resolved }));
364
+ const rowsets = {}; for (const m of opts.models) rowsets[m.key] = rowsFor(m);
365
+ const best = opts.models.slice().sort((a, b) => (rowsets[b.key].filter((r) => r.pass).length / Math.max(1, rowsets[b.key].length))
366
+ - (rowsets[a.key].filter((r) => r.pass).length / Math.max(1, rowsets[a.key].length)))[0];
367
+ const fits = {};
368
+ for (const m of opts.models) fits[m.key] = fitModel(rowsets[m.key], m.key === best.key ? null : rowsets[best.key], opts.floor, opts.rel);
369
+ const { tau, source: tauSource } = fitTau(fits);
370
+ const overrides = {}; for (const m of opts.models) overrides[m.key] = fits[m.key].caps;
371
+
372
+ // Hold-out simulation: tasks every model was measured on, every Nth held out.
373
+ const common = scored.filter((t) => opts.models.every((m) => m.tasks[t] && m.tasks[t].servedShare >= 0.8));
374
+ const held = common.filter((_, i) => i % opts.holdoutEvery === 0);
375
+ const candidates = opts.models.map((m) => ({ provider: m.provider, model: m.model, tier: m.key === best.key ? 'COMPLEX' : 'MEDIUM', cost: 1 }));
376
+ let simPass = 0, simCost = 0, bestPass = 0, bestCost = 0, picks = {};
377
+ for (const t of held) {
378
+ const sel = simulateShortfall(reqs[t].req, candidates, overrides, tau);
379
+ const chosen = sel ? opts.models.find((m) => m.model === sel.model) : best;
380
+ picks[chosen.key] = (picks[chosen.key] || 0) + 1;
381
+ simPass += chosen.tasks[t].resolved ? 1 : 0; simCost += taskCost(chosen.tasks[t], opts.prices[chosen.short]) ?? 0;
382
+ bestPass += best.tasks[t].resolved ? 1 : 0; bestCost += taskCost(best.tasks[t], opts.prices[best.short]) ?? 0;
383
+ }
384
+
385
+ // Phase 4
386
+ const proposed = { modelOverrides: overrides, tau, generated: new Date().toISOString(), floor: opts.floor, rel: opts.rel, tauSource };
387
+ fs.writeFileSync(path.join(opts.out, 'model-capabilities.proposed.json'), JSON.stringify(proposed, null, 2));
388
+ const lines = [];
389
+ lines.push(`# Capability calibration — ${new Date().toISOString()}`, '');
390
+ lines.push(`Tasks with requirement vectors: ${scored.length}. Best model: ${best.key}.`, '');
391
+ lines.push('## Measured pass rates (tasks served ≥80% by the model)', '');
392
+ for (const m of opts.models) { const rs = rowsets[m.key]; lines.push(`- ${m.key}: ${rs.filter((r) => r.pass).length}/${rs.length} passed (run ${m.runRoot})`); }
393
+ lines.push('', `## Fitted profiles (floor ${opts.floor}, rel ${opts.rel})`, '');
394
+ lines.push('| model | ' + HEADS.join(' | ') + ' |', '|---|' + HEADS.map(() => '---').join('|') + '|');
395
+ for (const m of opts.models) lines.push(`| ${m.key} | ` + HEADS.map((h) => fits[m.key].caps[h].toFixed(2)).join(' | ') + ' |');
396
+ lines.push('', `tau = ${tau} (${tauSource})`, '');
397
+ lines.push('## Pass-rate curves', '');
398
+ for (const m of opts.models) for (const h of HEADS) {
399
+ const c = fits[m.key].detail[h].curve; if (!c.length) continue;
400
+ lines.push(`- ${m.short} / ${h}: ` + c.map((p) => `${p.level}:${Math.round(p.rate * 100)}%${p.best !== null ? '/' + Math.round(p.best * 100) + '%' : ''}(n${p.n})${p.ok ? '' : '✗'}`).join(' '));
401
+ }
402
+ lines.push('', `## Hold-out check (${held.length} tasks, every ${opts.holdoutEvery}th of ${common.length} common)`, '');
403
+ if (held.length === 0) lines.push('Not enough tasks measured on every model — run the missing models solo.');
404
+ else {
405
+ lines.push(`- shortfall (fitted): ${simPass}/${held.length} passed, $${simCost.toFixed(2)}; picks ${JSON.stringify(picks)}`);
406
+ lines.push(`- ${best.short} everywhere: ${bestPass}/${held.length} passed, $${bestCost.toFixed(2)}`);
407
+ lines.push(`- verdict: ${simPass >= bestPass && simCost < bestCost ? 'shortfall WINS (same or better accuracy, cheaper)' : simPass >= bestPass ? 'same accuracy, not cheaper' : `shortfall loses ${bestPass - simPass} task(s) for $${(bestCost - simCost).toFixed(2)} saved`}`);
408
+ }
409
+ fs.writeFileSync(path.join(opts.out, 'report.md'), lines.join('\n') + '\n');
410
+ console.log('\n' + lines.join('\n'));
411
+ log(`wrote ${path.join(opts.out, 'report.md')} and model-capabilities.proposed.json`);
412
+
413
+ if (opts.apply) {
414
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
415
+ fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + `.bak-${Date.now()}`);
416
+ cfg.modelOverrides = { ...(cfg.modelOverrides || {}), ...overrides };
417
+ cfg.tau = tau;
418
+ // Measured records: shortfall prefers these over overrides and seeds.
419
+ const existing = Array.isArray(cfg.evaluation?.records) ? cfg.evaluation.records : [];
420
+ const fresh = opts.models.map((m) => ({
421
+ model: m.key, host: null, effort: null,
422
+ benchmark: `${opts.dataset}`, date: new Date().toISOString().slice(0, 10),
423
+ n_tasks: rowsets[m.key].length, pass_rate: rowsets[m.key].length ? Math.round(1000 * rowsets[m.key].filter((r) => r.pass).length / rowsets[m.key].length) / 1000 : null,
424
+ heads: fits[m.key].caps, run_dir: m.runRoot,
425
+ }));
426
+ cfg.evaluation = { records: [...existing.filter((r) => !fresh.some((f) => f.model === r.model && f.benchmark === r.benchmark)), ...fresh] };
427
+ cfg.notes = (cfg.notes || '') + ` | calibrate-capabilities ${new Date().toISOString().slice(0, 10)}: modelOverrides/tau fitted from measured runs (see ${path.join(opts.out, 'report.md')}).`;
428
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
429
+ log(`applied to ${CONFIG_PATH} (backup written). Restart Lynkr; shortfall "enabled" is left as-is.`);
430
+ } else {
431
+ log('not applied (pass --apply to merge into config/model-capabilities.json)');
432
+ }
433
+ })().catch((err) => { console.error(err); process.exit(1); });
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Evaluate the complexity signal (and, for comparison, the anchor score and the
4
+ * structural requirement) as predictors of pass/fail on a Terminal-Bench run.
5
+ *
6
+ * Prints AUC per predictor against the strong model's solo outcomes and the
7
+ * cheap model's outcomes. AUC 0.5 = no information.
8
+ *
9
+ * Usage: node scripts/eval-complexity-signal.js --prompts ~/tb-runs-lynkr-v14 \
10
+ * --strong ~/tb-runs-auto-v2 [--cheap ~/tb-runs-lynkr-v14]
11
+ */
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ function expand(p) { return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p); }
19
+ function findRunRoot(dir) {
20
+ if (fs.existsSync(path.join(dir, 'results.json'))) return dir;
21
+ const subs = fs.readdirSync(dir).filter((d) => fs.existsSync(path.join(dir, d, 'results.json'))).sort().reverse();
22
+ return subs.length ? path.join(dir, subs[0]) : null;
23
+ }
24
+ function outcomes(root) { const o = {}; for (const r of JSON.parse(fs.readFileSync(path.join(root, 'results.json'), 'utf8')).results) o[r.task_id] = !!r.is_resolved; return o; }
25
+ function auc(scores, labels) {
26
+ // probability that a random FAIL task scores higher (harder) than a random PASS task
27
+ const pos = [], neg = [];
28
+ scores.forEach((s, i) => { if (s == null) return; (labels[i] ? neg : pos).push(s); });
29
+ if (!pos.length || !neg.length) return null;
30
+ let wins = 0; for (const p of pos) for (const n of neg) wins += p > n ? 1 : p === n ? 0.5 : 0;
31
+ return wins / (pos.length * neg.length);
32
+ }
33
+
34
+ async function main() {
35
+ const argv = process.argv.slice(2);
36
+ const arg = (k) => { const i = argv.indexOf(k); return i >= 0 ? expand(argv[i + 1]) : null; };
37
+ const promptsRoot = findRunRoot(arg('--prompts')); const strongRoot = findRunRoot(arg('--strong')); const cheapRoot = arg('--cheap') ? findRunRoot(arg('--cheap')) : null;
38
+ if (!promptsRoot || !strongRoot) { console.error('--prompts and --strong run dirs required'); process.exit(2); }
39
+ const envPath = fs.existsSync(path.join(process.cwd(), '.env')) ? path.join(process.cwd(), '.env') : path.join(os.homedir(), '.env');
40
+ require('dotenv').config({ path: envPath }); process.env.LOG_LEVEL = 'silent'; process.env.LOG_FILE_ENABLED = 'false';
41
+ const { determineProviderSmart } = require('../src/routing');
42
+ const strong = outcomes(strongRoot); const cheap = cheapRoot ? outcomes(cheapRoot) : null;
43
+ const tasks = Object.keys(strong);
44
+ const rows = [];
45
+ for (const t of tasks) {
46
+ const tdir = path.join(promptsRoot, t); let trial; try { trial = fs.readdirSync(tdir).find((x) => fs.existsSync(path.join(tdir, x, 'agent-logs', 'episode-0', 'prompt.txt'))); } catch { continue; }
47
+ if (!trial) continue;
48
+ const raw = fs.readFileSync(path.join(tdir, trial, 'agent-logs', 'episode-0', 'prompt.txt'), 'utf8');
49
+ const d = await determineProviderSmart({ messages: [{ role: 'user', content: raw }], tools: [] }, {});
50
+ const sig = d.engine?.signals || {};
51
+ const struct = d.shortfall?.structuralReq ? Object.values(d.shortfall.structuralReq).reduce((a, b) => a + b, 0) / 4 : null;
52
+ const lifted = d.shortfall?.req ? Object.values(d.shortfall.req).reduce((a, b) => a + b, 0) / 4 : null;
53
+ rows.push({ task: t, anchor: sig.anchor?.value ?? null, judgeP: sig.judge?.probabilities ? (sig.judge.probabilities.COMPLEX || 0) + (sig.judge.probabilities.REASONING || 0) : null, complexity: sig.complexity?.value ?? null, structural: struct, lifted, strongPass: strong[t], cheapPass: cheap ? cheap[t] : null });
54
+ }
55
+ const preds = ['anchor', 'judgeP', 'complexity', 'structural', 'lifted'];
56
+ console.log(`\n${rows.length} tasks. AUC = P(predictor ranks a FAILED task as harder than a PASSED one). 0.50 = no information.\n`);
57
+ console.log(`${'predictor'.padEnd(12)} ${'vs strong solo'.padStart(15)} ${'vs cheap'.padStart(10)} coverage`);
58
+ for (const p of preds) {
59
+ const s = rows.map((r) => r[p]);
60
+ const a1 = auc(s, rows.map((r) => r.strongPass)); const a2 = cheap ? auc(s, rows.map((r) => r.cheapPass)) : null;
61
+ console.log(`${p.padEnd(12)} ${a1 == null ? ' n/a' : a1.toFixed(3).padStart(15)} ${a2 == null ? ' n/a' : a2.toFixed(3).padStart(10)} ${s.filter((x) => x != null).length}/${rows.length}`);
62
+ }
63
+ const outPath = path.join(promptsRoot, 'complexity-eval.json'); fs.writeFileSync(outPath, JSON.stringify(rows, null, 1)); console.log(`\nrows → ${outPath}`);
64
+ }
65
+ main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate the grounding check against a Terminal-Bench run.
4
+ *
5
+ * For every task whose final reply claimed completion, run grounding.check()
6
+ * with the evidence the model actually saw (that episode's prompt), and
7
+ * compare the verdict with the test result. Reports the confusion matrix:
8
+ * flagged false-completions (good) vs flagged true-completions (bad).
9
+ *
10
+ * Usage: node scripts/validate-grounding.js --run ~/tb-runs-lynkr-v14 [--threshold 0.6]
11
+ */
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ function expand(p) { return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p); }
19
+ function findRunRoot(dir) {
20
+ if (fs.existsSync(path.join(dir, 'results.json'))) return dir;
21
+ const subs = fs.readdirSync(dir).filter((d) => fs.existsSync(path.join(dir, d, 'results.json'))).sort().reverse();
22
+ return subs.length ? path.join(dir, subs[0]) : null;
23
+ }
24
+
25
+ async function main() {
26
+ const argv = process.argv.slice(2);
27
+ const run = findRunRoot(expand(argv[argv.indexOf('--run') + 1] || '')); if (!run) { console.error('--run <dir> required'); process.exit(2); }
28
+ const ti = argv.indexOf('--threshold'); const threshold = ti >= 0 ? Number(argv[ti + 1]) : 0.6;
29
+ const envPath = fs.existsSync(path.join(process.cwd(), '.env')) ? path.join(process.cwd(), '.env') : path.join(os.homedir(), '.env');
30
+ require('dotenv').config({ path: envPath });
31
+ process.env.LOG_LEVEL = 'silent'; process.env.LOG_FILE_ENABLED = 'false';
32
+ const grounding = require('../src/routing/grounding');
33
+ await grounding.warm();
34
+ const results = JSON.parse(fs.readFileSync(path.join(run, 'results.json'), 'utf8')).results;
35
+ const rows = [];
36
+ for (const r of results) {
37
+ const tdir = path.join(run, r.task_id); let trial;
38
+ try { trial = fs.readdirSync(tdir).find((x) => fs.existsSync(path.join(tdir, x, 'agent-logs'))); } catch { continue; }
39
+ if (!trial) continue;
40
+ const eps = fs.readdirSync(path.join(tdir, trial, 'agent-logs')).filter((e) => e.startsWith('episode-')).sort((a, b) => Number(a.split('-')[1]) - Number(b.split('-')[1]));
41
+ if (!eps.length) continue;
42
+ const last = path.join(tdir, trial, 'agent-logs', eps[eps.length - 1]);
43
+ let reply, prompt; try { reply = fs.readFileSync(path.join(last, 'response.json'), 'utf8'); prompt = fs.readFileSync(path.join(last, 'prompt.txt'), 'utf8'); } catch { continue; }
44
+ const g = await grounding.check({ replyText: reply, evidence: prompt, onlyWhenDone: true, threshold });
45
+ if (g.verdict === 'skipped' && g.reason === 'no_completion_claim') continue;
46
+ rows.push({ task: r.task_id, resolved: !!r.is_resolved, verdict: g.verdict, maxContradiction: g.maxContradiction ?? null, minEntailment: g.minEntailment ?? null, ms: g.ms });
47
+ }
48
+ const flagged = (v) => v === 'contradicted';
49
+ const tp = rows.filter((x) => !x.resolved && flagged(x.verdict)).length; // false completion caught
50
+ const fn = rows.filter((x) => !x.resolved && !flagged(x.verdict)).length;
51
+ const fp = rows.filter((x) => x.resolved && flagged(x.verdict)).length; // true completion wrongly flagged
52
+ const tn = rows.filter((x) => x.resolved && !flagged(x.verdict)).length;
53
+ console.log(`\nrun ${run}\ncompletion claims: ${rows.length} (false completions ${tp + fn}, true completions ${fp + tn}) threshold ${threshold}`);
54
+ console.log(` caught false completions : ${tp}/${tp + fn} (${tp + fn ? Math.round(100 * tp / (tp + fn)) : 0}%)`);
55
+ console.log(` wrongly flagged true ones : ${fp}/${fp + tn} (${fp + tn ? Math.round(100 * fp / (fp + tn)) : 0}%)`);
56
+ const byVerdict = {}; for (const x of rows) { const k = `${x.resolved ? 'PASS' : 'FAIL'}:${x.verdict}`; byVerdict[k] = (byVerdict[k] || 0) + 1; }
57
+ console.log(' verdict matrix:', JSON.stringify(byVerdict));
58
+ console.log(` median ms/check: ${rows.map((x) => x.ms).sort((a, b) => a - b)[Math.floor(rows.length / 2)]}`);
59
+ console.log('\n false completions NOT caught:', rows.filter((x) => !x.resolved && !flagged(x.verdict)).map((x) => `${x.task}(${x.verdict},c=${x.maxContradiction})`).join(', '));
60
+ console.log(' true completions flagged :', rows.filter((x) => x.resolved && flagged(x.verdict)).map((x) => `${x.task}(c=${x.maxContradiction})`).join(', ') || 'none');
61
+ const outPath = path.join(run, 'grounding-validation.json'); fs.writeFileSync(outPath, JSON.stringify({ threshold, rows }, null, 1)); console.log(`\n rows → ${outPath}`);
62
+ }
63
+ main().catch((e) => { console.error(e); process.exit(1); });