- 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.
135 lines
6.3 KiB
JavaScript
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",
|
|
);
|
|
}
|
|
});
|