/* * distance.ts — Damerau-Levenshtein (OSA) distance, hand-rolled. * ------------------------------------------------------------ * The ONE place hand-rolling IS correct (RESEARCH §"Don't Hand-Roll"): * at ~500 entries x ~5 aliases the comparison count is tiny, the * algorithm is ~25 lines of standard Optimal String Alignment dynamic * programming, and a dependency would add package weight to a Worker * bundle D-07 deliberately wants minimal. Every dedicated string-distance * npm package is 2022-vintage and unmaintained (verified 2026-10-23). * * Adjacent transpositions count as ONE edit — "KCLC" must reach "KLCC" * (D-13's transposition typo bucket); a Levenshtein-only distance would * score it 2 and drop it out of the FUZZY tier's admission window. * * OSA restriction: a substring is never edited twice, so overlapping * edit operations ("ca" -> "abc") cost more than unrestricted * Damerau-Levenshtein. For typo detection on place names that * restriction is what we want — real typos are local. * * Implementation shape: three rotating rows (prev2 / prev / cur). The * transposition term reads prev2 at [j-2], i.e. the row two iterations * back — which is why the rotation order below is load-bearing: * after each row, prev2 <- prev, prev <- cur, cur <- the recycled row. * The recycled row is fully overwritten (cur[0] first, then every j) * before any read, so its initial zeros are never observed. Character * comparisons go through charCodeAt (always a number; index arithmetic * keeps every read in range), and row reads are nullish-guarded for the * noUncheckedIndexedAccess law even though every read cell is written * before it is read. * * Pure: no I/O, no imports, no third-party packages. */ /** Optimal String Alignment edit distance (adjacent transposition = 1). */ export function damerau(a: string, b: string): number { if (a === b) return 0; if (a.length === 0) return b.length; if (b.length === 0) return a.length; const n = a.length; const m = b.length; let prev2 = new Int32Array(m + 1); let prev = new Int32Array(m + 1); let cur = new Int32Array(m + 1); for (let j = 0; j <= m; j++) prev[j] = j; for (let i = 1; i <= n; i++) { cur[0] = i; for (let j = 1; j <= m; j++) { const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; let best = Math.min( (cur[j - 1] ?? 0) + 1, // insertion (prev[j] ?? 0) + 1, // deletion (prev[j - 1] ?? 0) + cost, // substitution / match ); if ( i > 1 && j > 1 && a.charCodeAt(i - 1) === b.charCodeAt(j - 2) && a.charCodeAt(i - 2) === b.charCodeAt(j - 1) ) { const transposed = (prev2[j - 2] ?? 0) + 1; // adjacent transposition if (transposed < best) best = transposed; } cur[j] = best; } const recycled = prev2; prev2 = prev; prev = cur; cur = recycled; } return prev[m] ?? 0; }