fix ampersand ordering bug for fuzzy matching

This commit is contained in:
2026-07-21 10:49:50 -04:00
parent 2c14831c35
commit cad6688174
2 changed files with 55 additions and 26 deletions

View File

@@ -37,7 +37,12 @@ const FUZZY_UNMATCHED_PENALTY = -1;
const FUZZY_RECURSION_LIMIT = 10;
const FUZZY_TRANSPOSE_PENALTY = -20;
const FUZZY_EXACT_SUBSTRING_BONUS = 100;
const FUZZY_AND_AMPERSAND_PENALTY = -20;
// Per swapped "&" <-> "and" token. Each swap lengthens the matched run by two
// characters, worth up to 2*FUZZY_SEQUENTIAL_BONUS to the longer "and" spelling;
// penalize each swap by more than that so the literal spelling the user typed always
// ranks above the substituted one. (This lowers rank, not visibility — the
// substituted match still appears in the results.)
const FUZZY_AND_AMPERSAND_PENALTY = -(2 * FUZZY_SEQUENTIAL_BONUS + 20); // -50
// Options that turn on extra, penalized match passes. Off by default so the base
// matcher stays exact-only.
@@ -103,17 +108,25 @@ export function fuzzyMatch(query: string, target: string): FuzzyMatchResult | nu
return { score, positions: matches };
}
// An alternative spelling of the query plus the number of tokens swapped to
// produce it, so the penalty can scale with how far it strayed.
interface FuzzyVariant {
text: string;
swaps: number;
}
// Alternative spellings of `query` with the word "and" and the symbol "&" swapped
// for each other, excluding `query` itself. `\band\b` only rewrites a standalone
// "and", so "brand" and "island" are left alone. These are the candidates the
// matcher tries (penalized) so "... and ..." can still hit a "... & ..." target
// and vice versa, while the exact spelling ranks first.
function andAmpersandVariants(query: string): string[] {
const out: string[] = [];
const amp = query.replace(/\band\b/gi, "&");
if (amp !== query) out.push(amp);
const and = query.replace(/&/g, "and");
if (and !== query) out.push(and);
// matcher tries (penalized per swap) so "... and ..." can still hit a "... & ..."
// target and vice versa, while the exact spelling ranks first.
function andAmpersandVariants(query: string): FuzzyVariant[] {
const out: FuzzyVariant[] = [];
let ands = 0;
const amp = query.replace(/\band\b/gi, () => { ands++; return "&"; });
if (ands > 0) out.push({ text: amp, swaps: ands });
const amps = (query.match(/&/g) || []).length;
if (amps > 0) out.push({ text: query.replace(/&/g, "and"), swaps: amps });
return out;
}
@@ -133,9 +146,9 @@ export function fuzzyMatchTypoTolerant(query: string, target: string, opts?: Fuz
}
if (opts?.andAmpersand) {
for (const variant of andAmpersandVariants(query)) {
const m = fuzzyMatch(variant, target);
const m = fuzzyMatch(variant.text, target);
if (!m) continue;
const score = m.score + FUZZY_AND_AMPERSAND_PENALTY;
const score = m.score + variant.swaps * FUZZY_AND_AMPERSAND_PENALTY;
if (!best || score > best.score) best = { score, positions: m.positions };
}
}