kritmatta-lead-scorer 1.2.0 → 1.2.2

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 CHANGED
@@ -5,16 +5,34 @@ app (`hire-chat-next-app`) and the enrichment worker (`enrichment-worker`) so th
5
5
  scoring logic can never drift between them.
6
6
 
7
7
  ## What's in it
8
- - `scorer.js` - weight-profile scorer with per-ICP `signal_overrides` (the app UI
9
- and the worker score identically). Hiring intent is read from either the website
10
- scrape (`tier2.hiring_signals.hiring_velocity`) or a native LinkedIn-jobs signal
11
- (`enrichment_data.hiring_signal`, from the app's hiringSignalService) - so a
12
- company hiring a relevant role scores even before any website enrichment.
8
+ - `scorer.js` - the LIVE (v1) weight-profile scorer with per-ICP `signal_overrides`
9
+ (the app UI and the worker score identically). Hiring intent is read from either
10
+ the website scrape (`tier2.hiring_signals.hiring_velocity`) or a native
11
+ LinkedIn-jobs signal (`enrichment_data.hiring_signal`, from the app's
12
+ hiringSignalService) - so a company hiring a relevant role scores even before
13
+ any website enrichment. Also exports the weight-profile key allowlists
14
+ (`VALID_SIGNAL_OVERRIDE_KEYS`, `VALID_CATEGORY_MULTIPLIER_KEYS`,
15
+ `VALID_V2_CONFIG_KEYS`) - writers must validate against them because the
16
+ scorer silently ignores unknown keys.
17
+ - `scorerV2.js` - the SHADOW (v2) fit-gated scorer:
18
+ `score = 100 * fit^strictness * (floor + (1-floor) * quality) + capped intent`.
19
+ Fit is earned/evidenced over the ICP's defined core criteria (bonus criteria
20
+ only lift), disqualifiers hard-cap the score, and category multipliers act as
21
+ normalized intra-axis weights. `scoreLeadsMultiIcp` computes it alongside v1
22
+ on every rescore and persists `qualification.shadow_v2` - observability only,
23
+ nothing live reads it until cutover. A v2 load failure disables the shadow;
24
+ it can never break live v1 scoring. Per-ICP tunables: `weight_profile.v2`.
25
+ - `contextMatch.js` - stemmed token-overlap matching for ICP fit/bad-fit phrases
26
+ (used by v2; v1 still uses verbatim substring matching).
13
27
  - `decay.js` - intent-signal time decay.
14
28
  - `reasoningRewriter.js` - optional LLM rephrasing of the deterministic reasoning.
15
29
  The AI call is **dependency-injected** so this package has no app/worker-specific
16
30
  requires.
17
31
 
32
+ Consumer shims in both repos re-export the package ROOT (not the `/scorer`
33
+ subpath) so v1 and v2 entry points are reachable everywhere. Run `npm test`
34
+ (no server or credentials needed) before publishing.
35
+
18
36
  ## Install
19
37
  Public package on npmjs.com - no auth or `.npmrc` needed:
20
38
 
package/contextMatch.js CHANGED
@@ -39,20 +39,27 @@ function stem(word) {
39
39
  return w;
40
40
  }
41
41
 
42
- /** Two stems match when one is a prefix of the other and both are >= 4 chars
43
- * (or exactly equal for short stems). Absorbs stemmer roughness (hir/hire). */
42
+ /** Two stems match when equal, or when one is a prefix of the other with at
43
+ * most ONE trailing char of difference (absorbs stemmer roughness like
44
+ * hir/hire, plann/plan). A looser prefix rule produced semantic false
45
+ * positives (firm/firmware, care/career, part/partner) that are unacceptable
46
+ * because a single bad-fit phrase match hard-caps the score. */
44
47
  function stemsMatch(a, b) {
45
48
  if (a === b) return true;
46
49
  if (a.length < 4 || b.length < 4) return false;
50
+ if (Math.abs(a.length - b.length) > 1) return false;
47
51
  return a.startsWith(b) || b.startsWith(a);
48
52
  }
49
53
 
50
- /** Tokenize free text into deduplicated stemmed content words. */
54
+ /** Tokenize free text into deduplicated stemmed content words. Two-char
55
+ * tokens are KEPT - domain acronyms (HR, AI, VC, UK, ML) carry the meaning
56
+ * of many ICP phrases, and dropping them degraded phrases like "no HR team"
57
+ * to a single generic word that matched everything. */
51
58
  function contentStems(text) {
52
59
  if (!text || typeof text !== 'string') return [];
53
60
  const out = new Set();
54
61
  for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
55
- if (raw.length < 3) continue;
62
+ if (raw.length < 2) continue;
56
63
  if (STOPWORDS.has(raw)) continue;
57
64
  out.add(stem(raw));
58
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritmatta-lead-scorer",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Lead qualification scorer - weight-profile scoring with per-ICP signal overrides and provenance-scoped multi-product candidacy. Single source of truth shared by the Vega app and the enrichment worker.",
5
5
  "main": "index.js",
6
6
  "files": [
package/scorer.js CHANGED
@@ -17,13 +17,19 @@
17
17
  const { decayScore } = require('./decay');
18
18
 
19
19
  // Lazy require: scorerV2 requires this module for shared helpers, so a
20
- // top-level require here would be circular. Resolved on first use instead.
20
+ // top-level require here would be circular. Guarded so a v2 load failure can
21
+ // never take down live v1 scoring - shadow scoring just switches off.
21
22
  let _scorerV2 = null;
22
23
  function getScorerV2() {
23
24
  if (_scorerV2 === null) {
24
- _scorerV2 = require('./scorerV2');
25
+ try {
26
+ _scorerV2 = require('./scorerV2');
27
+ } catch (err) {
28
+ console.error('[scorer] scorerV2 failed to load - shadow scoring disabled:', err.message);
29
+ _scorerV2 = false;
30
+ }
25
31
  }
26
- return _scorerV2;
32
+ return _scorerV2 || null;
27
33
  }
28
34
 
29
35
  // Lazy require so the rewriter is only loaded when actually used; keeps the
@@ -1089,8 +1095,9 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
1089
1095
  // Shadow v2 (fit-gated model): computed alongside v1 for observability,
1090
1096
  // never drives live scores. Tracks v2's own best ICP plus the v2 score for
1091
1097
  // whichever ICP v1 picks, so distribution and best-ICP drift are both
1092
- // measurable from persisted data.
1093
- const { scoreLeadV2, toShadowSummary } = getScorerV2();
1098
+ // measurable from persisted data. v2mod is null if scorerV2 failed to
1099
+ // load - live v1 scoring proceeds without the shadow.
1100
+ const v2mod = getScorerV2();
1094
1101
  const v2ByIcp = {};
1095
1102
  let v2Best = null;
1096
1103
  const icpRows = [];
@@ -1115,17 +1122,19 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
1115
1122
  _icp_context: icp.context,
1116
1123
  }, globalConfig);
1117
1124
 
1118
- try {
1119
- const v2 = scoreLeadV2(lead, {
1120
- ...icp.criteria,
1121
- _icp_name: icp.name,
1122
- _icp_context: icp.context,
1123
- }, globalConfig);
1124
- v2ByIcp[icp.id] = v2;
1125
- if (!v2Best || v2.score > v2Best.result.score) v2Best = { icpId: icp.id, result: v2 };
1126
- } catch (v2Err) {
1127
- // Shadow scoring must never break live scoring
1128
- console.error(`[scorer] shadow v2 failed for lead ${lead.id} icp ${icp.id}:`, v2Err.message);
1125
+ if (v2mod) {
1126
+ try {
1127
+ const v2 = v2mod.scoreLeadV2(lead, {
1128
+ ...icp.criteria,
1129
+ _icp_name: icp.name,
1130
+ _icp_context: icp.context,
1131
+ }, globalConfig);
1132
+ v2ByIcp[icp.id] = v2;
1133
+ if (!v2Best || v2.score > v2Best.result.score) v2Best = { icpId: icp.id, result: v2 };
1134
+ } catch (v2Err) {
1135
+ // Shadow scoring must never break live scoring
1136
+ console.error(`[scorer] shadow v2 failed for lead ${lead.id} icp ${icp.id}:`, v2Err.message);
1137
+ }
1129
1138
  }
1130
1139
 
1131
1140
  icpRows.push({
@@ -1152,9 +1161,11 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
1152
1161
  if (icps.length === 0) {
1153
1162
  bestResult = scoreLeadFromEnrichment(lead, null, globalConfig);
1154
1163
  bestScore = bestResult.score;
1155
- try {
1156
- v2Best = { icpId: null, result: scoreLeadV2(lead, null, globalConfig) };
1157
- } catch { /* shadow only */ }
1164
+ if (v2mod) {
1165
+ try {
1166
+ v2Best = { icpId: null, result: v2mod.scoreLeadV2(lead, null, globalConfig) };
1167
+ } catch { /* shadow only */ }
1168
+ }
1158
1169
  }
1159
1170
 
1160
1171
  // For meaningful scores, ask the rewriter to rephrase reasoning in the
@@ -1194,9 +1205,9 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
1194
1205
  // Shadow v2 fit-gated score - additive, observability only. `icp_id`
1195
1206
  // inside is v1's best ICP (same lead-level comparison basis as `score`);
1196
1207
  // v2_best records which ICP v2 itself would pick.
1197
- shadow_v2: v2ForV1Best
1208
+ shadow_v2: v2mod && v2ForV1Best
1198
1209
  ? {
1199
- ...toShadowSummary(v2ForV1Best, bestIcpId),
1210
+ ...v2mod.toShadowSummary(v2ForV1Best, bestIcpId),
1200
1211
  v2_best: v2Best ? { icp_id: v2Best.icpId, score: v2Best.result.score } : null,
1201
1212
  }
1202
1213
  : null,
package/scorerV2.js CHANGED
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  const { decayScore } = require('./decay');
34
- const { buildCorpusMatcher } = require('./contextMatch');
34
+ const { buildCorpusMatcher, contentStems } = require('./contextMatch');
35
35
  const {
36
36
  normalizeIcpCriteria,
37
37
  _internal: {
@@ -127,18 +127,21 @@ function round2(x) {
127
127
  function resolveV2Config(icp) {
128
128
  const v2 = icp?.weight_profile?.v2 || {};
129
129
  const ov = icp?.weight_profile?.signal_overrides || {};
130
+ const num = (x) => (typeof x === 'number' ? x : undefined);
130
131
  const cfg = { ...V2_DEFAULTS };
131
132
  for (const key of Object.keys(V2_DEFAULTS)) {
132
- if (typeof v2[key] === 'number') cfg[key] = v2[key];
133
+ if (num(v2[key]) !== undefined) cfg[key] = v2[key];
133
134
  }
134
- // Honor the v1 overrides that translate cleanly into v2 semantics.
135
- if (typeof ov.fit_signal_cap === 'number' && v2.fit_signal_cap === undefined) {
135
+ // Honor the v1 overrides that translate cleanly into v2 semantics. Guards
136
+ // use the same numeric check as the v2 loop, so a malformed (non-numeric)
137
+ // v2 value falls through to the legacy override rather than to defaults.
138
+ if (num(ov.fit_signal_cap) !== undefined && num(v2.fit_signal_cap) === undefined) {
136
139
  cfg.fit_signal_cap = ov.fit_signal_cap;
137
140
  }
138
- if (typeof ov.bad_signal_cap === 'number' && v2.bad_signal_cap === undefined) {
141
+ if (num(ov.bad_signal_cap) !== undefined && num(v2.bad_signal_cap) === undefined) {
139
142
  cfg.bad_signal_cap = ov.bad_signal_cap;
140
143
  }
141
- if (typeof ov.industry_mismatch === 'number' && v2.mismatch_fraction === undefined) {
144
+ if (num(ov.industry_mismatch) !== undefined && num(v2.mismatch_fraction) === undefined) {
142
145
  // v1 default penalty is -25 against a +25-weight criterion; scale linearly
143
146
  // (override of 0 = mismatch costs nothing, -50 = subtracts 1.44x weight).
144
147
  cfg.mismatch_fraction = Math.abs(ov.industry_mismatch) / 25 * V2_DEFAULTS.mismatch_fraction;
@@ -185,6 +188,32 @@ function resolveAxisWeights(icp) {
185
188
  return { fit, quality, intentMultiplier: m.intent };
186
189
  }
187
190
 
191
+ // ---------------------------------------------------------------------------
192
+ // Context phrase eligibility
193
+ // ---------------------------------------------------------------------------
194
+
195
+ const NEGATION_LEAD = /^(no|not|without|non|never|zero|lacks?|lacking|doesn'?t|don'?t|isn'?t|aren'?t)\b/i;
196
+ const NUMERIC_COMPARISON = /\b(under|over|below|above|less than|fewer than|more than|at least|at most|maximum|minimum|min|max|<|>)\s*\d/i;
197
+
198
+ /**
199
+ * A phrase participates in context matching only when presence-in-corpus is a
200
+ * meaningful test of it. Three classes are NOT and are skipped entirely
201
+ * (numerator AND denominator, so ratios stay fair):
202
+ * - single-content-word phrases ("investment", "carbon") - one generic word
203
+ * matches nearly any corpus, and on the bad-fit side one match hard-caps;
204
+ * - negation-led phrases ("no sustainability focus") - matching the words'
205
+ * PRESENCE inverts the author's intent;
206
+ * - numeric comparisons ("headcount under 10") - substring/token matching
207
+ * cannot evaluate a threshold.
208
+ * All three classes were exactly the false bad-fit caps observed in the
209
+ * 2026-09-08 full-population audit (94.8k leads).
210
+ */
211
+ function presenceCheckable(phrase) {
212
+ if (NEGATION_LEAD.test(phrase)) return false;
213
+ if (NUMERIC_COMPARISON.test(phrase)) return false;
214
+ return contentStems(phrase).length >= 2;
215
+ }
216
+
188
217
  // ---------------------------------------------------------------------------
189
218
  // Fit axis
190
219
  // ---------------------------------------------------------------------------
@@ -296,14 +325,15 @@ function scoreFitAxis(lead, icp, weights, cfg, matcher) {
296
325
  add('geography', true, evidence, match ? 1 : 0, match ? 'Geography matches' : null);
297
326
  }
298
327
 
299
- // Funding stage
328
+ // Funding stage - check ALL observed stages (tier4 signal AND tier2), same
329
+ // as v1: a stale tier4 stage must not mask a matching tier2.funding_stage
300
330
  const fundingDefined = (icp.funding_stages?.length || 0) > 0;
301
331
  if (fundingDefined) {
302
332
  const fundingSignal = tier4?.signals?.find?.((s) => s.type === 'recent_funding');
303
- const observed = fundingSignal?.data?.stage || tier2?.funding_stage || null;
304
- const evidence = !!observed;
305
- const match = evidence && icp.funding_stages.some(
306
- (f) => f.toLowerCase() === observed.toLowerCase()
333
+ const observedStages = [fundingSignal?.data?.stage, tier2?.funding_stage].filter(Boolean);
334
+ const evidence = observedStages.length > 0;
335
+ const match = evidence && observedStages.some((o) =>
336
+ icp.funding_stages.some((f) => f.toLowerCase() === o.toLowerCase())
307
337
  );
308
338
  if (match) signals.push('icp_funding_match');
309
339
  add('funding', true, evidence, match ? 1 : 0, match ? 'Funding stage matches' : null);
@@ -326,23 +356,24 @@ function scoreFitAxis(lead, icp, weights, cfg, matcher) {
326
356
  add('tech_stack', true, evidence, ratio, ratio > 0 ? 'Tech stack matches' : null);
327
357
  }
328
358
 
329
- // Context fit signals (great_fit_signals + pain_solved), token-overlap matched
359
+ // Context fit signals (great_fit_signals + pain_solved), token-overlap
360
+ // matched. Deduplicate BEFORE the denominator - a phrase repeated across
361
+ // both fields can only ever match once, so counting it twice in the
362
+ // denominator structurally depresses the ratio. Phrases that presence
363
+ // matching cannot evaluate are excluded entirely (see presenceCheckable).
330
364
  const context = icp._icp_context;
331
365
  const fitPhrases = context
332
- ? [
366
+ ? [...new Set([
333
367
  ...tokenizeContextSignals(context.great_fit_signals),
334
368
  ...tokenizeContextSignals(context.pain_solved),
335
- ]
369
+ ])].filter(presenceCheckable)
336
370
  : [];
337
371
  if (fitPhrases.length > 0) {
338
372
  const evidence = !!matcher;
339
373
  let matches = 0;
340
374
  if (evidence) {
341
- const seen = new Set();
342
375
  for (const phrase of fitPhrases) {
343
376
  if (matches >= cfg.fit_signal_cap) break;
344
- if (seen.has(phrase)) continue;
345
- seen.add(phrase);
346
377
  if (matcher.phraseMatches(phrase)) {
347
378
  matches++;
348
379
  signals.push(`context_fit:${phrase}`);
@@ -354,8 +385,13 @@ function scoreFitAxis(lead, icp, weights, cfg, matcher) {
354
385
  matches > 0 ? `${matches} fit signal match${matches > 1 ? 'es' : ''}` : null);
355
386
  }
356
387
 
357
- // Bad-fit signals - hard cap, not subtraction
358
- const badPhrases = context ? tokenizeContextSignals(context.bad_fit_signals) : [];
388
+ // Bad-fit signals - hard cap, not subtraction. The presenceCheckable filter
389
+ // matters most here: the 2026-09-08 full-population audit showed every
390
+ // false bad-fit cap came from an un-checkable phrase (single generic word,
391
+ // negation, or numeric comparison).
392
+ const badPhrases = context
393
+ ? tokenizeContextSignals(context.bad_fit_signals).filter(presenceCheckable)
394
+ : [];
359
395
  if (badPhrases.length > 0 && matcher) {
360
396
  let badMatches = 0;
361
397
  const seenBad = new Set();
@@ -509,7 +545,9 @@ function scoreIntentBonus(lead, cfg, intentMultiplier) {
509
545
  }
510
546
  }
511
547
  }
512
- return { bonus: Math.min(points * (intentMultiplier || 1), cfg.intent_cap), raw: points, signals };
548
+ // 0 is a valid multiplier (tenant disabled intent) - only default when unset
549
+ const mult = typeof intentMultiplier === 'number' ? intentMultiplier : 1;
550
+ return { bonus: Math.min(points * mult, cfg.intent_cap), raw: points, signals };
513
551
  }
514
552
 
515
553
  // ---------------------------------------------------------------------------