- distance.ts: hand-rolled 3-band OSA, adjacent transposition = 1 edit, zero imports (D-07); the row rotation is pinned by the D1-D9 vectors - score.ts: TIER bands strictly disjoint descending — EXACT 1.00 / ALIAS_EXACT 0.90 / PREFIX 0.75 / TOKEN_SET 0.65 / FUZZY as the 0.60 CEILING (a fuzzy score is fuzzySim * 0.60, never a 0.00 multiplier); FUZZY_ADMIT_SIMILARITY = 0.72 exported for source.ts + the corpus runner; token-level residual lets 'Sentrl' reach 'KL Sentral' while 'Jln Tun Razak' cannot reach 'Jalan Ampang' - scoreCandidate returns the RAW matched alias (corpus provenance); an exact ALIAS hit rides ALIAS_EXACT so a name-exact outranks it - score.test.mjs S7 corrected during GREEN: admission belongs to source.ts — scoreCandidate asserts the sub-floor similarity instead D1-D9 + S1-S7 green; typecheck clean.
74 lines
2.9 KiB
TypeScript
74 lines
2.9 KiB
TypeScript
/*
|
|
* 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;
|
|
}
|