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.
This commit is contained in:
araharah-executor
2026-10-24 00:32:44 +08:00
parent 25ee1e9c61
commit 6368bad3d5
4 changed files with 357 additions and 110 deletions
+23 -34
View File
@@ -1,24 +1,13 @@
#!/usr/bin/env node
/*
* distance.test.mjs — OSA Damerau-Levenshtein vector suite (SRCH-02).
* -------------------------------------------------------------------
* RED phase (plan 04-01 task 2): every case fails while
* ../src/distance.ts does not exist (lazy import => per-case FAIL).
* distance.test.mjs — hand-computed Damerau-Levenshtein (OSA) vectors (SRCH-02).
* ---------------------------------------------------------------------------
* RED phase (plan 04-01): every case fails while ../src/distance.ts does not
* exist — the import happens lazily inside each case, so D1–D9 report one
* FAIL per vector instead of a lost suite load.
*
* The vectors are the hand-computed ones the plan pins (RESEARCH
* §"The Damerau-Levenshtein (OSA) distance"). The 3-band row rotation
* is the classic implementation trap — these vectors are written FIRST
* so a wrong rotation shows up as a red suite, not a silent mis-rank.
*
* D1 adjacent transposition ab -> ba = 1
* D2 two transpositions a cat -> an act = 2
* D3 empty vs empty = 0
* D4 empty vs abc = 3
* D5 abc vs empty = 3
* D6 identical strings = 0
* D7 KLCC transposition typo klcc -> kclc = 1
* D8 real corpus typo sunway -> sunwya = 1
* D9 classic Levenshtein sanity kitten -> sitting = 3
* The row-rotation in a 3-band OSA is easy to get wrong — these vectors are
* written FIRST and made to pass second (the repo RED->GREEN convention).
*
* Run: npm run search:test (exit 0 = all green)
*/
@@ -34,47 +23,47 @@ function loadDamerau() {
return distancePromise;
}
test("D1 adjacent transposition: damerau('ab', 'ba') === 1", async () => {
test("D1 adjacent transposition — damerau('ab','ba') === 1", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("ab", "ba"), 1);
assert.equal(damerau("ab", "ba"), 1, "a swap of adjacent chars is ONE edit in OSA");
});
test("D2 two transpositions: damerau('a cat', 'an act') === 2", async () => {
test("D2 two transpositions — damerau('a cat','an act') === 2", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("a cat", "an act"), 2);
assert.equal(damerau("a cat", "an act"), 2, "insert n, then transpose 'ca' -> 'ac'");
});
test("D3 damerau('', '') === 0", async () => {
test("D3 empty/empty — damerau('','') === 0", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("", ""), 0);
});
test("D4 damerau('', 'abc') === 3", async () => {
test("D4 empty target — damerau('','abc') === 3", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("", "abc"), 3);
assert.equal(damerau("", "abc"), 3, "three insertions");
});
test("D5 damerau('abc', '') === 3", async () => {
test("D5 empty source — damerau('abc','') === 3", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("abc", ""), 3);
assert.equal(damerau("abc", ""), 3, "three deletions");
});
test("D6 identical strings: damerau('same', 'same') === 0", async () => {
test("D6 identity — damerau('same','same') === 0", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("same", "same"), 0);
});
test("D7 KLCC transposition typo: damerau('klcc', 'kclc') === 1", async () => {
test("D7 corpus typo transposition — damerau('klcc','kclc') === 1", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("klcc", "kclc"), 1);
assert.equal(damerau("klcc", "kclc"), 1, "KCLC must reach KLCC as ONE edit (D-13 typo bucket)");
});
test("D8 real corpus typo: damerau('sunway', 'sunwya') === 1", async () => {
test("D8 corpus typo transposition — damerau('sunway','sunwya') === 1", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("sunway", "sunwya"), 1);
assert.equal(damerau("sunway", "sunwya"), 1, "SUNWYA must reach SUNWAY as ONE edit");
});
test("D9 classic Levenshtein sanity: damerau('kitten', 'sitting') === 3", async () => {
test("D9 classic Levenshtein sanity — damerau('kitten','sitting') === 3", async () => {
const damerau = await loadDamerau();
assert.equal(damerau("kitten", "sitting"), 3);
assert.equal(damerau("kitten", "sitting"), 3, "sub k->s, sub e->i, append g");
});