@uniweb/projections 0.5.11 → 0.5.12

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniweb/projections",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "Projections of a Uniweb site's content — agent index, per-page markdown, search index. Pure JS, runs anywhere.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
- "./search": "./src/search/index.js"
8
+ "./search": "./src/search/index.js",
9
+ "./search/engine": "./src/search/engine.js"
9
10
  },
10
11
  "files": [
11
12
  "src"
@@ -0,0 +1,639 @@
1
+ /**
2
+ * The search engine — an inverted index over search entries, and BM25F ranking.
3
+ *
4
+ * ## What this is, and what it is not
5
+ *
6
+ * `generateSearchIndex` produces the **entries**: a site's searchable content,
7
+ * one record per page or record, emitted at build time. **This module indexes
8
+ * and ranks them.** Two jobs, one artifact between them, and the entries are the
9
+ * seam.
10
+ *
11
+ * ⭐ **It exists so that one site ranks the same wherever it is served.** The
12
+ * entries were already shared; the ranking was not, so a lesson learned about
13
+ * ranking this corpus landed in one consumer and could not reach another, and
14
+ * the same query over the same content could come back in a different order
15
+ * depending on who answered it. A reader experiences that as the product being
16
+ * inconsistent, not as two implementations.
17
+ *
18
+ * ⚖️ **It is a default, not a monopoly.** A site may point its search at a
19
+ * third-party service, and a host may answer search itself; that pluggability is
20
+ * deliberate and this does not touch it.
21
+ *
22
+ * ## ⛔ THE CONSTRAINT THAT SHAPED THE FORMAT: PARSE COST
23
+ *
24
+ * The structure is built once per corpus and then **serialized and re-parsed** by
25
+ * whoever caches it. **A fat inverted index can cost more to parse than the
26
+ * linear scan it replaces**, which would make it a regression at exactly the
27
+ * scale it exists to fix.
28
+ *
29
+ * ⇒ **Everything but the term dictionary is a flat array of integers.** Postings
30
+ * are `[doc, tfTitle, tfBody, doc, tfTitle, tfBody, …]` — no objects, no key
31
+ * names repeated per posting. A `{doc,tfT,tfB}` object form measured **~4× the
32
+ * bytes** for the same information, all of it key names. ⛔ Do not "tidy" the
33
+ * postings into objects; the flatness is the feature.
34
+ *
35
+ * ## What a global product needs that an English one does not
36
+ *
37
+ * ⭐ **Diacritic folding.** `café` and `cafe` must be one term, or French,
38
+ * Spanish and Portuguese sites silently under-match. NFD, then drop combining
39
+ * marks.
40
+ *
41
+ * ⭐ **Bigrams for scripts written without spaces.** Chinese and Japanese do not
42
+ * separate words, so a whitespace tokenizer yields ONE enormous token per run and
43
+ * the index is useless. Bigrams are the standard cheap answer and need no
44
+ * dictionary.
45
+ *
46
+ * ⛔ **NO STOPWORD LIST, deliberately.** A hardcoded English list is wrong for
47
+ * every other language. **IDF does the job a stopword list was invented to fake**
48
+ * — a term in every document earns an IDF at or near zero and stops affecting the
49
+ * ranking on its own.
50
+ *
51
+ * ⛔ **NO STEMMING.** Each language needs its own stemmer, so it is N
52
+ * dependencies and N failure modes rather than one; and a wrong stemmer **merges
53
+ * terms that differ and splits terms that do not, invisibly** — no error, no test
54
+ * failure, just quietly worse results. Reopenable on evidence, per locale, one
55
+ * language at a time: an index is built per locale, so the language is known.
56
+ *
57
+ * ## Provenance
58
+ *
59
+ * The index, BM25F scoring, folding, bigrams, prefix completion and the bounded
60
+ * top-k were written for a server-side search lane and contributed here in full
61
+ * on 2026-09-06, so that both lanes rank identically. The fuzzy fallback (§
62
+ * `fuzzyTerms`) is this package's, and it is the one thing the contributed engine
63
+ * did not have.
64
+ *
65
+ * ⭐ Zero dependencies and no platform APIs — no `node:*`, no DOM, no filesystem
66
+ * — so it runs in a browser, in a build, and in a server isolate alike.
67
+ * `tests/environment.test.js` walks the import graph and fails if that stops
68
+ * being true.
69
+ */
70
+
71
+ // ── BM25F parameters ────────────────────────────────────────────────────────
72
+ const K1 = 1.2 // term-frequency saturation
73
+ const B_TITLE = 0.5 // titles are short; normalize them gently
74
+ const B_BODY = 0.75 // the usual BM25 default for prose
75
+ const W_TITLE = 3.0 // a title hit is worth about three body hits
76
+ const W_BODY = 1.0
77
+ const PHRASE_BOOST = 1.35 // multiplicative
78
+ const PHRASE_WINDOW = 200 // how deep the phrase check goes — see the note at its use
79
+ const DEFAULT_WEIGHT = 0.6 // an entry that declares none
80
+
81
+ /**
82
+ * Scripts written WITHOUT SPACES between words, which a whitespace tokenizer
83
+ * turns into one useless token per run. These get bigrammed instead.
84
+ *
85
+ * ⛔ **HANGUL IS DELIBERATELY ABSENT.** Korean **is** space delimited —
86
+ * bigramming it is wrong on its own terms, and combined with NFD decomposition it
87
+ * produces jamo-pair tokens rather than words. *Two bugs that look like one,
88
+ * because the output is garbage either way.*
89
+ *
90
+ * ⭐ Thai, Lao, Khmer and Myanmar are here for the reason Han is: no spaces, so
91
+ * without bigrams a whole sentence is a single term and only an exact full-phrase
92
+ * query can find it.
93
+ */
94
+ const NO_SPACE_SCRIPT =
95
+ /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u
96
+ const WORD = /[\p{L}\p{N}][\p{L}\p{N}_'’-]*/gu
97
+
98
+ /**
99
+ * Fold to a comparable form: lowercase, and strip combining marks.
100
+ *
101
+ * ⭐ NFD then drop `\p{M}` rather than a hand-written character map — a map covers
102
+ * the accents its author happened to think of, which is a guarantee about the
103
+ * author rather than about the text.
104
+ *
105
+ * Exported because a caller that highlights a match must fold the same way, or it
106
+ * highlights the wrong span.
107
+ *
108
+ * @param {*} text
109
+ * @returns {string}
110
+ */
111
+ export function fold(text) {
112
+ return String(text == null ? '' : text)
113
+ // NFKC first: full-width Latin → ASCII, half-width katakana → katakana,
114
+ // ligatures → letters. ⛔ Without it `UNIWEB` — routine on Japanese sites —
115
+ // never matches `uniweb`, and `file` never matches `file`.
116
+ .normalize('NFKC')
117
+ // Decompose so combining marks can be dropped: `café` → `cafe`.
118
+ .normalize('NFD')
119
+ .replace(/\p{M}+/gu, '')
120
+ // ⛔⛔ RECOMPOSE, AND THIS LINE IS LOAD-BEARING FOR KOREAN. NFD decomposes each
121
+ // Hangul syllable into jamo, which are LETTERS, not marks — so the strip above
122
+ // leaves them and `한국어` tokenizes as jamo pairs. **Korean search returns
123
+ // garbage without this.** NFC puts the syllables back.
124
+ .normalize('NFC')
125
+ .toLowerCase()
126
+ }
127
+
128
+ /**
129
+ * Text → terms.
130
+ *
131
+ * ⛔ A run in a no-space script becomes BIGRAMS, not one token. `東京都` yields
132
+ * `東京`, `京都` — so a query for either matches, which whitespace tokenization
133
+ * cannot do at all. A single-character run still yields that character, or it
134
+ * would be unfindable.
135
+ *
136
+ * @param {*} text
137
+ * @returns {string[]}
138
+ */
139
+ export function tokenize(text) {
140
+ const folded = fold(text)
141
+ if (!folded) return []
142
+ const out = []
143
+ for (const m of folded.matchAll(WORD)) {
144
+ const tok = m[0]
145
+ if (!NO_SPACE_SCRIPT.test(tok)) {
146
+ if (tok.length >= 2 || /\p{N}/u.test(tok)) out.push(tok)
147
+ continue
148
+ }
149
+ // Mixed runs are possible (`iPhone用`), so split into CJK / non-CJK spans.
150
+ let span = ''
151
+ const flush = () => {
152
+ if (!span) return
153
+ if (NO_SPACE_SCRIPT.test(span[0])) {
154
+ if (span.length === 1) out.push(span)
155
+ else for (let i = 0; i < span.length - 1; i++) out.push(span.slice(i, i + 2))
156
+ } else if (span.length >= 2 || /\p{N}/u.test(span)) out.push(span)
157
+ span = ''
158
+ }
159
+ let cjkSpan = null
160
+ for (const ch of tok) {
161
+ const isC = NO_SPACE_SCRIPT.test(ch)
162
+ if (cjkSpan === null) cjkSpan = isC
163
+ if (isC !== cjkSpan) { flush(); cjkSpan = isC }
164
+ span += ch
165
+ }
166
+ flush()
167
+ }
168
+ return out
169
+ }
170
+
171
+ /**
172
+ * Build the inverted structure over an entry array.
173
+ *
174
+ * ⭐ PURE, and the entry array is the only input — so the same entries always
175
+ * yield the same structure, which is what lets a caller cache it under a content
176
+ * hash and reuse it across queries.
177
+ *
178
+ * @param {Array<{title?: string, content?: string, weight?: number}>} entries
179
+ * @returns {{v:number, terms:string[], df:number[], post:number[][],
180
+ * lenT:number[], lenB:number[], avgT:number, avgB:number, n:number}}
181
+ */
182
+ export function buildSearchStructure(entries) {
183
+ const docs = Array.isArray(entries) ? entries : []
184
+ const n = docs.length
185
+ const termIds = new Map() // term -> id
186
+ const terms = []
187
+ const postings = [] // id -> Map(doc -> [tfT, tfB])
188
+ const lenT = new Array(n).fill(0)
189
+ const lenB = new Array(n).fill(0)
190
+
191
+ const idOf = (t) => {
192
+ let id = termIds.get(t)
193
+ if (id === undefined) {
194
+ id = terms.length
195
+ termIds.set(t, id)
196
+ terms.push(t)
197
+ postings.push(new Map())
198
+ }
199
+ return id
200
+ }
201
+
202
+ for (let d = 0; d < n; d++) {
203
+ const e = docs[d] || {}
204
+ const tTerms = tokenize(e.title)
205
+ // `content` already carries the searchable body the projection selected.
206
+ const bTerms = tokenize(e.content)
207
+ lenT[d] = tTerms.length
208
+ lenB[d] = bTerms.length
209
+
210
+ for (const t of tTerms) {
211
+ const p = postings[idOf(t)]
212
+ const cur = p.get(d)
213
+ if (cur) cur[0]++
214
+ else p.set(d, [1, 0])
215
+ }
216
+ for (const t of bTerms) {
217
+ const p = postings[idOf(t)]
218
+ const cur = p.get(d)
219
+ if (cur) cur[1]++
220
+ else p.set(d, [0, 1])
221
+ }
222
+ }
223
+
224
+ // ⭐ SORT THE DICTIONARY. This is what makes `terms` a term DICTIONARY rather
225
+ // than a list: a sorted array supports binary search, so every term sharing a
226
+ // prefix is a contiguous range and prefix expansion costs a lookup plus a short
227
+ // scan. An unsorted array would force a full pass over the vocabulary per
228
+ // keystroke.
229
+ const order = terms.map((t, i) => i).sort((a, b) => (terms[a] < terms[b] ? -1 : terms[a] > terms[b] ? 1 : 0))
230
+
231
+ // ⛔ FLATTEN TO INTEGERS, in the new order. See the parse-cost note in the
232
+ // header — this is the difference between a structure that is cheaper than the
233
+ // scan and one that is not.
234
+ const post = order.map((oldId) => {
235
+ const flat = []
236
+ for (const [d, tf] of postings[oldId]) flat.push(d, tf[0], tf[1])
237
+ return flat
238
+ })
239
+ const df = order.map((oldId) => postings[oldId].size)
240
+ const sortedTerms = order.map((oldId) => terms[oldId])
241
+
242
+ const sum = (a) => a.reduce((x, y) => x + y, 0)
243
+ return {
244
+ v: 1,
245
+ terms: sortedTerms,
246
+ df,
247
+ post,
248
+ lenT,
249
+ lenB,
250
+ avgT: n ? sum(lenT) / n : 0,
251
+ avgB: n ? sum(lenB) / n : 0,
252
+ n,
253
+ }
254
+ }
255
+
256
+ /**
257
+ * ⛔ THE TERM→ID MAP LIVES IN A WeakMap, NOT ON THE STRUCTURE.
258
+ *
259
+ * Written once as `structure._ids || (structure._ids = new Map(...))`. The
260
+ * structure is **serialized** by callers that cache it — and `JSON.stringify`
261
+ * turns a `Map` into `{}`, which is TRUTHY, so a later reader parsing that cached
262
+ * copy short-circuits to the empty object and fails with
263
+ * `termIds.get is not a function`.
264
+ *
265
+ * ⚠️ **It was latent rather than live, for a reason nobody should have to rely
266
+ * on**: the caller serialized eagerly, before any query had set `_ids`. Wrap that
267
+ * in an async closure — the most natural refactor there is — and it goes live, as
268
+ * a search outage hours later, on a cache entry poisoned by a different request.
269
+ *
270
+ * ⇒ *Do not mutate an object you serialize.* The WeakMap costs nothing and
271
+ * removes the invariant entirely rather than documenting it.
272
+ */
273
+ const IDS = new WeakMap()
274
+ function idsFor(structure) {
275
+ let m = IDS.get(structure)
276
+ if (!m) {
277
+ m = new Map(structure.terms.map((t, i) => [t, i]))
278
+ IDS.set(structure, m)
279
+ }
280
+ return m
281
+ }
282
+
283
+ const PREFIX_MIN = 2 // below this, a prefix matches most of the vocabulary
284
+ const PREFIX_CAP = 24 // bound the fan-out of one keystroke
285
+ const PREFIX_PENALTY = 0.5 // a completion is a guess; an exact term outranks it
286
+
287
+ /**
288
+ * Term ids whose term starts with `prefix`, by binary search on the sorted
289
+ * dictionary.
290
+ *
291
+ * ⭐ **This is search-as-you-type.** A substring engine gives prefix matching for
292
+ * free and badly — `includes` matches mid-word too, so `art` hits `smart`. Token
293
+ * matching alone would LOSE the useful half of that behaviour, which a visitor
294
+ * notices immediately: typing `zebr` would find nothing until the final `a`.
295
+ *
296
+ * ⛔ **Capped, and the cap keeps the COMMONEST completions.** A two-letter prefix
297
+ * can match thousands of terms; scoring all of them would make an early keystroke
298
+ * the most expensive query of the session. Highest `df` first is what autocomplete
299
+ * wants — the completion a visitor is most likely reaching for.
300
+ *
301
+ * @param {object} structure
302
+ * @param {string} prefix
303
+ * @param {number} [cap]
304
+ * @returns {number[]} term ids
305
+ */
306
+ export function prefixTerms(structure, prefix, cap = PREFIX_CAP) {
307
+ const terms = structure.terms
308
+ if (!prefix || prefix.length < PREFIX_MIN) return []
309
+ let lo = 0
310
+ let hi = terms.length
311
+ while (lo < hi) {
312
+ const mid = (lo + hi) >> 1
313
+ if (terms[mid] < prefix) lo = mid + 1
314
+ else hi = mid
315
+ }
316
+ const hits = []
317
+ for (let i = lo; i < terms.length && terms[i].startsWith(prefix); i++) hits.push(i)
318
+ if (hits.length <= cap) return hits
319
+ return hits.sort((a, b) => structure.df[b] - structure.df[a]).slice(0, cap)
320
+ }
321
+
322
+ // ── The fuzzy fallback — this package's addition ────────────────────────────
323
+
324
+ const FUZZY_MIN = 4 // below this, one edit is most of the word
325
+ const FUZZY_SCAN = 400 // terms examined per query term; a bound, not a target
326
+ const FUZZY_CAP = 8 // corrections kept, commonest first
327
+
328
+ /**
329
+ * ⭐ **STRICTLY BELOW LITERAL, AND THAT IS THE WHOLE DESIGN.** This runs **only
330
+ * when the literal pass scored nothing at all** — so it can never reorder a
331
+ * result set that has a real match in it, and every property of the ranking above
332
+ * is untouched whenever anything matched.
333
+ *
334
+ * ⛔ Why not a scoring tier mixed in with the rest: a fuzzy score that competes
335
+ * with an exact one is how a near-miss outranks a page that actually contains the
336
+ * word. That failure was measured on the engine this replaces — a query returned
337
+ * 68 hits of which 4 contained the term, and all four ranked below the tenth
338
+ * result. **Fuzzy is the right FALLBACK and the wrong TIER.**
339
+ *
340
+ * The scan is bounded two ways: only terms sharing the first character are
341
+ * considered (a first-character typo is the rare case, and covering it would cost
342
+ * a pass over the whole vocabulary), and at most `FUZZY_SCAN` of them.
343
+ *
344
+ * @param {object} structure
345
+ * @param {string} term - a folded query term
346
+ * @returns {number[]} term ids within edit distance 1
347
+ */
348
+ export function fuzzyTerms(structure, term) {
349
+ if (!term || term.length < FUZZY_MIN) return []
350
+ const terms = structure.terms
351
+ const head = term[0]
352
+ // Binary search to the first term sharing the leading character.
353
+ let lo = 0
354
+ let hi = terms.length
355
+ while (lo < hi) {
356
+ const mid = (lo + hi) >> 1
357
+ if (terms[mid] < head) lo = mid + 1
358
+ else hi = mid
359
+ }
360
+ const hits = []
361
+ let scanned = 0
362
+ for (let i = lo; i < terms.length && terms[i][0] === head && scanned < FUZZY_SCAN; i++, scanned++) {
363
+ const t = terms[i]
364
+ // Length differs by more than one edit — cheap reject before the real check.
365
+ if (Math.abs(t.length - term.length) > 1) continue
366
+ if (t !== term && withinOneEdit(term, t)) hits.push(i)
367
+ }
368
+ if (hits.length <= FUZZY_CAP) return hits
369
+ return hits.sort((a, b) => structure.df[b] - structure.df[a]).slice(0, FUZZY_CAP)
370
+ }
371
+
372
+ /**
373
+ * Is `b` reachable from `a` by at most one insertion, deletion, substitution — or
374
+ * one transposition of adjacent characters?
375
+ *
376
+ * A bounded walk rather than an edit-distance matrix: O(len) rather than O(len²),
377
+ * which is what lets it run against hundreds of dictionary terms per query.
378
+ *
379
+ * ⭐ **THE TRANSPOSITION CASE IS NOT DECORATION.** Plain Levenshtein scores two
380
+ * crossed characters as **two** edits, so `fomr` → `form` — the shape of typo a
381
+ * touch typist actually produces — would have been the one this fallback could not
382
+ * fix. Damerau's addition is four lines and it is the difference between
383
+ * correcting real typing and correcting a textbook.
384
+ *
385
+ * ⚠️ **`teh` → `the` is the example everyone reaches for, and it does NOT work
386
+ * here** — at three characters it is below `FUZZY_MIN` and never reaches this
387
+ * function. *This comment used to cite it, which made the justification a case the
388
+ * code cannot handle.* Verified against a Damerau reference over 864 pairs: no
389
+ * false positives, no misses, 111 of them reachable only by transposition. Raising
390
+ * the floor's exception for transposition alone is defensible — a crossed pair is
391
+ * far less ambiguous at three letters than a substitution — but there is no corpus
392
+ * evidence for it, so it is not done on a hunch.
393
+ */
394
+ function withinOneEdit(a, b) {
395
+ const la = a.length
396
+ const lb = b.length
397
+ if (Math.abs(la - lb) > 1) return false
398
+ let i = 0
399
+ let j = 0
400
+ let edited = false
401
+ while (i < la && j < lb) {
402
+ if (a[i] === b[j]) { i++; j++; continue }
403
+ if (edited) return false
404
+ edited = true
405
+ if (la === lb) {
406
+ // Adjacent transposition, checked before substitution: two crossed
407
+ // characters consume both positions at once.
408
+ if (a[i] === b[j + 1] && a[i + 1] === b[j]) { i += 2; j += 2; continue }
409
+ i++; j++ // substitution
410
+ } else if (la > lb) i++ // deletion from a
411
+ else j++ // insertion into a
412
+ }
413
+ // Whatever is left over is at most the one remaining edit.
414
+ return (la - i) + (lb - j) + (edited ? 1 : 0) <= 1
415
+ }
416
+
417
+ // ── Selection ───────────────────────────────────────────────────────────────
418
+
419
+ /** Best-first, with a deterministic tiebreak — ties by score fall back to doc order. */
420
+ const better = (a, b) => b.score - a.score || a.doc - b.doc
421
+
422
+ /**
423
+ * The `k` best, sorted. O(n log k) with a bounded min-heap instead of O(n log n).
424
+ *
425
+ * ⛔ **THE TIEBREAK IS PART OF THE COMPARISON, not just the final sort.** A heap
426
+ * ordering on score alone would keep an arbitrary member of a tied group at the
427
+ * cutoff, so two identical corpora could return different results — the kind of
428
+ * nondeterminism that looks like a caching bug for a week.
429
+ */
430
+ function selectTop(items, k) {
431
+ if (k >= items.length) return items.sort(better)
432
+ const heap = [] // min-heap: heap[0] is the WORST kept so far
433
+ const worse = (a, b) => better(b, a) < 0 // a is worse than b
434
+ const up = (i) => {
435
+ while (i > 0) {
436
+ const p = (i - 1) >> 1
437
+ // ⛔ CHILD vs PARENT, in that order. Reversed, this builds a MAX-heap: the
438
+ // root holds the BEST kept, so every later candidate is compared against
439
+ // the wrong end and the selection silently keeps the wrong members. Caught
440
+ // by a three-document fixture where top-2 returned the 1st and 3rd.
441
+ if (!worse(heap[i], heap[p])) break
442
+ ;[heap[p], heap[i]] = [heap[i], heap[p]]
443
+ i = p
444
+ }
445
+ }
446
+ const down = () => {
447
+ let i = 0
448
+ for (;;) {
449
+ const l = 2 * i + 1
450
+ const r = l + 1
451
+ let m = i
452
+ if (l < heap.length && worse(heap[l], heap[m])) m = l
453
+ if (r < heap.length && worse(heap[r], heap[m])) m = r
454
+ if (m === i) break
455
+ ;[heap[m], heap[i]] = [heap[i], heap[m]]
456
+ i = m
457
+ }
458
+ }
459
+ for (const x of items) {
460
+ if (heap.length < k) {
461
+ heap.push(x)
462
+ up(heap.length - 1)
463
+ } else if (better(x, heap[0]) < 0) {
464
+ heap[0] = x
465
+ down()
466
+ }
467
+ }
468
+ return heap.sort(better)
469
+ }
470
+
471
+ /**
472
+ * Score a query against a built structure.
473
+ *
474
+ * ⭐ **ONLY DOCUMENTS CONTAINING A QUERY TERM ARE TOUCHED.** That is the whole
475
+ * point of inverting: cost is the sum of the matched terms' posting lists, not the
476
+ * corpus size. A query for a rare word on a 50,000-entry site reads a handful of
477
+ * postings.
478
+ *
479
+ * ⛔ **A term absent from the dictionary contributes NOTHING and does not fail.**
480
+ * A two-word query where one word is unknown still ranks on the other, which is
481
+ * what a visitor expects.
482
+ *
483
+ * @param {object} structure - from `buildSearchStructure`
484
+ * @param {string} query
485
+ * @param {Array} entries - the same array the structure was built from
486
+ * @param {object} [opts]
487
+ * @param {number} [opts.limit] - keep only the best N (0 = all)
488
+ * @param {boolean} [opts.prefix=true] - complete the last term as it is typed
489
+ * @param {boolean} [opts.fuzzy=true] - fall back to near-misses when nothing matched
490
+ * @returns {{hits: Array<{doc:number, score:number, fuzzy?:boolean}>, total:number,
491
+ * corrections?: Array<{term:string, to:string}>}}
492
+ * `corrections` is present only when the fuzzy fallback answered — one entry per
493
+ * query term it substituted, the commonest candidate for each — so a caller can
494
+ * render *"showing results for form"*. **Absent, never empty**, so presence is
495
+ * the branch.
496
+ */
497
+ export function rankSearchEntries(structure, query, entries, opts = {}) {
498
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 0
499
+ if (!structure || structure.v !== 1 || !structure.n) return { hits: [], total: 0 }
500
+ const qTerms = tokenize(query)
501
+ if (!qTerms.length) return { hits: [], total: 0 }
502
+
503
+ const termIds = idsFor(structure)
504
+ const { df, post, lenT, lenB, avgT, avgB, n } = structure
505
+
506
+ // ⭐ ONLY THE LAST TOKEN IS EXPANDED — it is the one that may still be being
507
+ // typed. Expanding earlier tokens would silently widen a query the visitor has
508
+ // already finished, which is a different and worse behaviour than completing the
509
+ // current word.
510
+ const wantPrefix = opts.prefix !== false && !/[\s]$/.test(String(query || ''))
511
+ const last = qTerms[qTerms.length - 1]
512
+ const weights = new Map() // term id -> multiplier
513
+ for (const t of new Set(qTerms)) {
514
+ const id = termIds.get(t)
515
+ if (id !== undefined) weights.set(id, 1)
516
+ }
517
+ // ⛔ EXPAND EVEN WHEN THE LAST TOKEN IS AN EXACT TERM. Gating this on
518
+ // `!termIds.has(last)` makes recall inconsistent in a way a visitor hits
519
+ // immediately: `tes` finds `testing`, and then typing the final `t` LOSES it,
520
+ // because `test` is itself a term. ⭐ *An exact match is not evidence that the
521
+ // visitor wanted only that word.* The exact id keeps weight 1 and completions
522
+ // stay at the penalty, so exactness still ranks first.
523
+ if (wantPrefix) {
524
+ for (const id of prefixTerms(structure, last)) {
525
+ if (!weights.has(id)) weights.set(id, PREFIX_PENALTY)
526
+ }
527
+ }
528
+
529
+ let scores = score(weights)
530
+ let fuzzy = false
531
+ let corrections = null
532
+
533
+ // The fallback, and it runs only on a total miss — see `fuzzyTerms`.
534
+ if (!scores.size && opts.fuzzy !== false) {
535
+ const weighted = new Map()
536
+ const substitutes = []
537
+ for (const t of new Set(qTerms)) {
538
+ const ids = fuzzyTerms(structure, t)
539
+ if (!ids.length) continue
540
+ for (const id of ids) if (!weighted.has(id)) weighted.set(id, 1)
541
+ // ⭐ ONE substitute per query term, and it is the COMMONEST of the
542
+ // candidates — the same reasoning the prefix cap uses: `df` order is the
543
+ // word a visitor was most likely reaching for. The others still score; this
544
+ // is only what a caller renders as "showing results for …".
545
+ let best = ids[0]
546
+ for (const id of ids) if (df[id] > df[best]) best = id
547
+ substitutes.push({ term: t, to: structure.terms[best] })
548
+ }
549
+ if (weighted.size) {
550
+ scores = score(weighted)
551
+ fuzzy = scores.size > 0
552
+ if (fuzzy) corrections = substitutes
553
+ }
554
+ }
555
+ if (!scores.size) return { hits: [], total: 0 }
556
+
557
+ // Weight is a property read, so every candidate can afford it.
558
+ const out = []
559
+ for (const [d, base] of scores) {
560
+ const e = (entries && entries[d]) || {}
561
+ const w = typeof e.weight === 'number' ? e.weight : DEFAULT_WEIGHT
562
+ out.push(fuzzy ? { doc: d, score: base * w, fuzzy: true } : { doc: d, score: base * w })
563
+ }
564
+ const total = out.length
565
+
566
+ // ⭐ SELECT, DO NOT SORT. Profiled at 50,000 entries with every document
567
+ // matching: posting traversal 2.4 ms, building these objects 1.8 ms, and the
568
+ // **full sort 13.5 ms** — two thirds of the query, thrown away one frame later
569
+ // when the caller slices to 20. A bounded selection does the same job in 0.7 ms.
570
+ //
571
+ // ⛔ The window must cover the PHRASE pass too, or a boost could promote
572
+ // something from outside it and be invisible. `total` is the count BEFORE
573
+ // truncation, so a caller can still say "showing 10 of 4,312".
574
+ const want = Math.max(limit || total, qTerms.length > 1 && !fuzzy ? PHRASE_WINDOW : 0) || total
575
+ const head = selectTop(out, Math.min(want, total))
576
+
577
+ // ⛔⛔ THE PHRASE BOOST IS BOUNDED TO THE TOP OF THE RANKING, and the comment
578
+ // here once claimed it was free.
579
+ //
580
+ // It read *"applied to CANDIDATES ONLY — a bounded set"*. **The candidate set is
581
+ // not bounded: for a common term it IS the corpus.** Measured at 50,000 entries
582
+ // — one word 14 ms, two words 61 ms, and 50,000 candidates — so re-folding every
583
+ // candidate's title and body gave back exactly the work inverting had saved.
584
+ // ⭐ *A plausible claim about one's own code, written as a virtue, never
585
+ // measured.*
586
+ //
587
+ // ⚖️ **The trade, stated rather than hidden:** a document below the window
588
+ // cannot be promoted by a phrase match. A 1.35× boost can only move something
589
+ // already near the top into it, so the bound costs ranking quality only where
590
+ // the score distribution is nearly flat — and it is what keeps a two-word query
591
+ // from costing four times a one-word query.
592
+ // ⛔ Skipped when the hits are corrections: the phrase is the ORIGINAL query,
593
+ // which by definition matched nothing literally, so no entry can contain it.
594
+ // Scanning up to `PHRASE_WINDOW` entries for a guaranteed miss is the whole cost
595
+ // with none of the benefit.
596
+ if (qTerms.length > 1 && !fuzzy) {
597
+ const phrase = fold(query)
598
+ const window = Math.min(head.length, PHRASE_WINDOW)
599
+ let boosted = false
600
+ for (let i = 0; i < window; i++) {
601
+ const e = (entries && entries[head[i].doc]) || {}
602
+ if (`${fold(e.title)} ${fold(e.content)}`.includes(phrase)) {
603
+ head[i].score *= PHRASE_BOOST
604
+ boosted = true
605
+ }
606
+ }
607
+ if (boosted) head.sort(better)
608
+ }
609
+ const out2 = { hits: limit ? head.slice(0, limit) : head, total }
610
+ // ⛔ ABSENT when nothing was corrected, never `[]` — a consumer branching on
611
+ // presence is the shape asked for, and an empty array reads as "corrected, to
612
+ // nothing".
613
+ if (corrections && corrections.length) out2.corrections = corrections
614
+ return out2
615
+
616
+ /** BM25F over a term-id → multiplier map. */
617
+ function score(weighted) {
618
+ const acc = new Map()
619
+ for (const [id, mult] of weighted) {
620
+ const docFreq = df[id]
621
+ // Lucene-style IDF: always positive, and a term in every document lands
622
+ // near zero rather than negative — which is what makes a stopword list
623
+ // unnecessary.
624
+ const idf = Math.log(1 + (n - docFreq + 0.5) / (docFreq + 0.5))
625
+ const flat = post[id]
626
+ for (let i = 0; i < flat.length; i += 3) {
627
+ const d = flat[i]
628
+ const tfT = flat[i + 1]
629
+ const tfB = flat[i + 2]
630
+ const normT = avgT ? tfT / (1 - B_TITLE + (B_TITLE * lenT[d]) / avgT) : 0
631
+ const normB = avgB ? tfB / (1 - B_BODY + (B_BODY * lenB[d]) / avgB) : 0
632
+ const tf = W_TITLE * normT + W_BODY * normB
633
+ if (tf <= 0) continue
634
+ acc.set(d, (acc.get(d) || 0) + mult * idf * ((tf * (K1 + 1)) / (tf + K1)))
635
+ }
636
+ }
637
+ return acc
638
+ }
639
+ }
@@ -44,3 +44,18 @@ export {
44
44
  } from './generate.js'
45
45
 
46
46
  export { generateRecordSearchIndex } from './records.js'
47
+
48
+ // ── The engine: indexing and ranking the entries the generators above produce ──
49
+ //
50
+ // ⭐ Two jobs, one artifact between them. `generateSearchIndex` produces the
51
+ // ENTRIES; these index and rank them, so one site ranks the same wherever it is
52
+ // served. Contributed in full on 2026-09-06 from a server-side search lane that
53
+ // had measured the format against the alternative; the fuzzy fallback is ours.
54
+ export {
55
+ buildSearchStructure,
56
+ rankSearchEntries,
57
+ prefixTerms,
58
+ fuzzyTerms,
59
+ fold,
60
+ tokenize,
61
+ } from './engine.js'