lynkr 9.14.16 → 9.14.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/cli.js +2 -0
- package/bin/lynkr-audit.js +58 -0
- package/bin/lynkr-route.js +76 -0
- package/config/complexity-exemplars.json +366 -0
- package/config/routing.example.json +186 -0
- package/config/routing.json +93 -0
- package/package.json +2 -2
- package/scripts/build-complexity-exemplars.js +84 -0
- package/scripts/build-routing-corpus.js +55 -0
- package/scripts/cache-probe.js +66 -0
- package/scripts/calibrate-capabilities.js +433 -0
- package/scripts/eval-complexity-signal.js +65 -0
- package/scripts/validate-grounding.js +63 -0
- package/src/api/router.js +53 -4
- package/src/cache/embeddings.js +22 -3
- package/src/clients/cursor-utils.js +1 -1
- package/src/clients/databricks.js +258 -124
- package/src/context/output-format-guard.js +1 -0
- package/src/orchestrator/index.js +11 -2
- package/src/routing/agentic-detector.js +6 -1
- package/src/routing/client-profiles.js +22 -0
- package/src/routing/complexity-analyzer.js +7 -0
- package/src/routing/decisions.js +100 -0
- package/src/routing/grounding.js +118 -0
- package/src/routing/harness-envelope.js +71 -1
- package/src/routing/index.js +104 -7
- package/src/routing/intent-score.js +5 -0
- package/src/routing/outcomes.js +136 -0
- package/src/routing/routing-config.js +154 -0
- package/src/routing/shortfall.js +90 -1
- package/src/routing/signals.js +179 -0
- package/src/routing/switch-gate.js +90 -0
- package/src/routing/telemetry.js +66 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-outcome attribution.
|
|
3
|
+
*
|
|
4
|
+
* When request N+1 of a session arrives, it carries evidence about what
|
|
5
|
+
* happened after request N: the assistant reply the client kept, the tool
|
|
6
|
+
* results or terminal output it produced, whether the client retried the
|
|
7
|
+
* identical conversation (parse/schema failure), whether the same command
|
|
8
|
+
* batch was issued again. Combined with what the gateway recorded about
|
|
9
|
+
* request N (provider error, failover, tier fallback), that is enough to
|
|
10
|
+
* classify N's outcome without any model-as-judge.
|
|
11
|
+
*
|
|
12
|
+
* Categories (and whether the MODEL is attributable):
|
|
13
|
+
* progress yes new tool output, no error markers, no repeat
|
|
14
|
+
* no_progress yes same command signature twice with the same output
|
|
15
|
+
* regression yes client retried the identical conversation (parse /
|
|
16
|
+
* schema failure) or repeated a failing command batch
|
|
17
|
+
* provider_error no upstream 5xx / timeout / failover / tier fallback
|
|
18
|
+
* tool_error no tool_result is_error or terminal error markers that
|
|
19
|
+
* are clearly environmental (command not found, OOM)
|
|
20
|
+
* missing no no following turn (filled by a sweeper)
|
|
21
|
+
*
|
|
22
|
+
* Only attributable outcomes may train the policy (reward pipeline, kNN,
|
|
23
|
+
* bandit). Environment noise never does.
|
|
24
|
+
*/
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
const RING = 8;
|
|
30
|
+
const TTL_MS = 6 * 60 * 60 * 1000;
|
|
31
|
+
const _sessions = new Map(); // sessionId -> { ts, turns: [] }
|
|
32
|
+
|
|
33
|
+
const ATTRIBUTABLE = new Set(['progress', 'no_progress', 'regression']);
|
|
34
|
+
const ENV_ERROR_RE = /command not found|No such file or directory|Permission denied|Killed|Out of memory|Connection refused|Temporary failure in name resolution|E: Unable to locate package/i;
|
|
35
|
+
const RUNTIME_ERROR_RE = /Traceback \(most recent call last\)|SyntaxError|TypeError|ReferenceError|error\[E\d+\]|FAILED|AssertionError|exit code [1-9]/;
|
|
36
|
+
|
|
37
|
+
function _hash(s) { return crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 16); }
|
|
38
|
+
function _text(msg) {
|
|
39
|
+
if (!msg) return '';
|
|
40
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
41
|
+
if (Array.isArray(msg.content)) return msg.content.filter((b) => b && (b.type === 'text' || b.type === 'tool_result')).map((b) => typeof b.text === 'string' ? b.text : (typeof b.content === 'string' ? b.content : JSON.stringify(b.content || ''))).join('\n');
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
function _lastIdx(messages, role) { for (let i = messages.length - 1; i >= 0; i--) if (messages[i]?.role === role) return i; return -1; }
|
|
45
|
+
function _commandSig(text) {
|
|
46
|
+
const s = text.indexOf('{'), e = text.lastIndexOf('}');
|
|
47
|
+
if (s === -1 || e <= s) return null;
|
|
48
|
+
try {
|
|
49
|
+
const obj = JSON.parse(text.slice(s, e + 1));
|
|
50
|
+
if (obj && Array.isArray(obj.commands)) return _hash(JSON.stringify(obj.commands.map((c) => String(c?.keystrokes ?? '').replace(/\s+/g, ' ').trim())));
|
|
51
|
+
} catch { /* not json */ }
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function _toolResultError(msg) {
|
|
55
|
+
return Array.isArray(msg?.content) && msg.content.some((b) => b && b.type === 'tool_result' && b.is_error === true);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _get(sessionId) {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
let s = _sessions.get(sessionId);
|
|
61
|
+
if (s && now - s.ts > TTL_MS) { _sessions.delete(sessionId); s = null; }
|
|
62
|
+
if (!s) { s = { ts: now, turns: [] }; _sessions.set(sessionId, s); }
|
|
63
|
+
s.ts = now;
|
|
64
|
+
if (_sessions.size > 20000) { // crude eviction
|
|
65
|
+
const oldest = [..._sessions.entries()].sort((a, b) => a[1].ts - b[1].ts).slice(0, 2000);
|
|
66
|
+
for (const [k] of oldest) _sessions.delete(k);
|
|
67
|
+
}
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify the previous turn from the incoming request.
|
|
73
|
+
* @param {object} args
|
|
74
|
+
* @param {string} args.sessionId
|
|
75
|
+
* @param {object} args.payload incoming request (messages)
|
|
76
|
+
* @param {object} [args.prevRecord] what the gateway recorded about the previous turn:
|
|
77
|
+
* { statusCode, errorType, failover:boolean, tierFallback:boolean, servedModel, tier }
|
|
78
|
+
* @returns {{ outcome, attributable, evidence, streak }|null} null when there is no previous turn
|
|
79
|
+
*/
|
|
80
|
+
function classifyPrevious({ sessionId, payload, prevRecord = null }) {
|
|
81
|
+
const msgs = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
82
|
+
const aIdx = _lastIdx(msgs, 'assistant');
|
|
83
|
+
if (aIdx < 0) return null;
|
|
84
|
+
const lastA = _text(msgs[aIdx]);
|
|
85
|
+
const after = msgs.slice(aIdx + 1).filter((m) => m?.role === 'user');
|
|
86
|
+
const lastU = after.length ? after[after.length - 1] : null;
|
|
87
|
+
const uText = _text(lastU);
|
|
88
|
+
const convHash = _hash(msgs.slice(0, aIdx + 1).map((m) => _text(m)).join('\u0000'));
|
|
89
|
+
const cmdSig = _commandSig(lastA);
|
|
90
|
+
const s = sessionId ? _get(sessionId) : { turns: [] };
|
|
91
|
+
const prev = s.turns.length ? s.turns[s.turns.length - 1] : null;
|
|
92
|
+
|
|
93
|
+
let outcome, evidence = {};
|
|
94
|
+
if (prevRecord && (prevRecord.failover || prevRecord.tierFallback || (prevRecord.statusCode && prevRecord.statusCode >= 500) || /timeout|UPSTREAM/i.test(String(prevRecord.errorType || '')))) {
|
|
95
|
+
outcome = 'provider_error'; evidence = { statusCode: prevRecord.statusCode, errorType: prevRecord.errorType, failover: !!prevRecord.failover };
|
|
96
|
+
} else if (!lastU) {
|
|
97
|
+
// Client re-sent the conversation ending in an assistant turn, or no user turn followed yet.
|
|
98
|
+
outcome = 'missing';
|
|
99
|
+
} else if (prev && prev.convHash === convHash && prev.outcome !== 'provider_error') {
|
|
100
|
+
outcome = 'regression'; evidence = { reason: 'identical_conversation_retry' };
|
|
101
|
+
} else if (_toolResultError(lastU) || ENV_ERROR_RE.test(uText)) {
|
|
102
|
+
outcome = 'tool_error'; evidence = { reason: _toolResultError(lastU) ? 'tool_result_is_error' : 'environment_error_marker' };
|
|
103
|
+
} else if (cmdSig && prev && prev.cmdSig === cmdSig) {
|
|
104
|
+
outcome = (prev.outputHash === _hash(uText)) ? 'no_progress' : 'regression';
|
|
105
|
+
evidence = { reason: outcome === 'no_progress' ? 'same_commands_same_output' : 'same_commands_different_output' };
|
|
106
|
+
} else if (RUNTIME_ERROR_RE.test(uText)) {
|
|
107
|
+
outcome = 'regression'; evidence = { reason: 'runtime_error_in_output' };
|
|
108
|
+
} else {
|
|
109
|
+
outcome = 'progress';
|
|
110
|
+
}
|
|
111
|
+
const attributable = ATTRIBUTABLE.has(outcome);
|
|
112
|
+
// Streak of consecutive attributable regressions/no_progress, skipping noise.
|
|
113
|
+
let streak = 0;
|
|
114
|
+
if (attributable && outcome !== 'progress') {
|
|
115
|
+
streak = 1;
|
|
116
|
+
for (let i = s.turns.length - 1; i >= 0; i--) {
|
|
117
|
+
const t = s.turns[i];
|
|
118
|
+
if (!t.attributable) continue;
|
|
119
|
+
if (t.outcome === 'progress') break;
|
|
120
|
+
streak++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const rec = { ts: Date.now(), outcome, attributable, evidence, streak, convHash, cmdSig, outputHash: _hash(uText), tier: prevRecord?.tier ?? null, servedModel: prevRecord?.servedModel ?? null };
|
|
124
|
+
if (sessionId) { s.turns.push(rec); if (s.turns.length > RING) s.turns.shift(); }
|
|
125
|
+
return { outcome, attributable, evidence, streak };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ring(sessionId) { return sessionId && _sessions.has(sessionId) ? [..._sessions.get(sessionId).turns] : []; }
|
|
129
|
+
/** Reward for the learning loop: null means "do not update" (environment noise). */
|
|
130
|
+
function rewardFor(outcome) {
|
|
131
|
+
if (!ATTRIBUTABLE.has(outcome)) return null;
|
|
132
|
+
return outcome === 'progress' ? 1 : outcome === 'no_progress' ? 0.3 : 0;
|
|
133
|
+
}
|
|
134
|
+
function _clear() { _sessions.clear(); }
|
|
135
|
+
|
|
136
|
+
module.exports = { classifyPrevious, ring, rewardFor, ATTRIBUTABLE, _clear };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative routing config (signals → decisions).
|
|
3
|
+
*
|
|
4
|
+
* Loads config/routing.json (override path: LYNKR_ROUTING_CONFIG). Hot-reloads
|
|
5
|
+
* on mtime change. Validation is strict about shape and lenient about intent:
|
|
6
|
+
* an invalid file is logged and the previous good config (or the built-in
|
|
7
|
+
* default) stays in force — routing must never fail because of a config typo.
|
|
8
|
+
*
|
|
9
|
+
* Shape:
|
|
10
|
+
* {
|
|
11
|
+
* version: 1,
|
|
12
|
+
* mode: "observe" | "enforce", // observe: log what the engine would do
|
|
13
|
+
* harness: { patterns: [{ name, preamble, instruction }] },
|
|
14
|
+
* signals: { <name>: { type, ...options } },
|
|
15
|
+
* decisions: [{ name, priority, rules, tier, effort, hosts, plugins }],
|
|
16
|
+
* anchor_bands: { SIMPLE: [0,20], MEDIUM: [20,51], COMPLEX: [51,76], REASONING: [76,101] }
|
|
17
|
+
* }
|
|
18
|
+
* Rule: { operator: "AND"|"OR"|"NOT", conditions: [ cond | rule ] }
|
|
19
|
+
* Cond: { signal, matched?, equals?, in?, band_in?, tier_in?, min?, max?, min_confidence? }
|
|
20
|
+
* Tier: "SIMPLE"|"MEDIUM"|"COMPLEX"|"REASONING"|"from:legacy"|"from:anchor"|"from:judge"
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const logger = require('../logger');
|
|
27
|
+
|
|
28
|
+
const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
29
|
+
const DEFAULT_PATH = path.join(__dirname, '..', '..', 'config', 'routing.json');
|
|
30
|
+
|
|
31
|
+
const DEFAULT_CONFIG = {
|
|
32
|
+
version: 1,
|
|
33
|
+
mode: 'observe',
|
|
34
|
+
anchor_bands: { SIMPLE: [0, 20], MEDIUM: [20, 51], COMPLEX: [51, 76], REASONING: [76, 101] },
|
|
35
|
+
harness: {
|
|
36
|
+
patterns: [
|
|
37
|
+
{
|
|
38
|
+
name: 'terminus',
|
|
39
|
+
preamble: 'You are an AI assistant tasked with solving command-line tasks',
|
|
40
|
+
instruction: '(?:^|\\n)Instruction:\\s*\\n([\\s\\S]*?)\\n\\s*\\n(?:Your response must be|Your response|Respond)',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
signals: {
|
|
45
|
+
anchor: { type: 'anchor_score' },
|
|
46
|
+
judge: { type: 'jev' },
|
|
47
|
+
harness: { type: 'harness' },
|
|
48
|
+
structured: { type: 'request_field', any_of: ['output_format', 'response_format'] },
|
|
49
|
+
tool_count: { type: 'tool_count' },
|
|
50
|
+
turn: { type: 'session_turn' },
|
|
51
|
+
phase: { type: 'session_phase' },
|
|
52
|
+
risk: { type: 'risk' },
|
|
53
|
+
},
|
|
54
|
+
decisions: [
|
|
55
|
+
{ name: 'legacy', priority: 0, rules: { operator: 'AND', conditions: [] }, tier: 'from:legacy' },
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let _cache = { path: null, mtime: 0, config: DEFAULT_CONFIG, source: 'default' };
|
|
60
|
+
|
|
61
|
+
function configPath() {
|
|
62
|
+
return process.env.LYNKR_ROUTING_CONFIG ? path.resolve(process.env.LYNKR_ROUTING_CONFIG) : DEFAULT_PATH;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _isRule(x) { return x && typeof x === 'object' && typeof x.operator === 'string'; }
|
|
66
|
+
|
|
67
|
+
function validate(cfg) {
|
|
68
|
+
const errors = [];
|
|
69
|
+
if (!cfg || typeof cfg !== 'object') return ['config is not an object'];
|
|
70
|
+
if (cfg.mode && !['observe', 'enforce'].includes(cfg.mode)) errors.push(`mode must be observe|enforce (got ${cfg.mode})`);
|
|
71
|
+
const signals = cfg.signals || {};
|
|
72
|
+
for (const [name, s] of Object.entries(signals)) {
|
|
73
|
+
if (!s || typeof s.type !== 'string') errors.push(`signal ${name}: missing type`);
|
|
74
|
+
}
|
|
75
|
+
const decisions = Array.isArray(cfg.decisions) ? cfg.decisions : [];
|
|
76
|
+
if (decisions.length === 0) errors.push('decisions: at least one decision is required');
|
|
77
|
+
const seenNames = new Set(), seenPri = new Map();
|
|
78
|
+
const checkRule = (rule, where) => {
|
|
79
|
+
if (!_isRule(rule)) { errors.push(`${where}: rules must have operator`); return; }
|
|
80
|
+
const op = rule.operator.toUpperCase();
|
|
81
|
+
if (!['AND', 'OR', 'NOT'].includes(op)) errors.push(`${where}: operator must be AND|OR|NOT`);
|
|
82
|
+
const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
|
|
83
|
+
if (op === 'NOT' && conds.length !== 1) errors.push(`${where}: NOT takes exactly one condition`);
|
|
84
|
+
conds.forEach((c, i) => {
|
|
85
|
+
if (_isRule(c)) return checkRule(c, `${where}.conditions[${i}]`);
|
|
86
|
+
if (!c || typeof c.signal !== 'string') errors.push(`${where}.conditions[${i}]: missing signal`);
|
|
87
|
+
else if (!signals[c.signal]) errors.push(`${where}.conditions[${i}]: unknown signal "${c.signal}"`);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
decisions.forEach((d, i) => {
|
|
91
|
+
const where = `decisions[${i}]${d?.name ? ` (${d.name})` : ''}`;
|
|
92
|
+
if (!d || typeof d.name !== 'string') errors.push(`${where}: missing name`);
|
|
93
|
+
else if (seenNames.has(d.name)) errors.push(`${where}: duplicate name`); else seenNames.add(d.name);
|
|
94
|
+
if (!Number.isFinite(d?.priority)) errors.push(`${where}: priority must be a number`);
|
|
95
|
+
else if (seenPri.has(d.priority)) errors.push(`${where}: priority ${d.priority} also used by ${seenPri.get(d.priority)}`); else seenPri.set(d.priority, d.name);
|
|
96
|
+
checkRule(d?.rules, `${where}.rules`);
|
|
97
|
+
const t = d?.tier;
|
|
98
|
+
if (t !== undefined && !(TIERS.includes(t) || /^from:(legacy|anchor|judge)$/.test(String(t)))) errors.push(`${where}: tier must be a tier name or from:legacy|anchor|judge`);
|
|
99
|
+
if (d?.effort !== undefined && !['none', 'low', 'medium', 'high'].includes(d.effort)) errors.push(`${where}: effort must be none|low|medium|high`);
|
|
100
|
+
if (d?.hosts !== undefined && !Array.isArray(d.hosts)) errors.push(`${where}: hosts must be an array`);
|
|
101
|
+
});
|
|
102
|
+
for (const p of (cfg.harness?.patterns || [])) {
|
|
103
|
+
try { new RegExp(p.preamble, 'i'); if (p.instruction) new RegExp(p.instruction, 'i'); } catch (e) { errors.push(`harness pattern ${p.name}: ${e.message}`); }
|
|
104
|
+
}
|
|
105
|
+
return errors;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function load() {
|
|
109
|
+
if (_cache.source === 'test') return _cache.config;
|
|
110
|
+
const p = configPath();
|
|
111
|
+
try {
|
|
112
|
+
if (!fs.existsSync(p)) {
|
|
113
|
+
if (_cache.source !== 'default') logger.info({ path: p }, '[RoutingConfig] file missing — using built-in default');
|
|
114
|
+
_cache = { path: p, mtime: 0, config: DEFAULT_CONFIG, source: 'default' };
|
|
115
|
+
return _cache.config;
|
|
116
|
+
}
|
|
117
|
+
const st = fs.statSync(p);
|
|
118
|
+
if (_cache.path === p && _cache.mtime === st.mtimeMs) return _cache.config;
|
|
119
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
120
|
+
const merged = { ...DEFAULT_CONFIG, ...raw, signals: { ...DEFAULT_CONFIG.signals, ...(raw.signals || {}) } };
|
|
121
|
+
const errors = validate(merged);
|
|
122
|
+
if (errors.length) {
|
|
123
|
+
logger.error({ path: p, errors }, '[RoutingConfig] invalid — keeping previous config');
|
|
124
|
+
_cache.mtime = st.mtimeMs; // don't re-log every request
|
|
125
|
+
return _cache.config;
|
|
126
|
+
}
|
|
127
|
+
merged.decisions = [...merged.decisions].sort((a, b) => b.priority - a.priority);
|
|
128
|
+
_cache = { path: p, mtime: st.mtimeMs, config: merged, source: 'file' };
|
|
129
|
+
logger.info({ path: p, mode: merged.mode, decisions: merged.decisions.map((d) => d.name) }, '[RoutingConfig] loaded');
|
|
130
|
+
return merged;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
logger.error({ path: p, err: err.message }, '[RoutingConfig] load failed — keeping previous config');
|
|
133
|
+
return _cache.config;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function mode() { return load().mode === 'enforce' ? 'enforce' : 'observe'; }
|
|
138
|
+
function anchorBandFor(score) {
|
|
139
|
+
const bands = load().anchor_bands || DEFAULT_CONFIG.anchor_bands;
|
|
140
|
+
const s = Number(score);
|
|
141
|
+
if (!Number.isFinite(s)) return null;
|
|
142
|
+
for (const t of TIERS) { const [lo, hi] = bands[t] || []; if (s >= lo && s < hi) return t; }
|
|
143
|
+
return s >= 100 ? 'REASONING' : null;
|
|
144
|
+
}
|
|
145
|
+
function harnessPatterns() {
|
|
146
|
+
return (load().harness?.patterns || []).map((p) => ({
|
|
147
|
+
name: p.name,
|
|
148
|
+
preamble: new RegExp(p.preamble, 'i'),
|
|
149
|
+
instruction: p.instruction ? new RegExp(p.instruction, 'i') : null,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
function _resetForTests(cfg) { _cache = { path: null, mtime: 0, config: cfg || DEFAULT_CONFIG, source: cfg ? 'test' : 'default' }; }
|
|
153
|
+
|
|
154
|
+
module.exports = { TIERS, DEFAULT_CONFIG, load, validate, mode, anchorBandFor, harnessPatterns, configPath, _resetForTests };
|
package/src/routing/shortfall.js
CHANGED
|
@@ -78,6 +78,7 @@ function loadProfiles() {
|
|
|
78
78
|
const weights = _normalizeWeights(raw?.weights);
|
|
79
79
|
_profilesCache = {
|
|
80
80
|
tierProfiles,
|
|
81
|
+
evaluation: Array.isArray(raw?.evaluation?.records) ? raw.evaluation.records : [],
|
|
81
82
|
modelOverrides,
|
|
82
83
|
enabled: raw?.enabled === true,
|
|
83
84
|
tau: Number.isFinite(tau) && tau >= 0 ? tau : DEFAULT_TAU,
|
|
@@ -85,7 +86,7 @@ function loadProfiles() {
|
|
|
85
86
|
};
|
|
86
87
|
} catch (err) {
|
|
87
88
|
logger.debug({ err: err.message }, '[Shortfall] profiles load failed — disabled with tier fallbacks');
|
|
88
|
-
_profilesCache = { tierProfiles: {}, modelOverrides: {}, enabled: false, tau: DEFAULT_TAU, weights: _defaultWeights() };
|
|
89
|
+
_profilesCache = { tierProfiles: {}, modelOverrides: {}, evaluation: [], enabled: false, tau: DEFAULT_TAU, weights: _defaultWeights() };
|
|
89
90
|
}
|
|
90
91
|
return _profilesCache;
|
|
91
92
|
}
|
|
@@ -99,6 +100,7 @@ function _setProfilesForTests(profiles) {
|
|
|
99
100
|
const base = loadProfiles();
|
|
100
101
|
_profilesCache = {
|
|
101
102
|
tierProfiles: profiles?.tierProfiles ?? base.tierProfiles,
|
|
103
|
+
evaluation: profiles?.evaluation ?? base.evaluation ?? [],
|
|
102
104
|
modelOverrides: profiles?.modelOverrides ?? base.modelOverrides,
|
|
103
105
|
enabled: profiles?.enabled ?? base.enabled,
|
|
104
106
|
tau: profiles?.tau ?? base.tau,
|
|
@@ -153,6 +155,14 @@ function isEnabled() {
|
|
|
153
155
|
* seed:shipped|family|tier|tier-fallback (telemetry provenance).
|
|
154
156
|
*/
|
|
155
157
|
function resolveCapabilitiesWithSource({ provider, model, tier }) {
|
|
158
|
+
{
|
|
159
|
+
// Measured evaluation records (written by scripts/calibrate-capabilities.js
|
|
160
|
+
// --apply) outrank operator overrides and shipped seeds.
|
|
161
|
+
const _ev = loadProfiles().evaluation;
|
|
162
|
+
const _key = `${provider}:${model}`.toLowerCase();
|
|
163
|
+
const _rec = Array.isArray(_ev) ? _ev.find((r) => String(r.model || '').toLowerCase() === _key && r.heads) : null;
|
|
164
|
+
if (_rec) return { caps: _sanitizeCaps(_rec.heads), source: `evaluation:${_rec.benchmark || 'measured'}` };
|
|
165
|
+
}
|
|
156
166
|
const { tierProfiles, modelOverrides } = loadProfiles();
|
|
157
167
|
const key = `${String(provider || '').toLowerCase()}:${String(model || '').toLowerCase()}`;
|
|
158
168
|
const wild = `${String(provider || '').toLowerCase()}:*`;
|
|
@@ -280,8 +290,87 @@ function selectByShortfall(req, candidates, opts = {}) {
|
|
|
280
290
|
}
|
|
281
291
|
}
|
|
282
292
|
|
|
293
|
+
const TIER_MIDPOINTS = [['SIMPLE', 10], ['MEDIUM', 35], ['COMPLEX', 63], ['REASONING', 88]];
|
|
294
|
+
|
|
295
|
+
function _tierVec(tierProfiles, tier) {
|
|
296
|
+
const tp = tierProfiles?.[tier];
|
|
297
|
+
if (!tp) return null;
|
|
298
|
+
const v = {};
|
|
299
|
+
for (const h of HEADS) v[h] = Number.isFinite(tp[h]) ? Math.max(0, Math.min(1, tp[h])) : 0;
|
|
300
|
+
return v;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Anchor score (0–100) → head vector interpolated between tier profiles. */
|
|
304
|
+
const _r3 = (v) => Math.round(Math.max(0, Math.min(1, v)) * 1000) / 1000;
|
|
305
|
+
|
|
306
|
+
function anchorRequirement(anchorScore, tierProfiles = null) {
|
|
307
|
+
if (anchorScore === null || anchorScore === undefined || anchorScore === '') return null;
|
|
308
|
+
const s = Number(anchorScore);
|
|
309
|
+
if (!Number.isFinite(s)) return null;
|
|
310
|
+
const tps = tierProfiles || loadProfiles().tierProfiles;
|
|
311
|
+
const pts = TIER_MIDPOINTS.map(([t, mid]) => [mid, _tierVec(tps, t)]).filter(([, v]) => v);
|
|
312
|
+
if (pts.length === 0) return null;
|
|
313
|
+
if (s <= pts[0][0]) return pts[0][1];
|
|
314
|
+
if (s >= pts[pts.length - 1][0]) return pts[pts.length - 1][1];
|
|
315
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
316
|
+
const [a, va] = pts[i], [b, vb] = pts[i + 1];
|
|
317
|
+
if (s >= a && s <= b) {
|
|
318
|
+
const f = (s - a) / (b - a);
|
|
319
|
+
const v = {};
|
|
320
|
+
for (const h of HEADS) v[h] = _r3(va[h] + (vb[h] - va[h]) * f);
|
|
321
|
+
return v;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Jev verdict { probabilities:{TIER:p} } (or { tier, confidence }) → head vector. */
|
|
328
|
+
function jevRequirement(jev, tierProfiles = null) {
|
|
329
|
+
if (!jev || typeof jev !== 'object') return null;
|
|
330
|
+
const tps = tierProfiles || loadProfiles().tierProfiles;
|
|
331
|
+
let probs = jev.probabilities && typeof jev.probabilities === 'object' ? jev.probabilities : null;
|
|
332
|
+
if (!probs && jev.tier) probs = { [jev.tier]: Number.isFinite(jev.confidence) ? jev.confidence : 1 };
|
|
333
|
+
if (!probs) return null;
|
|
334
|
+
const v = {}; for (const h of HEADS) v[h] = 0;
|
|
335
|
+
let total = 0;
|
|
336
|
+
for (const [tier, p] of Object.entries(probs)) {
|
|
337
|
+
const tv = _tierVec(tps, tier); const w = Number(p);
|
|
338
|
+
if (!tv || !Number.isFinite(w) || w <= 0) continue;
|
|
339
|
+
total += w;
|
|
340
|
+
for (const h of HEADS) v[h] += tv[h] * w;
|
|
341
|
+
}
|
|
342
|
+
if (total <= 0) return null;
|
|
343
|
+
for (const h of HEADS) v[h] = _r3(v[h] / total);
|
|
344
|
+
return v;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Lift a structural requirement vector with the semantic signals.
|
|
349
|
+
* @returns {{ req: object, lift: { anchor: object|null, jev: object|null, applied: string[] } }}
|
|
350
|
+
*/
|
|
351
|
+
function liftRequirement(req, { anchorScore = null, jev = null } = {}, tierProfiles = null) {
|
|
352
|
+
try {
|
|
353
|
+
const base = {}; for (const h of HEADS) base[h] = Number.isFinite(req?.[h]) ? req[h] : 0;
|
|
354
|
+
const a = anchorRequirement(anchorScore, tierProfiles);
|
|
355
|
+
const j = jevRequirement(jev, tierProfiles);
|
|
356
|
+
const out = { ...base }; const applied = [];
|
|
357
|
+
for (const h of HEADS) {
|
|
358
|
+
if (a && a[h] > out[h]) { out[h] = a[h]; if (!applied.includes('anchor')) applied.push('anchor'); }
|
|
359
|
+
if (j && j[h] > out[h]) { out[h] = j[h]; if (!applied.includes('jev')) applied.push('jev'); }
|
|
360
|
+
out[h] = Math.round(Math.max(0, Math.min(1, out[h])) * 1000) / 1000;
|
|
361
|
+
}
|
|
362
|
+
return { req: out, lift: { anchor: a, jev: j, applied } };
|
|
363
|
+
} catch (err) {
|
|
364
|
+
logger.debug({ err: err.message }, '[Shortfall] semantic lift failed — structural vector kept');
|
|
365
|
+
return { req, lift: { anchor: null, jev: null, applied: [] } };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
283
369
|
module.exports = {
|
|
284
370
|
HEADS,
|
|
371
|
+
anchorRequirement,
|
|
372
|
+
jevRequirement,
|
|
373
|
+
liftRequirement,
|
|
285
374
|
DEFAULT_TAU,
|
|
286
375
|
loadProfiles,
|
|
287
376
|
_resetProfilesCache,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal registry for declarative routing.
|
|
3
|
+
*
|
|
4
|
+
* A signal is a pure function of the request context that returns
|
|
5
|
+
* { matched: boolean, value: any, confidence: number|null, detail?: object }
|
|
6
|
+
* Signals never route; decisions (decisions.js) combine them. Every evaluator
|
|
7
|
+
* fails open: an exception yields { matched:false, value:null, error }.
|
|
8
|
+
*
|
|
9
|
+
* Context (ctx): { payload, analysis, legacy:{tier,provider,model}, risk,
|
|
10
|
+
* agenticResult, sessionId, prevTurns:[] }
|
|
11
|
+
*/
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const logger = require('../logger');
|
|
15
|
+
const rc = require('./routing-config');
|
|
16
|
+
|
|
17
|
+
const TIER_PRI = { SIMPLE: 1, MEDIUM: 2, COMPLEX: 3, REASONING: 4 };
|
|
18
|
+
|
|
19
|
+
function _text(msg) {
|
|
20
|
+
if (!msg) return '';
|
|
21
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
22
|
+
if (Array.isArray(msg.content)) return msg.content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
function _lastOfRole(messages, role) {
|
|
26
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i]?.role === role) return messages[i];
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function _hasToolResult(msg) {
|
|
30
|
+
return Array.isArray(msg?.content) && msg.content.some((b) => b && b.type === 'tool_result');
|
|
31
|
+
}
|
|
32
|
+
function _completionClaim(text) {
|
|
33
|
+
return /"is_task_complete"\s*:\s*true/.test(text) || /\b(task (is )?complete|all done|completed successfully)\b/i.test(text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const EVALUATORS = {
|
|
37
|
+
anchor_score(ctx) {
|
|
38
|
+
const v = ctx.analysis?.anchorScore ?? ctx.analysis?.score ?? null;
|
|
39
|
+
if (!Number.isFinite(v)) return { matched: false, value: null, confidence: null };
|
|
40
|
+
return { matched: true, value: v, confidence: null, band: rc.anchorBandFor(v) };
|
|
41
|
+
},
|
|
42
|
+
jev(ctx, opt) {
|
|
43
|
+
const j = ctx.analysis?.jev;
|
|
44
|
+
if (!j || !j.tier) return { matched: false, value: null, confidence: null };
|
|
45
|
+
const minC = Number.isFinite(opt.min_confidence) ? opt.min_confidence : 0;
|
|
46
|
+
return { matched: (j.confidence ?? 0) >= minC, value: j.tier, confidence: j.confidence ?? null, tier: j.tier, probabilities: j.probabilities || null, risky: j.risky ?? null };
|
|
47
|
+
},
|
|
48
|
+
harness(ctx) {
|
|
49
|
+
const { harnessAskFromPayload } = require('./harness-envelope');
|
|
50
|
+
const ask = harnessAskFromPayload(ctx.payload);
|
|
51
|
+
return ask ? { matched: true, value: ask.name || 'harness', confidence: 1, detail: { instruction: ask.text.slice(0, 200) } } : { matched: false, value: null, confidence: null };
|
|
52
|
+
},
|
|
53
|
+
request_field(ctx, opt) {
|
|
54
|
+
const keys = Array.isArray(opt.any_of) ? opt.any_of : [];
|
|
55
|
+
const hit = keys.find((k) => ctx.payload && ctx.payload[k] !== undefined && ctx.payload[k] !== null);
|
|
56
|
+
return hit ? { matched: true, value: hit, confidence: 1 } : { matched: false, value: null, confidence: null };
|
|
57
|
+
},
|
|
58
|
+
tool_count(ctx) {
|
|
59
|
+
const n = Array.isArray(ctx.payload?.tools) ? ctx.payload.tools.length : 0;
|
|
60
|
+
return { matched: n > 0, value: n, confidence: 1 };
|
|
61
|
+
},
|
|
62
|
+
session_turn(ctx) {
|
|
63
|
+
const msgs = Array.isArray(ctx.payload?.messages) ? ctx.payload.messages : [];
|
|
64
|
+
const n = msgs.filter((m) => m?.role === 'assistant').length;
|
|
65
|
+
return { matched: n > 0, value: n, confidence: 1 };
|
|
66
|
+
},
|
|
67
|
+
session_phase(ctx) {
|
|
68
|
+
const msgs = Array.isArray(ctx.payload?.messages) ? ctx.payload.messages : [];
|
|
69
|
+
const assistants = msgs.filter((m) => m?.role === 'assistant');
|
|
70
|
+
if (assistants.length === 0) return { matched: true, value: 'planning', confidence: 0.9 };
|
|
71
|
+
const lastA = _text(_lastOfRole(msgs, 'assistant'));
|
|
72
|
+
const lastU = _lastOfRole(msgs, 'user');
|
|
73
|
+
if (_completionClaim(lastA)) return { matched: true, value: 'done', confidence: 0.8 };
|
|
74
|
+
if (/\b(pytest|npm test|go test|make test|verify|check that|assert)\b/i.test(lastA)) return { matched: true, value: 'verification', confidence: 0.6 };
|
|
75
|
+
if (_hasToolResult(lastU) || /\$ |root@|Traceback|error:/i.test(_text(lastU))) return { matched: true, value: 'tool_loop', confidence: 0.7 };
|
|
76
|
+
return { matched: true, value: 'tool_loop', confidence: 0.4 };
|
|
77
|
+
},
|
|
78
|
+
risk(ctx) {
|
|
79
|
+
const lvl = ctx.risk?.level || null;
|
|
80
|
+
return { matched: lvl === 'high', value: lvl, confidence: ctx.risk?.score ?? null };
|
|
81
|
+
},
|
|
82
|
+
keyword(ctx, opt) {
|
|
83
|
+
const terms = Array.isArray(opt.terms) ? opt.terms : [];
|
|
84
|
+
if (!terms.length) return { matched: false, value: null, confidence: null };
|
|
85
|
+
const { harnessAskFromPayload } = require('./harness-envelope');
|
|
86
|
+
const ask = harnessAskFromPayload(ctx.payload);
|
|
87
|
+
const text = (ask ? ask.text : _text(_lastOfRole(ctx.payload?.messages || [], 'user'))).toLowerCase();
|
|
88
|
+
const method = opt.method || 'any';
|
|
89
|
+
const hits = terms.filter((t) => opt.regex ? new RegExp(t, 'i').test(text) : text.includes(String(t).toLowerCase()));
|
|
90
|
+
if (method === 'all') return { matched: hits.length === terms.length, value: hits, confidence: hits.length / terms.length };
|
|
91
|
+
const thr = Number.isFinite(opt.min_hits) ? opt.min_hits : 1;
|
|
92
|
+
return { matched: hits.length >= thr, value: hits, confidence: terms.length ? hits.length / terms.length : 0 };
|
|
93
|
+
},
|
|
94
|
+
async complexity(ctx, opt) {
|
|
95
|
+
// Exemplar contrast: sim(hard centroid) − sim(easy centroid), per head,
|
|
96
|
+
// using the embedder already configured. Exemplars from
|
|
97
|
+
// config/complexity-exemplars.json (scripts/build-complexity-exemplars.js).
|
|
98
|
+
const emb = require('../cache/embeddings');
|
|
99
|
+
const fs = require('fs'), path = require('path');
|
|
100
|
+
const file = opt.exemplars ? path.resolve(opt.exemplars) : path.join(__dirname, '..', '..', 'config', 'complexity-exemplars.json');
|
|
101
|
+
if (!fs.existsSync(file)) return { matched: false, value: null, confidence: null, error: 'no exemplars file' };
|
|
102
|
+
const st = fs.statSync(file);
|
|
103
|
+
if (!EVALUATORS._cx || EVALUATORS._cx.file !== file || EVALUATORS._cx.mtime !== st.mtimeMs) {
|
|
104
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
105
|
+
const centroid = async (texts) => {
|
|
106
|
+
const vs = []; for (const t of (texts || []).slice(0, 60)) { const v = await emb.generateEmbedding(t); if (Array.isArray(v) && v.length) vs.push(v); }
|
|
107
|
+
if (!vs.length) return null;
|
|
108
|
+
const c = new Array(vs[0].length).fill(0); for (const v of vs) for (let i = 0; i < c.length; i++) c[i] += v[i] / vs.length; return c;
|
|
109
|
+
};
|
|
110
|
+
const heads = {};
|
|
111
|
+
for (const [h, sets] of Object.entries(doc.heads || {})) heads[h] = { hard: await centroid(sets.hard), easy: await centroid(sets.easy) };
|
|
112
|
+
EVALUATORS._cx = { file, mtime: st.mtimeMs, heads };
|
|
113
|
+
}
|
|
114
|
+
const { harnessAskFromPayload } = require('./harness-envelope');
|
|
115
|
+
const ask = harnessAskFromPayload(ctx.payload);
|
|
116
|
+
const text = ask ? ask.text : _text(_lastOfRole(ctx.payload?.messages || [], 'user'));
|
|
117
|
+
if (!text) return { matched: false, value: null, confidence: null };
|
|
118
|
+
const q = await emb.generateEmbedding(text.slice(0, 4000));
|
|
119
|
+
if (!Array.isArray(q)) return { matched: false, value: null, confidence: null };
|
|
120
|
+
const perHead = {}; let sum = 0, n = 0;
|
|
121
|
+
for (const [h, c] of Object.entries(EVALUATORS._cx.heads)) {
|
|
122
|
+
if (!c.hard || !c.easy) continue;
|
|
123
|
+
const d = emb.cosineSimilarity(q, c.hard) - emb.cosineSimilarity(q, c.easy); // typically within ±0.3
|
|
124
|
+
const v = Math.max(0, Math.min(1, 0.5 + d / (2 * (Number(opt.scale) || 0.15))));
|
|
125
|
+
perHead[h] = Math.round(v * 1000) / 1000; sum += v; n++;
|
|
126
|
+
}
|
|
127
|
+
if (!n) return { matched: false, value: null, confidence: null };
|
|
128
|
+
const value = Math.round((sum / n) * 1000) / 1000;
|
|
129
|
+
const thr = Number.isFinite(opt.threshold) ? opt.threshold : 0.6;
|
|
130
|
+
return { matched: value >= thr, value, confidence: Math.abs(value - 0.5) * 2, heads: perHead, band: value >= 0.75 ? 'REASONING' : value >= 0.6 ? 'COMPLEX' : value >= 0.4 ? 'MEDIUM' : 'SIMPLE' };
|
|
131
|
+
},
|
|
132
|
+
legacy_tier(ctx) {
|
|
133
|
+
const t = ctx.legacy?.tier || null;
|
|
134
|
+
return { matched: !!t, value: t, confidence: 1 };
|
|
135
|
+
},
|
|
136
|
+
prev_outcome(ctx) {
|
|
137
|
+
const last = Array.isArray(ctx.prevTurns) && ctx.prevTurns.length ? ctx.prevTurns[ctx.prevTurns.length - 1] : null;
|
|
138
|
+
if (!last) return { matched: false, value: null, confidence: null };
|
|
139
|
+
return { matched: true, value: last.outcome, confidence: 1, attributable: !!last.attributable, streak: last.streak ?? null };
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Evaluate every configured signal once. Returns { name: result }. */
|
|
144
|
+
async function evaluateAll(ctx, config) {
|
|
145
|
+
const cfg = config || rc.load();
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const [name, def] of Object.entries(cfg.signals || {})) {
|
|
148
|
+
const fn = EVALUATORS[def.type];
|
|
149
|
+
if (!fn) { out[name] = { matched: false, value: null, confidence: null, error: `unknown type ${def.type}` }; continue; }
|
|
150
|
+
try {
|
|
151
|
+
const r = await fn(ctx, def);
|
|
152
|
+
out[name] = { matched: !!r.matched, value: r.value ?? null, confidence: r.confidence ?? null, ...r };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logger.debug({ signal: name, err: err.message }, '[Signals] evaluator failed — treated as unmatched');
|
|
155
|
+
out[name] = { matched: false, value: null, confidence: null, error: err.message };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Condition test used by the decision engine. */
|
|
162
|
+
function testCondition(cond, signals) {
|
|
163
|
+
const s = signals[cond.signal];
|
|
164
|
+
if (!s) return false;
|
|
165
|
+
// Default semantics: a condition on a signal requires that signal to have
|
|
166
|
+
// matched (e.g. a judge below min_confidence must not satisfy tier_in).
|
|
167
|
+
if (cond.matched === undefined ? !s.matched : (!!s.matched !== !!cond.matched)) return false;
|
|
168
|
+
if (cond.equals !== undefined && s.value !== cond.equals) return false;
|
|
169
|
+
if (Array.isArray(cond.in) && !cond.in.includes(s.value)) return false;
|
|
170
|
+
if (Array.isArray(cond.band_in) && !cond.band_in.includes(s.band)) return false;
|
|
171
|
+
if (Array.isArray(cond.tier_in) && !cond.tier_in.includes(s.tier ?? s.value)) return false;
|
|
172
|
+
if (Number.isFinite(cond.min) && !(Number(s.value) >= cond.min)) return false;
|
|
173
|
+
if (Number.isFinite(cond.max) && !(Number(s.value) <= cond.max)) return false;
|
|
174
|
+
if (Number.isFinite(cond.min_confidence) && !((s.confidence ?? 0) >= cond.min_confidence)) return false;
|
|
175
|
+
if (cond.min_tier && !((TIER_PRI[s.tier ?? s.value] || 0) >= (TIER_PRI[cond.min_tier] || 0))) return false;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { EVALUATORS, evaluateAll, testCondition, TIER_PRI };
|