File size: 17,747 Bytes
20f83d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
/**
 * story-identity β€” the ONE similarity definition for "are these the same
 * news story?" (#4919, Bet 1 of the 2026-07-05 strategic review).
 *
 * Before this module the codebase answered that question three different
 * ways: scripts/_clustering.mjs (title-token Jaccard β‰₯ 0.5),
 * server/worldmonitor/news/v1/dedup.mjs (word-overlap > 0.6 of the smaller
 * set), and list-feed-digest.ts story tracking (EXACT sha256 of the
 * normalized title β€” so any wording edit forked the story and deflated
 * corroboration). All three now delegate here.
 *
 * ── Method ──────────────────────────────────────────────────────────────
 * DUAL-VIEW feature-hashed lexical vectors; similarity = min of the two
 * views' cosines (see lexicalStoryVector for why two views). Features:
 *   - word tokens            (weight 2.0)  β€” core lexical identity
 *   - word bigrams           (weight 1.5)  β€” order/direction ("ukraine
 *     drone" vs "russian drone" separates actor-flipped headlines that
 *     bag-of-words alone cannot)
 *   - char 4-grams per token (weight 1.0)  β€” morphology fuzz
 *     (iran/iranian, sanction/sanctions)
 *   - char bigrams for non-ASCII tokens β€” CJK and other unsegmented
 *     scripts get no whitespace tokens, so bigrams carry the signal
 * hashed (signed FNV-1a) into 512 dims and L2-normalized. Deterministic,
 * dependency-free, script-agnostic, ~Β΅s per title.
 *
 * This is an EDIT-TOLERANT identity, not a semantic one: it merges the
 * real-world corroboration killers (source suffixes, truncations,
 * qualifier edits, reorders, morphology) and keeps distinct events apart.
 * It can NOT merge a full cross-language paraphrase ("Iran threatens…" /
 * "TeherΓ‘n amenaza…") β€” that requires a semantic embedding provider,
 * which plugs in behind `setStoryVectorProvider()` without touching any
 * consumer. Known hard limit either way: two events differing by one
 * token ("12th sanctions package" vs "13th sanctions package") are not
 * separable by similarity alone; the 96h ingest window bounds the damage.
 *
 * Mirrored byte-for-byte to scripts/shared/story-identity.js (enforced by
 * tests/scripts-shared-mirror.test.mjs β€” Railway seed bundles deploy with
 * rootDirectory=scripts and cannot see repo-root shared/).
 */

const DIM = 512;

// Tuned on the labeled pair set in tests/story-identity.test.mjs
// (edit-variant positives vs same-topic-different-event negatives). The
// test asserts full separation with margin on both sides; retune there
// if the vectorizer changes.
export const STORY_SIMILARITY_THRESHOLD = 0.615;

const WEIGHT_TOKEN = 2.0;
const WEIGHT_BIGRAM = 1.5;
const WEIGHT_CHARGRAM = 1.0;
// Discriminative boosts, applied to the token feature only (bigrams keep
// their flat weight β€” they already encode order). Without these, a
// one-entity swap ("Turkey hikes rates to 50%" vs "Argentina hikes rates
// to 50%") scores ~0.82 because the shared verb/number mass dominates;
// capitalized-in-raw-text tokens are entity-shaped and numbers are
// event parameters (magnitudes, percentages, ordinals), so both carry
// the discriminating signal. In Title Case or ALL-CAPS headlines every
// token gets the boost β€” uniform scaling, which cosine ignores β€” so the
// heuristic only sharpens sentence-case headlines and never hurts.
const BOOST_ENTITY = 3.0;
const BOOST_NUMBER = 2.0;

/** FNV-1a 32-bit over a string, with a seed so we can derive two
 * independent hashes (index + sign) from one feature. */
function fnv1a(str, seed) {
  let h = (0x811c9dc5 ^ seed) >>> 0;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h >>> 0;
}

/**
 * Generic story-text normalization: lowercase, strip everything that is
 * not a Unicode letter/number, collapse whitespace. Callers that know
 * about source-attribution suffixes ("… - Reuters") strip those BEFORE
 * calling (list-feed-digest's normalizeTitle already does).
 * @param {string} text
 * @returns {string}
 */
export function normalizeStoryText(text) {
  return (text || '')
    .toLowerCase()
    .replace(/[^\p{L}\p{N}\s]/gu, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

/** @param {string} token */
function isNonAscii(token) {
  for (let i = 0; i < token.length; i++) {
    if (token.charCodeAt(i) > 127) return true;
  }
  return false;
}

/**
 * Tokens used for inverted-index candidate generation by clustering
 * callers (cheap pre-filter: only pairs sharing β‰₯1 token are scored).
 * ASCII tokens shorter than 3 chars are dropped (stopword-weight noise);
 * non-ASCII tokens are kept whole AND as char bigrams so unsegmented
 * scripts still produce index keys.
 * @param {string} text
 * @returns {Set<string>}
 */
export function candidateTokens(text) {
  const out = new Set();
  const clamped = stripAttributionSuffix(text).slice(0, MAX_IDENTITY_CHARS);
  for (const tok of normalizeStoryText(clamped).split(' ')) {
    if (!tok) continue;
    if (isNonAscii(tok)) {
      out.add(tok);
      for (let i = 0; i + 2 <= tok.length; i++) out.add(tok.slice(i, i + 2));
    } else if (tok.length >= 3) {
      out.add(tok);
    }
  }
  return out;
}

// Trailing source-attribution suffixes ("… - Reuters", "… - example.com")
// must not enter the vector: Google-News wrapper titles carry them on
// EVERY item, so the publisher token (capitalized β†’ entity-boosted Γ—3)
// adds shared mass across DISTINCT same-publisher stories and pulls them
// toward a false merge (cross-model review finding, PR #4924). Mirrors
// list-feed-digest's normalizeTitle suffix rules, but case-preserving.
const ATTRIBUTION_SUFFIX_RES = [
  /\s*[-\u2013\u2014|]\s*[\w\s.]+\.(?:com|org|net|co\.uk)\s*$/i,
  /\s*[-\u2013\u2014|]\s*(?:reuters|ap news|bbc|cnn|al jazeera|france 24|dw news|pbs newshour|cbs news|nbc|abc|associated press|the guardian|nos nieuws|tagesschau|cnbc|the national)\s*$/i,
];

// Unbounded feed titles feed char-4gram loops inside a 25s serverless
// budget; clamp AFTER suffix stripping. 300 chars β‰ˆ 3Γ— a long headline.
const MAX_IDENTITY_CHARS = 300;

/** @param {string} text @returns {string} */
export function stripAttributionSuffix(text) {
  let out = text || '';
  for (const re of ATTRIBUTION_SUFFIX_RES) out = out.replace(re, '');
  return out;
}

/**
 * Content tokens WITH the discriminative flags read from the raw
 * (pre-lowercase) text. Callers should pass raw titles β€” lowercasing
 * upstream destroys the entity signal (harmless, but the boost is lost).
 * @param {string} text
 * @returns {Array<{ tok: string; boost: number }>}
 */
function contentTokens(text) {
  const kept = [];
  const clamped = stripAttributionSuffix(text).slice(0, MAX_IDENTITY_CHARS);
  for (const raw of clamped.split(/\s+/)) {
    // Strip everything that is not a Unicode letter/number, keeping the
    // original case so the entity heuristic can read it.
    const clean = raw.replace(/[^\p{L}\p{N}]/gu, '');
    if (!clean) continue;
    const tok = clean.toLowerCase();
    if (!isNonAscii(tok) && tok.length < 3) continue;
    const capitalized = /^\p{Lu}/u.test(clean);
    const hasDigit = /\p{N}/u.test(clean);
    const boost = hasDigit ? BOOST_NUMBER : capitalized ? BOOST_ENTITY : 1;
    kept.push({ tok, boost });
  }
  return kept;
}

/** @param {Float64Array} vec @param {string} feature @param {number} weight */
function addFeature(vec, feature, weight) {
  const idx = fnv1a(feature, 0) % DIM;
  const sign = (fnv1a(feature, 0x9e3779b9) & 1) === 1 ? 1 : -1;
  vec[idx] += sign * weight;
}

/** @param {Float64Array} vec */
function l2normalize(vec) {
  let norm = 0;
  for (let i = 0; i < DIM; i++) norm += vec[i] * vec[i];
  norm = Math.sqrt(norm);
  if (norm === 0) return null;
  for (let i = 0; i < DIM; i++) vec[i] /= norm;
  return vec;
}

/**
 * The default lexical vectorizer β€” DUAL VIEW. Returns two L2-normalized
 * 512-dim views of the same text:
 *   - `u` (uniform): every token feature at flat weight. Sensitive to
 *     action/verb substitutions ("seizes tanker" vs "threatens to
 *     close") that entity weighting would wash out.
 *   - `b` (boosted): entity-shaped (capitalized-in-raw) tokens Γ—3 and
 *     numeric tokens Γ—2. Sensitive to one-entity swaps ("Turkey hikes
 *     rates…" vs "Argentina hikes rates…") that flat weighting scores
 *     ~0.82 because the shared verb mass dominates.
 * A pair is the same story only when BOTH views agree (similarity =
 * min of the two cosines) β€” each view catches the failure mode the
 * other is blind to. Tuned on the labeled pair set in
 * tests/story-identity.test.mjs: min positive 0.634, max negative
 * 0.595 with THRESHOLD 0.615 between them.
 *
 * Returns null for texts with no usable tokens (callers treat null as
 * "cannot match" β€” never same-story).
 * @param {string} text
 * @returns {{ u: Float64Array; b: Float64Array } | null}
 */
function lexicalStoryVector(text) {
  const tokens = contentTokens(text);
  if (tokens.length === 0) return null;
  const u = new Float64Array(DIM);
  const b = new Float64Array(DIM);
  for (let i = 0; i < tokens.length; i++) {
    const { tok, boost } = tokens[i];
    addFeature(u, `w:${tok}`, WEIGHT_TOKEN);
    addFeature(b, `w:${tok}`, WEIGHT_TOKEN * boost);
    if (i + 1 < tokens.length) {
      const bigram = `b:${tok} ${tokens[i + 1].tok}`;
      addFeature(u, bigram, WEIGHT_BIGRAM);
      addFeature(b, bigram, WEIGHT_BIGRAM);
    }
    if (isNonAscii(tok)) {
      // Unsegmented-script fallback: char bigrams of the raw token.
      for (let j = 0; j + 2 <= tok.length; j++) {
        const g = `c2:${tok.slice(j, j + 2)}`;
        addFeature(u, g, WEIGHT_CHARGRAM);
        addFeature(b, g, WEIGHT_CHARGRAM);
      }
    }
    if (tok.length >= 4) {
      const padded = `<${tok}>`;
      for (let j = 0; j + 4 <= padded.length; j++) {
        const g = `c4:${padded.slice(j, j + 4)}`;
        addFeature(u, g, WEIGHT_CHARGRAM);
        addFeature(b, g, WEIGHT_CHARGRAM);
      }
    }
  }
  const un = l2normalize(u);
  const bn = l2normalize(b);
  if (!un || !bn) return null;
  // Token set rides along for the containment rescue in
  // cosineSimilarity β€” severe RSS truncation (a headline cut to ~40% of
  // its tokens) drops the cosine below threshold even though the short
  // form is a strict subset of the long form. The old word-overlap
  // dedup metric (|∩|/min) handled exactly this class; keep that
  // guarantee via token containment.
  return { u: un, b: bn, t: new Set(tokens.map((entry) => entry.tok)) };
}

/** Active vectorizer β€” swappable for a semantic embedding provider. */
let activeVectorizer = lexicalStoryVector;

/**
 * Plug in a semantic embedding provider (must be synchronous or the
 * caller precomputes; must return `{ u, b }` of L2-normalized
 * Float64Arrays of a consistent dimension β€” a single-embedding provider
 * sets u === b β€” or null). Pass null to restore the default lexical
 * vectorizer. Consumers never change β€” only the vector source.
 * @param {((text: string) => { u: Float64Array; b: Float64Array } | null) | null} provider
 */
export function setStoryVectorProvider(provider) {
  activeVectorizer = typeof provider === 'function' ? provider : lexicalStoryVector;
}

/**
 * @param {string} text
 * @returns {{ u: Float64Array; b: Float64Array } | null} dual-view story
 *   vector (opaque β€” pass to cosineSimilarity), or null when the text
 *   has no usable signal.
 */
export function storyVector(text) {
  return activeVectorizer(text);
}

/** @param {Float64Array} a @param {Float64Array} b */
function dot(a, b) {
  if (a.length !== b.length) return 0;
  let d = 0;
  for (let i = 0; i < a.length; i++) d += a[i] * b[i];
  return d;
}

/**
 * Similarity of two dual-view story vectors: the MIN of the uniform-view
 * and boosted-view cosines β€” a pair is the same story only when both
 * views agree. Null vectors never match anything.
 * @param {{ u: Float64Array; b: Float64Array } | null} a
 * @param {{ u: Float64Array; b: Float64Array } | null} b
 * @returns {number}
 */
// Containment rescue floor: a title whose content tokens are β‰₯90%
// contained in the other's (with at least 4 tokens on the smaller side,
// so fragments like "Iran" can't rescue) IS the same story β€” the
// truncated-wire-copy class the old |∩|/min dedup metric guaranteed.
const CONTAINMENT_RESCUE_MIN_TOKENS = 4;
const CONTAINMENT_RESCUE_RATIO = 0.9;
const CONTAINMENT_RESCUE_SCORE = 0.9;

export function cosineSimilarity(a, b) {
  if (!a || !b) return 0;
  const score = Math.min(dot(a.u, b.u), dot(a.b, b.b));
  // Rescue only applies to lexical vectors carrying token sets β€” a
  // semantic provider's vectors skip it (semantic cosine already
  // handles truncation).
  if (score < CONTAINMENT_RESCUE_SCORE && a.t && b.t) {
    const [small, large] = a.t.size <= b.t.size ? [a.t, b.t] : [b.t, a.t];
    if (small.size >= CONTAINMENT_RESCUE_MIN_TOKENS) {
      let shared = 0;
      for (const tok of small) {
        if (large.has(tok)) shared++;
      }
      if (shared / small.size >= CONTAINMENT_RESCUE_RATIO) {
        return CONTAINMENT_RESCUE_SCORE;
      }
    }
  }
  return score;
}

/**
 * Convenience: similarity of two raw texts.
 * @param {string} textA @param {string} textB
 * @returns {number}
 */
export function storySimilarity(textA, textB) {
  return cosineSimilarity(storyVector(textA), storyVector(textB));
}

/**
 * @param {string} textA @param {string} textB
 * @param {number} [threshold]
 * @returns {boolean}
 */
export function isSameStory(textA, textB, threshold = STORY_SIMILARITY_THRESHOLD) {
  return storySimilarity(textA, textB) >= threshold;
}

// A token shared by more than this many titles carries no clustering
// signal (it is the batch's "the") but drives O(bucketΒ²) pair scoring β€”
// an adversarial or organic hot-entity spike (thousands of titles naming
// one entity) would otherwise burn seconds of CPU inside the digest
// handler's 25s budget. Pairs joined ONLY by ultra-hot tokens almost
// always share a rarer token too.
const MAX_CANDIDATE_BUCKET = 250;

/**
 * Cluster texts into same-story groups: connected components over the
 * "similarity β‰₯ threshold" edge set (union-find), with inverted-index
 * candidate generation so only pairs sharing β‰₯1 token are scored.
 *
 * Connected components β€” NOT the greedy first-seed pass the legacy
 * _clustering.mjs used β€” because component membership is independent of
 * input order: feed arrival order varies run to run, and under greedy
 * assignment a chain (A~B, B~C, A≁C) could land C in or out of A's
 * cluster depending on which seeded first, churning the canonical
 * story:track identity downstream (cross-model review finding,
 * PR #4924). Transitive chains merge by design; the threshold's
 * precision bounds chain length in practice.
 * @param {string[]} texts
 * @param {{ threshold?: number }} [opts]
 * @returns {number[][]} clusters of indices into `texts`, ordered by
 *   smallest member index; members ascending
 */
export function clusterTexts(texts, opts = {}) {
  const threshold = typeof opts.threshold === 'number' ? opts.threshold : STORY_SIMILARITY_THRESHOLD;
  const vectors = texts.map((t) => storyVector(t));
  const tokenSets = texts.map((t) => candidateTokens(t));

  // Exact-duplicate pre-union (#4924 external review): identical
  // normalized texts union unconditionally BEFORE the candidate scan.
  // Without this, a mega-story (e.g. 251 verbatim syndications) makes
  // every shared token bucket exceed MAX_CANDIDATE_BUCKET, no pairs get
  // scored, and the most-corroborated story of the day degrades to
  // singletons β€” the exact case corroboration exists for.
  const byExactText = new Map();

  const invertedIndex = new Map();
  for (let i = 0; i < texts.length; i++) {
    for (const token of tokenSets[i]) {
      const bucket = invertedIndex.get(token);
      if (bucket) bucket.push(i);
      else invertedIndex.set(token, [i]);
    }
  }

  const parent = new Array(texts.length);
  for (let i = 0; i < texts.length; i++) parent[i] = i;

  const find = (x) => {
    let root = x;
    while (parent[root] !== root) root = parent[root];
    while (parent[x] !== root) {
      const next = parent[x];
      parent[x] = root;
      x = next;
    }
    return root;
  };
  const union = (a, b) => {
    const ra = find(a);
    const rb = find(b);
    if (ra === rb) return;
    // Deterministic: smaller index becomes the root.
    if (ra < rb) parent[rb] = ra;
    else parent[ra] = rb;
  };

  for (let i = 0; i < texts.length; i++) {
    const normalized = normalizeStoryText(texts[i]);
    if (!normalized) continue;
    const first = byExactText.get(normalized);
    if (first === undefined) byExactText.set(normalized, i);
    else union(first, i);
  }

  for (let i = 0; i < texts.length; i++) {
    if (!vectors[i]) continue;
    const candidates = new Set();
    for (const token of tokenSets[i]) {
      const bucket = invertedIndex.get(token);
      if (!bucket || bucket.length > MAX_CANDIDATE_BUCKET) continue;
      for (const idx of bucket) {
        if (idx > i) candidates.add(idx);
      }
    }
    for (const j of candidates) {
      if (find(i) === find(j)) continue;
      if (cosineSimilarity(vectors[i], vectors[j]) >= threshold) union(i, j);
    }
  }

  const byRoot = new Map();
  for (let i = 0; i < texts.length; i++) {
    const root = find(i);
    const members = byRoot.get(root);
    if (members) members.push(i);
    else byRoot.set(root, [i]);
  }
  return Array.from(byRoot.entries())
    .sort((a, b) => a[0] - b[0])
    .map(([, members]) => members);
}