/* * score.ts — the disjoint-tier scorer (D-09, SRCH-02). * ---------------------------------------------------- * Five tiers with STRICTLY DISJOINT, DESCENDING score bands — the * ordering is a property of the TIER, not of the arithmetic (RESEARCH * Pitfall 4), so an exact match can never be outranked by a fuzzy one * and the corpus results stay stable when the fuzzy function is tuned. * * EXACT 1.00 normalized query === the candidate's normalized NAME * ALIAS_EXACT 0.90 normalized query === a normalized ALIAS (name differs) * PREFIX 0.75 one side is a mid-word prefix of the other (>= 3 chars) * TOKEN_SET 0.65 every query token is present in the candidate's tokens * FUZZY 0.60 the residual band's CEILING — a fuzzy score is * fuzzySim * TIER.FUZZY and lands strictly inside (0, 0.60) * (a similarity of 1.0 would have been caught by EXACT) * * FUZZY IS NOT A 0.00 MULTIPLIER: multiplying the similarity by 0.00 * would zero the whole residual tier. scoreOne() returns the raw * (possibly sub-admission) fuzzy score; the ADMISSION FLOOR lives in * scoreCandidate — a fuzzy-band candidate is admitted only when its * similarity reaches FUZZY_ADMIT_SIMILARITY, so "KCLC" vs "KLCC" (sim * 0.75) passes while "Penang" vs "Padang" (sim 0.667) is refused with * null — the mechanism behind D-13's honest refusal. * * Tier order inside scoreOne(): the min-length guard fires FIRST (even * an exact match below 3 characters scores 0 — RESEARCH Pitfall 5: * "KLCC" and "DUKE" must be ALIAS hits, not fuzzy hits); then EXACT; * then TOKEN_SET (a query ending at a token boundary of a longer alias * is a token-set hit, not a prefix hit); then PREFIX (mid-word only); * then the FUZZY residual — computed at BOTH the whole-string level and * the token level (the token level is what lets "Sentrl" reach * "KL Sentral" — the error is in one word — while "Jln Tun Razak" still * cannot reach "Jalan Ampang", because non-matching tokens drag the * token-level mean down). * * The scorer consumes ALREADY-NORMALIZED strings and never re-normalizes * inside scoreOne (normalization happened once, upstream — D-09). Only * scoreCandidate normalizes, because it must return the RAW candidate * string as provenance; normalize() is idempotent, so passing * pre-normalized candidates is equally safe. * * Pure: no I/O, no storage, no logging (D-15). */ import { damerau } from "./distance.ts"; import { normalize } from "./normalize.ts"; /** Disjoint descending score bands (exact literals — asserted by S1). */ export const TIER = { EXACT: 1.00, ALIAS_EXACT: 0.90, PREFIX: 0.75, TOKEN_SET: 0.65, FUZZY: 0.60, } as const; /** A FUZZY-band hit is ADMITTED only at this similarity or above. * Tuned between the corpus's two loads: "KCLC" vs "KLCC" is sim 0.75 * (admitted — the typo bucket passes) while "Penang" vs "Padang" is * sim 0.667 (refused — no fabricated pin). Imported by source.ts and * re-exported through index.ts so the corpus runner reads the floor * from the module instead of hardcoding it. */ export const FUZZY_ADMIT_SIMILARITY = 0.72; /** Strings shorter than this never score — the min-length guard. */ const MIN_SCOREABLE_LENGTH = 3; /** Damerau similarity in [0, 1]: 1 - normalized OSA distance. */ function similarity(a: string, b: string): number { const maxLen = Math.max(a.length, b.length); if (maxLen === 0) return 1; return 1 - damerau(a, b) / maxLen; } /** * Score one normalized query against one normalized candidate string. * Returns the tier-band score (0 = refused), never a negative. */ export function scoreOne(queryNorm: string, aliasNorm: string): number { // — the min-length guard fires BEFORE the exact tier (S2) — if (queryNorm.length < MIN_SCOREABLE_LENGTH || aliasNorm.length < MIN_SCOREABLE_LENGTH) { return 0; } // — EXACT — if (queryNorm === aliasNorm) return TIER.EXACT; const queryTokens = queryNorm.split(" "); const aliasTokens = aliasNorm.split(" "); // — TOKEN_SET: every query token present in the candidate's tokens. // Checked BEFORE PREFIX so "taman bangsar" vs "taman bangsar kib" // rides the token-set band, while the mid-word case below — // "jalan amp" vs "jalan ampang" — still reaches PREFIX. — let subset = true; for (const token of queryTokens) { if (!aliasTokens.includes(token)) { subset = false; break; } } if (subset) return TIER.TOKEN_SET; // — PREFIX: mid-word prefix (token-boundary prefixes were TOKEN_SET) — if (queryNorm.startsWith(aliasNorm) || aliasNorm.startsWith(queryNorm)) { return TIER.PREFIX; } // — FUZZY residual: max(whole-string sim, token-level sim), scaled // into the (0, 0.60) band. Token level: each >=3-char query token // against each >=3-char candidate token; the mean of the per-token // bests, scaled by the fraction of query tokens that matched // anything. Sub-3-char tokens cannot be compared and contribute 0. — const wholeStringSim = similarity(queryNorm, aliasNorm); let sum = 0; let found = 0; for (const queryToken of queryTokens) { if (queryToken.length < MIN_SCOREABLE_LENGTH) continue; let best = 0; for (const aliasToken of aliasTokens) { if (aliasToken.length < MIN_SCOREABLE_LENGTH) continue; const sim = similarity(queryToken, aliasToken); if (sim > best) best = sim; } if (best > 0) found++; sum += best; } const tokenLevelSim = queryTokens.length > 0 ? (sum / queryTokens.length) * (found / queryTokens.length) : 0; const fuzzySim = Math.max(wholeStringSim, tokenLevelSim); return fuzzySim * TIER.FUZZY; } /** One scored candidate: the band score and the RAW string that earned it. */ export interface ScoredCandidate { score: number; matchedAlias: string; } /** * Score a normalized query against a candidate string list — the entry's * NAME at index 0 followed by its aliases — and return the BEST admitted * hit plus the raw candidate string that produced it (the corpus report's * provenance), or null when nothing scores. * * Band assignment: an exact hit on the NAME is EXACT; an exact hit on any * LATER string (an alias) is ALIAS_EXACT, so a name-exact on one entry * outranks an alias-exact on another. Admission is TIERED: non-fuzzy * bands always pass; a FUZZY-band score passes only when its similarity * reaches FUZZY_ADMIT_SIMILARITY — below that the query is "not in the * v1 area" and the result is null (D-13/D-14), never a fabricated pin. */ export function scoreCandidate(queryNorm: string, candidates: readonly string[]): ScoredCandidate | null { const fuzzyAdmitScore = FUZZY_ADMIT_SIMILARITY * TIER.FUZZY; let best: ScoredCandidate | null = null; for (let i = 0; i < candidates.length; i++) { const raw = candidates[i]; if (raw === undefined || raw.length === 0) continue; let score = scoreOne(queryNorm, normalize(raw)); if (score === TIER.EXACT && i > 0) score = TIER.ALIAS_EXACT; if (score <= 0) continue; if (score < TIER.TOKEN_SET && score < fuzzyAdmitScore) continue; // sub-admission fuzzy: refused if (best === null || score > best.score) { best = { score, matchedAlias: raw }; } } return best; }