lynkr 9.9.0 → 9.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,14 +47,15 @@ const ANCHORS_PATHS = [
47
47
  ];
48
48
  const VECTORS_CACHE_PATH = path.join(__dirname, '../../data/difficulty-anchors.vectors.json');
49
49
 
50
- // Class → representative score. Chosen so each class lands inside an
51
- // existing default band (SIMPLE 0-25, MEDIUM 26-50, COMPLEX 51-75) and the
52
- // blend can NEVER reach REASONING (76+). Calibration may later collapse
53
- // bands into degenerate ranges without touching these.
50
+ // Class → representative score. Chosen so each class lands inside a tier
51
+ // band and routing boundaries are tunable via tier edges (model-tiers.js).
52
+ // Config B (local GLM → Claude): substantive=MEDIUM/ollama,
53
+ // heavyweight=COMPLEX/GLM, frontier=REASONING/Claude.
54
54
  const CLASS_VALUES = {
55
55
  trivial: 10,
56
56
  substantive: 45,
57
57
  heavyweight: 68,
58
+ frontier: 85,
58
59
  };
59
60
 
60
61
  // Softmax temperature over cosine sims. Real inter-class sim gaps on
@@ -77,8 +78,16 @@ const CLASS_BANDS = {
77
78
  trivial: [0, 25],
78
79
  substantive: [26, 50],
79
80
  heavyweight: [51, 75],
81
+ frontier: [76, 100],
80
82
  };
81
83
 
84
+ // Frontier class (REASONING tier) requires minimum similarity — a weak
85
+ // topical match can't jump to the expensive tier. Tuned on the validation
86
+ // set (scripts/validate-intent-anchors.js). Below this floor, frontier is
87
+ // excluded from the blend entirely and scoring behaves like the 3-class
88
+ // baseline. Hardcoded constant (no env var per user directive).
89
+ const FRONTIER_MIN_SIM = 0.50;
90
+
82
91
  function intentScoreMode() {
83
92
  const m = (process.env.LYNKR_INTENT_SCORE_MODE || 'anchor').toLowerCase();
84
93
  return m === 'legacy' ? 'legacy' : 'anchor';
@@ -191,21 +200,32 @@ function classify(embedding, centroids) {
191
200
  }
192
201
 
193
202
  /**
194
- * Softmax-blend class sims into a continuous score. Bounded by construction
195
- * to [min(CLASS_VALUES), max(CLASS_VALUES)]REASONING stays unreachable.
203
+ * Softmax-blend class sims into a continuous score. Frontier class requires
204
+ * minimum similarity (FRONTIER_MIN_SIM) to participate below the floor,
205
+ * it's excluded from the blend and scoring behaves like the 3-class baseline.
206
+ * Lexical fallback path stays clamped ≤75 (see _lexicalCleanScore).
196
207
  */
197
208
  function blendScore(sims) {
198
209
  const classes = Object.keys(CLASS_VALUES);
199
- const max = Math.max(...classes.map((c) => sims[c] ?? -1));
210
+ // Frontier similarity floor: a weak topical match can't jump tiers.
211
+ const frontierSim = sims.frontier ?? -1;
212
+ const activeSims = { ...sims };
213
+ if (frontierSim < FRONTIER_MIN_SIM) {
214
+ delete activeSims.frontier; // excluded from blend
215
+ }
216
+ const activeClasses = Object.keys(CLASS_VALUES).filter(c => c in activeSims);
217
+ if (activeClasses.length === 0) return CLASS_VALUES.substantive; // degenerate
218
+
219
+ const max = Math.max(...activeClasses.map((c) => activeSims[c] ?? -1));
200
220
  let totalW = 0;
201
221
  let total = 0;
202
- for (const cls of classes) {
203
- const w = Math.exp(((sims[cls] ?? -1) - max) / BLEND_TEMPERATURE);
222
+ for (const cls of activeClasses) {
223
+ const w = Math.exp(((activeSims[cls] ?? -1) - max) / BLEND_TEMPERATURE);
204
224
  totalW += w;
205
225
  total += w * CLASS_VALUES[cls];
206
226
  }
207
227
  const score = totalW > 0 ? total / totalW : CLASS_VALUES.substantive;
208
- return Math.round(Math.max(0, Math.min(75, score)));
228
+ return Math.round(Math.max(0, Math.min(100, score)));
209
229
  }
210
230
 
211
231
  // --- default centroids (lazy singleton, disk-cached) ------------------------
@@ -294,6 +314,46 @@ function _lexicalCleanScore(text) {
294
314
  * @returns {Promise<{score:number, mode:'anchor'|'lexical', class?:string, sims?:object, text:string}|null>}
295
315
  * null → caller keeps its legacy score (legacy mode, or nothing to score)
296
316
  */
317
+ // Reconcile anchor's implied tier with the LLM classifier's tier.
318
+ // - Agreement → keep anchor score as-is.
319
+ // - Classifier lower than anchor → trust classifier (catches embedding
320
+ // false-positives like "list exports" scoring REASONING). Position
321
+ // score at midpoint of classifier's target band.
322
+ // - Classifier higher than anchor → safety-gate: require confidence≥0.8
323
+ // before trusting an escalation to a more expensive tier. Below that,
324
+ // keep the cheaper anchor decision.
325
+ function _reconcile(anchorScore, anchorClass, classifierResult) {
326
+ if (!classifierResult) return { score: anchorScore, reconciled: false };
327
+ if (classifierResult.confidence < 0.6) return { score: anchorScore, reconciled: false };
328
+
329
+ // Anchor class → implied tier (matches model-tiers.js band definitions).
330
+ const anchorTier = anchorScore <= 19 ? 'SIMPLE'
331
+ : anchorScore <= 50 ? 'MEDIUM'
332
+ : anchorScore <= 75 ? 'COMPLEX'
333
+ : 'REASONING';
334
+ const classifierTier = classifierResult.tier;
335
+
336
+ if (anchorTier === classifierTier) return { score: anchorScore, reconciled: false };
337
+
338
+ const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
339
+ const anchorIdx = TIER_ORDER.indexOf(anchorTier);
340
+ const classifierIdx = TIER_ORDER.indexOf(classifierTier);
341
+
342
+ // Midpoints of each tier band (from model-tiers.js defaults):
343
+ // SIMPLE 0-19 → 10, MEDIUM 20-50 → 35, COMPLEX 51-75 → 63, REASONING 76-100 → 88
344
+ const TIER_MIDPOINT = { SIMPLE: 10, MEDIUM: 35, COMPLEX: 63, REASONING: 88 };
345
+
346
+ if (classifierIdx < anchorIdx) {
347
+ // Classifier says LOWER tier — trust it. Fixes over-routing.
348
+ return { score: TIER_MIDPOINT[classifierTier], reconciled: 'down' };
349
+ }
350
+ // Classifier says HIGHER tier — gate on confidence.
351
+ if (classifierResult.confidence >= 0.8) {
352
+ return { score: TIER_MIDPOINT[classifierTier], reconciled: 'up' };
353
+ }
354
+ return { score: anchorScore, reconciled: 'up_gated' };
355
+ }
356
+
297
357
  async function scoreIntent(payload, opts = {}) {
298
358
  const mode = opts.mode ?? intentScoreMode();
299
359
  if (mode === 'legacy') return null;
@@ -314,8 +374,46 @@ async function scoreIntent(payload, opts = {}) {
314
374
  if (Array.isArray(embedding) && embedding.length > 0) {
315
375
  const { cls, sims } = classify(embedding, centroids);
316
376
  const [lo, hi] = CLASS_BANDS[cls];
317
- const score = Math.max(lo, Math.min(hi, blendScore(sims)));
318
- return { score, mode: 'anchor', class: cls, sims, text };
377
+ const anchorScore = Math.max(lo, Math.min(hi, blendScore(sims)));
378
+
379
+ // LLM classifier — second opinion. Skipped in tests (opts.skipClassifier)
380
+ // to keep unit tests hermetic. Runs live otherwise.
381
+ let classifierResult = null;
382
+ if (!opts.skipClassifier) {
383
+ try {
384
+ const { classifyDifficulty } = require('./difficulty-classifier');
385
+ classifierResult = await classifyDifficulty(text, {
386
+ forceMatched: opts.forceMatched,
387
+ riskLevel: opts.riskLevel,
388
+ });
389
+ } catch (err) {
390
+ logger.debug({ err: err.message }, '[IntentScore] classifier failed — anchor only');
391
+ }
392
+ }
393
+
394
+ const { score, reconciled } = _reconcile(anchorScore, cls, classifierResult);
395
+ if (reconciled) {
396
+ logger.debug({
397
+ text: text.slice(0, 80),
398
+ anchorScore,
399
+ anchorClass: cls,
400
+ classifierTier: classifierResult?.tier,
401
+ classifierConfidence: classifierResult?.confidence,
402
+ reconciled,
403
+ finalScore: score,
404
+ }, '[IntentScore] classifier reconciled anchor score');
405
+ }
406
+ return {
407
+ score,
408
+ mode: reconciled ? 'anchor+classifier' : 'anchor',
409
+ class: cls,
410
+ sims,
411
+ text,
412
+ anchorScore,
413
+ classifierTier: classifierResult?.tier ?? null,
414
+ classifierConfidence: classifierResult?.confidence ?? null,
415
+ reconciled,
416
+ };
319
417
  }
320
418
  }
321
419
  } catch (err) {
@@ -327,6 +425,8 @@ async function scoreIntent(payload, opts = {}) {
327
425
 
328
426
  module.exports = {
329
427
  CLASS_VALUES,
428
+ CLASS_BANDS,
429
+ FRONTIER_MIN_SIM,
330
430
  intentScoreMode,
331
431
  extractCleanUserText,
332
432
  cosine,
@@ -336,4 +436,6 @@ module.exports = {
336
436
  scoreIntent,
337
437
  // exposed for the replay script
338
438
  getDefaultCentroids,
439
+ // exposed for tests
440
+ _reconcile,
339
441
  };
package/src/server.js CHANGED
@@ -246,6 +246,26 @@ async function start() {
246
246
  console.log(`Claude→Databricks proxy listening on http://localhost:${config.port}`);
247
247
  });
248
248
 
249
+ // Classifier bootstrap check — non-blocking, log-only.
250
+ // Detects ollama + confirms the classifier model is pulled. Never auto-
251
+ // installs (that's `lynkr init`'s job); warns and lets scoring fall back
252
+ // to anchor-only if either is missing.
253
+ (async () => {
254
+ try {
255
+ const { ensureClassifierReady } = require('./routing/classifier-setup');
256
+ const result = await ensureClassifierReady({
257
+ mode: 'boot',
258
+ log: (m) => logger.info(m),
259
+ warn: (m) => logger.warn(m),
260
+ });
261
+ if (result.ready) {
262
+ logger.info({ classifier: 'ready', warmed: result.warmed }, '[classifier-setup] Difficulty classifier ready');
263
+ }
264
+ } catch (err) {
265
+ logger.debug({ err: err.message }, '[classifier-setup] bootstrap check failed (classifier will fall back)');
266
+ }
267
+ })();
268
+
249
269
  // Start session cleanup manager. It also drives routing-side maintenance
250
270
  // (telemetry retention + session-pin TTL) via its runCleanup tick — see
251
271
  // src/sessions/cleanup.js.