lynkr 9.14.1 → 9.14.3

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 (96) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +16 -22
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/health.js +0 -1
  17. package/src/api/mcp-broker.js +149 -0
  18. package/src/api/middleware/budget-enforcer.js +77 -7
  19. package/src/api/middleware/budget.js +36 -2
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +210 -15
  22. package/src/api/providers-handler.js +16 -1
  23. package/src/api/router.js +73 -106
  24. package/src/auth-mode.js +40 -4
  25. package/src/budget/index.js +86 -0
  26. package/src/cache/embeddings.js +125 -35
  27. package/src/cache/onnx-embedder.js +92 -0
  28. package/src/cache/prompt.js +0 -7
  29. package/src/clients/bedrock-utils.js +2 -1
  30. package/src/clients/codex-process.js +1 -1
  31. package/src/clients/codex-utils.js +0 -2
  32. package/src/clients/databricks.js +337 -46
  33. package/src/clients/gpt-utils.js +84 -8
  34. package/src/clients/health-probe.js +157 -0
  35. package/src/clients/openai-format.js +73 -23
  36. package/src/clients/provider-capabilities.js +27 -2
  37. package/src/clients/resilience.js +0 -1
  38. package/src/clients/responses-format.js +0 -56
  39. package/src/clients/routing.js +1 -1
  40. package/src/config/index.js +13 -2
  41. package/src/context/budget.js +1 -1
  42. package/src/context/compression.js +127 -19
  43. package/src/context/tool-result-compressor.js +3 -5
  44. package/src/dashboard/api.js +1 -1
  45. package/src/logger/oversized-error-stream.js +1 -1
  46. package/src/memory/extractor.js +3 -3
  47. package/src/memory/search.js +2 -2
  48. package/src/memory/store.js +9 -24
  49. package/src/memory/tools.js +14 -0
  50. package/src/observability/metrics.js +52 -3
  51. package/src/observability/otel.js +180 -0
  52. package/src/orchestrator/azure-responses-sse.js +223 -0
  53. package/src/orchestrator/index.js +216 -212
  54. package/src/orchestrator/passthrough-stream.js +7 -1
  55. package/src/orchestrator/sse-transformer.js +130 -13
  56. package/src/policy/safe-commands.js +0 -1
  57. package/src/routing/bandit.js +39 -0
  58. package/src/routing/calibration.js +21 -1
  59. package/src/routing/capabilities.js +88 -0
  60. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  61. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  62. package/src/routing/capability-seeds/family.js +105 -0
  63. package/src/routing/capability-seeds/models-dev.js +64 -0
  64. package/src/routing/capability-seeds/normalize.js +160 -0
  65. package/src/routing/capability-seeds/registry.js +125 -0
  66. package/src/routing/capability-seeds/sources.js +69 -0
  67. package/src/routing/capability-seeds/swebench.js +66 -0
  68. package/src/routing/client-profiles.js +26 -0
  69. package/src/routing/complexity-analyzer.js +5 -8
  70. package/src/routing/confidence-scorer.js +2 -2
  71. package/src/routing/cost-optimizer.js +1 -2
  72. package/src/routing/decide.js +149 -0
  73. package/src/routing/deescalator.js +38 -1
  74. package/src/routing/embedding-cache.js +0 -1
  75. package/src/routing/index.js +202 -86
  76. package/src/routing/intent-score.js +0 -1
  77. package/src/routing/knn-router.js +27 -5
  78. package/src/routing/latency-tracker.js +1 -1
  79. package/src/routing/model-registry.js +25 -0
  80. package/src/routing/model-slots.js +65 -0
  81. package/src/routing/model-tiers.js +2 -0
  82. package/src/routing/ope.js +255 -0
  83. package/src/routing/openai-model-slots.js +82 -0
  84. package/src/routing/regret-estimator.js +1 -1
  85. package/src/routing/shortfall.js +290 -0
  86. package/src/routing/stuck-detector.js +127 -0
  87. package/src/routing/telemetry.js +11 -2
  88. package/src/server.js +41 -3
  89. package/src/stores/file-store.js +5 -1
  90. package/src/tools/web-search-exec.js +182 -0
  91. package/src/training/trajectory-compressor.js +1 -1
  92. package/src/workers/pool.js +1 -1
  93. package/src/workers/worker.js +1 -1
  94. package/scripts/convert-routellm.py +0 -105
  95. package/src/diff/comments.js +0 -153
  96. package/src/policy/web-fallback.js +0 -33
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Routing decision core — the harness-shaped, standalone pieces of the
3
+ * per-request model decision, extracted from routing/index.js so they can be
4
+ * consumed by more than one caller:
5
+ *
6
+ * - routing/index.js (the live request path, unchanged behavior)
7
+ * - the off-policy evaluator (WS4 follow-up), which must re-derive a
8
+ * candidate policy's π(a|x) on logged rows using the exact same context
9
+ * vector and candidate-eligibility rules the live policy used
10
+ * - a future remote decision endpoint, which needs decide-shaped logic
11
+ * that doesn't reach into request/response plumbing
12
+ *
13
+ * Deliberately excluded: session pins, the escalation ladder, kNN confidence
14
+ * branching, deadline/tenant overrides. Those are decision *inputs and
15
+ * post-passes* owned by the caller; this module owns only "given a context
16
+ * and a candidate set, pick one and report the probability of that pick."
17
+ *
18
+ * Nothing here catches errors — callers keep their existing
19
+ * degradation.record() wrapping so failure accounting stays where it was.
20
+ */
21
+
22
+ const { getBandit } = require('./bandit');
23
+
24
+ /**
25
+ * Task-type one-hot vocabulary. Order is load-bearing: it defines feature
26
+ * indices 6..11 of the context vector, and every persisted bandit arm was
27
+ * trained against this order. Do not reorder or insert — append only, and
28
+ * only together with a bandit-state migration.
29
+ */
30
+ const TASK_TYPES = ['code_gen', 'summarization', 'reasoning', 'factoid', 'chat', 'other'];
31
+
32
+ /**
33
+ * Build the 12-dim context vector the bandit (and any off-policy estimator
34
+ * replaying its decisions) scores against.
35
+ *
36
+ * Layout: [score, log-tokens, has-tools, streaming, risk, agentic,
37
+ * ...one-hot task type (6)].
38
+ *
39
+ * @param {object} args
40
+ * @param {object} args.analysis — complexity analysis ({ score, breakdown })
41
+ * @param {object} [args.payload] — request payload (for tools presence)
42
+ * @param {object} [args.options] — routing options (for streaming flag)
43
+ * @param {object} [args.risk] — risk analysis ({ level })
44
+ * @param {object} [args.agenticResult] — agentic detection ({ isAgentic })
45
+ * @returns {number[]} 12-dim feature vector
46
+ */
47
+ function buildContextVector({ analysis, payload, options, risk, agenticResult }) {
48
+ const inferredTask = (analysis?.breakdown?.taskType?.reason || 'other').toLowerCase();
49
+ const taskIdx = Math.max(0, TASK_TYPES.findIndex(t => inferredTask.includes(t)));
50
+ return [
51
+ (analysis?.score || 0) / 100,
52
+ Math.log(Math.max(1, analysis?.breakdown?.tokenCount || 0) + 1) / 15,
53
+ ((payload?.tools?.length ?? 0) > 0) ? 1 : 0,
54
+ options?.streaming ? 1 : 0,
55
+ risk?.level === 'high' ? 1 : risk?.level === 'medium' ? 0.5 : 0,
56
+ agenticResult?.isAgentic ? 1 : 0,
57
+ ...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
58
+ ];
59
+ }
60
+
61
+ /**
62
+ * Build the bandit's candidate set: the current selection plus the kNN
63
+ * alternative, if it differs AND is configured in some TIER_* entry.
64
+ *
65
+ * Tier-aware filter: the bandit may explore freely across the user's
66
+ * configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier model),
67
+ * but never pick a credentialed-but-untiered model (e.g. an Azure deployment
68
+ * present in .env for another purpose but referenced by no TIER_*). Tier
69
+ * routing stays the source of truth for eligibility.
70
+ *
71
+ * @param {{ provider: string, model: string }} current
72
+ * @param {{ provider: string, model: string }|null} alternative
73
+ * @returns {Array<{ provider: string, model: string }>}
74
+ */
75
+ function buildCandidates(current, alternative) {
76
+ const candidates = [{ provider: current.provider, model: current.model }];
77
+ if (alternative && alternative.model && alternative.model !== current.model) {
78
+ const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
79
+ const inConfig = configured.some(
80
+ m => m.provider === alternative.provider && m.model === alternative.model
81
+ );
82
+ if (inConfig) {
83
+ candidates.push({ provider: alternative.provider, model: alternative.model });
84
+ }
85
+ }
86
+ return candidates;
87
+ }
88
+
89
+ /**
90
+ * The decision core: given a tier, a candidate set, and a context vector,
91
+ * have the bandit pick one and report the pick's propensity.
92
+ *
93
+ * Returns null when there's nothing to adjudicate (fewer than 2 candidates)
94
+ * or the bandit declined — callers keep their pre-existing selection.
95
+ *
96
+ * @param {string} tier
97
+ * @param {Array<{ provider, model }>} candidates
98
+ * @param {number[]} ctx — from buildContextVector
99
+ * @returns {null | { provider, model, ucb, explored, propensity, candidates, context }}
100
+ */
101
+ function decide(tier, candidates, ctx) {
102
+ if (!candidates || candidates.length < 2) return null;
103
+ const picked = getBandit().pick(tier, candidates, ctx);
104
+ if (!picked) return null;
105
+ return { ...picked, candidates, context: ctx };
106
+ }
107
+
108
+ /**
109
+ * Stamp propensity/candidates/_banditContext onto a built decision (WS4.2).
110
+ *
111
+ * Collapse rule: the bandit's propensity only describes the served choice if
112
+ * the served (provider, model) is still one of the bandit's candidates. If a
113
+ * deterministic downstream override (deadline / tenant) swapped the served
114
+ * model out of that set — or the bandit never ran — collapse to
115
+ * propensity=1.0 with a single-entry candidate list, so off-policy
116
+ * estimators treat the row as a deterministic decision.
117
+ *
118
+ * _banditContext is underscored so it never leaks to response headers; the
119
+ * feedback path consumes it to call bandit.update().
120
+ *
121
+ * @param {object} decision — mutated in place (matches prior inline behavior)
122
+ * @param {{ provider: string, model: string }} served
123
+ * @param {null | { propensity, candidates, context }} banditResult — from decide()
124
+ * @returns {object} the same decision, for chaining
125
+ */
126
+ function stampPropensity(decision, served, banditResult) {
127
+ const banditPickedServed = banditResult?.candidates
128
+ && banditResult.candidates.some(
129
+ c => c.provider === served.provider && c.model === served.model
130
+ );
131
+ if (banditPickedServed) {
132
+ decision.propensity = banditResult.propensity ?? 1.0;
133
+ decision.candidates = banditResult.candidates;
134
+ decision._banditContext = banditResult.context;
135
+ } else {
136
+ decision.propensity = 1.0;
137
+ decision.candidates = [{ provider: served.provider, model: served.model }];
138
+ decision._banditContext = null;
139
+ }
140
+ return decision;
141
+ }
142
+
143
+ module.exports = {
144
+ TASK_TYPES,
145
+ buildContextVector,
146
+ buildCandidates,
147
+ decide,
148
+ stampPropensity,
149
+ };
@@ -57,7 +57,7 @@ function _lowerTier(tier) {
57
57
  * @param {Function} [args.deps.now] - Override Date.now() for tests.
58
58
  * @returns {string|null} The lower tier, or null if demotion is not warranted.
59
59
  */
60
- function suggestDemotion({ tier, requestType, analysis, deps = {} } = {}) {
60
+ function suggestDemotion({ tier, requestType, analysis: _analysis, deps = {} } = {}) {
61
61
  if (!tier || !requestType) return null;
62
62
  const lower = _lowerTier(tier);
63
63
  if (!lower) return null;
@@ -103,6 +103,42 @@ function _clearCache() {
103
103
  _cache.clear();
104
104
  }
105
105
 
106
+ /**
107
+ * Percentage-based demotion holdout (ROUTING-NOTES §4.10.4).
108
+ *
109
+ * Even when the evidence supports demotion, a configurable slice of sessions
110
+ * is deliberately excluded and served at the original tier. Those held-out
111
+ * rows are a continuously-running baseline: comparing their outcomes against
112
+ * demoted rows (method suffix '+deescalated' vs '+deescalation_holdout' in
113
+ * routing telemetry) proves the demotion rule is still net-positive over
114
+ * time, instead of trusting a one-time calibration.
115
+ *
116
+ * The bucket is a deterministic hash of the session key, so a session lands
117
+ * on the same side of the holdout on every turn — cohorts stay clean and a
118
+ * conversation never flip-flops tiers because of the holdout itself.
119
+ *
120
+ * LYNKR_DEESCALATION_HOLDOUT_PCT: 0–100, default 10. 0 disables the holdout.
121
+ * Read at call time (not module load) so tests and live re-config work.
122
+ *
123
+ * @param {string|null} sessionKey — session id/fingerprint; null → never held out
124
+ * @returns {boolean} true when this session must NOT be demoted (baseline cohort)
125
+ */
126
+ function isHeldOut(sessionKey) {
127
+ if (!sessionKey) return false;
128
+ const raw = Number.parseInt(process.env.LYNKR_DEESCALATION_HOLDOUT_PCT, 10);
129
+ const pct = Number.isNaN(raw) ? 10 : Math.min(100, Math.max(0, raw));
130
+ if (pct === 0) return false;
131
+ // FNV-1a over the session key → stable bucket in 0..99.
132
+ let hash = 0x811c9dc5;
133
+ const s = String(sessionKey);
134
+ for (let i = 0; i < s.length; i++) {
135
+ hash ^= s.charCodeAt(i);
136
+ hash = Math.imul(hash, 0x01000193);
137
+ }
138
+ const bucket = (hash >>> 0) % 100;
139
+ return bucket < pct;
140
+ }
141
+
106
142
  /**
107
143
  * Shadow-mode policy. Wraps the live routing decision by delegating to
108
144
  * `determineProviderSmart` and then applying `suggestDemotion` on the result.
@@ -143,6 +179,7 @@ async function shadowDeescalate(payload) {
143
179
  module.exports = {
144
180
  suggestDemotion,
145
181
  shadowDeescalate,
182
+ isHeldOut,
146
183
  TIER_ORDER,
147
184
  _clearCache,
148
185
  };
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  const crypto = require('crypto');
9
- const logger = require('../logger');
10
9
 
11
10
  const DEFAULT_MAX = 5000;
12
11
 
@@ -29,6 +29,7 @@ const { scoreIntent, intentScoreMode } = require('./intent-score');
29
29
  // Phase 3-6 routing modules
30
30
  const { getKnnRouter } = require('./knn-router');
31
31
  const { getBandit } = require('./bandit');
32
+ const { buildCandidates, buildContextVector, decide, stampPropensity } = require('./decide');
32
33
  const { getShadowPolicy, compareAndLog: shadowCompareAndLog } = require('./shadow-mode');
33
34
  const { chooseFastest } = require('./deadline');
34
35
  const { applyTenantOverrides } = require('./tenant-policy');
@@ -391,6 +392,11 @@ function _pinToDecision(pin, { reason, risk }) {
391
392
  async function determineProviderSmart(payload, options = {}) {
392
393
  const pinCheck = checkSessionPin(payload, options);
393
394
 
395
+ // Thread the session identity into fresh routing without mutating the
396
+ // caller's options object. Used by the de-escalation holdout so a session
397
+ // deterministically lands on one side of the holdout on every turn.
398
+ options = pinCheck.sessionId ? { ...options, _sessionId: pinCheck.sessionId } : options;
399
+
394
400
  // Bypass (no session / forceProvider / feature-off) or no pin yet →
395
401
  // straight to fresh routing, then persist the outcome for the next turn.
396
402
  if (pinCheck.reason === 'bypass' || pinCheck.reason === 'no_pin') {
@@ -680,6 +686,27 @@ function checkSessionPin(payload, options = {}) {
680
686
  const refreshOk = Array.isArray(payload?.tools) && payload.tools.length > 0;
681
687
 
682
688
  if (sessionAffinity.payloadHasToolHistory(payload)) {
689
+ // Stuck-loop detection: the pinned model re-issuing the same tool call
690
+ // (or the same text) over and over. Switching mid-exchange is forbidden
691
+ // (tool-call IDs aren't provider-portable), so the intervention is the
692
+ // same safe pattern as the embedded-text triggers below — serve the pin
693
+ // this turn but DROP it, so the next turn boundary re-routes fresh.
694
+ try {
695
+ const { detectStuckLoop } = require('./stuck-detector');
696
+ const stuck = detectStuckLoop(payload);
697
+ if (stuck.stuck) {
698
+ sessionAffinity.removePin(sessionId);
699
+ logger.warn({
700
+ sessionId,
701
+ trigger: stuck.reason,
702
+ repeats: stuck.repeats,
703
+ signature: stuck.signature,
704
+ pinnedTier: pin.tier,
705
+ pinnedModel: pin.model,
706
+ }, '[Routing] Stuck loop detected — pin dropped, next boundary re-routes fresh');
707
+ return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
708
+ }
709
+ } catch { /* never block the pin-serve path */ }
683
710
  // Text typed during a tool loop arrives merged with the pending
684
711
  // tool_result, where the pin serves unconditionally (id linkage forbids
685
712
  // switching mid-exchange). If that embedded text trips a trigger, drop
@@ -705,6 +732,32 @@ function checkSessionPin(payload, options = {}) {
705
732
  }
706
733
  }
707
734
  } catch { /* never block the pin-serve path */ }
735
+ // Context-overflow escape hatch. Every frame of a tool loop is an
736
+ // unconditional pin serve, so a long agentic session could NEVER escape
737
+ // to a larger-context model — the guards that check context fit only run
738
+ // on non-tool turns, which a busy agent may not produce for hours (live
739
+ // incident: an 884k-token session stayed pinned to a 128k model for 43
740
+ // straight requests, with 93.7% of history amputated on every one).
741
+ // Same safe pattern as the triggers above: serve the pin THIS turn
742
+ // (tool-call IDs aren't provider-portable), drop it so the next boundary
743
+ // re-routes — and fresh routing's context guard picks a model that fits.
744
+ try {
745
+ if (pin.model) {
746
+ const promptTokensEst = countPayloadTokens(payload, pin.model);
747
+ const ctxResult = contextValidator.validate(pin.model, promptTokensEst);
748
+ if (!ctxResult.ok) {
749
+ sessionAffinity.removePin(sessionId);
750
+ logger.warn({
751
+ sessionId,
752
+ trigger: 'context_overflow',
753
+ pinnedModel: pin.model,
754
+ pinnedTier: pin.tier,
755
+ promptTokensEst,
756
+ }, '[Routing] Session outgrew pinned model\'s context window — pin dropped, next boundary re-routes to a context-capable model');
757
+ return { serve: true, pin, reason: 'tool_history_pin_dropped', sessionId };
758
+ }
759
+ }
760
+ } catch { /* never block the pin-serve path */ }
708
761
  if (refreshOk) {
709
762
  sessionAffinity.setPin(sessionId, pin, {
710
763
  // Monotonic within a pin's lifetime: refreshes must never SHRINK the
@@ -1177,6 +1230,91 @@ async function _determineProviderSmartInner(payload, options = {}) {
1177
1230
  selectedModel = modelSelection.model;
1178
1231
  logger.debug({ tier, provider, model: selectedModel }, '[Routing] Using tier config');
1179
1232
 
1233
+ // Item 1 — capability-decoupled shortfall matching (HyDRA port).
1234
+ // Shadow-computes whenever possible (weighted mode only); serves only when
1235
+ // config/model-capabilities.json has enabled:true. Never overrides risk-high (returned earlier
1236
+ // upstream), static mode (tier null), or legacy scorer mode. Downstream
1237
+ // guards (de-escalation, context, vision, kNN, bandit, deadline, tenant)
1238
+ // still run after and dominate — shortfall only replaces the tier_config pick.
1239
+ let shortfallInfo = null;
1240
+ try {
1241
+ if (tier && config.modelTiers?.enabled && risk?.level !== 'high'
1242
+ && analysis?.mode === 'weighted' && analysis?.breakdown) {
1243
+ const { buildRequirementVector } = require('./capabilities');
1244
+ const sf = require('./shortfall');
1245
+ const req = buildRequirementVector({ dimensions: analysis.breakdown, agenticResult });
1246
+ // Candidates constrained to the user's TIER_* (same eligibility rule
1247
+ // as the bandit in decide.js) with tier labels attached for capability
1248
+ // resolution. Dedupe identical provider:model keeping the highest tier.
1249
+ const seen = new Map();
1250
+ for (const t of ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']) {
1251
+ for (const m of selector.getModelsForTier(t)) {
1252
+ const key = `${m.provider}:${m.model}`;
1253
+ const prev = seen.get(key);
1254
+ if (!prev || (TIER_DEFINITIONS[t]?.priority || 0) > (TIER_DEFINITIONS[prev.tier]?.priority || 0)) {
1255
+ seen.set(key, { provider: m.provider, model: m.model, tier: t });
1256
+ }
1257
+ }
1258
+ }
1259
+ let registry = null;
1260
+ try { registry = require('./model-registry').getModelRegistrySync(); } catch { registry = null; }
1261
+ let optimizer = null;
1262
+ try { optimizer = require('./cost-optimizer').getCostOptimizer(); } catch { optimizer = null; }
1263
+ const candidates = [...seen.values()].map((c) => {
1264
+ let cost = Number.POSITIVE_INFINITY; // unknown price never wins on cheapness
1265
+ try {
1266
+ const info = registry?.getCost?.(c.model);
1267
+ if (info && !info.unknown) {
1268
+ const est = optimizer?.estimateCost?.(c.model, 1000);
1269
+ cost = Number.isFinite(est?.totalEstimate)
1270
+ ? est.totalEstimate
1271
+ : (Number(info.input) || 0) + (Number(info.output) || 0);
1272
+ }
1273
+ } catch { /* keep Infinity */ }
1274
+ return { ...c, cost };
1275
+ });
1276
+ const result = sf.selectByShortfall(req, candidates);
1277
+ if (result) {
1278
+ const agreed = result.selected.provider === provider && result.selected.model === selectedModel;
1279
+ shortfallInfo = {
1280
+ req,
1281
+ tau: result.tau,
1282
+ selected: result.selected,
1283
+ agreed,
1284
+ legacy: { provider, model: selectedModel, tier },
1285
+ };
1286
+ logger.debug({
1287
+ req,
1288
+ tau: result.tau,
1289
+ legacy: `${tier}:${provider}:${selectedModel}`,
1290
+ shortfall: `${result.selected.tier}:${result.selected.provider}:${result.selected.model}`,
1291
+ agreed,
1292
+ }, '[Routing] Shortfall shadow compare');
1293
+ if (sf.isEnabled() && !agreed) {
1294
+ const fromTier = tier;
1295
+ const fromModel = selectedModel;
1296
+ provider = result.selected.provider;
1297
+ selectedModel = result.selected.model;
1298
+ tier = result.selected.tier;
1299
+ analysis.tier = tier;
1300
+ method = method + '+shortfall';
1301
+ if ((TIER_DEFINITIONS[tier]?.priority || 0) > (TIER_DEFINITIONS[fromTier]?.priority || 0)) {
1302
+ escalations.push({
1303
+ source: 'shortfall',
1304
+ fromTier,
1305
+ toTier: tier,
1306
+ fromModel,
1307
+ toModel: selectedModel,
1308
+ });
1309
+ }
1310
+ logger.info({ from: `${fromTier}:${fromModel}`, to: `${tier}:${selectedModel}` }, '[Routing] Shortfall override');
1311
+ }
1312
+ }
1313
+ }
1314
+ } catch (err) {
1315
+ degradation.record('shortfall', err);
1316
+ }
1317
+
1180
1318
  // WS2.3 — evidence-based de-escalation.
1181
1319
  //
1182
1320
  // The check is intentionally gated by evidence, not a feature flag: the
@@ -1200,17 +1338,31 @@ async function _determineProviderSmartInner(payload, options = {}) {
1200
1338
  analysis,
1201
1339
  });
1202
1340
  if (demoted && demoted !== tier) {
1203
- const demotedSelection = selector.selectModel(demoted, null);
1204
- logger.debug({
1205
- from: `${tier}:${provider}:${selectedModel}`,
1206
- to: `${demoted}:${demotedSelection.provider}:${demotedSelection.model}`,
1207
- requestType,
1208
- }, '[Routing] De-escalation — demoting tier on evidence');
1209
- demotedFrom = tier;
1210
- provider = demotedSelection.provider;
1211
- selectedModel = demotedSelection.model;
1212
- tier = demoted;
1213
- method = method + '+deescalated';
1341
+ // Percentage holdout (LYNKR_DEESCALATION_HOLDOUT_PCT, default 10):
1342
+ // a deterministic slice of sessions is served at the original tier
1343
+ // even though the evidence supports demotion. Their telemetry rows
1344
+ // (method '+deescalation_holdout') are the running baseline that
1345
+ // proves demoted sessions aren't quietly doing worse.
1346
+ if (deescalator.isHeldOut(options._sessionId)) {
1347
+ method = method + '+deescalation_holdout';
1348
+ logger.debug({
1349
+ tier,
1350
+ wouldDemoteTo: demoted,
1351
+ requestType,
1352
+ }, '[Routing] De-escalation — evidence supports demotion, session held out as baseline');
1353
+ } else {
1354
+ const demotedSelection = selector.selectModel(demoted, null);
1355
+ logger.debug({
1356
+ from: `${tier}:${provider}:${selectedModel}`,
1357
+ to: `${demoted}:${demotedSelection.provider}:${demotedSelection.model}`,
1358
+ requestType,
1359
+ }, '[Routing] De-escalation — demoting tier on evidence');
1360
+ demotedFrom = tier;
1361
+ provider = demotedSelection.provider;
1362
+ selectedModel = demotedSelection.model;
1363
+ tier = demoted;
1364
+ method = method + '+deescalated';
1365
+ }
1214
1366
  }
1215
1367
  } catch (err) {
1216
1368
  degradation.record('tier_select', err);
@@ -1441,65 +1593,29 @@ async function _determineProviderSmartInner(payload, options = {}) {
1441
1593
  // WS4.2 — capture propensity + candidates + context so the outcome row can
1442
1594
  // support off-policy evaluation. banditContext is stashed on the decision
1443
1595
  // (underscored → won't leak through headers, see WS4.2 verification).
1444
- let banditPropensity = null;
1445
- let banditCandidates = null;
1446
- let banditContext = null;
1596
+ //
1597
+ // The candidate-building / context-vector / pick logic lives in decide.js
1598
+ // so the off-policy evaluator can replay it against logged rows.
1599
+ let banditResult = null;
1447
1600
  if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
1448
1601
  try {
1449
- // Build candidates: current selection and kNN alternative if different.
1450
- //
1451
- // Tier-aware filter: only treat the kNN suggestion as a real candidate
1452
- // if it matches a (provider, model) combo configured in ANY TIER_*
1453
- // entry. The bandit is allowed to explore freely across the user's
1454
- // configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier
1455
- // model), but is forbidden from picking a credentialed-but-untiered
1456
- // model (e.g. an Azure OpenAI deployment whose endpoint is set in .env
1457
- // for some other use, but not referenced by any TIER_*). This keeps
1458
- // tier routing as the source of truth for what's eligible while
1459
- // preserving cross-tier bandit exploration.
1460
- const allCandidates = [{ provider, model: selectedModel }];
1461
- if (knnResult.model !== selectedModel) {
1462
- const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
1463
- const inConfig = configured.some(
1464
- m => m.provider === knnResult.provider && m.model === knnResult.model
1465
- );
1466
- if (inConfig) {
1467
- allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
1468
- }
1469
- }
1470
-
1471
- if (allCandidates.length > 1) {
1472
- const bandit = getBandit();
1473
- const TASK_TYPES = ['code_gen', 'summarization', 'reasoning', 'factoid', 'chat', 'other'];
1474
- const inferredTask = (analysis.breakdown?.taskType?.reason || 'other').toLowerCase();
1475
- const taskIdx = Math.max(0, TASK_TYPES.findIndex(t => inferredTask.includes(t)));
1476
- const ctx = [
1477
- (analysis.score || 0) / 100,
1478
- Math.log(Math.max(1, analysis.breakdown?.tokenCount || 0) + 1) / 15,
1479
- ((payload?.tools?.length ?? 0) > 0) ? 1 : 0,
1480
- options.streaming ? 1 : 0,
1481
- risk?.level === 'high' ? 1 : risk?.level === 'medium' ? 0.5 : 0,
1482
- agenticResult?.isAgentic ? 1 : 0,
1483
- ...TASK_TYPES.map((_, i) => i === taskIdx ? 1 : 0),
1484
- ];
1485
- const picked = bandit.pick(tier, allCandidates, ctx);
1486
- if (picked) {
1487
- banditCandidates = allCandidates;
1488
- banditPropensity = picked.propensity ?? null;
1489
- banditContext = ctx;
1490
- if (picked.model !== selectedModel) {
1491
- logger.debug({
1492
- from: `${provider}:${selectedModel}`,
1493
- to: `${picked.provider}:${picked.model}`,
1494
- ucb: picked.ucb?.toFixed(4),
1495
- explored: picked.explored,
1496
- propensity: picked.propensity,
1497
- }, '[Routing] Bandit override');
1498
- provider = picked.provider;
1499
- selectedModel = picked.model;
1500
- method = method + (picked.explored ? '+bandit_explore' : '+bandit');
1501
- }
1502
- }
1602
+ const allCandidates = buildCandidates(
1603
+ { provider, model: selectedModel },
1604
+ { provider: knnResult.provider, model: knnResult.model },
1605
+ );
1606
+ const ctx = buildContextVector({ analysis, payload, options, risk, agenticResult });
1607
+ banditResult = decide(tier, allCandidates, ctx);
1608
+ if (banditResult && banditResult.model !== selectedModel) {
1609
+ logger.debug({
1610
+ from: `${provider}:${selectedModel}`,
1611
+ to: `${banditResult.provider}:${banditResult.model}`,
1612
+ ucb: banditResult.ucb?.toFixed(4),
1613
+ explored: banditResult.explored,
1614
+ propensity: banditResult.propensity,
1615
+ }, '[Routing] Bandit override');
1616
+ provider = banditResult.provider;
1617
+ selectedModel = banditResult.model;
1618
+ method = method + (banditResult.explored ? '+bandit_explore' : '+bandit');
1503
1619
  }
1504
1620
  } catch (err) {
1505
1621
  degradation.record('bandit', err);
@@ -1568,6 +1684,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1568
1684
  knnResult,
1569
1685
  base_tier: baseTier,
1570
1686
  escalations,
1687
+ shortfall: shortfallInfo,
1571
1688
  // Upward escalations take precedence in the source label; a demotion is
1572
1689
  // only surfaced when nothing else escalated (guarded by the wire above,
1573
1690
  // but re-checked here for clarity).
@@ -1577,24 +1694,13 @@ async function _determineProviderSmartInner(payload, options = {}) {
1577
1694
  });
1578
1695
 
1579
1696
  // WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
1580
- // Bandit picks populate both. If a deterministic downstream override
1581
- // (deadline / tenant) then swapped the served model out of the bandit's
1582
- // candidate set, the bandit's propensity no longer describes the served
1583
- // choice — collapse to propensity=1.0 with a single candidate. Otherwise
1584
- // deterministic branches (bandit didn't run at all) always collapse.
1585
- // _banditContext is underscored so it never leaks to response headers;
1586
- // WS5 will consume it in the feedback path to call bandit.update().
1587
- const banditPickedServed = banditCandidates
1588
- && banditCandidates.some(c => c.provider === provider && c.model === selectedModel);
1589
- if (banditPickedServed) {
1590
- decision.propensity = banditPropensity ?? 1.0;
1591
- decision.candidates = banditCandidates;
1592
- decision._banditContext = banditContext;
1593
- } else {
1594
- decision.propensity = 1.0;
1595
- decision.candidates = [{ provider, model: selectedModel }];
1596
- decision._banditContext = null;
1597
- }
1697
+ // Collapse rule lives in decide.js (stampPropensity): if a deterministic
1698
+ // downstream override (deadline / tenant) swapped the served model out of
1699
+ // the bandit's candidate set — or the bandit never ran — the row collapses
1700
+ // to propensity=1.0 with a single candidate. _banditContext is underscored
1701
+ // so it never leaks to response headers; the feedback path consumes it to
1702
+ // call bandit.update().
1703
+ stampPropensity(decision, { provider, model: selectedModel }, banditResult);
1598
1704
 
1599
1705
  // WS5.5 — attach the query embedding + raw query text so the feedback
1600
1706
  // path can turn conclusive outcomes into new kNN exemplars without
@@ -1658,6 +1764,16 @@ function getRoutingHeaders(decision) {
1658
1764
 
1659
1765
  if (decision.model) {
1660
1766
  headers['X-Lynkr-Model'] = decision.model;
1767
+ // Served-model context window (workweave pattern): the client can't know
1768
+ // the real window behind a virtual model name when tier routing serves
1769
+ // different models per request — this header is the authoritative per-
1770
+ // turn answer, intended as the client's compaction budget. Omitted (never
1771
+ // guessed) when the registry doesn't know the model.
1772
+ const { contextWindowFor } = require('./model-registry');
1773
+ const contextWindow = contextWindowFor(decision.model);
1774
+ if (contextWindow) {
1775
+ headers['X-Lynkr-Context-Window'] = String(contextWindow);
1776
+ }
1661
1777
  }
1662
1778
 
1663
1779
  if (decision.agenticResult?.isAgentic) {
@@ -206,7 +206,6 @@ function classify(embedding, centroids) {
206
206
  * Lexical fallback path stays clamped ≤75 (see _lexicalCleanScore).
207
207
  */
208
208
  function blendScore(sims) {
209
- const classes = Object.keys(CLASS_VALUES);
210
209
  // Frontier similarity floor: a weak topical match can't jump tiers.
211
210
  const frontierSim = sims.frontier ?? -1;
212
211
  const activeSims = { ...sims };
@@ -22,7 +22,11 @@ const logger = require('../logger');
22
22
  const { generateEmbedding } = require('../cache/embeddings');
23
23
  const { getEmbeddingCache } = require('./embedding-cache');
24
24
 
25
- const INDEX_DIR = path.join(__dirname, '../../data/knn');
25
+ // LYNKR_KNN_DIR override exists so test runs point at a scratch dir instead
26
+ // of the production index — parallel test processes saving to the real path
27
+ // produced a torn read → corrupt link-list lengths → a 135GB zero-filled
28
+ // index file (2026-08-21).
29
+ const INDEX_DIR = process.env.LYNKR_KNN_DIR || path.join(__dirname, '../../data/knn');
26
30
  const INDEX_FILE = path.join(INDEX_DIR, 'index.hnsw');
27
31
  const META_FILE = path.join(INDEX_DIR, 'meta.json');
28
32
 
@@ -85,7 +89,7 @@ class KnnRouter {
85
89
  // (Earlier Lynkr code passed MAX_ELEMENTS here — wrong type, threw on load.)
86
90
  this.index.readIndexSync(INDEX_FILE, false);
87
91
  // resize if needed so we can keep adding up to MAX_ELEMENTS
88
- try { this.index.resizeIndex(MAX_ELEMENTS); } catch (_) {}
92
+ try { this.index.resizeIndex(MAX_ELEMENTS); } catch (err) { logger.debug({ err: err.message }, '[KnnRouter] resizeIndex failed — index may not grow past loaded size'); }
89
93
  this.ready = true;
90
94
  logger.info({ size: this.size, dim: this.dim }, '[KnnRouter] Index loaded');
91
95
  return true;
@@ -99,8 +103,26 @@ class KnnRouter {
99
103
  if (!this.ready || !this.index) return;
100
104
  try {
101
105
  fs.mkdirSync(INDEX_DIR, { recursive: true });
102
- this.index.writeIndexSync(INDEX_FILE);
103
- fs.writeFileSync(META_FILE, JSON.stringify({ dim: this.dim, entries: this.meta }, null, 0));
106
+ // Atomic save: hnswlib's writeIndexSync is truncate-then-write, so a
107
+ // concurrent reader can observe a torn image and later persist a
108
+ // corrupt index. Write to a per-pid temp file and rename into place —
109
+ // readers then always see a complete image.
110
+ const tmpIndex = `${INDEX_FILE}.tmp-${process.pid}`;
111
+ const tmpMeta = `${META_FILE}.tmp-${process.pid}`;
112
+ this.index.writeIndexSync(tmpIndex);
113
+ // Sanity bound: a healthy index is ~(elements × ~3.3KB) + slack. A
114
+ // corrupted link-list length manifests as a wildly oversized file —
115
+ // refuse to install it over the good one.
116
+ const expectedMax = (this.size + 1000) * 8192 + 1_000_000;
117
+ const written = fs.statSync(tmpIndex).size;
118
+ if (written > expectedMax) {
119
+ fs.rmSync(tmpIndex, { force: true });
120
+ logger.error({ written, expectedMax, size: this.size }, '[KnnRouter] Refusing to save implausibly large index — in-memory index likely corrupt');
121
+ return;
122
+ }
123
+ fs.writeFileSync(tmpMeta, JSON.stringify({ dim: this.dim, entries: this.meta }, null, 0));
124
+ fs.renameSync(tmpIndex, INDEX_FILE);
125
+ fs.renameSync(tmpMeta, META_FILE);
104
126
  } catch (err) {
105
127
  logger.warn({ err: err.message }, '[KnnRouter] Index save failed');
106
128
  }
@@ -197,7 +219,7 @@ class KnnRouter {
197
219
 
198
220
  let best = null;
199
221
  let bestScore = -Infinity;
200
- for (const [model, agg] of byModel) {
222
+ for (const [, agg] of byModel) {
201
223
  const avgQ = agg.quality / agg.weight;
202
224
  const avgC = agg.cost / agg.weight;
203
225
  // Score = quality / log(cost+1) — reward quality, penalise cost gently
@@ -95,7 +95,7 @@ class LatencyTracker {
95
95
  }
96
96
  // Aggregate across all models for this provider
97
97
  const provEntries = [];
98
- for (const [k, entry] of this._entries) {
98
+ for (const [_k, entry] of this._entries) {
99
99
  if (entry.provider === provider) provEntries.push(entry);
100
100
  }
101
101
  if (provEntries.length === 0) return null;