Files
arah2/search/test/score.test.mjs
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

135 lines
6.3 KiB
JavaScript

#!/usr/bin/env node
/*
* score.test.mjs — the disjoint-tier scorer suite (D-09, SRCH-02).
* ----------------------------------------------------------------
* RED phase (plan 04-01): every case fails while ../src/score.ts does not
* exist — the import happens lazily inside each case.
*
* Contract under test:
* S1 tier disjointness — EXACT > ALIAS_EXACT > PREFIX > TOKEN_SET >
* FUZZY, with FUZZY the 0.60 CEILING of its band (never a 0.00
* multiplier) and every admitted fuzzy score strictly inside (0, 0.60)
* S2 min-length guard — either side < 3 chars scores 0, EVEN on an
* exact match (the guard fires first — Pitfall 5)
* S3 exact outranks fuzzy — always
* S4 prefix tier — a mid-word prefix lands exactly on TIER.PREFIX
* S5 token-set tier — a query whose every token is present in the
* candidate's tokens is TOKEN_SET; a non-subset must NOT be
* S6 fuzzy residual + admission boundary — 'kclc'/'klcc' is
* 0.75 * TIER.FUZZY exactly; sim 0.75 >= 0.72 admits while the
* 'penang'/'padang' shape (sim 0.667) stays below FUZZY_ADMIT_SIMILARITY
* S7 matchedAlias provenance — scoreCandidate returns the RAW alias
* string that produced the score, and an exact alias hit rides the
* ALIAS_EXACT band (so a name-exact on another entry outranks it)
*
* Run: npm run search:test (exit 0 = all green)
*/
import { strict as assert } from "node:assert";
import { test } from "./harness.mjs";
// Lazy module loads — missing modules => per-case ERR_MODULE_NOT_FOUND (the RED state).
let scorePromise = null;
function loadScore() {
if (!scorePromise) {
scorePromise = import("../src/score.ts");
}
return scorePromise;
}
let distancePromise = null;
function loadDamerau() {
if (!distancePromise) {
distancePromise = import("../src/distance.ts").then((m) => m.damerau);
}
return distancePromise;
}
/** The whole-string similarity law the scorer uses: 1 - damerau/maxLen. */
async function sim(a, b) {
const damerau = await loadDamerau();
return 1 - damerau(a, b) / Math.max(a.length, b.length);
}
test("S1 tier disjointness — strictly descending bands, FUZZY is the 0.60 ceiling", async () => {
const { TIER, scoreOne } = await loadScore();
assert.ok(TIER.EXACT > TIER.ALIAS_EXACT, "EXACT > ALIAS_EXACT");
assert.ok(TIER.ALIAS_EXACT > TIER.PREFIX, "ALIAS_EXACT > PREFIX");
assert.ok(TIER.PREFIX > TIER.TOKEN_SET, "PREFIX > TOKEN_SET");
assert.ok(TIER.TOKEN_SET > TIER.FUZZY, "TOKEN_SET > FUZZY");
assert.equal(TIER.FUZZY, 0.60, "FUZZY is the band ceiling, not a 0.00 multiplier");
const fuzzyScore = scoreOne("kclc", "klcc");
assert.ok(fuzzyScore > 0, "an admitted fuzzy score is > 0");
assert.ok(fuzzyScore < TIER.FUZZY, "an admitted fuzzy score is < 0.60 — a sim of 1.0 would be EXACT");
});
test("S2 min-length guard — sub-3-char sides score 0 even on an exact match", async () => {
const { scoreOne } = await loadScore();
assert.equal(scoreOne("ab", "abcd"), 0, "short query is refused");
assert.equal(scoreOne("kl", "kl"), 0, "the guard fires BEFORE the exact tier");
});
test("S3 exact outranks fuzzy — always", async () => {
const { TIER, scoreOne } = await loadScore();
assert.ok(scoreOne("klcc", "klcc") > scoreOne("klcc", "klca"), "exact beats the near-miss");
assert.ok(scoreOne("klcc", "klcc") > TIER.FUZZY, "EXACT sits above the whole fuzzy band");
});
test("S4 prefix tier — 'jalan amp' vs 'jalan ampang' lands exactly on TIER.PREFIX", async () => {
const { TIER, scoreOne } = await loadScore();
assert.equal(scoreOne("jalan amp", "jalan ampang"), TIER.PREFIX);
});
test("S5 token-set tier — full token subset is TOKEN_SET; a non-subset is not", async () => {
const { TIER, scoreOne } = await loadScore();
assert.equal(
scoreOne("taman bangsar", "taman bangsar kib"),
TIER.TOKEN_SET,
"every query token present in the candidate's tokens",
);
const nonSubset = scoreOne("taman bangsar", "taman bukit");
assert.notEqual(nonSubset, TIER.TOKEN_SET, "a non-subset must NOT ride the token-set band");
assert.ok(nonSubset < TIER.FUZZY, "it falls into the fuzzy band strictly below the ceiling");
});
test("S6 fuzzy residual + admission boundary — 0.75 admits, the 0.667 shape does not", async () => {
const { TIER, FUZZY_ADMIT_SIMILARITY, scoreOne } = await loadScore();
assert.equal(scoreOne("kclc", "klcc"), 0.75 * TIER.FUZZY, "damerau 1 over len 4 -> sim 0.75 exactly");
const typoSim = await sim("kclc", "klcc");
assert.equal(typoSim, 0.75);
assert.ok(typoSim >= FUZZY_ADMIT_SIMILARITY, "the KCLC->KLCC typo bucket is ADMITTED");
const negativeSim = await sim("penang", "padang");
assert.equal(negativeSim, 1 - 2 / 6, "two substitutions over 6 chars -> sim 0.667");
assert.ok(negativeSim < FUZZY_ADMIT_SIMILARITY, "the Penang/Padang near-miss shape is REFUSED");
});
test("S7 matchedAlias provenance — the RAW alias string rides the hit", async () => {
const { TIER, FUZZY_ADMIT_SIMILARITY, scoreCandidate } = await loadScore();
const aliasHit = scoreCandidate("klcc", ["Suria KLCC", "KLCC"]);
assert.notEqual(aliasHit, null, "an exact alias match must score");
assert.equal(aliasHit.matchedAlias, "KLCC", "provenance is the raw, un-normalized alias");
assert.ok(
aliasHit.score === TIER.ALIAS_EXACT || aliasHit.score === TIER.EXACT,
`an exact alias hit rides the exact/alias-exact band, got ${aliasHit.score}`,
);
assert.equal(aliasHit.score, TIER.ALIAS_EXACT, "an alias exact (name differs) is ALIAS_EXACT");
const nameHit = scoreCandidate("suria klcc", ["Suria KLCC", "KLCC"]);
assert.notEqual(nameHit, null);
assert.equal(nameHit.score, TIER.EXACT, "a name exact is the EXACT band");
assert.equal(nameHit.matchedAlias, "Suria KLCC", "the name produced it");
// Out-of-area: scoreCandidate still SCORES ("Kuching" lands somewhere in
// the fuzzy band) — but its SIMILARITY stays below FUZZY_ADMIT_SIMILARITY,
// so the source refuses it. Admission is source.ts's job, not scoring's.
const refused = scoreCandidate("kuching", ["Suria KLCC", "KLCC"]);
if (refused !== null) {
assert.ok(
refused.score < TIER.FUZZY,
"the out-of-area shape must not ride a cheap tier",
);
assert.ok(
refused.score / TIER.FUZZY < FUZZY_ADMIT_SIMILARITY,
"the out-of-area similarity must stay below the 0.72 admission floor",
);
}
});