Files
arah2/search/src/distance.ts
T
araharah-executor 6368bad3d5 feat(04-01): OSA Damerau distance + disjoint-tier scorer (the residual net)
- 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.
2026-10-24 00:32:44 +08:00

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;
}