diff --git a/search/test/distance.test.mjs b/search/test/distance.test.mjs new file mode 100644 index 0000000..3974078 --- /dev/null +++ b/search/test/distance.test.mjs @@ -0,0 +1,80 @@ +#!/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). + * + * 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 + * + * Run: npm run search:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { test } from "./harness.mjs"; + +// Lazy distance load — missing module => per-case ERR_MODULE_NOT_FOUND (the RED state). +let distancePromise = null; +function loadDamerau() { + if (!distancePromise) { + distancePromise = import("../src/distance.ts").then((m) => m.damerau); + } + return distancePromise; +} + +test("D1 adjacent transposition: damerau('ab', 'ba') === 1", async () => { + const damerau = await loadDamerau(); + assert.equal(damerau("ab", "ba"), 1); +}); + +test("D2 two transpositions: damerau('a cat', 'an act') === 2", async () => { + const damerau = await loadDamerau(); + assert.equal(damerau("a cat", "an act"), 2); +}); + +test("D3 damerau('', '') === 0", async () => { + const damerau = await loadDamerau(); + assert.equal(damerau("", ""), 0); +}); + +test("D4 damerau('', 'abc') === 3", async () => { + const damerau = await loadDamerau(); + assert.equal(damerau("", "abc"), 3); +}); + +test("D5 damerau('abc', '') === 3", async () => { + const damerau = await loadDamerau(); + assert.equal(damerau("abc", ""), 3); +}); + +test("D6 identical strings: 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 () => { + const damerau = await loadDamerau(); + assert.equal(damerau("klcc", "kclc"), 1); +}); + +test("D8 real corpus typo: damerau('sunway', 'sunwya') === 1", async () => { + const damerau = await loadDamerau(); + assert.equal(damerau("sunway", "sunwya"), 1); +}); + +test("D9 classic Levenshtein sanity: damerau('kitten', 'sitting') === 3", async () => { + const damerau = await loadDamerau(); + assert.equal(damerau("kitten", "sitting"), 3); +}); diff --git a/search/test/score.test.mjs b/search/test/score.test.mjs new file mode 100644 index 0000000..7ff1c94 --- /dev/null +++ b/search/test/score.test.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/* + * score.test.mjs — the disjoint-tier scorer suite (D-09, SRCH-02). + * ---------------------------------------------------------------- + * RED phase (plan 04-01 task 2): every case fails while + * ../src/score.ts does not exist (lazy import => per-case FAIL). + * + * The ordering law (RESEARCH Pitfall 4): an exact match can NEVER be + * outranked by a fuzzy one — the ordering is a property of the TIER, + * not of the arithmetic. The tier bands are strictly disjoint and + * descending: + * + * EXACT 1.00 > ALIAS_EXACT 0.90 > PREFIX 0.75 > TOKEN_SET 0.65 > FUZZY < 0.60 + * + * FUZZY is the band's CEILING (0.60), not a zero multiplier: a fuzzy + * score is similarity * TIER.FUZZY, always inside (0, 0.60). + * + * S1 tier disjointness + exact literals + * S2 min-length guard fires BEFORE the exact tier + * S3 exact outranks fuzzy, always + * S4 prefix tier (mid-word prefix) + * S5 token-set tier (token-boundary superset; non-subset is refused) + * S6 fuzzy residual arithmetic + the 0.72 admission boundary + * S7 matchedAlias provenance (the RAW alias string comes back) + * + * Run: npm run search:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { test } from "./harness.mjs"; + +// Lazy score load — missing module => per-case ERR_MODULE_NOT_FOUND (the RED state). +let scorePromise = null; +function loadScore() { + if (!scorePromise) { + scorePromise = import("../src/score.ts"); + } + return scorePromise; +} + +// Lazy distance load (S6 pins the hand-computed Penang/Padang OSA distance). +let distancePromise = null; +function loadDamerau() { + if (!distancePromise) { + distancePromise = import("../src/distance.ts").then((m) => m.damerau); + } + return distancePromise; +} + +test("S1 tier disjointness — exact literals, strictly descending bands, FUZZY is the 0.60 ceiling", async () => { + const { TIER } = await loadScore(); + assert.equal(TIER.EXACT, 1.0); + assert.equal(TIER.ALIAS_EXACT, 0.9); + assert.equal(TIER.PREFIX, 0.75); + assert.equal(TIER.TOKEN_SET, 0.65); + assert.equal(TIER.FUZZY, 0.6); + assert.ok(TIER.EXACT > TIER.ALIAS_EXACT); + assert.ok(TIER.ALIAS_EXACT > TIER.PREFIX); + assert.ok(TIER.PREFIX > TIER.TOKEN_SET); + assert.ok(TIER.TOKEN_SET > TIER.FUZZY); + const { scoreOne } = await loadScore(); + const fuzzy = scoreOne("kclc", "klcc"); + assert.ok(fuzzy > 0, "a near-miss fuzzy score is strictly positive"); + assert.ok(fuzzy < TIER.FUZZY, "a fuzzy score stays under the 0.60 ceiling"); +}); + +test("S2 min-length guard — either side under 3 chars scores 0, even on an exact match", async () => { + const { scoreOne } = await loadScore(); + assert.equal(scoreOne("ab", "abcd"), 0); + assert.equal(scoreOne("kl", "kl"), 0, "the guard fires BEFORE the exact tier"); +}); + +test("S3 exact outranks fuzzy — always", async () => { + const { scoreOne, TIER } = await loadScore(); + assert.ok(scoreOne("klcc", "klcc") > scoreOne("klcc", "klca")); + assert.ok(scoreOne("klcc", "klcc") > TIER.FUZZY); +}); + +test("S4 prefix tier — mid-word prefix hits PREFIX", async () => { + const { scoreOne, TIER } = await loadScore(); + assert.equal(scoreOne("jalan amp", "jalan ampang"), TIER.PREFIX); +}); + +test("S5 token-set tier — token-boundary superset is TOKEN_SET; a non-subset never is", async () => { + const { scoreOne, TIER } = await loadScore(); + assert.equal(scoreOne("taman bangsar", "taman bangsar kib"), TIER.TOKEN_SET); + const nonSubset = scoreOne("taman bangsar", "taman bukit"); + assert.notEqual(nonSubset, TIER.TOKEN_SET); + assert.ok(nonSubset >= 0 && nonSubset < TIER.FUZZY, "a non-subset lands in the residual band, never TOKEN_SET"); +}); + +test("S6 fuzzy residual + admission boundary — 0.75 admits, the Penang/Padang shape (0.667) refuses", async () => { + const { scoreOne, TIER, FUZZY_ADMIT_SIMILARITY } = await loadScore(); + const damerau = await loadDamerau(); + // "kclc" vs "klcc": one transposition over 4 chars -> sim exactly 0.75. + assert.equal(scoreOne("kclc", "klcc"), 0.75 * TIER.FUZZY); + const klccSim = 1 - 1 / 4; + assert.equal(klccSim, 0.75); + assert.ok(klccSim >= FUZZY_ADMIT_SIMILARITY, "sim 0.75 reaches the 0.72 admission floor"); + // "penang" vs "padang": two substitutions over 6 chars -> sim 0.667, refused. + const penangPadangDistance = damerau("penang", "padang"); + const penangSim = 1 - penangPadangDistance / 6; + assert.equal(penangPadangDistance, 2); + assert.ok(penangSim < FUZZY_ADMIT_SIMILARITY, "sim 0.667 stays below the 0.72 admission floor"); + assert.ok(FUZZY_ADMIT_SIMILARITY === 0.72); +}); + +test("S7 matchedAlias provenance — the RAW alias string comes back", async () => { + const { scoreCandidate, TIER } = await loadScore(); + const hit = scoreCandidate("klcc", ["Suria KLCC", "KLCC"]); + assert.ok(hit !== null); + assert.equal(hit.matchedAlias, "KLCC", "the matched alias is the RAW string, not the normalized form"); + assert.ok( + hit.score === TIER.EXACT || hit.score === TIER.ALIAS_EXACT, + `an exact alias match lands in the exact/alias-exact band, got ${hit.score}`, + ); +});