/** * Dependency-light fuzzy scorer used for entity search and mention * autocomplete. * * This is a subsequence scorer in the spirit of "sublime" / Fuse.js: a query is * a sequence of characters that must appear (in order) within a target string. * It scores matches so that: * * - consecutive matches score higher than scattered ones, * - matches at the start of a word (after a separator) score higher, * - earlier matches in the string score higher. * * The result is a number in `[0, 1]` where `1` is a perfect (whole-string, * case-insensitive, consecutive) match. It is intentionally allocation-light * and fast enough to run on every keystroke against a few hundred entities * well under the 50 ms budget the plan calls for. */ const WORD_SEP = /[^a-zA-Z0-9]+/; export interface FuzzyResult { /** Match score in [0, 1]; 0 means no match. */ score: number; /** Indices in `target` that were matched by the query, in order. */ indices: number[]; } /** Score a single matched cell from its predecessor score. */ function scoreCell(prev: number, consecutive: number, wordStart: number): number { let s = prev + 1; // base reward for a match if (consecutive) s *= 1.4; // multiplicative streak bonus if (wordStart) s += 0.8; // word-start bonus return s; } /** Normalize a raw score into [0, 1]. */ function normalize(raw: number): number { if (!isFinite(raw)) return 0; return Math.max(0, Math.min(1, raw / 3)); } /** * Score `query` against `target`. Returns `{ score: 0 }` when the query is not a * subsequence of the target (i.e. no match). */ export function fuzzyScore(query: string, target: string): FuzzyResult { const q = query; const t = target; if (q.length === 0) return { score: 0, indices: [] }; if (q.length > t.length) return { score: 0, indices: [] }; const ql = q.length; const tl = t.length; const tq = q.toLowerCase(); const tt = t.toLowerCase(); // best[i] = best score having matched the first i query chars. // where[i] = target index at which the i-th match landed (for recovery). const best = new Float64Array(ql + 1).fill(-Infinity); const where = new Int32Array(ql + 1); const consec = new Uint8Array(ql + 1); best[0] = 0; for (let ti = 0; ti < tl; ti++) { const tc = tt[ti]!; // Walk from high to low so each cell reads the previous row only. for (let i = ql; i >= 1; i--) { const qc = tq[i - 1]!; if (tc !== qc) continue; const fromPrev = best[i - 1]!; if (fromPrev <= -Infinity) continue; const wordStart = ti === 0 || WORD_SEP.test(t[ti - 1]!) ? 1 : 0; const c = consec[i - 1] ? 1 : 0; const s = scoreCell(fromPrev, c, wordStart); if (s > best[i]!) { best[i] = s; where[i] = ti; consec[i] = c; } } } if (best[ql]! <= -Infinity) return { score: 0, indices: [] }; // Recover matched indices by walking `where` backwards. const indices: number[] = []; for (let i = ql; i >= 1; i--) { indices.push(where[i]!); } indices.reverse(); return { score: normalize(best[ql]!), indices }; } /** Convenience: does `query` match `target` as a subsequence? */ export function fuzzyMatch(query: string, target: string): boolean { return fuzzyScore(query, target).score > 0; } /** * Sort `items` by descending fuzzy score against `query`, dropping non-matches. * Stable: equal scores keep input order. */ export function fuzzySort(items: T[], query: string, pick: (item: T) => string): T[] { if (!query) return items.slice(); const scored = items .map((item) => ({ item, res: fuzzyScore(query, pick(item)) })) .filter((r) => r.res.score > 0); scored.sort((a, b) => b.res.score - a.res.score); return scored.map((r) => r.item); }