webcrawl 1.2.3 → 1.2.5

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 (114) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/core/engine/cache.d.ts +70 -0
  3. package/dist/core/engine/cache.js +297 -0
  4. package/dist/core/engine/crawl-policy.d.ts +36 -0
  5. package/dist/core/engine/crawl-policy.js +134 -0
  6. package/dist/core/engine/crawler.d.ts +151 -0
  7. package/dist/core/engine/crawler.js +885 -0
  8. package/dist/core/engine/fetcher.d.ts +66 -0
  9. package/dist/core/engine/fetcher.js +185 -0
  10. package/dist/core/engine/index.d.ts +4 -0
  11. package/dist/core/engine/index.js +4 -0
  12. package/dist/core/engine/markdown.d.ts +33 -0
  13. package/dist/core/engine/markdown.js +334 -0
  14. package/dist/core/engine/path-scope.d.ts +21 -0
  15. package/dist/core/engine/path-scope.js +88 -0
  16. package/dist/core/engine/safe-url.d.ts +23 -0
  17. package/dist/core/engine/safe-url.js +92 -0
  18. package/dist/core/engine/stealth.d.ts +79 -0
  19. package/dist/core/engine/stealth.js +155 -0
  20. package/dist/core/engine.d.ts +1 -0
  21. package/dist/core/engine.js +1 -0
  22. package/dist/core/extract/business.d.ts +28 -0
  23. package/dist/core/extract/business.js +344 -0
  24. package/dist/core/extract/currency.d.ts +15 -0
  25. package/dist/core/extract/currency.js +259 -0
  26. package/dist/core/extract/cve-cache.d.ts +62 -0
  27. package/dist/core/extract/cve-cache.js +277 -0
  28. package/dist/core/extract/cve.d.ts +57 -0
  29. package/dist/core/extract/cve.js +397 -0
  30. package/dist/core/extract/dedupe-key.d.ts +12 -0
  31. package/dist/core/extract/dedupe-key.js +36 -0
  32. package/dist/core/extract/flights.d.ts +37 -0
  33. package/dist/core/extract/flights.js +373 -0
  34. package/dist/core/extract/hotels.d.ts +35 -0
  35. package/dist/core/extract/hotels.js +473 -0
  36. package/dist/core/extract/index.d.ts +8 -0
  37. package/dist/core/extract/index.js +8 -0
  38. package/dist/core/extract/real-estate.d.ts +53 -0
  39. package/dist/core/extract/real-estate.js +835 -0
  40. package/dist/core/extract/scaledown.d.ts +14 -0
  41. package/dist/core/extract/scaledown.js +484 -0
  42. package/dist/core/extract/shopping.d.ts +44 -0
  43. package/dist/core/extract/shopping.js +393 -0
  44. package/dist/core/image-url.d.ts +5 -0
  45. package/dist/core/image-url.js +13 -0
  46. package/dist/core/interact/driver.d.ts +49 -0
  47. package/dist/core/interact/driver.js +264 -0
  48. package/dist/core/interact/remote.d.ts +41 -0
  49. package/dist/core/interact/remote.js +133 -0
  50. package/dist/core/interact/spans.d.ts +46 -0
  51. package/dist/core/interact/spans.js +32 -0
  52. package/dist/core/interact/validate.d.ts +34 -0
  53. package/dist/core/interact/validate.js +135 -0
  54. package/dist/core/interact/vm.d.ts +42 -0
  55. package/dist/core/interact/vm.js +296 -0
  56. package/dist/core/local-engine.d.ts +35 -0
  57. package/dist/core/local-engine.js +367 -0
  58. package/dist/core/parse/detect.d.ts +29 -0
  59. package/dist/core/parse/detect.js +152 -0
  60. package/dist/core/parse/fetch.d.ts +20 -0
  61. package/dist/core/parse/fetch.js +39 -0
  62. package/dist/core/parse/image.d.ts +13 -0
  63. package/dist/core/parse/image.js +100 -0
  64. package/dist/core/parse/index.d.ts +53 -0
  65. package/dist/core/parse/index.js +153 -0
  66. package/dist/core/parse/limits.d.ts +12 -0
  67. package/dist/core/parse/limits.js +29 -0
  68. package/dist/core/parse/media.d.ts +42 -0
  69. package/dist/core/parse/media.js +238 -0
  70. package/dist/core/parse/merge.d.ts +19 -0
  71. package/dist/core/parse/merge.js +48 -0
  72. package/dist/core/parse/ocr.d.ts +25 -0
  73. package/dist/core/parse/ocr.js +121 -0
  74. package/dist/core/parse/office.d.ts +28 -0
  75. package/dist/core/parse/office.js +83 -0
  76. package/dist/core/parse/pdf.d.ts +27 -0
  77. package/dist/core/parse/pdf.js +156 -0
  78. package/dist/core/parse/types.d.ts +38 -0
  79. package/dist/core/parse/types.js +1 -0
  80. package/dist/core/parse/youtube.d.ts +30 -0
  81. package/dist/core/parse/youtube.js +264 -0
  82. package/dist/core/ranker/decision.d.ts +28 -0
  83. package/dist/core/ranker/decision.js +151 -0
  84. package/dist/core/ranker/index.d.ts +5 -0
  85. package/dist/core/ranker/index.js +361 -0
  86. package/dist/core/ranker/lexical.d.ts +82 -0
  87. package/dist/core/ranker/lexical.js +642 -0
  88. package/dist/core/ranker/types.d.ts +90 -0
  89. package/dist/core/ranker/types.js +1 -0
  90. package/dist/core/ranker.d.ts +1 -0
  91. package/dist/core/ranker.js +1 -0
  92. package/dist/core/sandbox.d.ts +29 -0
  93. package/dist/core/sandbox.js +182 -0
  94. package/dist/core/search/catalog.d.ts +174 -0
  95. package/dist/core/search/catalog.js +488 -0
  96. package/dist/core/search/index.d.ts +3 -0
  97. package/dist/core/search/index.js +3 -0
  98. package/dist/core/search/pipeline.d.ts +7 -0
  99. package/dist/core/search/pipeline.js +704 -0
  100. package/dist/core/search/providers.d.ts +62 -0
  101. package/dist/core/search/providers.js +1175 -0
  102. package/dist/core/search.d.ts +1 -0
  103. package/dist/core/search.js +1 -0
  104. package/dist/core/targets/developer.d.ts +44 -0
  105. package/dist/core/targets/developer.js +338 -0
  106. package/dist/core/targets/index.d.ts +3 -0
  107. package/dist/core/targets/index.js +3 -0
  108. package/dist/core/targets/registry.d.ts +32 -0
  109. package/dist/core/targets/registry.js +411 -0
  110. package/dist/core/targets/types.d.ts +72 -0
  111. package/dist/core/targets/types.js +8 -0
  112. package/dist/core/targets.d.ts +1 -0
  113. package/dist/core/targets.js +1 -0
  114. package/package.json +82 -4
@@ -0,0 +1,361 @@
1
+ export * from "./types.js";
2
+ export * from "./lexical.js";
3
+ export * from "./decision.js";
4
+ import { runDecisionModelReranker } from "./decision.js";
5
+ import { computeFreshnessAndAuthorityScore, computeGeoAffinityScore, computePassageMaxSimAndHighlights, computeProximityAndExactScore, computeTokenSimilarity, extractMarkdownHeadings, extractPublisherDomainKey, fuseWithWeightedRrf, tokenizeAndHash, } from "./lexical.js";
6
+ export async function rankDocuments(query, rawCandidates, options = {}) {
7
+ const startTotal = performance.now();
8
+ const topK = Math.min(Math.max(options.topK ?? 10, 1), 50);
9
+ const rrfK = options.rrfK ?? 60;
10
+ const useDecisionModel = options.useDecisionModel !== false;
11
+ const decisionModel = options.decisionModel ?? "auto";
12
+ const decisionTimeoutMs = options.decisionTimeoutMs ?? 1200;
13
+ const decisionTopN = Math.min(options.decisionTopN ?? 10, 16);
14
+ const maxHighlights = options.maxHighlights ?? 2;
15
+ if (rawCandidates.length === 0) {
16
+ return {
17
+ results: [],
18
+ rankingMetrics: {
19
+ totalCandidates: 0,
20
+ uniqueCandidates: 0,
21
+ lexicalLatencyMs: 0,
22
+ decisionLatencyMs: 0,
23
+ totalRankingLatencyMs: 0,
24
+ decisionModelUsed: null,
25
+ enginesContributing: [],
26
+ },
27
+ };
28
+ }
29
+ // Stage 1 & 2: Canonical deduplication + Weighted RRF
30
+ const { merged, enginesContributing } = fuseWithWeightedRrf(rawCandidates, rrfK);
31
+ const N = merged.length;
32
+ // Tokenize query once
33
+ const rawQueryLower = query.trim().toLowerCase();
34
+ const queryTok = tokenizeAndHash(query, true);
35
+ // Pre-tokenize all document fields for Fielded BM25F+
36
+ const FIELD_WEIGHTS = {
37
+ title: 3.5,
38
+ headings: 2.2,
39
+ url: 2.0,
40
+ description: 1.5,
41
+ body: 1.0,
42
+ };
43
+ const docFields = merged.map((item) => {
44
+ const titleTok = tokenizeAndHash(item.doc.title || "");
45
+ const headingsTok = tokenizeAndHash(extractMarkdownHeadings(item.doc.markdown));
46
+ const urlTok = tokenizeAndHash(item.canonicalUrl.replace(/https?:\/\//, " "));
47
+ const descTok = tokenizeAndHash(item.doc.description || "");
48
+ const bodyTok = tokenizeAndHash((item.doc.markdown || "").slice(0, 12000));
49
+ const weightedLen = titleTok.hashes.length * FIELD_WEIGHTS.title +
50
+ headingsTok.hashes.length * FIELD_WEIGHTS.headings +
51
+ urlTok.hashes.length * FIELD_WEIGHTS.url +
52
+ descTok.hashes.length * FIELD_WEIGHTS.description +
53
+ bodyTok.hashes.length * FIELD_WEIGHTS.body;
54
+ return {
55
+ titleTok,
56
+ headingsTok,
57
+ urlTok,
58
+ descTok,
59
+ bodyTok,
60
+ weightedLen: Math.max(1, weightedLen),
61
+ };
62
+ });
63
+ const avgWeightedLen = docFields.reduce((acc, f) => acc + f.weightedLen, 0) / Math.max(1, N);
64
+ // Compute Document Frequency (DF) per query term across the candidate pool
65
+ const dfMap = new Map();
66
+ for (let qi = 0; qi < queryTok.hashes.length; qi++) {
67
+ const qHash = queryTok.hashes[qi];
68
+ let df = 0;
69
+ for (let di = 0; di < N; di++) {
70
+ const f = docFields[di];
71
+ if (f.titleTok.freqMap.has(qHash) ||
72
+ f.headingsTok.freqMap.has(qHash) ||
73
+ f.urlTok.freqMap.has(qHash) ||
74
+ f.descTok.freqMap.has(qHash) ||
75
+ f.bodyTok.freqMap.has(qHash)) {
76
+ df++;
77
+ }
78
+ }
79
+ dfMap.set(qHash, df);
80
+ }
81
+ // Stage 3: Fielded BM25F+ (`k1 = 1.5, b = 0.75, delta = 1.0`)
82
+ const k1 = 1.5;
83
+ const b = 0.75;
84
+ const delta = 1.0;
85
+ const bm25RawScores = new Float32Array(N);
86
+ let maxBm25 = 0;
87
+ let maxRrf = 0;
88
+ for (let di = 0; di < N; di++) {
89
+ const f = docFields[di];
90
+ const normFactor = 1 - b + b * (f.weightedLen / Math.max(1, avgWeightedLen));
91
+ let docBm25 = 0;
92
+ for (let qi = 0; qi < queryTok.hashes.length; qi++) {
93
+ const qHash = queryTok.hashes[qi];
94
+ const pseudoTf = ((f.titleTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.title +
95
+ (f.headingsTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.headings +
96
+ (f.urlTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.url +
97
+ (f.descTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.description +
98
+ (f.bodyTok.freqMap.get(qHash) ?? 0) * FIELD_WEIGHTS.body) /
99
+ normFactor;
100
+ if (pseudoTf <= 0) {
101
+ continue;
102
+ }
103
+ const df = dfMap.get(qHash) ?? 0;
104
+ // Robertson-Sparck Jones IDF with smoothing
105
+ const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5) + 0.5);
106
+ const tfComponent = ((k1 + 1) * pseudoTf) / (k1 + pseudoTf) + delta;
107
+ docBm25 += idf * tfComponent;
108
+ }
109
+ bm25RawScores[di] = docBm25;
110
+ if (docBm25 > maxBm25) {
111
+ maxBm25 = docBm25;
112
+ }
113
+ if (merged[di].rrfRaw > maxRrf) {
114
+ maxRrf = merged[di].rrfRaw;
115
+ }
116
+ }
117
+ // Stage 4: ColBERT Subword MaxSim + Proximity + Freshness/Authority
118
+ const stage4Candidates = merged.map((entry, di) => {
119
+ const rrfScore = maxRrf > 0 ? entry.rrfRaw / maxRrf : 0.5;
120
+ const bm25fScore = maxBm25 > 0 ? bm25RawScores[di] / maxBm25 : 0;
121
+ const { maxSimScore: passageMaxSimScore, highlights } = computePassageMaxSimAndHighlights(queryTok, rawQueryLower, entry.doc, maxHighlights);
122
+ const proximityScore = computeProximityAndExactScore(queryTok, rawQueryLower, docFields[di].titleTok, docFields[di].descTok, (entry.doc.title || "").toLowerCase(), (entry.doc.description || "").toLowerCase());
123
+ const freshnessAuthorityScore = computeFreshnessAndAuthorityScore(entry.doc, entry.canonicalUrl);
124
+ const hasUserLocation = Boolean(options.userLocation?.country ||
125
+ options.userLocation?.city ||
126
+ options.userLocation?.region);
127
+ const geoAffinityScore = computeGeoAffinityScore(entry.doc, entry.canonicalUrl, options.userLocation, options.filterContext);
128
+ // Strip trailing " - Publisher" or " | Publisher" before computing title dedup signature
129
+ const cleanHeadline = (entry.doc.title || "")
130
+ .replace(/\s+[-|–\u2014]\s+[^-|–\u2014]{2,50}$/, "")
131
+ .trim();
132
+ const cleanHeadlineTok = tokenizeAndHash(cleanHeadline);
133
+ const queryHashSet = new Set(queryTok.hashes);
134
+ const nonQueryTitleHashes = new Set(Array.from(cleanHeadlineTok.hashes).filter((h) => !queryHashSet.has(h)));
135
+ // Dynamic filter penalties for agent-supplied `mustIncludeTerms` and `excludeTerms`
136
+ let filterMultiplier = 1.0;
137
+ const combinedLower = `${entry.doc.title || ""} ${entry.doc.description || ""} ${entry.canonicalUrl}`.toLowerCase();
138
+ if (options.filterContext?.mustIncludeTerms?.length) {
139
+ for (const reqTerm of options.filterContext.mustIncludeTerms) {
140
+ const cleanTerm = reqTerm.trim().toLowerCase();
141
+ if (cleanTerm && !combinedLower.includes(cleanTerm)) {
142
+ filterMultiplier *= 0.55;
143
+ }
144
+ }
145
+ }
146
+ if (options.filterContext?.excludeTerms?.length) {
147
+ for (const exTerm of options.filterContext.excludeTerms) {
148
+ const cleanTerm = exTerm.trim().toLowerCase();
149
+ if (cleanTerm && combinedLower.includes(cleanTerm)) {
150
+ filterMultiplier *= 0.25;
151
+ }
152
+ }
153
+ }
154
+ // Calibrated Stage 1-4 Lexical + Structural + Geo Hybrid Score in [0, 1]
155
+ const baseLexical = rrfScore * 0.22 +
156
+ bm25fScore * 0.3 +
157
+ passageMaxSimScore * 0.24 +
158
+ proximityScore * 0.14 +
159
+ freshnessAuthorityScore * 0.1;
160
+ const geoWeight = hasUserLocation
161
+ ? options.filterContext?.preferLocalDomains
162
+ ? 0.22
163
+ : 0.14
164
+ : options.filterContext?.subcategory ||
165
+ options.filterContext?.boostTerms?.length
166
+ ? 0.1
167
+ : 0;
168
+ const lexicalHybridScore = (baseLexical * (1 - geoWeight) + geoAffinityScore * geoWeight) *
169
+ filterMultiplier;
170
+ return {
171
+ entry,
172
+ highlights,
173
+ titleHashSet: new Set(cleanHeadlineTok.hashes),
174
+ nonQueryTitleHashes,
175
+ signatureHashSet: new Set([
176
+ ...cleanHeadlineTok.hashes,
177
+ ...docFields[di].descTok.hashes.slice(0, 24),
178
+ ]),
179
+ publisherKey: extractPublisherDomainKey(entry.canonicalUrl, entry.doc.title || "", entry.doc.description || ""),
180
+ breakdown: {
181
+ rrfScore: Number(rrfScore.toFixed(4)),
182
+ bm25fScore: Number(bm25fScore.toFixed(4)),
183
+ passageMaxSimScore: Number(passageMaxSimScore.toFixed(4)),
184
+ proximityScore: Number(proximityScore.toFixed(4)),
185
+ freshnessAuthorityScore: Number(freshnessAuthorityScore.toFixed(4)),
186
+ ...(hasUserLocation
187
+ ? { geoAffinityScore: Number(geoAffinityScore.toFixed(4)) }
188
+ : {}),
189
+ lexicalHybridScore: Number(lexicalHybridScore.toFixed(4)),
190
+ multiEngineConsensusCount: entry.sources.length,
191
+ },
192
+ finalScore: lexicalHybridScore,
193
+ };
194
+ });
195
+ // Sort by Stage 1-4 score, then apply a light domain diversity pre-pass before Stage 5
196
+ // so our Decision Model top-N budget isn't wasted scoring 3 pages from the same domain.
197
+ stage4Candidates.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
198
+ const preDomainCounts = new Map();
199
+ for (const cand of stage4Candidates) {
200
+ const prevCount = preDomainCounts.get(cand.publisherKey) ?? 0;
201
+ if (prevCount > 0) {
202
+ cand.finalScore *= Math.pow(0.84, prevCount);
203
+ }
204
+ preDomainCounts.set(cand.publisherKey, prevCount + 1);
205
+ }
206
+ stage4Candidates.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
207
+ const lexicalLatencyMs = Number((performance.now() - startTotal).toFixed(2));
208
+ // Stage 5: Vercel AI Gateway Decision Model Reranking (`baseten/mammoth-1` / `convaiinnovations/laya`)
209
+ let decisionLatencyMs = 0;
210
+ let decisionModelUsed = null;
211
+ if (useDecisionModel &&
212
+ decisionModel !== "none" &&
213
+ stage4Candidates.length > 0) {
214
+ const decisionStart = performance.now();
215
+ const topSlice = stage4Candidates.slice(0, decisionTopN);
216
+ const decisionOut = await runDecisionModelReranker(query, topSlice.map((c) => ({
217
+ title: c.entry.doc.title || "",
218
+ url: c.entry.canonicalUrl,
219
+ description: c.entry.doc.description || "",
220
+ highlights: c.highlights,
221
+ })), decisionModel, decisionTimeoutMs, options.userLocation, options.filterContext);
222
+ decisionLatencyMs = Number((performance.now() - decisionStart).toFixed(2));
223
+ if (decisionOut && decisionOut.scores.size > 0) {
224
+ decisionModelUsed = decisionOut.modelUsed;
225
+ for (let i = 0; i < topSlice.length; i++) {
226
+ const dScore = decisionOut.scores.get(i);
227
+ if (dScore) {
228
+ topSlice[i].breakdown.decisionScore = dScore.normalizedScore;
229
+ topSlice[i].breakdown.decisionRawScore = dScore.rawScore;
230
+ topSlice[i].breakdown.decisionConfidence = dScore.confidence;
231
+ topSlice[i].breakdown.decisionModelUsed =
232
+ decisionModelUsed || undefined;
233
+ // Blend 52% Stage 1-4 IR hybrid + 48% Stage 5 Neural Decision Model probability score
234
+ topSlice[i].finalScore =
235
+ topSlice[i].breakdown.lexicalHybridScore * 0.52 +
236
+ dScore.normalizedScore * 0.48;
237
+ }
238
+ }
239
+ stage4Candidates.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
240
+ }
241
+ }
242
+ // Stage 6: Maximal Marginal Relevance (MMR) Near-Duplicate Suppression & optional Per-Domain Cap.
243
+ // Distinct URLs from the same provider are distinct results (e.g. several listings on one
244
+ // portal, several articles from one publisher), so there is no hard cap unless the caller
245
+ // asks for one. A soft MMR decay still spreads providers when scores are close.
246
+ const maxPerDomain = options.maxPerDomain ?? Number.POSITIVE_INFINITY;
247
+ const mmrLambda = options.mmrLambda ?? 0.82;
248
+ const selected = [];
249
+ const deferredOverflow = [];
250
+ const nearDuplicates = [];
251
+ const domainCounts = new Map();
252
+ const remaining = [...stage4Candidates];
253
+ while (selected.length < topK && remaining.length > 0) {
254
+ let bestIdx = -1;
255
+ let bestMmrScore = -Infinity;
256
+ for (let i = 0; i < remaining.length; i++) {
257
+ const cand = remaining[i];
258
+ // Check near-duplicate title or title+snippet similarity against already-selected results
259
+ let maxSim = 0;
260
+ let isHardNearDuplicate = false;
261
+ for (const sel of selected) {
262
+ const titleSim = computeTokenSimilarity(cand.titleHashSet, sel.titleHashSet);
263
+ const sigSim = computeTokenSimilarity(cand.signatureHashSet, sel.signatureHashSet);
264
+ const combinedSim = Math.max(titleSim * 0.95, sigSim);
265
+ if (combinedSim > maxSim) {
266
+ maxSim = combinedSim;
267
+ }
268
+ // Suppress syndicated copies (same story on a different publisher) with >= 0.72
269
+ // title/snippet overlap. Same publisher + different URL is a distinct page
270
+ // (e.g. two listings with templated titles) and is kept.
271
+ if (cand.publisherKey !== sel.publisherKey &&
272
+ (titleSim >= 0.75 || sigSim >= 0.72)) {
273
+ isHardNearDuplicate = true;
274
+ break;
275
+ }
276
+ }
277
+ if (isHardNearDuplicate) {
278
+ // Defer (don't drop): backfilled with a penalty only if the pool runs short.
279
+ nearDuplicates.push(cand);
280
+ remaining.splice(i, 1);
281
+ i--;
282
+ continue;
283
+ }
284
+ const currentDomainCount = domainCounts.get(cand.publisherKey) ?? 0;
285
+ if (currentDomainCount >= maxPerDomain) {
286
+ deferredOverflow.push(cand);
287
+ remaining.splice(i, 1);
288
+ i--;
289
+ continue;
290
+ }
291
+ const domainDecay = Math.pow(0.8, currentDomainCount);
292
+ const mmrScore = mmrLambda * (cand.finalScore * domainDecay) - (1 - mmrLambda) * maxSim;
293
+ if (mmrScore > bestMmrScore) {
294
+ bestMmrScore = mmrScore;
295
+ bestIdx = i;
296
+ }
297
+ }
298
+ if (bestIdx === -1) {
299
+ break;
300
+ }
301
+ const chosen = remaining.splice(bestIdx, 1)[0];
302
+ const prevDomainCount = domainCounts.get(chosen.publisherKey) ?? 0;
303
+ if (prevDomainCount > 0) {
304
+ chosen.finalScore = Number((chosen.finalScore * Math.pow(0.85, prevDomainCount)).toFixed(4));
305
+ }
306
+ domainCounts.set(chosen.publisherKey, prevDomainCount + 1);
307
+ selected.push(chosen);
308
+ }
309
+ // If strict domain cap left fewer than topK results and we have overflow candidates, backfill
310
+ for (const overflow of deferredOverflow) {
311
+ if (selected.length >= topK) {
312
+ break;
313
+ }
314
+ const prevDomainCount = domainCounts.get(overflow.publisherKey) ?? 1;
315
+ overflow.finalScore = Number((overflow.finalScore * Math.pow(0.82, prevDomainCount)).toFixed(4));
316
+ domainCounts.set(overflow.publisherKey, prevDomainCount + 1);
317
+ selected.push(overflow);
318
+ }
319
+ for (const dup of nearDuplicates) {
320
+ if (selected.length >= topK) {
321
+ break;
322
+ }
323
+ dup.finalScore = Number((dup.finalScore * 0.6).toFixed(4));
324
+ selected.push(dup);
325
+ }
326
+ selected.sort((lhs, rhs) => rhs.finalScore - lhs.finalScore);
327
+ const results = selected
328
+ .slice(0, topK)
329
+ .map((item, idx) => ({
330
+ title: item.entry.doc.title,
331
+ url: item.entry.doc.url,
332
+ canonicalUrl: item.entry.canonicalUrl,
333
+ description: item.entry.doc.description,
334
+ ...(item.entry.doc.markdown !== undefined
335
+ ? { markdown: item.entry.doc.markdown }
336
+ : {}),
337
+ ...(item.entry.doc.links !== undefined
338
+ ? { links: item.entry.doc.links }
339
+ : {}),
340
+ ...(item.entry.doc.publishedDate !== undefined
341
+ ? { publishedDate: item.entry.doc.publishedDate }
342
+ : {}),
343
+ score: Number(item.finalScore.toFixed(4)),
344
+ rank: idx + 1,
345
+ sources: item.entry.sources,
346
+ highlights: item.highlights,
347
+ scoreBreakdown: item.breakdown,
348
+ }));
349
+ return {
350
+ results,
351
+ rankingMetrics: {
352
+ totalCandidates: rawCandidates.length,
353
+ uniqueCandidates: N,
354
+ lexicalLatencyMs,
355
+ decisionLatencyMs,
356
+ totalRankingLatencyMs: Number((performance.now() - startTotal).toFixed(2)),
357
+ decisionModelUsed,
358
+ enginesContributing,
359
+ },
360
+ };
361
+ }
@@ -0,0 +1,82 @@
1
+ import type { RawCandidateDocument, SearchEngineSource, SearchFilterContext, UserLocationContext } from "./types.js";
2
+ /**
3
+ * Canonicalize a URL for cross-engine deduplication and RRF fusion.
4
+ */
5
+ export declare function canonicalizeUrl(rawUrl: string): string;
6
+ /**
7
+ * Fast 32-bit FNV-1a integer hash for zero-allocation token/trigram comparison.
8
+ */
9
+ export declare function fnv1a32(str: string): number;
10
+ /**
11
+ * Ultra-fast Porter-style suffix stripper for English IR normalization.
12
+ */
13
+ export declare function stemToken(word: string): string;
14
+ export interface TokenizedText {
15
+ tokens: string[];
16
+ hashes: Uint32Array;
17
+ freqMap: Map<number, number>;
18
+ trigramSet: Set<number>;
19
+ }
20
+ export declare function tokenizeAndHash(text: string, includeTrigrams?: boolean): TokenizedText;
21
+ /**
22
+ * Stage 2: Weighted Reciprocal Rank Fusion (W-RRF) across heterogeneous engines.
23
+ * Formula: RRF(d) = sum_{e in Engines} w_e / (k + rank_e(d))
24
+ */
25
+ export declare function fuseWithWeightedRrf(rawCandidates: RawCandidateDocument[], rrfK?: number): {
26
+ merged: Array<{
27
+ doc: RawCandidateDocument;
28
+ canonicalUrl: string;
29
+ sources: SearchEngineSource[];
30
+ engineRanks: Partial<Record<SearchEngineSource, number>>;
31
+ rrfRaw: number;
32
+ }>;
33
+ enginesContributing: SearchEngineSource[];
34
+ };
35
+ /**
36
+ * Extract headings (H1-H3) from Markdown text for fielded BM25F scoring.
37
+ */
38
+ export declare function extractMarkdownHeadings(markdown?: string): string;
39
+ /**
40
+ * Stage 4a: ColBERT-inspired Subword + Token MaxSim Passage Scorer & Highlight Extractor.
41
+ * Splits document into semantic blocks and computes maximum late-interaction similarity
42
+ * per query token + minimum window proximity.
43
+ */
44
+ export declare function computePassageMaxSimAndHighlights(queryTok: TokenizedText, rawQueryLower: string, doc: RawCandidateDocument, maxHighlights?: number): {
45
+ maxSimScore: number;
46
+ highlights: string[];
47
+ };
48
+ /**
49
+ * Stage 4b: Exact phrase, ordered bigram, and minimum window proximity score.
50
+ */
51
+ export declare function computeProximityAndExactScore(queryTok: TokenizedText, rawQueryLower: string, titleTok: TokenizedText, descTok: TokenizedText, rawTitleLower: string, rawDescLower: string): number;
52
+ /**
53
+ * Extract effective publisher/domain key for domain diversity.
54
+ * For Google News RSS redirect URLs (`news.google.com/rss/articles/...`), extracts the
55
+ * actual publisher (`[Publisher]` or `- Publisher` suffix) so distinct news outlets
56
+ * aren't collapsed under `news.google.com`, while true same-publisher pages share a key.
57
+ */
58
+ export declare function extractPublisherDomainKey(canonicalUrl: string, title: string, description: string): string;
59
+ /**
60
+ * Compute token-set Jaccard + containment similarity in [0, 1] between two candidates
61
+ * to detect near-duplicate headlines or syndicated wire stories.
62
+ */
63
+ export declare function computeTokenSimilarity(setA: Set<number>, setB: Set<number>): number;
64
+ /**
65
+ * Dynamically resolve an ISO 3166-1 alpha-2 country code (e.g. "AU", "GB", "JP", "BR")
66
+ * into its full English name ("Australia", "United Kingdom", "Japan", "Brazil") using
67
+ * standard runtime Intl.DisplayNames: zero hardcoded country tables.
68
+ */
69
+ export declare function resolveCountryDisplayName(countryCode?: string): string | undefined;
70
+ /**
71
+ * Stage 4d: Dynamic Geolocation & Filter Affinity score in [0, 1].
72
+ * Zero hardcoded retailer/city/country lists:
73
+ * - Resolves country display name dynamically via `Intl.DisplayNames`
74
+ * - Matches any 2-letter ccTLD algorithmically (`.${cc}`, `.com.${cc}`, `.co.${cc}`, `.org.${cc}`, `.net.${cc}`, `.gov.${cc}`)
75
+ * - Penalizes foreign 2-letter ccTLDs when `preferLocalDomains` or user country is set
76
+ * - Evaluates dynamic `userLocation` (`city`, `region`, `countryName`) and agent `filterContext` (`boostTerms`, `mustIncludeTerms`, `excludeTerms`)
77
+ */
78
+ export declare function computeGeoAffinityScore(doc: RawCandidateDocument, canonicalUrl: string, userLocation?: UserLocationContext, filterContext?: SearchFilterContext): number;
79
+ /**
80
+ * Stage 4c: Freshness & Domain Authority score.
81
+ */
82
+ export declare function computeFreshnessAndAuthorityScore(doc: RawCandidateDocument, canonicalUrl: string): number;