test(04-01): RED — OSA distance vectors (D1-D9) + disjoint-tier scorer suite (S1-S7)

- 9 hand-computed Damerau-Levenshtein vectors (transpositions, empties,
  kitten/sitting sanity, the corpus's klcc/kclc + sunway/sunwya typos)
- 7 scorer laws: tier literals + disjointness, min-length guard before
  exact, exact-outranks-fuzzy, mid-word PREFIX vs token-boundary
  TOKEN_SET, fuzzy residual 0.75*0.60 arithmetic, the 0.72 admission
  boundary (Penang/Padang 0.667 refuses), matchedAlias raw provenance

All 16 new cases fail while src/distance.ts + src/score.ts are absent.
This commit is contained in:
araharah-executor
2026-10-24 00:25:19 +08:00
parent a9f9ab380c
commit 25ee1e9c61
2 changed files with 196 additions and 0 deletions
+116
View File
@@ -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}`,
);
});