From cad66881747c0158e11249017979372cabb5c4f9 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Tue, 21 Jul 2026 10:49:50 -0400 Subject: [PATCH] fix ampersand ordering bug for fuzzy matching --- go/jsruntime/uikit/FuzzyMatch.tsx | 35 +++++++++++++++-------- go/webui/fuzzymatch.go | 46 +++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/go/jsruntime/uikit/FuzzyMatch.tsx b/go/jsruntime/uikit/FuzzyMatch.tsx index 7b98f3c0..3473559d 100644 --- a/go/jsruntime/uikit/FuzzyMatch.tsx +++ b/go/jsruntime/uikit/FuzzyMatch.tsx @@ -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 }; } } diff --git a/go/webui/fuzzymatch.go b/go/webui/fuzzymatch.go index 6353937e..b2650827 100644 --- a/go/webui/fuzzymatch.go +++ b/go/webui/fuzzymatch.go @@ -57,7 +57,12 @@ const ( fuzzyRecursionLimit = 10 fuzzyTransposePenalty = -20 fuzzyExactSubstrBonus = 100 - fuzzyAndAmpersandPen = -20 + // Per swapped "&"<->"and" token. Each swap lengthens the matched run by two + // characters, worth up to 2*fuzzySequentialBonus 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.) + fuzzyAndAmpersandPen = -(2*fuzzySequentialBonus + 20) // -50 ) // FuzzyOptions turns on extra, penalized match passes. The zero value is exact- @@ -176,10 +181,12 @@ func fuzzyByteAt(s string, i int) byte { } // fuzzyReplaceAndWord replaces every standalone "and" (case-insensitive) in s -// with repl, leaving substrings like "brand" or "island" untouched. "and", "&" -// and the ASCII separators are all single-byte, so a byte scan suffices. -func fuzzyReplaceAndWord(s, repl string) string { +// with repl, leaving substrings like "brand" or "island" untouched, and reports +// how many it replaced. "and", "&" and the ASCII separators are all single-byte, +// so a byte scan suffices. +func fuzzyReplaceAndWord(s, repl string) (string, int) { var b strings.Builder + n := 0 for i := 0; i < len(s); { isAnd := i+3 <= len(s) && (s[i] == 'a' || s[i] == 'A') && @@ -187,26 +194,35 @@ func fuzzyReplaceAndWord(s, repl string) string { (s[i+2] == 'd' || s[i+2] == 'D') if isAnd && !fuzzyIsWordByte(fuzzyByteAt(s, i-1)) && !fuzzyIsWordByte(fuzzyByteAt(s, i+3)) { b.WriteString(repl) + n++ i += 3 continue } b.WriteByte(s[i]) i++ } - return b.String() + return b.String(), n +} + +// fuzzyVariant is 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. +type fuzzyVariant struct { + text string + swaps int } // fuzzyAndAmpersandVariants returns alternative spellings of query with the word // "and" and the symbol "&" swapped for each other, excluding query itself. These -// are the candidates the matcher tries (penalized) so "... and ..." can still hit -// a "... & ..." target and vice versa, while the exact spelling ranks first. -func fuzzyAndAmpersandVariants(query string) []string { - var out []string - if amp := fuzzyReplaceAndWord(query, "&"); amp != query { - out = append(out, amp) +// are the candidates the matcher tries (penalized per swap) so "... and ..." can +// still hit a "... & ..." target and vice versa, while the exact spelling ranks +// first. +func fuzzyAndAmpersandVariants(query string) []fuzzyVariant { + var out []fuzzyVariant + if amp, n := fuzzyReplaceAndWord(query, "&"); n > 0 { + out = append(out, fuzzyVariant{amp, n}) } - if and := strings.ReplaceAll(query, "&", "and"); and != query { - out = append(out, and) + if n := strings.Count(query, "&"); n > 0 { + out = append(out, fuzzyVariant{strings.ReplaceAll(query, "&", "and"), n}) } return out } @@ -232,11 +248,11 @@ func FuzzyMatchTypoTolerant(query, target string, opts ...FuzzyOptions) *FuzzyMa } if fuzzyOpt(opts).AndAmpersand { for _, variant := range fuzzyAndAmpersandVariants(query) { - m := FuzzyMatchOne(variant, target) + m := FuzzyMatchOne(variant.text, target) if m == nil { continue } - score := m.Score + fuzzyAndAmpersandPen + score := m.Score + variant.swaps*fuzzyAndAmpersandPen if best == nil || score > best.Score { best = &FuzzyMatchResult{Score: score, Positions: m.Positions} }