Skip to content

A rematch that loses is charged as unmatched but still sets prevMatched, so the next letter gets an unearned +5: Score("EditItemManager","em") = 2 vs "EditItxmManager" = 12 #130

Open

Description

@matt-edmondson

What's wrong

In Fuzzy.Contains, a copy of the current pattern letter that loses the rematch is charged as skipped by the losing-rematch else branch (FuzzySearch/Fuzzy.cs:295-301):

else
{
    // A rematch that loses to the current best letter is skipped too, so charge it like one.
    score += unmatchedLetterPenalty;
    ChargeProvisional(firstLetterProvisional, ref provisionalCharges);
}

prevMatched = true;   // line 303: still runs for the losing copy

Control then falls through to prevMatched = true, so the character that was just charged as unmatched is treated as the previous match. The next character then:

  • gets adjacentMatchBonus (+5) in ApplyBonuses;
  • is marked as continuing a run (ContinuesRun, ~lines 270 and 361-365);
  • through displacesRun (~line 269), blocks a later camelCase/separator copy from replacing it.

Repro (main @ d25a1c7)

Fuzzy.Contains("EditItemManager", "em", out int a); // 2
Fuzzy.Contains("EditItxmManager", "em", out int b); // 12
Fuzzy.Contains("axaB", "ab", out int c);            // 23
Fuzzy.Contains("axB", "ab", out int d);             // 19
  • Changing one unmatched letter (e→x inside "Item") changes the score by 10. With the e present, the mid-word m of "Item" gets +5 for being "adjacent" to the losing e. It is then treated as continuing a run, so the camelCase M of "Manager" can't displace it.
  • Adding an unmatched a raises the score from 19 to 23.

The README defines the +5 as a "Match adjacent to the previous match". A brute-force enumeration of every alignment over random subjects drawn from abAB_x, with no doubled letters so #101 is excluded, found 642 of 157,532 matching cases where the returned score is higher than any alignment can score under the documented rules (e.g. Baxab/bab 18 vs max 13, b_xbAx_A/ba 19 vs max 14).

Not a duplicate of

Why it matters

Identifiers commonly contain the pattern's letters next to each other inside words like "Item", "System" and "Element". When the first pattern letter matched earlier, those words make a camelCase hit (EditItemManager for "em") lose to a mid-word match, and the ranking flips on a change to letters that aren't matched at all.

Suggested fix

In the losing-rematch branch, treat the character as unmatched for bookkeeping too: set prevMatched = false (update prevLower/prevSeparator and the indices, then continue) instead of falling through to prevMatched = true. With this change applied to a scratch copy, all 116 existing tests pass and:

  • EditItemManager/em → 12
  • axaB/ab → 18
  • EditItemManager/emm → 13 (was 18)

Acceptance criteria

  • Score("EditItemManager","em") == Score("EditItxmManager","em")
  • Score("axaB","ab") <= Score("axB","ab")
  • There is a regression test asserting that a losing rematch doesn't grant the following letter the adjacency bonus.

Activity

  1. matt-edmondson commented on Nov 2, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions